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
64 changes: 36 additions & 28 deletions Core/Services/API/YTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1027,26 +1027,44 @@ final class YTMusicClient: YTMusicClientProtocol {
APICache.shared.invalidate(matching: "browse:")
}

// MARK: - Podcast ID Conversion

/// Converts a podcast show ID (MPSPP prefix) to a playlist ID (PL prefix) for the like/unlike API.
/// - Podcast show IDs use "MPSPP" + "L" + {idSuffix}, e.g. "MPSPPLXz2p9...".
/// - The corresponding playlist ID is "PL" + {idSuffix}, e.g. "PLXz2p9...".
/// - We strip "MPSPP" (5 chars) leaving "LXz2p9...", then prepend "P" to get "PLXz2p9...".
/// - Parameter showId: The podcast show ID to convert
/// - Returns: The playlist ID for the like API
/// - Throws: YTMusicError.invalidInput if the ID format is invalid
private func convertPodcastShowIdToPlaylistId(_ showId: String) throws -> String {
guard showId.hasPrefix("MPSPP") else {
self.logger.warning("ShowId does not have MPSPP prefix, using as-is: \(showId)")
return showId
}

let suffix = String(showId.dropFirst(5)) // "LXz2p9..."

guard !suffix.isEmpty else {
self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)")
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
}

guard suffix.hasPrefix("L") else {
self.logger.error("Invalid podcast show ID (suffix must start with 'L'): \(showId)")
throw YTMusicError.invalidInput("Invalid podcast show ID format: \(showId)")
}

return "P" + suffix // "P" + "LXz2p9..." = "PLXz2p9..."
}

/// Subscribes to a podcast show (adds to library).
/// This uses the playlist-style "like" subscription API (`like/like` endpoint) by treating podcast shows as playlist-like entities.
/// This uses the like/like endpoint with the playlist ID (PL prefix).
/// Podcast shows have an MPSPP prefix that maps to PL for the like API.
/// - Parameter showId: The podcast show ID (MPSPP prefix)
func subscribeToPodcast(showId: String) async throws {
self.logger.info("Subscribing to podcast: \(showId)")

// Extract the playlist ID portion from MPSPP prefix.
// Podcast show IDs use the form "MPSPP" + {idSuffix}, where the corresponding
// playlist ID is "PL" + {idSuffix}. We validate the suffix is non-empty.
let playlistId: String
if showId.hasPrefix("MPSPP") {
let suffix = String(showId.dropFirst(5))
if suffix.isEmpty {
self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)")
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
}
playlistId = "PL" + suffix
} else {
playlistId = showId
}
let playlistId = try self.convertPodcastShowIdToPlaylistId(showId)

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

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

// Extract the playlist ID portion from MPSPP prefix.
let playlistId: String
if showId.hasPrefix("MPSPP") {
let suffix = String(showId.dropFirst(5))
if suffix.isEmpty {
self.logger.error("Invalid podcast show ID (missing suffix after MPSPP): \(showId)")
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
}
playlistId = "PL" + suffix
} else {
playlistId = showId
}
let playlistId = try self.convertPodcastShowIdToPlaylistId(showId)

let body: [String: Any] = [
"target": ["playlistId": playlistId],
Expand Down
26 changes: 15 additions & 11 deletions Core/Utilities/IntelligenceModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,36 @@ struct RequiresIntelligenceModifier: ViewModifier {
/// Whether to show a sparkle overlay when AI is available and active.
let showSparkleOverlay: Bool

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

func body(content: Content) -> some View {
// Cache availability for consistent reads within this render pass
let isAvailable = self.isAvailable

Group {
if self.hideWhenUnavailable, !self.isAvailable {
if self.hideWhenUnavailable, !isAvailable {
EmptyView()
} else {
content
.disabled(!self.isAvailable)
.opacity(self.isAvailable ? 1.0 : 0.4)
.disabled(!isAvailable)
.opacity(isAvailable ? 1.0 : 0.4)
.overlay(alignment: .topTrailing) {
if self.showSparkleOverlay, self.isAvailable {
if self.showSparkleOverlay, isAvailable {
Image(systemName: "sparkle")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.purple)
.offset(x: 2, y: -2)
}
}
.help(self.isAvailable ? "" : self.unavailableMessage)
.animation(.easeInOut(duration: 0.2), value: self.isAvailable)
.help(isAvailable ? "" : self.unavailableMessage)
.animation(.easeInOut(duration: 0.2), value: isAvailable)
}
}
.onReceive(NotificationCenter.default.publisher(for: .intelligenceAvailabilityChanged)) { _ in
self.isAvailable = FoundationModelsService.shared.isAvailable
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions Core/ViewModels/LibraryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ final class LibraryViewModel {
self.libraryPlaylistIds.insert(playlistId)
}

/// Adds a podcast to the library (called after successful subscription).
/// Updates both the ID set and the shows array for immediate UI update.
func addToLibrary(podcast: PodcastShow) {
self.libraryPodcastIds.insert(podcast.id)
// Add to shows array if not already present
if !self.podcastShows.contains(where: { $0.id == podcast.id }) {
self.podcastShows.insert(podcast, at: 0)
}
}

/// Adds a podcast ID to the library set (called after successful subscription).
func addToLibrarySet(podcastId: String) {
self.libraryPodcastIds.insert(podcastId)
Expand All @@ -76,6 +86,13 @@ final class LibraryViewModel {
}
}

/// Removes a podcast from the library (called after successful unsubscribe).
/// Updates both the ID set and the shows array for immediate UI update.
func removeFromLibrary(podcastId: String) {
self.libraryPodcastIds.remove(podcastId)
self.podcastShows.removeAll { $0.id == podcastId }
}

/// Removes a podcast ID from the library set (called after successful unsubscribe).
func removeFromLibrarySet(podcastId: String) {
self.libraryPodcastIds.remove(podcastId)
Expand Down
24 changes: 22 additions & 2 deletions Tests/KasetTests/Helpers/MockYTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -533,12 +533,32 @@ final class MockYTMusicClient: YTMusicClientProtocol {
if let error = shouldThrowError { throw error }
}

func subscribeToPodcast(showId _: String) async throws {
func subscribeToPodcast(showId: String) async throws {
if let error = shouldThrowError { throw error }
// Validate podcast show ID format (mirrors real YTMusicClient behavior)
if showId.hasPrefix("MPSPP") {
let suffix = String(showId.dropFirst(5))
if suffix.isEmpty {
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
}
if !suffix.hasPrefix("L") {
throw YTMusicError.invalidInput("Invalid podcast show ID format: \(showId)")
}
}
}

func unsubscribeFromPodcast(showId _: String) async throws {
func unsubscribeFromPodcast(showId: String) async throws {
if let error = shouldThrowError { throw error }
// Validate podcast show ID format (mirrors real YTMusicClient behavior)
if showId.hasPrefix("MPSPP") {
let suffix = String(showId.dropFirst(5))
if suffix.isEmpty {
throw YTMusicError.invalidInput("Invalid podcast show ID: \(showId)")
}
if !suffix.hasPrefix("L") {
throw YTMusicError.invalidInput("Invalid podcast show ID format: \(showId)")
}
}
}

func subscribeToArtist(channelId: String) async throws {
Expand Down
17 changes: 17 additions & 0 deletions Tests/KasetTests/Helpers/TestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,21 @@ enum TestFixtures {
}
)
}

// MARK: - Podcasts

static func makePodcastShow(
id: String = "MPSPPLXz2p9test123",
title: String = "Test Podcast",
author: String? = "Test Host"
) -> PodcastShow {
PodcastShow(
id: id,
title: title,
author: author,
description: "A test podcast show",
thumbnailURL: URL(string: "https://example.com/podcast.jpg"),
episodeCount: 50
)
}
}
72 changes: 72 additions & 0 deletions Tests/KasetTests/LibraryViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,76 @@ struct LibraryViewModelTests {

#expect(self.viewModel.playlists.count == 2)
}

// MARK: - Podcast Library Tests

@Test("addToLibrary inserts podcast at beginning and updates ID set")
func addToLibraryInsertsPodcast() {
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123", title: "Test Podcast")

self.viewModel.addToLibrary(podcast: podcast)

#expect(self.viewModel.libraryPodcastIds.contains("MPSPPLXz2p9test123"))
#expect(self.viewModel.podcastShows.first?.id == "MPSPPLXz2p9test123")
#expect(self.viewModel.podcastShows.first?.title == "Test Podcast")
}

@Test("addToLibrary does not duplicate existing podcast")
func addToLibraryNoDuplicate() {
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123")

self.viewModel.addToLibrary(podcast: podcast)
self.viewModel.addToLibrary(podcast: podcast)

#expect(self.viewModel.podcastShows.count(where: { $0.id == "MPSPPLXz2p9test123" }) == 1)
#expect(self.viewModel.libraryPodcastIds.count == 1)
}

@Test("addToLibrary inserts new podcast at position 0")
func addToLibraryInsertsAtBeginning() {
let podcast1 = TestFixtures.makePodcastShow(id: "MPSPPLA", title: "Podcast A")
let podcast2 = TestFixtures.makePodcastShow(id: "MPSPPLB", title: "Podcast B")

self.viewModel.addToLibrary(podcast: podcast1)
self.viewModel.addToLibrary(podcast: podcast2)

// Podcast B should be first (inserted at position 0)
#expect(self.viewModel.podcastShows.first?.id == "MPSPPLB")
#expect(self.viewModel.podcastShows[1].id == "MPSPPLA")
}

@Test("removeFromLibrary removes podcast from both set and array")
func removeFromLibraryRemovesPodcast() {
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123")
self.viewModel.addToLibrary(podcast: podcast)
#expect(self.viewModel.podcastShows.count == 1)
#expect(self.viewModel.libraryPodcastIds.count == 1)

self.viewModel.removeFromLibrary(podcastId: "MPSPPLXz2p9test123")

#expect(self.viewModel.podcastShows.isEmpty)
#expect(self.viewModel.libraryPodcastIds.isEmpty)
}

@Test("removeFromLibrary handles non-existent podcast gracefully")
func removeFromLibraryNonExistent() {
// Should not crash when removing non-existent podcast
self.viewModel.removeFromLibrary(podcastId: "nonexistent")

#expect(self.viewModel.podcastShows.isEmpty)
#expect(self.viewModel.libraryPodcastIds.isEmpty)
}

@Test("isInLibrary returns true for added podcast")
func isInLibraryForAddedPodcast() {
let podcast = TestFixtures.makePodcastShow(id: "MPSPPLXz2p9test123")
self.viewModel.addToLibrary(podcast: podcast)

#expect(self.viewModel.isInLibrary(podcastId: "MPSPPLXz2p9test123") == true)
}

@Test("isInLibrary returns false for non-added podcast")
func isInLibraryForNonAddedPodcast() {
#expect(self.viewModel.isInLibrary(podcastId: "MPSPPLXz2p9test123") == false)
}
}
Loading
Loading