-
Notifications
You must be signed in to change notification settings - Fork 28
fix: Show durations for artist top songs #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1d1f18
1f64f4f
7bceaa8
e72ffa7
027299c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -746,11 +746,99 @@ final class YTMusicClient: YTMusicClientProtocol { | |||||||||||||||||||||||||
| let topKeys = Array(data.keys) | ||||||||||||||||||||||||||
| self.logger.debug("Artist response top-level keys: \(topKeys)") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let detail = ArtistParser.parseArtistDetail(data, artistId: id) | ||||||||||||||||||||||||||
| var detail = ArtistParser.parseArtistDetail(data, artistId: id) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Artist page top songs don't include duration — fetch via queue endpoint (best-effort) | ||||||||||||||||||||||||||
| let songsNeedingDuration = detail.songs.filter { $0.duration == nil } | ||||||||||||||||||||||||||
| if !songsNeedingDuration.isEmpty { | ||||||||||||||||||||||||||
| do { | ||||||||||||||||||||||||||
| let durations = try await self.fetchSongDurations(videoIds: songsNeedingDuration.map(\.videoId)) | ||||||||||||||||||||||||||
| let enrichedSongs = detail.songs.map { song -> Song in | ||||||||||||||||||||||||||
| if song.duration == nil, let duration = durations[song.videoId] { | ||||||||||||||||||||||||||
| return Song( | ||||||||||||||||||||||||||
| id: song.id, | ||||||||||||||||||||||||||
| title: song.title, | ||||||||||||||||||||||||||
| artists: song.artists, | ||||||||||||||||||||||||||
| album: song.album, | ||||||||||||||||||||||||||
| duration: duration, | ||||||||||||||||||||||||||
| thumbnailURL: song.thumbnailURL, | ||||||||||||||||||||||||||
| videoId: song.videoId, | ||||||||||||||||||||||||||
| hasVideo: song.hasVideo, | ||||||||||||||||||||||||||
| musicVideoType: song.musicVideoType, | ||||||||||||||||||||||||||
| likeStatus: song.likeStatus, | ||||||||||||||||||||||||||
| isInLibrary: song.isInLibrary, | ||||||||||||||||||||||||||
| feedbackTokens: song.feedbackTokens | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return song | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| detail = ArtistDetail( | ||||||||||||||||||||||||||
| artist: detail.artist, | ||||||||||||||||||||||||||
| description: detail.description, | ||||||||||||||||||||||||||
| songs: enrichedSongs, | ||||||||||||||||||||||||||
| albums: detail.albums, | ||||||||||||||||||||||||||
| thumbnailURL: detail.thumbnailURL, | ||||||||||||||||||||||||||
| channelId: detail.channelId, | ||||||||||||||||||||||||||
| isSubscribed: detail.isSubscribed, | ||||||||||||||||||||||||||
| subscriberCount: detail.subscriberCount, | ||||||||||||||||||||||||||
| hasMoreSongs: detail.hasMoreSongs, | ||||||||||||||||||||||||||
| songsBrowseId: detail.songsBrowseId, | ||||||||||||||||||||||||||
| songsParams: detail.songsParams, | ||||||||||||||||||||||||||
| mixPlaylistId: detail.mixPlaylistId, | ||||||||||||||||||||||||||
| mixVideoId: detail.mixVideoId | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| } catch is CancellationError { | ||||||||||||||||||||||||||
| throw CancellationError() | ||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||
| self.logger.debug("Best-effort duration fetch failed: \(error.localizedDescription)") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| self.logger.info("Parsed artist '\(detail.artist.name)' with \(detail.songs.count) songs and \(detail.albums.count) albums") | ||||||||||||||||||||||||||
| return detail | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// Fetches durations for a batch of video IDs using the queue endpoint. | ||||||||||||||||||||||||||
| private func fetchSongDurations(videoIds: [String]) async throws -> [String: TimeInterval] { | ||||||||||||||||||||||||||
| guard !videoIds.isEmpty else { return [:] } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let body: [String: Any] = [ | ||||||||||||||||||||||||||
| "videoIds": videoIds, | ||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let data = try await request("music/get_queue", body: body, ttl: APICache.TTL.artist) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var durations: [String: TimeInterval] = [:] | ||||||||||||||||||||||||||
| if let queueDatas = data["queueDatas"] as? [[String: Any]] { | ||||||||||||||||||||||||||
| for queueData in queueDatas { | ||||||||||||||||||||||||||
| guard let content = queueData["content"] as? [String: Any] else { continue } | ||||||||||||||||||||||||||
| // Handle both direct and wrapped renderer structures | ||||||||||||||||||||||||||
| let renderer: [String: Any]? = if let direct = content["playlistPanelVideoRenderer"] as? [String: Any] { | ||||||||||||||||||||||||||
| direct | ||||||||||||||||||||||||||
| } else if let wrapper = content["playlistPanelVideoWrapperRenderer"] as? [String: Any], | ||||||||||||||||||||||||||
| let primary = wrapper["primaryRenderer"] as? [String: Any], | ||||||||||||||||||||||||||
| let wrapped = primary["playlistPanelVideoRenderer"] as? [String: Any] | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| wrapped | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| nil | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+815
to
+825
|
||||||||||||||||||||||||||
| // Handle both direct and wrapped renderer structures | |
| let renderer: [String: Any]? = if let direct = content["playlistPanelVideoRenderer"] as? [String: Any] { | |
| direct | |
| } else if let wrapper = content["playlistPanelVideoWrapperRenderer"] as? [String: Any], | |
| let primary = wrapper["primaryRenderer"] as? [String: Any], | |
| let wrapped = primary["playlistPanelVideoRenderer"] as? [String: Any] | |
| { | |
| wrapped | |
| } else { | |
| nil | |
| } | |
| let renderer = PlaylistParser.extractQueueRenderer(from: content) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fetchSongDurations(videoIds:)cachesmusic/get_queueresponses usingAPICache.TTL.artist, but elsewhere in this filemusic/get_queueis explicitly called withttl: nil(“No caching for queue endpoint”). Caching per uniquevideoIdsbatch can also thrash the small (50 entry) LRU cache and return stale duration data. Consider switching this call tottl: nil(or introducing a dedicated short TTL specifically for queue duration lookups if caching is desired).