Skip to content

Commit 8092995

Browse files
authored
fix: Show durations for artist top songs (#113)
1 parent 1f27296 commit 8092995

File tree

3 files changed

+133
-9
lines changed

3 files changed

+133
-9
lines changed

Core/Services/API/Parsers/ParsingHelpers.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -227,19 +227,22 @@ enum ParsingHelpers {
227227
}
228228
}
229229

230-
// Try flexColumns (artist page top songs often have duration in the last column)
230+
// Try flexColumns (artist page top songs often have duration in a combined column)
231231
if let flexColumns = data["flexColumns"] as? [[String: Any]] {
232-
// Check last flex column for duration (common pattern on artist pages)
233232
for column in flexColumns.reversed() {
234233
if let renderer = column["musicResponsiveListItemFlexColumnRenderer"] as? [String: Any],
235234
let text = renderer["text"] as? [String: Any],
236-
let runs = text["runs"] as? [[String: Any]],
237-
let firstRun = runs.first,
238-
let durationText = firstRun["text"] as? String
235+
let runs = text["runs"] as? [[String: Any]]
239236
{
240-
// Check if this looks like a duration (e.g., "3:45" or "1:23:45")
241-
if let duration = parseDuration(durationText) {
242-
return duration
237+
// Check all runs (duration is often the last run in "Artist • Album • 3:45")
238+
// Skip runs with navigationEndpoint to avoid matching album titles like "4:44"
239+
for run in runs.reversed() {
240+
if let durationText = run["text"] as? String,
241+
run["navigationEndpoint"] == nil,
242+
let duration = self.parseDuration(durationText)
243+
{
244+
return duration
245+
}
243246
}
244247
}
245248
}

Core/Services/API/YTMusicClient.swift

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,11 +746,99 @@ final class YTMusicClient: YTMusicClientProtocol {
746746
let topKeys = Array(data.keys)
747747
self.logger.debug("Artist response top-level keys: \(topKeys)")
748748

749-
let detail = ArtistParser.parseArtistDetail(data, artistId: id)
749+
var detail = ArtistParser.parseArtistDetail(data, artistId: id)
750+
751+
// Artist page top songs don't include duration — fetch via queue endpoint (best-effort)
752+
let songsNeedingDuration = detail.songs.filter { $0.duration == nil }
753+
if !songsNeedingDuration.isEmpty {
754+
do {
755+
let durations = try await self.fetchSongDurations(videoIds: songsNeedingDuration.map(\.videoId))
756+
let enrichedSongs = detail.songs.map { song -> Song in
757+
if song.duration == nil, let duration = durations[song.videoId] {
758+
return Song(
759+
id: song.id,
760+
title: song.title,
761+
artists: song.artists,
762+
album: song.album,
763+
duration: duration,
764+
thumbnailURL: song.thumbnailURL,
765+
videoId: song.videoId,
766+
hasVideo: song.hasVideo,
767+
musicVideoType: song.musicVideoType,
768+
likeStatus: song.likeStatus,
769+
isInLibrary: song.isInLibrary,
770+
feedbackTokens: song.feedbackTokens
771+
)
772+
}
773+
return song
774+
}
775+
detail = ArtistDetail(
776+
artist: detail.artist,
777+
description: detail.description,
778+
songs: enrichedSongs,
779+
albums: detail.albums,
780+
thumbnailURL: detail.thumbnailURL,
781+
channelId: detail.channelId,
782+
isSubscribed: detail.isSubscribed,
783+
subscriberCount: detail.subscriberCount,
784+
hasMoreSongs: detail.hasMoreSongs,
785+
songsBrowseId: detail.songsBrowseId,
786+
songsParams: detail.songsParams,
787+
mixPlaylistId: detail.mixPlaylistId,
788+
mixVideoId: detail.mixVideoId
789+
)
790+
} catch is CancellationError {
791+
throw CancellationError()
792+
} catch {
793+
self.logger.debug("Best-effort duration fetch failed: \(error.localizedDescription)")
794+
}
795+
}
796+
750797
self.logger.info("Parsed artist '\(detail.artist.name)' with \(detail.songs.count) songs and \(detail.albums.count) albums")
751798
return detail
752799
}
753800

801+
/// Fetches durations for a batch of video IDs using the queue endpoint.
802+
private func fetchSongDurations(videoIds: [String]) async throws -> [String: TimeInterval] {
803+
guard !videoIds.isEmpty else { return [:] }
804+
805+
let body: [String: Any] = [
806+
"videoIds": videoIds,
807+
]
808+
809+
let data = try await request("music/get_queue", body: body, ttl: APICache.TTL.artist)
810+
811+
var durations: [String: TimeInterval] = [:]
812+
if let queueDatas = data["queueDatas"] as? [[String: Any]] {
813+
for queueData in queueDatas {
814+
guard let content = queueData["content"] as? [String: Any] else { continue }
815+
// Handle both direct and wrapped renderer structures
816+
let renderer: [String: Any]? = if let direct = content["playlistPanelVideoRenderer"] as? [String: Any] {
817+
direct
818+
} else if let wrapper = content["playlistPanelVideoWrapperRenderer"] as? [String: Any],
819+
let primary = wrapper["primaryRenderer"] as? [String: Any],
820+
let wrapped = primary["playlistPanelVideoRenderer"] as? [String: Any]
821+
{
822+
wrapped
823+
} else {
824+
nil
825+
}
826+
if let renderer,
827+
let videoId = renderer["videoId"] as? String,
828+
let lengthText = renderer["lengthText"] as? [String: Any],
829+
let runs = lengthText["runs"] as? [[String: Any]],
830+
let durationText = runs.first?["text"] as? String,
831+
let duration = ParsingHelpers.parseDuration(durationText)
832+
{
833+
durations[videoId] = duration
834+
}
835+
}
836+
}
837+
838+
self.logger.debug("Fetched durations for \(durations.count)/\(videoIds.count) songs")
839+
return durations
840+
}
841+
754842
/// Fetches all songs for an artist using the songs browse endpoint.
755843
func getArtistSongs(browseId: String, params: String?) async throws -> [Song] {
756844
self.logger.info("Fetching artist songs: \(browseId)")

Tests/KasetTests/ParsingHelpersTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,37 @@ struct ParsingHelpersTests {
298298
#expect(artists.first?.name == "Artist Name")
299299
#expect(artists.first?.id == "UC123")
300300
}
301+
302+
// MARK: - Duration from Flex Columns (Artist Page)
303+
304+
@Test("Extract duration from combined flex column runs (artist top songs)")
305+
func extractDurationFromCombinedFlexRuns() {
306+
// Artist page top songs have duration as the last run in a combined flex column:
307+
// "Artist • Album • 4:55"
308+
let data: [String: Any] = [
309+
"flexColumns": [
310+
[
311+
"musicResponsiveListItemFlexColumnRenderer": [
312+
"text": ["runs": [["text": "Billie Jean"]]],
313+
],
314+
],
315+
[
316+
"musicResponsiveListItemFlexColumnRenderer": [
317+
"text": [
318+
"runs": [
319+
["text": "Michael Jackson", "navigationEndpoint": ["browseEndpoint": ["browseId": "UC123"]]],
320+
["text": ""],
321+
["text": "Thriller", "navigationEndpoint": ["browseEndpoint": ["browseId": "MPRE456"]]],
322+
["text": ""],
323+
["text": "4:55"],
324+
],
325+
],
326+
],
327+
],
328+
],
329+
]
330+
331+
let duration = ParsingHelpers.extractDurationFromFlexColumns(data)
332+
#expect(duration == 295.0) // 4 * 60 + 55
333+
}
301334
}

0 commit comments

Comments
 (0)