Skip to content
Open
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
7 changes: 6 additions & 1 deletion Core/Services/API/MockUITestYTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,12 @@ final class MockUITestYTMusicClient: YTMusicClientProtocol {

func getLibraryContent() async throws -> PlaylistParser.LibraryContent {
try? await Task.sleep(for: .milliseconds(100))
return PlaylistParser.LibraryContent(playlists: self.playlists, podcastShows: [])
return PlaylistParser.LibraryContent(playlists: self.playlists, podcastShows: [], artists: [])
}

func getLibraryArtists() async throws -> [Artist] {
try? await Task.sleep(for: .milliseconds(100))
return []
}

func getLikedSongs() async throws -> LikedSongsResponse {
Expand Down
97 changes: 87 additions & 10 deletions Core/Services/API/Parsers/PlaylistParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,24 @@ enum PlaylistParser {
self.parseLibraryContent(data).playlists
}

/// Result type for library content parsing containing both playlists and podcast shows.
/// Result type for library content parsing containing playlists, podcast shows, and artists.
struct LibraryContent {
let playlists: [Playlist]
let podcastShows: [PodcastShow]
let artists: [Artist]
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The artist parsing logic added to parseLibraryContent and its helper functions is never used since the LibraryViewModel only uses artists from getLibraryArtists() (line 137 in LibraryViewModel.swift sets self.artists = fetchedArtists, ignoring content.artists). Consider either using content.artists or removing the artist parsing logic from parseLibraryContent(), parseLibraryItem(), and parseLibraryItemFromResponsive() to avoid unnecessary computation and maintain clarity about which data sources are actually used.

Copilot uses AI. Check for mistakes.
}

/// Parses library content from browse response, returning both playlists and podcast shows.
/// Parses library content from browse response, returning playlists, podcast shows, and artists.
static func parseLibraryContent(_ data: [String: Any]) -> LibraryContent {
var playlists: [Playlist] = []
var podcastShows: [PodcastShow] = []
var artists: [Artist] = []

// Navigate to contents
guard let contents = data["contents"] as? [String: Any],
let singleColumnBrowseResults = contents["singleColumnBrowseResultsRenderer"] as? [String: Any]
else {
return LibraryContent(playlists: [], podcastShows: [])
return LibraryContent(playlists: [], podcastShows: [], artists: [])
}

guard let tabs = singleColumnBrowseResults["tabs"] as? [[String: Any]],
Expand All @@ -46,7 +48,7 @@ enum PlaylistParser {
let sectionListRenderer = tabContent["sectionListRenderer"] as? [String: Any],
let sectionContents = sectionListRenderer["contents"] as? [[String: Any]]
else {
return LibraryContent(playlists: [], podcastShows: [])
return LibraryContent(playlists: [], podcastShows: [], artists: [])
}

for sectionData in sectionContents {
Expand All @@ -59,7 +61,8 @@ enum PlaylistParser {
Self.parseLibraryItem(
twoRowRenderer,
playlists: &playlists,
podcastShows: &podcastShows
podcastShows: &podcastShows,
artists: &artists
)
}
}
Expand All @@ -78,7 +81,8 @@ enum PlaylistParser {
Self.parseLibraryItemFromResponsive(
responsiveRenderer,
playlists: &playlists,
podcastShows: &podcastShows
podcastShows: &podcastShows,
artists: &artists
)
}
}
Expand All @@ -95,21 +99,83 @@ enum PlaylistParser {
Self.parseLibraryItemFromResponsive(
responsiveRenderer,
playlists: &playlists,
podcastShows: &podcastShows
podcastShows: &podcastShows,
artists: &artists
)
}
}
}
}

return LibraryContent(playlists: playlists, podcastShows: podcastShows)
return LibraryContent(playlists: playlists, podcastShows: podcastShows, artists: artists)
}

/// Parses subscribed artists from a library artists browse response.
/// Used by getLibraryArtists() which browses FEmusic_library_corpus_track_artists.
static func parseLibraryArtists(_ data: [String: Any]) -> [Artist] {
var artists: [Artist] = []

guard let contents = data["contents"] as? [String: Any],
let singleColumnBrowseResults = contents["singleColumnBrowseResultsRenderer"] as? [String: Any],
let tabs = singleColumnBrowseResults["tabs"] as? [[String: Any]],
let firstTab = tabs.first,
let tabRenderer = firstTab["tabRenderer"] as? [String: Any],
let tabContent = tabRenderer["content"] as? [String: Any],
let sectionListRenderer = tabContent["sectionListRenderer"] as? [String: Any],
let sectionContents = sectionListRenderer["contents"] as? [[String: Any]]
else {
return []
}

for sectionData in sectionContents {
// Try gridRenderer (common for artist chips view)
if let gridRenderer = sectionData["gridRenderer"] as? [String: Any],
let items = gridRenderer["items"] as? [[String: Any]]
{
for itemData in items {
if let twoRowRenderer = itemData["musicTwoRowItemRenderer"] as? [String: Any],
let navigationEndpoint = twoRowRenderer["navigationEndpoint"] as? [String: Any],
let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any],
let browseId = browseEndpoint["browseId"] as? String,
browseId.hasPrefix("UC")
{
let title = ParsingHelpers.extractTitle(from: twoRowRenderer) ?? "Unknown"
let thumbnails = ParsingHelpers.extractThumbnails(from: twoRowRenderer)
let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
artists.append(Artist(id: browseId, name: title, thumbnailURL: thumbnailURL))
}
}
}

// Try musicShelfRenderer (responsive list format)
if let shelfRenderer = sectionData["musicShelfRenderer"] as? [String: Any],
let shelfContents = shelfRenderer["contents"] as? [[String: Any]]
{
for shelfItem in shelfContents {
if let responsiveRenderer = shelfItem["musicResponsiveListItemRenderer"] as? [String: Any],
let navigationEndpoint = responsiveRenderer["navigationEndpoint"] as? [String: Any],
let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any],
let browseId = browseEndpoint["browseId"] as? String,
browseId.hasPrefix("UC")
{
let title = ParsingHelpers.extractTitleFromFlexColumns(responsiveRenderer) ?? "Unknown"
let thumbnails = ParsingHelpers.extractThumbnails(from: responsiveRenderer)
let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
artists.append(Artist(id: browseId, name: title, thumbnailURL: thumbnailURL))
}
}
}
}

return artists
}

/// Parses a library item from twoRowRenderer, adding to the appropriate array.
private static func parseLibraryItem(
_ data: [String: Any],
playlists: inout [Playlist],
podcastShows: inout [PodcastShow]
podcastShows: inout [PodcastShow],
artists: inout [Artist]
) {
guard let navigationEndpoint = data["navigationEndpoint"] as? [String: Any],
let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any],
Expand Down Expand Up @@ -138,6 +204,11 @@ enum PlaylistParser {
)
podcastShows.append(show)
Self.logger.info("parseLibraryItem: Added podcast show: \(title)")
} else if browseId.hasPrefix("UC") {
// Artist (UC prefix = YouTube channel ID)
let artist = Artist(id: browseId, name: title, thumbnailURL: thumbnailURL)
artists.append(artist)
Self.logger.info("parseLibraryItem: Added artist: \(title)")
} else if browseId.hasPrefix("VL") || browseId.hasPrefix("PL") || browseId.hasPrefix("RDCLAK") {
// Playlist (VL prefix for saved playlists, PL for playlist IDs, RDCLAK for radio playlists)
let playlist = Playlist(
Expand All @@ -157,7 +228,8 @@ enum PlaylistParser {
private static func parseLibraryItemFromResponsive(
_ data: [String: Any],
playlists: inout [Playlist],
podcastShows: inout [PodcastShow]
podcastShows: inout [PodcastShow],
artists: inout [Artist]
) {
guard let navigationEndpoint = data["navigationEndpoint"] as? [String: Any],
let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any],
Expand Down Expand Up @@ -186,6 +258,11 @@ enum PlaylistParser {
)
podcastShows.append(show)
Self.logger.info("parseLibraryItemFromResponsive: Added podcast show: \(title)")
} else if browseId.hasPrefix("UC") {
// Artist (UC prefix = YouTube channel ID)
let artist = Artist(id: browseId, name: title, thumbnailURL: thumbnailURL)
artists.append(artist)
Self.logger.info("parseLibraryItemFromResponsive: Added artist: \(title)")
} else if browseId.hasPrefix("VL") || browseId.hasPrefix("PL") {
// Playlist
let playlist = Playlist(
Expand Down
16 changes: 16 additions & 0 deletions Core/Services/API/YTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,22 @@ final class YTMusicClient: YTMusicClientProtocol {
return PlaylistParser.parseLibraryContent(data)
}

/// Fetches the user's subscribed artists from the library.
/// Uses FEmusic_library_corpus_track_artists which is the artists filter chip
/// from FEmusic_library_landing and does not require additional params.
func getLibraryArtists() async throws -> [Artist] {
self.logger.info("Fetching library artists")

let body: [String: Any] = [
"browseId": "FEmusic_library_corpus_track_artists",
]

let data = try await request("browse", body: body, ttl: APICache.TTL.library)
let artists = PlaylistParser.parseLibraryArtists(data)
self.logger.info("Parsed \(artists.count) library artists")
return artists
}

// MARK: - Liked Songs with Pagination

/// Continuation token for liked songs pagination.
Expand Down
3 changes: 3 additions & 0 deletions Core/Services/Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ protocol YTMusicClientProtocol: Sendable {
/// Fetches the user's library content including playlists and podcast shows.
func getLibraryContent() async throws -> PlaylistParser.LibraryContent

/// Fetches the user's subscribed artists from the library.
func getLibraryArtists() async throws -> [Artist]

/// Fetches the user's liked songs with pagination support.
func getLikedSongs() async throws -> LikedSongsResponse

Expand Down
39 changes: 36 additions & 3 deletions Core/ViewModels/LibraryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ final class LibraryViewModel {
/// User's subscribed podcast shows.
private(set) var podcastShows: [PodcastShow] = []

/// User's subscribed artists.
private(set) var artists: [Artist] = []

/// Set of playlist IDs that are in the user's library (for quick lookup).
private(set) var libraryPlaylistIds: Set<String> = []

/// Set of podcast show IDs that are in the user's library (for quick lookup).
private(set) var libraryPodcastIds: Set<String> = []

/// Set of artist IDs that are in the user's library (for quick lookup).
private(set) var libraryArtistIds: Set<String> = []

/// Selected playlist detail.
private(set) var selectedPlaylistDetail: PlaylistDetail?

Expand Down Expand Up @@ -50,6 +56,11 @@ final class LibraryViewModel {
self.libraryPodcastIds.contains(podcastId)
}

/// Checks if an artist is in the user's library.
func isInLibrary(artistId: String) -> Bool {
self.libraryArtistIds.contains(artistId)
}

/// Adds a playlist ID to the library set (called after successful add to library).
func addToLibrarySet(playlistId: String) {
self.libraryPlaylistIds.insert(playlistId)
Expand Down Expand Up @@ -93,22 +104,43 @@ final class LibraryViewModel {
self.libraryPodcastIds.remove(podcastId)
}

/// Loads library content (playlists and podcasts).
/// Adds an artist to the library (called after successful subscription).
/// Updates both the ID set and the artists array for immediate UI update.
func addToLibrary(artist: Artist) {
self.libraryArtistIds.insert(artist.id)
if !self.artists.contains(where: { $0.id == artist.id }) {
self.artists.insert(artist, at: 0)
}
}

/// Removes an artist from the library (called after successful unsubscribe).
/// Updates both the ID set and the artists array for immediate UI update.
func removeFromLibrary(artistId: String) {
self.libraryArtistIds.remove(artistId)
self.artists.removeAll { $0.id == artistId }
}

/// Loads library content (playlists, podcasts, and artists).
func load() async {
guard self.loadingState != .loading else { return }

self.loadingState = .loading
self.logger.info("Loading library content")

do {
let content = try await client.getLibraryContent()
async let contentTask = client.getLibraryContent()
async let artistsTask = client.getLibraryArtists()

let (content, fetchedArtists) = try await (contentTask, artistsTask)
self.playlists = content.playlists
self.podcastShows = content.podcastShows
self.artists = fetchedArtists
// Update the sets for quick lookup
self.libraryPlaylistIds = Set(content.playlists.map(\.id))
self.libraryPodcastIds = Set(content.podcastShows.map(\.id))
self.libraryArtistIds = Set(fetchedArtists.map(\.id))
self.loadingState = .loaded
self.logger.info("Loaded \(content.playlists.count) playlists and \(content.podcastShows.count) podcasts")
self.logger.info("Loaded \(content.playlists.count) playlists, \(content.podcastShows.count) podcasts, and \(fetchedArtists.count) artists")
} catch is CancellationError {
// Task was cancelled (e.g., user navigated away) — reset to idle so it can retry
self.logger.debug("Library load cancelled")
Expand Down Expand Up @@ -152,6 +184,7 @@ final class LibraryViewModel {
func refresh() async {
self.playlists = []
self.podcastShows = []
self.artists = []
await self.load()
}
}
10 changes: 9 additions & 1 deletion Tests/KasetTests/Helpers/MockYTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ final class MockYTMusicClient: YTMusicClientProtocol {
var searchContinuationResponses: [SearchResponse] = []
var searchSuggestions: [SearchSuggestion] = []
var libraryPlaylists: [Playlist] = []
var libraryArtists: [Artist] = []
var likedSongs: [Song] = []
var likedSongsContinuationSongs: [[Song]] = []
var playlistDetails: [String: PlaylistDetail] = [:]
Expand Down Expand Up @@ -107,6 +108,7 @@ final class MockYTMusicClient: YTMusicClientProtocol {
private(set) var getSearchSuggestionsCalled = false
private(set) var getSearchSuggestionsQueries: [String] = []
private(set) var getLibraryPlaylistsCalled = false
private(set) var getLibraryArtistsCalled = false
private(set) var getLikedSongsCalled = false
private(set) var getLikedSongsContinuationCalled = false
private(set) var getLikedSongsContinuationCallCount = 0
Expand Down Expand Up @@ -426,7 +428,13 @@ final class MockYTMusicClient: YTMusicClientProtocol {
func getLibraryContent() async throws -> PlaylistParser.LibraryContent {
self.getLibraryPlaylistsCalled = true
if let error = shouldThrowError { throw error }
return PlaylistParser.LibraryContent(playlists: self.libraryPlaylists, podcastShows: [])
return PlaylistParser.LibraryContent(playlists: self.libraryPlaylists, podcastShows: [], artists: [])
}

func getLibraryArtists() async throws -> [Artist] {
self.getLibraryArtistsCalled = true
if let error = shouldThrowError { throw error }
return self.libraryArtists
}

func getLikedSongs() async throws -> LikedSongsResponse {
Expand Down
Loading
Loading