Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions Core/Services/API/Parsers/ParsingHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,19 +227,22 @@ enum ParsingHelpers {
}
}

// Try flexColumns (artist page top songs often have duration in the last column)
// Try flexColumns (artist page top songs often have duration in a combined column)
if let flexColumns = data["flexColumns"] as? [[String: Any]] {
// Check last flex column for duration (common pattern on artist pages)
for column in flexColumns.reversed() {
if let renderer = column["musicResponsiveListItemFlexColumnRenderer"] as? [String: Any],
let text = renderer["text"] as? [String: Any],
let runs = text["runs"] as? [[String: Any]],
let firstRun = runs.first,
let durationText = firstRun["text"] as? String
let runs = text["runs"] as? [[String: Any]]
{
// Check if this looks like a duration (e.g., "3:45" or "1:23:45")
if let duration = parseDuration(durationText) {
return duration
// Check all runs (duration is often the last run in "Artist • Album • 3:45")
// Skip runs with navigationEndpoint to avoid matching album titles like "4:44"
for run in runs.reversed() {
if let durationText = run["text"] as? String,
run["navigationEndpoint"] == nil,
let duration = self.parseDuration(durationText)
{
return duration
}
}
}
}
Expand Down
90 changes: 89 additions & 1 deletion Core/Services/API/YTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchSongDurations(videoIds:) caches music/get_queue responses using APICache.TTL.artist, but elsewhere in this file music/get_queue is explicitly called with ttl: nil (“No caching for queue endpoint”). Caching per unique videoIds batch can also thrash the small (50 entry) LRU cache and return stale duration data. Consider switching this call to ttl: nil (or introducing a dedicated short TTL specifically for queue duration lookups if caching is desired).

Suggested change
let data = try await request("music/get_queue", body: body, ttl: APICache.TTL.artist)
let data = try await request("music/get_queue", body: body, ttl: nil)

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The queue renderer extraction logic here duplicates PlaylistParser.extractQueueRenderer(from:) (which already handles direct vs playlistPanelVideoWrapperRenderer). Duplicating this parsing increases drift risk if the queue response shape changes; consider refactoring to share a single helper (e.g., move the extractor to a common utility or make it non-private and reuse it here).

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
if let renderer,
let videoId = renderer["videoId"] as? String,
let lengthText = renderer["lengthText"] as? [String: Any],
let runs = lengthText["runs"] as? [[String: Any]],
let durationText = runs.first?["text"] as? String,
let duration = ParsingHelpers.parseDuration(durationText)
{
durations[videoId] = duration
}
}
}

self.logger.debug("Fetched durations for \(durations.count)/\(videoIds.count) songs")
return durations
}

/// Fetches all songs for an artist using the songs browse endpoint.
func getArtistSongs(browseId: String, params: String?) async throws -> [Song] {
self.logger.info("Fetching artist songs: \(browseId)")
Expand Down
33 changes: 33 additions & 0 deletions Tests/KasetTests/ParsingHelpersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,4 +298,37 @@ struct ParsingHelpersTests {
#expect(artists.first?.name == "Artist Name")
#expect(artists.first?.id == "UC123")
}

// MARK: - Duration from Flex Columns (Artist Page)

@Test("Extract duration from combined flex column runs (artist top songs)")
func extractDurationFromCombinedFlexRuns() {
// Artist page top songs have duration as the last run in a combined flex column:
// "Artist • Album • 4:55"
let data: [String: Any] = [
"flexColumns": [
[
"musicResponsiveListItemFlexColumnRenderer": [
"text": ["runs": [["text": "Billie Jean"]]],
],
],
[
"musicResponsiveListItemFlexColumnRenderer": [
"text": [
"runs": [
["text": "Michael Jackson", "navigationEndpoint": ["browseEndpoint": ["browseId": "UC123"]]],
["text": " • "],
["text": "Thriller", "navigationEndpoint": ["browseEndpoint": ["browseId": "MPRE456"]]],
["text": " • "],
["text": "4:55"],
],
],
],
],
],
]

let duration = ParsingHelpers.extractDurationFromFlexColumns(data)
#expect(duration == 295.0) // 4 * 60 + 55
}
}
Loading