Conversation
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR implements comprehensive podcast support for the Kaset YouTube Music client, adding podcast discovery, playback, progress tracking, and library management features.
Key Changes:
- Added podcast discovery page with carousels showing podcast shows and episodes
- Implemented podcast show detail pages with episode lists and subscription management
- Integrated podcasts into search results, library view, and favorites system
- Enhanced PlayerBar time formatting to support hour-long podcast episodes
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/api-discovery.md | Documents new podcast API endpoints (FEmusic_podcasts, MPSPP{id}), search filters, and subscription APIs |
| README.md | Updates feature list to include podcast support |
| Core/Models/Podcast.swift | New data models for PodcastShow, PodcastEpisode, PodcastSection, and related types |
| Core/Models/SearchResponse.swift | Adds podcastShows field and SearchResultItem.podcastShow case |
| Core/Models/FavoriteItem.swift | Adds podcast show support to favorites system |
| Core/Services/API/YTMusicClient.swift | Implements getPodcasts, getPodcastShow, searchPodcasts, and subscription methods |
| Core/Services/API/Parsers/PodcastParser.swift | New parser for podcast discovery and show detail responses |
| Core/Services/API/Parsers/PlaylistParser.swift | Extends library parsing to handle both playlists and podcast shows |
| Core/Services/API/Parsers/SearchResponseParser.swift | Adds podcast show parsing for filtered search results |
| Core/ViewModels/PodcastsViewModel.swift | New view model managing podcast discovery state and background loading |
| Core/ViewModels/LibraryViewModel.swift | Extends to manage podcast shows alongside playlists |
| Core/ViewModels/SearchViewModel.swift | Adds podcast filter and result handling |
| Views/macOS/PodcastsView.swift | New podcast discovery and show detail views with progress tracking |
| Views/macOS/LibraryView.swift | Adds filter chips and unified grid for playlists and podcasts |
| Views/macOS/Sidebar.swift | Adds Podcasts navigation item and renames Library section to Collection |
| Views/macOS/PlayerBar.swift | Updates time formatting to support hour-long durations (H:MM:SS format) |
| Views/macOS/SearchView.swift | Adds podcast show result handling and navigation |
| Core/Services/ShareService.swift | Implements Shareable protocol for PodcastShow |
| Core/Services/FavoritesManager.swift | Adds podcast show favorites support |
| Tests/KasetTests/Helpers/MockYTMusicClient.swift | Updates mock client with podcast methods |
| Tests/KasetTests/FavoritesManagerTests.swift | Fixes nil handling for asHomeSectionItem |
| TEMP_TEST_COVERAGE_PLAN.md | Documents test coverage assessment (temporary planning file) |
| TEMP_PODCAST_PLAN.md | Documents podcast implementation plan (temporary planning file) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| /// Subscribes to a podcast show (adds to library). | ||
| /// This uses the same like/like endpoint as playlists since podcast shows are playlist-like. |
There was a problem hiding this comment.
The comment incorrectly refers to the API as "like/like" when describing podcast subscription. According to the actual implementation shown in the code below, the endpoint is "like/like" for subscription and "like/removelike" for unsubscription. However, the comment should clarify that this is a workaround that treats podcast shows as playlist-like entities for the subscription API, not that the endpoint itself is called "like/like".
| /// This uses the same like/like endpoint as playlists since podcast shows are playlist-like. | |
| /// This uses the same playlist-style "like" subscription API as playlists by treating podcast shows as playlist-like. |
| // Extract the playlist ID portion from MPSPP prefix | ||
| // MPSPP prefix = podcast show, the underlying playlist ID is after "MPSPP" with "PL" prefix | ||
| let playlistId: String = if showId.hasPrefix("MPSPP") { | ||
| // Convert MPSPP{id} to PL{id} | ||
| "PL" + String(showId.dropFirst(5)) | ||
| } else { | ||
| showId |
There was a problem hiding this comment.
The logic for extracting the playlist ID from the MPSPP prefix appears incorrect. The code converts "MPSPP" (5 characters) + id to "PL" + id by dropping the first 5 characters and prepending "PL". However, this assumes the underlying ID format is consistent. This transformation should be documented more clearly or validated, as it's performing string manipulation that could break if YouTube Music changes their ID format. Consider adding a guard to validate the conversion produces a reasonable result.
| // Extract the playlist ID portion from MPSPP prefix | |
| // MPSPP prefix = podcast show, the underlying playlist ID is after "MPSPP" with "PL" prefix | |
| let playlistId: String = if showId.hasPrefix("MPSPP") { | |
| // Convert MPSPP{id} to PL{id} | |
| "PL" + String(showId.dropFirst(5)) | |
| } else { | |
| showId | |
| // Extract the playlist ID portion from MPSPP prefix. | |
| // Assumption: Podcast show IDs use the form "MPSPP" + {idSuffix}, where the corresponding | |
| // playlist ID is "PL" + {idSuffix}. We defensively validate this mapping so that if the | |
| // upstream ID format changes, we don't generate clearly malformed playlist IDs. | |
| let playlistId: String | |
| if showId.hasPrefix("MPSPP") { | |
| let suffix = showId.dropFirst("MPSPP".count) | |
| if suffix.isEmpty { | |
| self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)") | |
| playlistId = showId | |
| } else { | |
| // Convert MPSPP{id} to PL{id} | |
| playlistId = "PL" + suffix | |
| } | |
| } else { | |
| playlistId = showId |
| import Foundation | ||
|
|
||
| /// Parser for podcast responses from YouTube Music API. | ||
| enum PodcastParser { | ||
| private static let logger = DiagnosticsLogger.api | ||
|
|
||
| // MARK: - Discovery Page Parsing | ||
|
|
||
| /// Parses the podcasts discovery page (FEmusic_podcasts) response. | ||
| /// This page uses the same structure as home/explore but with podcast-specific items. | ||
| static func parseDiscovery(_ data: [String: Any]) -> [PodcastSection] { | ||
| var sections: [PodcastSection] = [] | ||
|
|
||
| // Navigate to contents | ||
| 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 { | ||
| Self.logger.debug("PodcastParser: No standard structure found. Top keys: \(data.keys.sorted())") | ||
| return [] | ||
| } | ||
|
|
||
| for sectionData in sectionContents { | ||
| if let section = Self.parsePodcastSection(sectionData) { | ||
| sections.append(section) | ||
| } | ||
| } | ||
|
|
||
| return sections | ||
| } | ||
|
|
||
| /// Parses a continuation response for podcasts page. | ||
| static func parseContinuation(_ data: [String: Any]) -> [PodcastSection] { | ||
| var sections: [PodcastSection] = [] | ||
|
|
||
| // Continuation responses use continuationContents | ||
| if let continuationContents = data["continuationContents"] as? [String: Any], | ||
| let sectionListContinuation = continuationContents["sectionListContinuation"] as? [String: Any], | ||
| let contents = sectionListContinuation["contents"] as? [[String: Any]] | ||
| { | ||
| for sectionData in contents { | ||
| if let section = Self.parsePodcastSection(sectionData) { | ||
| sections.append(section) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Also try musicCarouselShelfContinuation for carousel-style continuations | ||
| if let continuationContents = data["continuationContents"] as? [String: Any], | ||
| let carouselContinuation = continuationContents["musicCarouselShelfContinuation"] as? [String: Any], | ||
| let contents = carouselContinuation["contents"] as? [[String: Any]] | ||
| { | ||
| var items: [PodcastSectionItem] = [] | ||
| for itemData in contents { | ||
| if let item = Self.parsePodcastItem(itemData) { | ||
| items.append(item) | ||
| } | ||
| } | ||
| if !items.isEmpty { | ||
| sections.append(PodcastSection(id: UUID().uuidString, title: "More", items: items)) | ||
| } | ||
| } | ||
|
|
||
| return sections | ||
| } | ||
|
|
||
| // MARK: - Section Parsing | ||
|
|
||
| private static func parsePodcastSection(_ data: [String: Any]) -> PodcastSection? { | ||
| // Try musicCarouselShelfRenderer (horizontal carousels) | ||
| if let carouselRenderer = data["musicCarouselShelfRenderer"] as? [String: Any] { | ||
| return self.parseMusicCarouselShelf(carouselRenderer) | ||
| } | ||
|
|
||
| // Try musicShelfRenderer (vertical lists) | ||
| if let shelfRenderer = data["musicShelfRenderer"] as? [String: Any] { | ||
| return Self.parseMusicShelf(shelfRenderer) | ||
| } | ||
|
|
||
| // Try itemSectionRenderer (wrapper for other renderers) | ||
| if let itemSectionRenderer = data["itemSectionRenderer"] as? [String: Any], | ||
| let itemContents = itemSectionRenderer["contents"] as? [[String: Any]] | ||
| { | ||
| for itemContent in itemContents { | ||
| if let section = Self.parsePodcastSection(itemContent) { | ||
| return section | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| private static func parseMusicCarouselShelf(_ data: [String: Any]) -> PodcastSection? { | ||
| let title = Self.extractCarouselTitle(from: data) ?? "Podcasts" | ||
|
|
||
| guard let contents = data["contents"] as? [[String: Any]] else { | ||
| return nil | ||
| } | ||
|
|
||
| var items: [PodcastSectionItem] = [] | ||
| for itemData in contents { | ||
| if let item = Self.parsePodcastItem(itemData) { | ||
| items.append(item) | ||
| } | ||
| } | ||
|
|
||
| guard !items.isEmpty else { return nil } | ||
|
|
||
| return PodcastSection( | ||
| id: UUID().uuidString, | ||
| title: title, | ||
| items: items | ||
| ) | ||
| } | ||
|
|
||
| private static func parseMusicShelf(_ data: [String: Any]) -> PodcastSection? { | ||
| let title = ParsingHelpers.extractTitle(from: data) ?? "Podcasts" | ||
|
|
||
| guard let contents = data["contents"] as? [[String: Any]] else { | ||
| return nil | ||
| } | ||
|
|
||
| var items: [PodcastSectionItem] = [] | ||
| for itemData in contents { | ||
| if let item = Self.parsePodcastItem(itemData) { | ||
| items.append(item) | ||
| } | ||
| } | ||
|
|
||
| guard !items.isEmpty else { return nil } | ||
|
|
||
| return PodcastSection( | ||
| id: UUID().uuidString, | ||
| title: title, | ||
| items: items | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - Item Parsing | ||
|
|
||
| private static func parsePodcastItem(_ data: [String: Any]) -> PodcastSectionItem? { | ||
| // Try musicTwoRowItemRenderer (podcast shows as cards) | ||
| if let twoRowRenderer = data["musicTwoRowItemRenderer"] as? [String: Any] { | ||
| return self.parseTwoRowItem(twoRowRenderer) | ||
| } | ||
|
|
||
| // Try musicMultiRowListItemRenderer (podcast episodes with progress) | ||
| if let multiRowRenderer = data["musicMultiRowListItemRenderer"] as? [String: Any] { | ||
| return Self.parseMultiRowListItem(multiRowRenderer) | ||
| } | ||
|
|
||
| // Try musicResponsiveListItemRenderer (fallback for some episodes) | ||
| if let responsiveRenderer = data["musicResponsiveListItemRenderer"] as? [String: Any] { | ||
| return Self.parseResponsiveListItem(responsiveRenderer) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| /// Parses a two-row item (typically a podcast show thumbnail card). | ||
| private static func parseTwoRowItem(_ data: [String: Any]) -> PodcastSectionItem? { | ||
| let thumbnails = ParsingHelpers.extractThumbnails(from: data) | ||
| let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } | ||
|
|
||
| guard let title = ParsingHelpers.extractTitle(from: data) else { | ||
| return nil | ||
| } | ||
|
|
||
| guard let navigationEndpoint = data["navigationEndpoint"] as? [String: Any], | ||
| let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], | ||
| let browseId = browseEndpoint["browseId"] as? String | ||
| else { | ||
| return nil | ||
| } | ||
|
|
||
| // Check if this is a podcast show (MPSPP prefix) | ||
| if browseId.hasPrefix("MPSPP") { | ||
| let author = ParsingHelpers.extractSubtitle(from: data) | ||
| let show = PodcastShow( | ||
| id: browseId, | ||
| title: title, | ||
| author: author, | ||
| description: nil, | ||
| thumbnailURL: thumbnailURL, | ||
| episodeCount: nil | ||
| ) | ||
| return .show(show) | ||
| } | ||
|
|
||
| // Otherwise it might be an episode with watchEndpoint | ||
| if let watchEndpoint = navigationEndpoint["watchEndpoint"] as? [String: Any], | ||
| let videoId = watchEndpoint["videoId"] as? String | ||
| { | ||
| let episode = PodcastEpisode( | ||
| id: videoId, | ||
| title: title, | ||
| showTitle: ParsingHelpers.extractSubtitle(from: data), | ||
| showBrowseId: nil, | ||
| description: nil, | ||
| thumbnailURL: thumbnailURL, | ||
| publishedDate: nil, | ||
| duration: nil, | ||
| durationSeconds: nil, | ||
| playbackProgress: 0, | ||
| isPlayed: false | ||
| ) | ||
| return .episode(episode) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| /// Parses a multi-row list item (podcast episodes with playback progress). | ||
| private static func parseMultiRowListItem(_ data: [String: Any]) -> PodcastSectionItem? { | ||
| // Extract video ID from navigation | ||
| guard let onTap = data["onTap"] as? [String: Any], | ||
| let watchEndpoint = onTap["watchEndpoint"] as? [String: Any], | ||
| let videoId = watchEndpoint["videoId"] as? String | ||
| else { | ||
| return nil | ||
| } | ||
|
|
||
| // Extract title from title field | ||
| let title = Self.extractMultiRowTitle(from: data) ?? "Unknown Episode" | ||
|
|
||
| // Extract thumbnail | ||
| let thumbnails = ParsingHelpers.extractThumbnails(from: data) | ||
| let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } | ||
|
|
||
| // Extract subtitle (show name) | ||
| let showTitle = Self.extractMultiRowSubtitle(from: data) | ||
|
|
||
| // Extract show browse ID for navigation | ||
| let showBrowseId = Self.extractShowBrowseId(from: data) | ||
|
|
||
| // Extract playback progress (0-100 or 0.0-1.0) | ||
| var playbackProgress: Double = 0 | ||
| var isPlayed = false | ||
|
|
||
| if let playbackProgressPercent = data["playbackProgress"] as? [String: Any], | ||
| let percentage = playbackProgressPercent["playbackProgressPercentage"] as? Int | ||
| { | ||
| playbackProgress = Double(percentage) / 100.0 | ||
| isPlayed = percentage >= 95 | ||
| } | ||
|
|
||
| // Check for "Played" text in played text field | ||
| if let playedTextRuns = data["playedText"] as? [String: Any], | ||
| let runs = playedTextRuns["runs"] as? [[String: Any]], | ||
| let text = runs.first?["text"] as? String, | ||
| text.lowercased() == "played" | ||
| { | ||
| isPlayed = true | ||
| playbackProgress = 1.0 | ||
| } | ||
|
|
||
| // Extract duration | ||
| var duration: String? | ||
| var durationSeconds: Int? | ||
|
|
||
| if let durationText = data["durationText"] as? [String: Any], | ||
| let runs = durationText["runs"] as? [[String: Any]], | ||
| let durationStr = runs.first?["text"] as? String | ||
| { | ||
| duration = durationStr | ||
| durationSeconds = Self.parseDurationToSeconds(durationStr) | ||
| } | ||
|
|
||
| // Extract published date | ||
| var publishedDate: String? | ||
| if let publishedTimeText = data["publishedTimeText"] as? [String: Any], | ||
| let runs = publishedTimeText["runs"] as? [[String: Any]], | ||
| let dateStr = runs.first?["text"] as? String | ||
| { | ||
| publishedDate = dateStr | ||
| } | ||
|
|
||
| // Extract description | ||
| var description: String? | ||
| if let descriptionData = data["description"] as? [String: Any], | ||
| let runs = descriptionData["runs"] as? [[String: Any]] | ||
| { | ||
| description = runs.compactMap { $0["text"] as? String }.joined() | ||
| } | ||
|
|
||
| let episode = PodcastEpisode( | ||
| id: videoId, | ||
| title: title, | ||
| showTitle: showTitle, | ||
| showBrowseId: showBrowseId, | ||
| description: description, | ||
| thumbnailURL: thumbnailURL, | ||
| publishedDate: publishedDate, | ||
| duration: duration, | ||
| durationSeconds: durationSeconds, | ||
| playbackProgress: playbackProgress, | ||
| isPlayed: isPlayed | ||
| ) | ||
|
|
||
| return .episode(episode) | ||
| } | ||
|
|
||
| /// Parses a responsive list item (fallback for some episodes). | ||
| private static func parseResponsiveListItem(_ data: [String: Any]) -> PodcastSectionItem? { | ||
| guard let videoId = ParsingHelpers.extractVideoId(from: data) else { | ||
| // Might be a podcast show | ||
| if let browseId = ParsingHelpers.extractBrowseId(from: data), | ||
| browseId.hasPrefix("MPSPP") | ||
| { | ||
| let title = ParsingHelpers.extractTitleFromFlexColumns(data) ?? "Unknown Show" | ||
| let thumbnails = ParsingHelpers.extractThumbnails(from: data) | ||
| let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } | ||
| let author = ParsingHelpers.extractSubtitleFromFlexColumns(data) | ||
|
|
||
| let show = PodcastShow( | ||
| id: browseId, | ||
| title: title, | ||
| author: author, | ||
| description: nil, | ||
| thumbnailURL: thumbnailURL, | ||
| episodeCount: nil | ||
| ) | ||
| return .show(show) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| let title = ParsingHelpers.extractTitleFromFlexColumns(data) ?? "Unknown Episode" | ||
| let thumbnails = ParsingHelpers.extractThumbnails(from: data) | ||
| let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } | ||
| let showTitle = ParsingHelpers.extractSubtitleFromFlexColumns(data) | ||
|
|
||
| let episode = PodcastEpisode( | ||
| id: videoId, | ||
| title: title, | ||
| showTitle: showTitle, | ||
| showBrowseId: nil, | ||
| description: nil, | ||
| thumbnailURL: thumbnailURL, | ||
| publishedDate: nil, | ||
| duration: nil, | ||
| durationSeconds: nil, | ||
| playbackProgress: 0, | ||
| isPlayed: false | ||
| ) | ||
|
|
||
| return .episode(episode) | ||
| } | ||
|
|
||
| // MARK: - Podcast Show Detail Parsing | ||
|
|
||
| /// Parses a podcast show detail page (MPSPP{id}). | ||
| static func parseShowDetail( // swiftlint:disable:this function_body_length cyclomatic_complexity | ||
| _ data: [String: Any], | ||
| showId: String | ||
| ) -> PodcastShowDetail { | ||
| var showTitle = "" | ||
| var author: String? | ||
| var description: String? | ||
| var thumbnailURL: URL? | ||
| var episodes: [PodcastEpisode] = [] | ||
| var continuationToken: String? | ||
| var isSubscribed = false | ||
|
|
||
| // Parse header from old format (musicDetailHeaderRenderer) | ||
| if let header = data["header"] as? [String: Any], | ||
| let musicDetailHeaderRenderer = header["musicDetailHeaderRenderer"] as? [String: Any] | ||
| { | ||
| showTitle = ParsingHelpers.extractTitle(from: musicDetailHeaderRenderer) ?? "" | ||
| author = ParsingHelpers.extractSubtitle(from: musicDetailHeaderRenderer) | ||
| description = Self.extractDescription(from: musicDetailHeaderRenderer) | ||
|
|
||
| let thumbnails = ParsingHelpers.extractThumbnails(from: musicDetailHeaderRenderer) | ||
| thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } | ||
| } | ||
|
|
||
| // Parse from twoColumnBrowseResultsRenderer (current format) | ||
| if let contents = data["contents"] as? [String: Any], | ||
| let twoColumnResults = contents["twoColumnBrowseResultsRenderer"] as? [String: Any] | ||
| { | ||
| // Parse header from tabs → sectionListRenderer → musicResponsiveHeaderRenderer | ||
| if let tabs = twoColumnResults["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]] | ||
| { | ||
| for sectionData in sectionContents { | ||
| if let headerRenderer = sectionData["musicResponsiveHeaderRenderer"] as? [String: Any] { | ||
| showTitle = ParsingHelpers.extractTitle(from: headerRenderer) ?? showTitle | ||
| author = ParsingHelpers.extractSubtitle(from: headerRenderer) ?? author | ||
| description = Self.extractDescription(from: headerRenderer) ?? description | ||
|
|
||
| let thumbnails = ParsingHelpers.extractThumbnails(from: headerRenderer) | ||
| if let thumb = thumbnails.last { | ||
| thumbnailURL = URL(string: thumb) | ||
| } | ||
|
|
||
| // Extract subscription status from buttons | ||
| if let buttons = headerRenderer["buttons"] as? [[String: Any]] { | ||
| for button in buttons { | ||
| if let toggleButton = button["toggleButtonRenderer"] as? [String: Any], | ||
| let toggled = toggleButton["isToggled"] as? Bool | ||
| { | ||
| isSubscribed = toggled | ||
| break | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Parse episodes from secondaryContents | ||
| if let secondaryContents = twoColumnResults["secondaryContents"] as? [String: Any], | ||
| let sectionListRenderer = secondaryContents["sectionListRenderer"] as? [String: Any], | ||
| let sectionContents = sectionListRenderer["contents"] as? [[String: Any]] | ||
| { | ||
| for sectionData in sectionContents { | ||
| if let shelfRenderer = sectionData["musicShelfRenderer"] as? [String: Any], | ||
| let shelfContents = shelfRenderer["contents"] as? [[String: Any]] | ||
| { | ||
| for itemData in shelfContents { | ||
| if let item = Self.parsePodcastItem(itemData), | ||
| case let .episode(episode) = item | ||
| { | ||
| episodes.append(episode) | ||
| } | ||
| } | ||
|
|
||
| // Extract continuation token | ||
| if let continuations = shelfRenderer["continuations"] as? [[String: Any]], | ||
| let firstContinuation = continuations.first, | ||
| let nextContinuationData = firstContinuation["nextContinuationData"] as? [String: Any], | ||
| let token = nextContinuationData["continuation"] as? String | ||
| { | ||
| continuationToken = token | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Fallback: Parse episodes from singleColumnBrowseResultsRenderer (old format) | ||
| if episodes.isEmpty, | ||
| 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]] | ||
| { | ||
| for sectionData in sectionContents { | ||
| if let shelfRenderer = sectionData["musicShelfRenderer"] as? [String: Any], | ||
| let shelfContents = shelfRenderer["contents"] as? [[String: Any]] | ||
| { | ||
| for itemData in shelfContents { | ||
| if let item = Self.parsePodcastItem(itemData), | ||
| case let .episode(episode) = item | ||
| { | ||
| episodes.append(episode) | ||
| } | ||
| } | ||
|
|
||
| if continuationToken == nil, | ||
| let continuations = shelfRenderer["continuations"] as? [[String: Any]], | ||
| let firstContinuation = continuations.first, | ||
| let nextContinuationData = firstContinuation["nextContinuationData"] as? [String: Any], | ||
| let token = nextContinuationData["continuation"] as? String | ||
| { | ||
| continuationToken = token | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let show = PodcastShow( | ||
| id: showId, | ||
| title: showTitle, | ||
| author: author, | ||
| description: description, | ||
| thumbnailURL: thumbnailURL, | ||
| episodeCount: episodes.count | ||
| ) | ||
|
|
||
| return PodcastShowDetail( | ||
| show: show, | ||
| episodes: episodes, | ||
| continuationToken: continuationToken, | ||
| isSubscribed: isSubscribed | ||
| ) | ||
| } | ||
|
|
||
| /// Parses a continuation response for more episodes. | ||
| static func parseEpisodesContinuation(_ data: [String: Any]) -> PodcastEpisodesContinuation { | ||
| var episodes: [PodcastEpisode] = [] | ||
| var continuationToken: String? | ||
|
|
||
| if let continuationContents = data["continuationContents"] as? [String: Any], | ||
| let shelfContinuation = continuationContents["musicShelfContinuation"] as? [String: Any], | ||
| let contents = shelfContinuation["contents"] as? [[String: Any]] | ||
| { | ||
| for itemData in contents { | ||
| if let item = Self.parsePodcastItem(itemData), | ||
| case let .episode(episode) = item | ||
| { | ||
| episodes.append(episode) | ||
| } | ||
| } | ||
|
|
||
| // Extract next continuation token | ||
| if let continuations = shelfContinuation["continuations"] as? [[String: Any]], | ||
| let firstContinuation = continuations.first, | ||
| let nextContinuationData = firstContinuation["nextContinuationData"] as? [String: Any], | ||
| let token = nextContinuationData["continuation"] as? String | ||
| { | ||
| continuationToken = token | ||
| } | ||
| } | ||
|
|
||
| return PodcastEpisodesContinuation( | ||
| episodes: episodes, | ||
| continuationToken: continuationToken | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - Helper Methods | ||
|
|
||
| private static func extractCarouselTitle(from data: [String: Any]) -> String? { | ||
| if let header = data["header"] as? [String: Any], | ||
| let headerRenderer = header["musicCarouselShelfBasicHeaderRenderer"] as? [String: Any] | ||
| { | ||
| return ParsingHelpers.extractTitle(from: headerRenderer) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private static func extractMultiRowTitle(from data: [String: Any]) -> String? { | ||
| if let title = data["title"] as? [String: Any], | ||
| let runs = title["runs"] as? [[String: Any]], | ||
| let text = runs.first?["text"] as? String | ||
| { | ||
| return text | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private static func extractMultiRowSubtitle(from data: [String: Any]) -> String? { | ||
| if let subtitle = data["subtitle"] as? [String: Any], | ||
| let runs = subtitle["runs"] as? [[String: Any]], | ||
| let text = runs.first?["text"] as? String | ||
| { | ||
| return text | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private static func extractShowBrowseId(from data: [String: Any]) -> String? { | ||
| // Look for browse endpoint in subtitle runs | ||
| if let subtitle = data["subtitle"] as? [String: Any], | ||
| let runs = subtitle["runs"] as? [[String: Any]] | ||
| { | ||
| for run in runs { | ||
| if let navigationEndpoint = run["navigationEndpoint"] as? [String: Any], | ||
| let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], | ||
| let browseId = browseEndpoint["browseId"] as? String, | ||
| browseId.hasPrefix("MPSPP") | ||
| { | ||
| return browseId | ||
| } | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private static func extractDescription(from data: [String: Any]) -> String? { | ||
| if let description = data["description"] as? [String: Any], | ||
| let runs = description["runs"] as? [[String: Any]] | ||
| { | ||
| return runs.compactMap { $0["text"] as? String }.joined() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| /// Parses duration string like "36 min" or "1:11:19" to seconds. | ||
| private static func parseDurationToSeconds(_ string: String) -> Int? { | ||
| // Try "X min" format | ||
| if string.hasSuffix(" min") { | ||
| let numberPart = string.dropLast(4) | ||
| if let minutes = Int(numberPart) { | ||
| return minutes * 60 | ||
| } | ||
| } | ||
|
|
||
| // Try "X:XX" or "X:XX:XX" format | ||
| let components = string.split(separator: ":").compactMap { Int($0) } | ||
| if components.count == 2 { | ||
| return components[0] * 60 + components[1] | ||
| } else if components.count == 3 { | ||
| return components[0] * 3600 + components[1] * 60 + components[2] | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // MARK: - Podcast Detection Helpers | ||
|
|
||
| /// Checks if a browse ID is a podcast show. | ||
| static func isPodcastShow(_ browseId: String) -> Bool { | ||
| browseId.hasPrefix("MPSPP") | ||
| } | ||
|
|
||
| /// Checks if content data contains podcast indicators. | ||
| static func isPodcastContent(_ data: [String: Any]) -> Bool { | ||
| // Check for multiRowListItemRenderer (podcast-specific) | ||
| if data["musicMultiRowListItemRenderer"] != nil { | ||
| return true | ||
| } | ||
|
|
||
| // Check for playbackProgress field | ||
| if let multiRow = data["musicMultiRowListItemRenderer"] as? [String: Any], | ||
| multiRow["playbackProgress"] != nil | ||
| { | ||
| return true | ||
| } | ||
|
|
||
| // Check for MPSPP browse ID | ||
| if let navigationEndpoint = data["navigationEndpoint"] as? [String: Any], | ||
| let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], | ||
| let browseId = browseEndpoint["browseId"] as? String, | ||
| browseId.hasPrefix("MPSPP") | ||
| { | ||
| return true | ||
| } | ||
|
|
||
| return false | ||
| } | ||
| } |
There was a problem hiding this comment.
The new PodcastParser implementation lacks test coverage. Given that the repository has comprehensive test coverage for other parsers (HomeResponseParser, PlaylistParser, SearchResponseParser all have dedicated test files), the PodcastParser should also have corresponding tests. This is especially important because podcast parsing involves complex logic for handling multiple renderer types (musicMultiRowListItemRenderer, musicTwoRowItemRenderer, musicResponsiveListItemRenderer) and progress tracking.
| import Foundation | ||
| import Observation | ||
|
|
||
| /// View model for the Podcasts view. | ||
| @MainActor | ||
| @Observable | ||
| final class PodcastsViewModel { | ||
| /// Current loading state. | ||
| private(set) var loadingState: LoadingState = .idle | ||
|
|
||
| /// Podcast sections to display. | ||
| private(set) var sections: [PodcastSection] = [] | ||
|
|
||
| /// Whether more sections are available to load. | ||
| private(set) var hasMoreSections: Bool = true | ||
|
|
||
| /// The API client (exposed for navigation to detail views). | ||
| let client: any YTMusicClientProtocol | ||
| private let logger = DiagnosticsLogger.api | ||
|
|
||
| /// Task for background loading of additional sections. | ||
| private var backgroundLoadTask: Task<Void, Never>? | ||
|
|
||
| /// Number of background continuations loaded. | ||
| private var continuationsLoaded = 0 | ||
|
|
||
| /// Maximum continuations to load in background. | ||
| private static let maxContinuations = 4 | ||
|
|
||
| init(client: any YTMusicClientProtocol) { | ||
| self.client = client | ||
| } | ||
|
|
||
| /// Loads podcasts content with fast initial load. | ||
| func load() async { | ||
| guard self.loadingState != .loading else { return } | ||
|
|
||
| self.loadingState = .loading | ||
| self.logger.info("Loading podcasts content") | ||
|
|
||
| do { | ||
| self.sections = try await self.client.getPodcasts() | ||
|
|
||
| self.hasMoreSections = self.client.hasMorePodcastsSections | ||
| self.loadingState = .loaded | ||
| self.continuationsLoaded = 0 | ||
| let sectionCount = self.sections.count | ||
| self.logger.info("Podcasts content loaded: \(sectionCount) sections") | ||
|
|
||
| // Start background loading of additional sections | ||
| self.startBackgroundLoading() | ||
| } catch is CancellationError { | ||
| // Task was cancelled (e.g., user navigated away) — reset to idle so it can retry | ||
| self.logger.debug("Podcasts load cancelled") | ||
| self.loadingState = .idle | ||
| } catch { | ||
| self.logger.error("Failed to load podcasts: \(error.localizedDescription)") | ||
| self.loadingState = .error(LoadingError(from: error)) | ||
| } | ||
| } | ||
|
|
||
| /// Loads more sections in the background progressively. | ||
| private func startBackgroundLoading() { | ||
| self.backgroundLoadTask?.cancel() | ||
| self.backgroundLoadTask = Task { [weak self] in | ||
| guard let self else { return } | ||
|
|
||
| // Brief delay to let the UI settle | ||
| try? await Task.sleep(for: .milliseconds(300)) | ||
|
|
||
| guard !Task.isCancelled else { return } | ||
|
|
||
| await self.loadMoreSections() | ||
| } | ||
| } | ||
|
|
||
| /// Loads additional sections from continuations progressively. | ||
| private func loadMoreSections() async { | ||
| while self.hasMoreSections, self.continuationsLoaded < Self.maxContinuations { | ||
| guard self.loadingState == .loaded else { break } | ||
|
|
||
| do { | ||
| if let additionalSections = try await client.getPodcastsContinuation() { | ||
| self.sections.append(contentsOf: additionalSections) | ||
| self.continuationsLoaded += 1 | ||
| self.hasMoreSections = self.client.hasMorePodcastsSections | ||
| let continuationNum = self.continuationsLoaded | ||
| self.logger.info("Background loaded \(additionalSections.count) more podcast sections (continuation \(continuationNum))") | ||
| } else { | ||
| self.hasMoreSections = false | ||
| break | ||
| } | ||
| } catch is CancellationError { | ||
| self.logger.debug("Background loading cancelled") | ||
| break | ||
| } catch { | ||
| self.logger.warning("Background section load failed: \(error.localizedDescription)") | ||
| break | ||
| } | ||
| } | ||
|
|
||
| let totalCount = self.sections.count | ||
| self.logger.info("Background section loading completed, total sections: \(totalCount)") | ||
| } | ||
|
|
||
| /// Refreshes podcasts content. | ||
| func refresh() async { | ||
| self.backgroundLoadTask?.cancel() | ||
| self.sections = [] | ||
| self.hasMoreSections = true | ||
| self.continuationsLoaded = 0 | ||
| await self.load() | ||
| } | ||
| } |
There was a problem hiding this comment.
The new PodcastsViewModel lacks test coverage. Given that the repository has comprehensive test coverage for other ViewModels (HomeViewModel, LibraryViewModel, SearchViewModel, ExploreViewModel all have dedicated test files), the PodcastsViewModel should also have corresponding tests. This is especially important because it implements background loading logic with continuation tokens and progressive section loading that should be verified to work correctly.
closes #30