Skip to content

Commit f82facc

Browse files
authored
fix: podcast add to library (#76)
1 parent 686ddf2 commit f82facc

File tree

9 files changed

+358
-67
lines changed

9 files changed

+358
-67
lines changed

Core/Services/API/YTMusicClient.swift

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,26 +1027,44 @@ final class YTMusicClient: YTMusicClientProtocol {
10271027
APICache.shared.invalidate(matching: "browse:")
10281028
}
10291029

1030+
// MARK: - Podcast ID Conversion
1031+
1032+
/// Converts a podcast show ID (MPSPP prefix) to a playlist ID (PL prefix) for the like/unlike API.
1033+
/// - Podcast show IDs use "MPSPP" + "L" + {idSuffix}, e.g. "MPSPPLXz2p9...".
1034+
/// - The corresponding playlist ID is "PL" + {idSuffix}, e.g. "PLXz2p9...".
1035+
/// - We strip "MPSPP" (5 chars) leaving "LXz2p9...", then prepend "P" to get "PLXz2p9...".
1036+
/// - Parameter showId: The podcast show ID to convert
1037+
/// - Returns: The playlist ID for the like API
1038+
/// - Throws: YTMusicError.invalidInput if the ID format is invalid
1039+
private func convertPodcastShowIdToPlaylistId(_ showId: String) throws -> String {
1040+
guard showId.hasPrefix("MPSPP") else {
1041+
self.logger.warning("ShowId does not have MPSPP prefix, using as-is: \(showId)")
1042+
return showId
1043+
}
1044+
1045+
let suffix = String(showId.dropFirst(5)) // "LXz2p9..."
1046+
1047+
guard !suffix.isEmpty else {
1048+
self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)")
1049+
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
1050+
}
1051+
1052+
guard suffix.hasPrefix("L") else {
1053+
self.logger.error("Invalid podcast show ID (suffix must start with 'L'): \(showId)")
1054+
throw YTMusicError.invalidInput("Invalid podcast show ID format: \(showId)")
1055+
}
1056+
1057+
return "P" + suffix // "P" + "LXz2p9..." = "PLXz2p9..."
1058+
}
1059+
10301060
/// Subscribes to a podcast show (adds to library).
1031-
/// This uses the playlist-style "like" subscription API (`like/like` endpoint) by treating podcast shows as playlist-like entities.
1061+
/// This uses the like/like endpoint with the playlist ID (PL prefix).
1062+
/// Podcast shows have an MPSPP prefix that maps to PL for the like API.
10321063
/// - Parameter showId: The podcast show ID (MPSPP prefix)
10331064
func subscribeToPodcast(showId: String) async throws {
10341065
self.logger.info("Subscribing to podcast: \(showId)")
10351066

1036-
// Extract the playlist ID portion from MPSPP prefix.
1037-
// Podcast show IDs use the form "MPSPP" + {idSuffix}, where the corresponding
1038-
// playlist ID is "PL" + {idSuffix}. We validate the suffix is non-empty.
1039-
let playlistId: String
1040-
if showId.hasPrefix("MPSPP") {
1041-
let suffix = String(showId.dropFirst(5))
1042-
if suffix.isEmpty {
1043-
self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)")
1044-
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
1045-
}
1046-
playlistId = "PL" + suffix
1047-
} else {
1048-
playlistId = showId
1049-
}
1067+
let playlistId = try self.convertPodcastShowIdToPlaylistId(showId)
10501068

10511069
let body: [String: Any] = [
10521070
"target": ["playlistId": playlistId],
@@ -1060,23 +1078,13 @@ final class YTMusicClient: YTMusicClientProtocol {
10601078
}
10611079

10621080
/// Unsubscribes from a podcast show (removes from library).
1063-
/// This uses the playlist-style "like" subscription API (`like/removelike` endpoint).
1081+
/// This uses the like/removelike endpoint with the playlist ID (PL prefix).
1082+
/// Podcast shows have an MPSPP prefix that maps to PL for the like API.
10641083
/// - Parameter showId: The podcast show ID (MPSPP prefix)
10651084
func unsubscribeFromPodcast(showId: String) async throws {
10661085
self.logger.info("Unsubscribing from podcast: \(showId)")
10671086

1068-
// Extract the playlist ID portion from MPSPP prefix.
1069-
let playlistId: String
1070-
if showId.hasPrefix("MPSPP") {
1071-
let suffix = String(showId.dropFirst(5))
1072-
if suffix.isEmpty {
1073-
self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)")
1074-
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
1075-
}
1076-
playlistId = "PL" + suffix
1077-
} else {
1078-
playlistId = showId
1079-
}
1087+
let playlistId = try self.convertPodcastShowIdToPlaylistId(showId)
10801088

10811089
let body: [String: Any] = [
10821090
"target": ["playlistId": playlistId],

Core/Utilities/IntelligenceModifier.swift

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,36 @@ struct RequiresIntelligenceModifier: ViewModifier {
1515
/// Whether to show a sparkle overlay when AI is available and active.
1616
let showSparkleOverlay: Bool
1717

18-
/// Access to the Foundation Models service.
19-
@State private var isAvailable = FoundationModelsService.shared.isAvailable
18+
/// Reads availability directly from the @Observable singleton.
19+
/// SwiftUI's Observation system automatically tracks this access
20+
/// and triggers re-renders when the underlying value changes.
21+
private var isAvailable: Bool {
22+
FoundationModelsService.shared.isAvailable
23+
}
2024

2125
func body(content: Content) -> some View {
26+
// Cache availability for consistent reads within this render pass
27+
let isAvailable = self.isAvailable
28+
2229
Group {
23-
if self.hideWhenUnavailable, !self.isAvailable {
30+
if self.hideWhenUnavailable, !isAvailable {
2431
EmptyView()
2532
} else {
2633
content
27-
.disabled(!self.isAvailable)
28-
.opacity(self.isAvailable ? 1.0 : 0.4)
34+
.disabled(!isAvailable)
35+
.opacity(isAvailable ? 1.0 : 0.4)
2936
.overlay(alignment: .topTrailing) {
30-
if self.showSparkleOverlay, self.isAvailable {
37+
if self.showSparkleOverlay, isAvailable {
3138
Image(systemName: "sparkle")
3239
.font(.system(size: 8, weight: .bold))
3340
.foregroundStyle(.purple)
3441
.offset(x: 2, y: -2)
3542
}
3643
}
37-
.help(self.isAvailable ? "" : self.unavailableMessage)
38-
.animation(.easeInOut(duration: 0.2), value: self.isAvailable)
44+
.help(isAvailable ? "" : self.unavailableMessage)
45+
.animation(.easeInOut(duration: 0.2), value: isAvailable)
3946
}
4047
}
41-
.onReceive(NotificationCenter.default.publisher(for: .intelligenceAvailabilityChanged)) { _ in
42-
self.isAvailable = FoundationModelsService.shared.isAvailable
43-
}
4448
}
4549
}
4650

Core/ViewModels/LibraryViewModel.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ final class LibraryViewModel {
6060
self.libraryPlaylistIds.insert(playlistId)
6161
}
6262

63+
/// Adds a podcast to the library (called after successful subscription).
64+
/// Updates both the ID set and the shows array for immediate UI update.
65+
func addToLibrary(podcast: PodcastShow) {
66+
self.libraryPodcastIds.insert(podcast.id)
67+
// Add to shows array if not already present
68+
if !self.podcastShows.contains(where: { $0.id == podcast.id }) {
69+
self.podcastShows.insert(podcast, at: 0)
70+
}
71+
}
72+
6373
/// Adds a podcast ID to the library set (called after successful subscription).
6474
func addToLibrarySet(podcastId: String) {
6575
self.libraryPodcastIds.insert(podcastId)
@@ -76,6 +86,13 @@ final class LibraryViewModel {
7686
}
7787
}
7888

89+
/// Removes a podcast from the library (called after successful unsubscribe).
90+
/// Updates both the ID set and the shows array for immediate UI update.
91+
func removeFromLibrary(podcastId: String) {
92+
self.libraryPodcastIds.remove(podcastId)
93+
self.podcastShows.removeAll { $0.id == podcastId }
94+
}
95+
7996
/// Removes a podcast ID from the library set (called after successful unsubscribe).
8097
func removeFromLibrarySet(podcastId: String) {
8198
self.libraryPodcastIds.remove(podcastId)

Tests/KasetTests/Helpers/MockYTMusicClient.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,12 +533,32 @@ final class MockYTMusicClient: YTMusicClientProtocol {
533533
if let error = shouldThrowError { throw error }
534534
}
535535

536-
func subscribeToPodcast(showId _: String) async throws {
536+
func subscribeToPodcast(showId: String) async throws {
537537
if let error = shouldThrowError { throw error }
538+
// Validate podcast show ID format (mirrors real YTMusicClient behavior)
539+
if showId.hasPrefix("MPSPP") {
540+
let suffix = String(showId.dropFirst(5))
541+
if suffix.isEmpty {
542+
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
543+
}
544+
if !suffix.hasPrefix("L") {
545+
throw YTMusicError.invalidInput("Invalid podcast show ID format: \(showId)")
546+
}
547+
}
538548
}
539549

540-
func unsubscribeFromPodcast(showId _: String) async throws {
550+
func unsubscribeFromPodcast(showId: String) async throws {
541551
if let error = shouldThrowError { throw error }
552+
// Validate podcast show ID format (mirrors real YTMusicClient behavior)
553+
if showId.hasPrefix("MPSPP") {
554+
let suffix = String(showId.dropFirst(5))
555+
if suffix.isEmpty {
556+
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
557+
}
558+
if !suffix.hasPrefix("L") {
559+
throw YTMusicError.invalidInput("Invalid podcast show ID format: \(showId)")
560+
}
561+
}
542562
}
543563

544564
func subscribeToArtist(channelId: String) async throws {

Tests/KasetTests/Helpers/TestFixtures.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,21 @@ enum TestFixtures {
161161
}
162162
)
163163
}
164+
165+
// MARK: - Podcasts
166+
167+
static func makePodcastShow(
168+
id: String = "MPSPPLXz2p9test123",
169+
title: String = "Test Podcast",
170+
author: String? = "Test Host"
171+
) -> PodcastShow {
172+
PodcastShow(
173+
id: id,
174+
title: title,
175+
author: author,
176+
description: "A test podcast show",
177+
thumbnailURL: URL(string: "https://example.com/podcast.jpg"),
178+
episodeCount: 50
179+
)
180+
}
164181
}

Tests/KasetTests/LibraryViewModelTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,76 @@ struct LibraryViewModelTests {
9393

9494
#expect(self.viewModel.playlists.count == 2)
9595
}
96+
97+
// MARK: - Podcast Library Tests
98+
99+
@Test("addToLibrary inserts podcast at beginning and updates ID set")
100+
func addToLibraryInsertsPodcast() {
101+
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123", title: "Test Podcast")
102+
103+
self.viewModel.addToLibrary(podcast: podcast)
104+
105+
#expect(self.viewModel.libraryPodcastIds.contains("MPSPPLXz2p9test123"))
106+
#expect(self.viewModel.podcastShows.first?.id == "MPSPPLXz2p9test123")
107+
#expect(self.viewModel.podcastShows.first?.title == "Test Podcast")
108+
}
109+
110+
@Test("addToLibrary does not duplicate existing podcast")
111+
func addToLibraryNoDuplicate() {
112+
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123")
113+
114+
self.viewModel.addToLibrary(podcast: podcast)
115+
self.viewModel.addToLibrary(podcast: podcast)
116+
117+
#expect(self.viewModel.podcastShows.count(where: { $0.id == "MPSPPLXz2p9test123" }) == 1)
118+
#expect(self.viewModel.libraryPodcastIds.count == 1)
119+
}
120+
121+
@Test("addToLibrary inserts new podcast at position 0")
122+
func addToLibraryInsertsAtBeginning() {
123+
let podcast1 = TestFixtures.makePodcastShow(id: "MPSPPLA", title: "Podcast A")
124+
let podcast2 = TestFixtures.makePodcastShow(id: "MPSPPLB", title: "Podcast B")
125+
126+
self.viewModel.addToLibrary(podcast: podcast1)
127+
self.viewModel.addToLibrary(podcast: podcast2)
128+
129+
// Podcast B should be first (inserted at position 0)
130+
#expect(self.viewModel.podcastShows.first?.id == "MPSPPLB")
131+
#expect(self.viewModel.podcastShows[1].id == "MPSPPLA")
132+
}
133+
134+
@Test("removeFromLibrary removes podcast from both set and array")
135+
func removeFromLibraryRemovesPodcast() {
136+
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123")
137+
self.viewModel.addToLibrary(podcast: podcast)
138+
#expect(self.viewModel.podcastShows.count == 1)
139+
#expect(self.viewModel.libraryPodcastIds.count == 1)
140+
141+
self.viewModel.removeFromLibrary(podcastId: "MPSPPLXz2p9test123")
142+
143+
#expect(self.viewModel.podcastShows.isEmpty)
144+
#expect(self.viewModel.libraryPodcastIds.isEmpty)
145+
}
146+
147+
@Test("removeFromLibrary handles non-existent podcast gracefully")
148+
func removeFromLibraryNonExistent() {
149+
// Should not crash when removing non-existent podcast
150+
self.viewModel.removeFromLibrary(podcastId: "nonexistent")
151+
152+
#expect(self.viewModel.podcastShows.isEmpty)
153+
#expect(self.viewModel.libraryPodcastIds.isEmpty)
154+
}
155+
156+
@Test("isInLibrary returns true for added podcast")
157+
func isInLibraryForAddedPodcast() {
158+
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123")
159+
self.viewModel.addToLibrary(podcast: podcast)
160+
161+
#expect(self.viewModel.isInLibrary(podcastId: "MPSPPLXz2p9test123") == true)
162+
}
163+
164+
@Test("isInLibrary returns false for non-added podcast")
165+
func isInLibraryForNonAddedPodcast() {
166+
#expect(self.viewModel.isInLibrary(podcastId: "MPSPPLXz2p9test123") == false)
167+
}
96168
}

0 commit comments

Comments
 (0)