diff --git a/Core/Services/AI/AIErrorHandler.swift b/Core/Services/AI/AIErrorHandler.swift index b13b588..f465555 100644 --- a/Core/Services/AI/AIErrorHandler.swift +++ b/Core/Services/AI/AIErrorHandler.swift @@ -123,16 +123,44 @@ enum AIErrorHandler { /// Handles specific GenerationError cases. private static func handleGenerationError(_ error: LanguageModelSession.GenerationError) -> AIError { switch error { - case let .exceededContextWindowSize(info): - self.logger.warning("Context window exceeded: \\(String(describing: info))") + case .exceededContextWindowSize: + self.logger.warning("Context window exceeded") return .contextWindowExceeded case .guardrailViolation: self.logger.warning("Content blocked by guardrails") return .contentBlocked + case .assetsUnavailable: + self.logger.warning("Model assets unavailable") + return .notAvailable(reason: "Model assets are not available") + + case .unsupportedGuide: + self.logger.warning("Unsupported generation guide") + return .unknown(underlying: error) + + case .unsupportedLanguageOrLocale: + self.logger.warning("Unsupported language or locale") + return .notAvailable(reason: "Language not supported") + + case .decodingFailure: + self.logger.warning("Failed to decode model response") + return .unknown(underlying: error) + + case .rateLimited: + self.logger.warning("Rate limited by model") + return .sessionBusy + + case .concurrentRequests: + self.logger.warning("Concurrent request limit exceeded") + return .sessionBusy + + case .refusal: + self.logger.warning("Model refused to respond") + return .contentBlocked + @unknown default: - self.logger.error("Unknown generation error: \\(error.localizedDescription)") + self.logger.error("Unknown generation error: \(error.localizedDescription)") return .unknown(underlying: error) } } @@ -145,9 +173,9 @@ enum AIErrorHandler { /// - Returns: A formatted string suitable for UI display. static func userMessage(for error: AIError) -> String { if let suggestion = error.recoverySuggestion { - return "\(error.localizedDescription ?? "Error"). \(suggestion)" + return "\(error.errorDescription ?? "Error"). \(suggestion)" } - return error.localizedDescription ?? "An error occurred" + return error.errorDescription ?? "An error occurred" } /// Logs an error with appropriate severity and returns a display message. diff --git a/Core/Services/AI/FoundationModelsService.swift b/Core/Services/AI/FoundationModelsService.swift index 4a9c3b8..3f84052 100644 --- a/Core/Services/AI/FoundationModelsService.swift +++ b/Core/Services/AI/FoundationModelsService.swift @@ -193,12 +193,8 @@ final class FoundationModelsService { let session = LanguageModelSession() - do { - // Use the official prewarm API instead of sending a dummy prompt - try await session.prewarm() - self.logger.debug("Foundation Models prewarm completed successfully") - } catch { - self.logger.error("Failed to prewarm session: \(error.localizedDescription)") - } + // Use the official prewarm API to load model resources + await session.prewarm() + self.logger.debug("Foundation Models prewarm completed successfully") } } diff --git a/Core/Services/API/Parsers/LyricsParser.swift b/Core/Services/API/Parsers/LyricsParser.swift index 3445528..2223b3b 100644 --- a/Core/Services/API/Parsers/LyricsParser.swift +++ b/Core/Services/API/Parsers/LyricsParser.swift @@ -2,6 +2,8 @@ import Foundation /// Parses lyrics responses from YouTube Music API. enum LyricsParser { + private static let logger = DiagnosticsLogger.api + /// Extracts the lyrics browse ID from the "next" endpoint response. /// - Parameter data: The response from the "next" endpoint /// - Returns: The browse ID for fetching lyrics, or nil if unavailable @@ -12,6 +14,7 @@ enum LyricsParser { let watchNextTabbedResults = tabbedRenderer["watchNextTabbedResultsRenderer"] as? [String: Any], let tabs = watchNextTabbedResults["tabs"] as? [[String: Any]] else { + self.logger.debug("LyricsParser: Failed to extract lyrics browse ID structure") return nil } diff --git a/Core/Services/API/Parsers/RadioQueueParser.swift b/Core/Services/API/Parsers/RadioQueueParser.swift index e468ff5..1f36a22 100644 --- a/Core/Services/API/Parsers/RadioQueueParser.swift +++ b/Core/Services/API/Parsers/RadioQueueParser.swift @@ -13,6 +13,8 @@ struct RadioQueueResult { /// Parses radio queue responses from YouTube Music API. enum RadioQueueParser { + private static let logger = DiagnosticsLogger.api + /// Parses the radio queue from the "next" endpoint response. /// - Parameter data: The response from the "next" endpoint with a radio playlist ID /// - Returns: RadioQueueResult containing songs and optional continuation token @@ -30,6 +32,7 @@ enum RadioQueueParser { let playlistPanelRenderer = queueContent["playlistPanelRenderer"] as? [String: Any], let playlistContents = playlistPanelRenderer["contents"] as? [[String: Any]] else { + self.logger.debug("RadioQueueParser: Failed to parse radio queue structure. Top keys: \(data.keys.sorted())") return RadioQueueResult(songs: [], continuationToken: nil) } diff --git a/Core/Services/API/Parsers/SearchResponseParser.swift b/Core/Services/API/Parsers/SearchResponseParser.swift index 2eb7be9..2f9ff24 100644 --- a/Core/Services/API/Parsers/SearchResponseParser.swift +++ b/Core/Services/API/Parsers/SearchResponseParser.swift @@ -2,6 +2,8 @@ import Foundation /// Parser for search responses from YouTube Music API. enum SearchResponseParser { + private static let logger = DiagnosticsLogger.api + /// Parses a search response. static func parse(_ data: [String: Any]) -> SearchResponse { var songs: [Song] = [] @@ -19,6 +21,7 @@ enum SearchResponseParser { let sectionListRenderer = tabContent["sectionListRenderer"] as? [String: Any], let sectionContents = sectionListRenderer["contents"] as? [[String: Any]] else { + Self.logger.debug("SearchResponseParser: Failed to parse response structure. Top keys: \(data.keys.sorted())") return SearchResponse.empty } diff --git a/Core/Services/API/Parsers/SearchSuggestionsParser.swift b/Core/Services/API/Parsers/SearchSuggestionsParser.swift index 68f76f1..bbae117 100644 --- a/Core/Services/API/Parsers/SearchSuggestionsParser.swift +++ b/Core/Services/API/Parsers/SearchSuggestionsParser.swift @@ -2,6 +2,8 @@ import Foundation /// Parser for search suggestions responses from YouTube Music API. enum SearchSuggestionsParser { + private static let logger = DiagnosticsLogger.api + /// Parses a search suggestions response. /// - Parameter data: The raw JSON response from the API. /// - Returns: An array of search suggestions. @@ -10,6 +12,7 @@ enum SearchSuggestionsParser { // Navigate to contents array guard let contents = data["contents"] as? [[String: Any]] else { + Self.logger.debug("SearchSuggestionsParser: No contents array found. Top keys: \(data.keys.sorted())") return suggestions } diff --git a/Core/Services/API/Parsers/SongMetadataParser.swift b/Core/Services/API/Parsers/SongMetadataParser.swift index 4403931..70b8a44 100644 --- a/Core/Services/API/Parsers/SongMetadataParser.swift +++ b/Core/Services/API/Parsers/SongMetadataParser.swift @@ -2,6 +2,8 @@ import Foundation /// Parses song metadata from YouTube Music API responses. enum SongMetadataParser { + private static let logger = DiagnosticsLogger.api + /// Contains parsed menu data including feedback tokens, library status, and like status. struct MenuParseResult { var feedbackTokens: FeedbackTokens? diff --git a/Core/Services/NetworkMonitor.swift b/Core/Services/NetworkMonitor.swift index 84dce83..1742f38 100644 --- a/Core/Services/NetworkMonitor.swift +++ b/Core/Services/NetworkMonitor.swift @@ -61,11 +61,8 @@ final class NetworkMonitor { } } - // swiftformat:disable modifierOrder - /// NWPathMonitor accessed from deinit, which is non-isolated in Swift 6. - /// Using nonisolated(unsafe) since monitor.cancel() is thread-safe. - nonisolated(unsafe) private let monitor: NWPathMonitor - // swiftformat:enable modifierOrder + /// NWPathMonitor is Sendable and immutable, so no isolation annotation needed. + private let monitor: NWPathMonitor private let queue: DispatchQueue private let logger = DiagnosticsLogger.network diff --git a/Core/Services/Notification/NotificationService.swift b/Core/Services/Notification/NotificationService.swift index 565d9da..c8a8de7 100644 --- a/Core/Services/Notification/NotificationService.swift +++ b/Core/Services/Notification/NotificationService.swift @@ -8,8 +8,8 @@ final class NotificationService { private let settingsManager: SettingsManager private let logger = DiagnosticsLogger.notification // swiftformat:disable modifierOrder - /// Task accessed from deinit, which is non-isolated in Swift 6. - /// Using nonisolated(unsafe) since Task.cancel() is thread-safe. + /// Task for observing player changes, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. nonisolated(unsafe) private var observationTask: Task? // swiftformat:enable modifierOrder private var lastNotifiedTrackId: String? diff --git a/Core/Services/WebKit/WebKitManager.swift b/Core/Services/WebKit/WebKitManager.swift index 667dc08..e26e555 100644 --- a/Core/Services/WebKit/WebKitManager.swift +++ b/Core/Services/WebKit/WebKitManager.swift @@ -46,6 +46,10 @@ enum KeychainCookieStorage { for (key, value) in properties { stringProperties[key.rawValue] = value } + // Note: Cookie properties dictionary contains types like String, Date, Number, Bool + // which all support NSSecureCoding. However, using requiringSecureCoding: false here + // because [String: Any] doesn't directly conform to NSSecureCoding. + // The unarchive side uses explicit class allowlists for security. return try? NSKeyedArchiver.archivedData( withRootObject: stringProperties, requiringSecureCoding: false @@ -54,8 +58,8 @@ enum KeychainCookieStorage { guard !cookieData.isEmpty, let data = try? NSKeyedArchiver.archivedData( - withRootObject: cookieData, - requiringSecureCoding: false + withRootObject: cookieData as NSArray, + requiringSecureCoding: true ) else { Self.logger.error("Failed to serialize cookies for Keychain") diff --git a/Core/Utilities/ImageCache.swift b/Core/Utilities/ImageCache.swift index fa08af1..90b016b 100644 --- a/Core/Utilities/ImageCache.swift +++ b/Core/Utilities/ImageCache.swift @@ -244,7 +244,8 @@ actor ImageCache { /// Evicts oldest files until disk cache is under the size limit. /// Uses LRU (Least Recently Used) eviction based on file modification dates. - private func evictDiskCacheIfNeeded() { + /// Marked async to document the I/O-bound nature and satisfy actor isolation. + private func evictDiskCacheIfNeeded() async { let currentSize = self.diskCacheSize() guard currentSize > Self.maxDiskCacheSize else { return } diff --git a/Core/ViewModels/ChartsViewModel.swift b/Core/ViewModels/ChartsViewModel.swift index 0f2f8f3..469f08a 100644 --- a/Core/ViewModels/ChartsViewModel.swift +++ b/Core/ViewModels/ChartsViewModel.swift @@ -18,9 +18,11 @@ final class ChartsViewModel { /// 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? + // swiftformat:disable modifierOrder + /// Task for background loading, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var backgroundLoadTask: Task? + // swiftformat:enable modifierOrder /// Number of background continuations loaded. private var continuationsLoaded = 0 @@ -32,6 +34,10 @@ final class ChartsViewModel { self.client = client } + deinit { + self.backgroundLoadTask?.cancel() + } + /// Loads charts content with fast initial load. func load() async { guard self.loadingState != .loading else { return } diff --git a/Core/ViewModels/ExploreViewModel.swift b/Core/ViewModels/ExploreViewModel.swift index 28d74f0..8b83a82 100644 --- a/Core/ViewModels/ExploreViewModel.swift +++ b/Core/ViewModels/ExploreViewModel.swift @@ -18,9 +18,11 @@ final class ExploreViewModel { /// 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? + // swiftformat:disable modifierOrder + /// Task for background loading, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var backgroundLoadTask: Task? + // swiftformat:enable modifierOrder /// Number of background continuations loaded. private var continuationsLoaded = 0 @@ -32,6 +34,10 @@ final class ExploreViewModel { self.client = client } + deinit { + self.backgroundLoadTask?.cancel() + } + /// Loads explore content with fast initial load. func load() async { guard self.loadingState != .loading else { return } diff --git a/Core/ViewModels/HomeViewModel.swift b/Core/ViewModels/HomeViewModel.swift index d140669..7015d79 100644 --- a/Core/ViewModels/HomeViewModel.swift +++ b/Core/ViewModels/HomeViewModel.swift @@ -18,9 +18,11 @@ final class HomeViewModel { /// 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? + // swiftformat:disable modifierOrder + /// Task for background loading, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var backgroundLoadTask: Task? + // swiftformat:enable modifierOrder /// Number of background continuations loaded. private var continuationsLoaded = 0 @@ -32,6 +34,10 @@ final class HomeViewModel { self.client = client } + deinit { + self.backgroundLoadTask?.cancel() + } + /// Loads home content with fast initial load. func load() async { guard self.loadingState != .loading else { return } diff --git a/Core/ViewModels/MoodsAndGenresViewModel.swift b/Core/ViewModels/MoodsAndGenresViewModel.swift index 2629844..497062c 100644 --- a/Core/ViewModels/MoodsAndGenresViewModel.swift +++ b/Core/ViewModels/MoodsAndGenresViewModel.swift @@ -18,9 +18,11 @@ final class MoodsAndGenresViewModel { /// 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? + // swiftformat:disable modifierOrder + /// Task for background loading, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var backgroundLoadTask: Task? + // swiftformat:enable modifierOrder /// Number of background continuations loaded. private var continuationsLoaded = 0 @@ -32,6 +34,10 @@ final class MoodsAndGenresViewModel { self.client = client } + deinit { + self.backgroundLoadTask?.cancel() + } + /// Loads moods and genres content with fast initial load. func load() async { guard self.loadingState != .loading else { return } diff --git a/Core/ViewModels/NewReleasesViewModel.swift b/Core/ViewModels/NewReleasesViewModel.swift index e0b9d8f..58a7193 100644 --- a/Core/ViewModels/NewReleasesViewModel.swift +++ b/Core/ViewModels/NewReleasesViewModel.swift @@ -18,9 +18,11 @@ final class NewReleasesViewModel { /// 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? + // swiftformat:disable modifierOrder + /// Task for background loading, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var backgroundLoadTask: Task? + // swiftformat:enable modifierOrder /// Number of background continuations loaded. private var continuationsLoaded = 0 @@ -32,6 +34,10 @@ final class NewReleasesViewModel { self.client = client } + deinit { + self.backgroundLoadTask?.cancel() + } + /// Loads new releases content with fast initial load. func load() async { guard self.loadingState != .loading else { return } diff --git a/Core/ViewModels/PodcastsViewModel.swift b/Core/ViewModels/PodcastsViewModel.swift index b470b1a..ca645ce 100644 --- a/Core/ViewModels/PodcastsViewModel.swift +++ b/Core/ViewModels/PodcastsViewModel.swift @@ -17,9 +17,11 @@ final class PodcastsViewModel { /// 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? + // swiftformat:disable modifierOrder + /// Task for background loading, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var backgroundLoadTask: Task? + // swiftformat:enable modifierOrder /// Number of background continuations loaded. private var continuationsLoaded = 0 @@ -31,6 +33,10 @@ final class PodcastsViewModel { self.client = client } + deinit { + self.backgroundLoadTask?.cancel() + } + /// Loads podcasts content with fast initial load. func load() async { guard self.loadingState != .loading else { return } diff --git a/Core/ViewModels/SearchViewModel.swift b/Core/ViewModels/SearchViewModel.swift index 3082aa5..96257f2 100644 --- a/Core/ViewModels/SearchViewModel.swift +++ b/Core/ViewModels/SearchViewModel.swift @@ -96,13 +96,22 @@ final class SearchViewModel { let client: any YTMusicClientProtocol private let logger = DiagnosticsLogger.api - private var searchTask: Task? - private var suggestionsTask: Task? + // swiftformat:disable modifierOrder + /// Tasks for search operations, cancelled in deinit. + /// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected. + nonisolated(unsafe) private var searchTask: Task? + nonisolated(unsafe) private var suggestionsTask: Task? + // swiftformat:enable modifierOrder init(client: any YTMusicClientProtocol) { self.client = client } + deinit { + searchTask?.cancel() + suggestionsTask?.cancel() + } + /// Fetches search suggestions with debounce. func fetchSuggestions() { self.suggestionsTask?.cancel() diff --git a/Tests/KasetTests/NetworkMonitorTests.swift b/Tests/KasetTests/NetworkMonitorTests.swift new file mode 100644 index 0000000..fbb9f82 --- /dev/null +++ b/Tests/KasetTests/NetworkMonitorTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Network +import Testing +@testable import Kaset + +/// Tests for NetworkMonitor. +@Suite("NetworkMonitor", .tags(.service)) +@MainActor +struct NetworkMonitorTests { + @Test("Shared instance exists") + func sharedInstanceExists() { + let monitor = NetworkMonitor.shared + #expect(monitor !== nil) + } + + @Test("Initial state defaults to connected") + func initialStateDefaults() { + let monitor = NetworkMonitor.shared + // By default, isConnected should be true (optimistic default) + #expect(monitor.isConnected == true) + } + + @Test("Interface type has description") + func interfaceTypeDescriptions() { + #expect(NetworkMonitor.InterfaceType.wifi.description == "Wi-Fi") + #expect(NetworkMonitor.InterfaceType.cellular.description == "Cellular") + #expect(NetworkMonitor.InterfaceType.wiredEthernet.description == "Ethernet") + #expect(NetworkMonitor.InterfaceType.loopback.description == "Loopback") + #expect(NetworkMonitor.InterfaceType.other.description == "Other") + #expect(NetworkMonitor.InterfaceType.unknown.description == "Unknown") + } + + @Test("Status description when connected shows interface type") + func statusDescriptionWhenConnected() { + let monitor = NetworkMonitor.shared + // When connected, statusDescription should include the interface type + if monitor.isConnected { + #expect(!monitor.statusDescription.isEmpty) + #expect(monitor.statusDescription != "No internet connection") + } + } + + @Test("Status description when disconnected shows no internet") + func statusDescriptionFormat() { + // This test verifies the format of the status description + // The actual state depends on the real network, so we just verify the format + let monitor = NetworkMonitor.shared + let description = monitor.statusDescription + + // Description should not be empty + #expect(!description.isEmpty) + + // If not connected, it should be the disconnect message + if !monitor.isConnected { + #expect(description == "No internet connection") + } + } + + @Test("Interface type is Sendable") + func interfaceTypeIsSendable() { + // Verify InterfaceType conforms to Sendable by using it across concurrency domains + let interfaceType: NetworkMonitor.InterfaceType = .wifi + Task.detached { + // This compiles only if InterfaceType is Sendable + _ = interfaceType.description + } + } + + @Test("Expensive connection flag is available") + func expensiveConnectionFlag() { + let monitor = NetworkMonitor.shared + // Just verify the property is accessible (actual value depends on real network) + _ = monitor.isExpensive + } + + @Test("Constrained connection flag is available") + func constrainedConnectionFlag() { + let monitor = NetworkMonitor.shared + // Just verify the property is accessible (actual value depends on real network) + _ = monitor.isConstrained + } +} diff --git a/Tools/api-explorer.swift b/Tools/api-explorer.swift index 9c6e6b3..e117e03 100755 --- a/Tools/api-explorer.swift +++ b/Tools/api-explorer.swift @@ -69,12 +69,18 @@ func loadCookiesFromAppBackup() -> [HTTPCookie]? { return nil } - guard let data = try? Data(contentsOf: cookieFile), - let cookieDataArray = try? NSKeyedUnarchiver.unarchivedObject( - ofClasses: [NSArray.self, NSData.self], - from: data - ) as? [Data] - else { + guard let data = try? Data(contentsOf: cookieFile) else { + print("⚠️ Cookie file exists but failed to read: \(cookieFile.path)") + return nil + } + + guard let cookieDataArray = try? NSKeyedUnarchiver.unarchivedObject( + ofClasses: [NSArray.self, NSData.self], + from: data + ) as? [Data] else { + print("⚠️ Cookie file exists but failed to unarchive. File may be corrupted or use a different format.") + print(" Path: \(cookieFile.path)") + print(" Size: \(data.count) bytes") return nil } @@ -407,6 +413,8 @@ func exploreBrowse(_ browseId: String, params: String? = nil, verbose: Bool = fa } /// Known action endpoints that require authentication +/// Known action endpoints that require authentication. +/// Note: music/get_queue works without auth but returns richer data with auth. let authRequiredActions = Set([ "like/like", "like/dislike", @@ -423,7 +431,6 @@ let authRequiredActions = Set([ "notification/get_notification_menu", "stats/watchtime", "next", - "music/get_queue", ]) func exploreAction(_ endpoint: String, bodyJson: String, verbose: Bool = false, outputFile: String? = nil) async { diff --git a/Views/macOS/PlayerBar.swift b/Views/macOS/PlayerBar.swift index c921b75..f8df5a4 100644 --- a/Views/macOS/PlayerBar.swift +++ b/Views/macOS/PlayerBar.swift @@ -132,20 +132,56 @@ struct PlayerBar: View { private var centerSection: some View { ZStack { - // Track info (blurred when hovering and track is playing) - self.trackInfoView - .blur(radius: self.isHovering && self.playerService.currentTrack != nil ? 8 : 0) - .opacity(self.isHovering && self.playerService.currentTrack != nil ? 0 : 1) - - // Seek bar (shown when hovering and track is playing) - if self.isHovering, self.playerService.currentTrack != nil { - self.seekBarView - .transition(.opacity) + // Error state display with retry option + if case let .error(message) = playerService.state { + self.errorView(message: message) + } else { + // Track info (blurred when hovering and track is playing) + self.trackInfoView + .blur(radius: self.isHovering && self.playerService.currentTrack != nil ? 8 : 0) + .opacity(self.isHovering && self.playerService.currentTrack != nil ? 0 : 1) + + // Seek bar (shown when hovering and track is playing) + if self.isHovering, self.playerService.currentTrack != nil { + self.seekBarView + .transition(.opacity) + } } } .frame(maxWidth: 400) } + // MARK: - Error View + + private func errorView(message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.system(size: 14)) + + Text(message) + .font(.system(size: 12)) + .lineLimit(1) + .foregroundStyle(.secondary) + + Button { + Task { + if let track = playerService.currentTrack { + await self.playerService.play(song: track) + } + } + } label: { + Text("Retry") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary) + .clipShape(.capsule) + } + } + // MARK: - Track Info View private var trackInfoView: some View { diff --git a/docs/api-discovery.md b/docs/api-discovery.md index e27d3f3..b50958c 100644 --- a/docs/api-discovery.md +++ b/docs/api-discovery.md @@ -18,6 +18,8 @@ - [Undocumented Endpoints](#undocumented-endpoints) - [Request Patterns](#request-patterns) - [Response Parsing](#response-parsing) +- [Parsers Reference](#parsers-reference) +- [Error Handling](#error-handling) - [Implementation Priorities](#implementation-priorities) - [Using the API Explorer](#using-the-api-explorer) @@ -153,6 +155,10 @@ Browse endpoints use `POST /browse` with a `browseId` parameter. |-----------|------|------|-------------|--------| | `FEmusic_home` | Home | 🌐 | Personalized recommendations, mixes, quick picks | `HomeResponseParser` | | `FEmusic_explore` | Explore | 🌐 | New releases, charts, moods shortcuts | `HomeResponseParser` | +| `FEmusic_charts` | Charts | 🌐 | Top songs, albums by country/genre | `ChartsParser` | +| `FEmusic_moods_and_genres` | Moods & Genres | 🌐 | Browse by mood/genre grids | `MoodsAndGenresParser` | +| `FEmusic_new_releases` | New Releases | 🌐 | Recent albums, singles, videos | `NewReleasesParser` | +| `FEmusic_library_landing` | Library Landing | 🔐 | All library content (playlists, podcasts, artists) | `LibraryParser` | | `FEmusic_liked_playlists` | Library Playlists | 🔐 | User's saved/created playlists | `PlaylistParser` | | `VLLM` | Liked Songs | 🔐 | All songs user has liked (with pagination) | `PlaylistParser` | | `VL{playlistId}` | Playlist Detail | 🌐 | Playlist tracks and metadata | `PlaylistParser` | @@ -240,11 +246,7 @@ These endpoints are functional but not yet implemented in Kaset. | Browse ID | Name | Auth | Priority | Notes | |-----------|------|------|----------|-------| -| `FEmusic_charts` | Charts | 🌐 | **High** | Top songs, albums by country/genre | -| `FEmusic_moods_and_genres` | Moods & Genres | 🌐 | **High** | Browse by mood/genre grids | -| `FEmusic_new_releases` | New Releases | 🌐 | **Medium** | Recent albums, singles, videos | | `FEmusic_history` | History | 🔐 | **High** | Recently played tracks | -| `FEmusic_library_landing` | Library Landing | 🔐 | **High** | All library content (playlists, podcasts, artists, etc.) | | `FEmusic_library_non_music_audio_list` | Subscribed Podcasts | 🔐 | Medium | User's subscribed podcast shows | | `FEmusic_library_albums` | Library Albums | 🔐 | Medium | Requires auth + params* | | `FEmusic_library_artists` | Library Artists | 🔐 | Medium | Requires auth + params* | @@ -441,12 +443,16 @@ let body = ["query": "never gonna give you up"] | Filter | Param Value | Description | |--------|-------------|-------------| -| Songs | `EgWKAQIIAWoQEBAQCRAEEAMQBRAKEBUQEQ%3D%3D` | Filter to songs only | -| Albums | `EgWKAQIYAWoQEBAQCRAEEAMQBRAKEBUQEQ%3D%3D` | Filter to albums only | -| Artists | `EgWKAQIgAWoQEBAQCRAEEAMQBRAKEBUQEQ%3D%3D` | Filter to artists only | -| Playlists | `EgeKAQQoAEABahAQEBAJEAQQAxAFEAoQFRAR` | Filter to playlists only | +| Songs | `EgWKAQIIAWoMEA4QChADEAQQCRAF` | Filter to songs only | +| Albums | `EgWKAQIYAWoMEA4QChADEAQQCRAF` | Filter to albums only | +| Artists | `EgWKAQIgAWoMEA4QChADEAQQCRAF` | Filter to artists only | +| Playlists | `EgWKAQIoAWoMEA4QChADEAQQCRAF` | Filter to all playlists | +| Featured Playlists | `EgeKAQQoADgBagwQDhAKEAMQBBAJEAU=` | YouTube Music curated playlists | +| Community Playlists | `EgeKAQQoAEABagwQDhAKEAMQBBAJEAU=` | User-created playlists | | Podcasts | `EgWKAQJQAWoQEBAQCRAEEAMQBRAKEBUQEQ%3D%3D` | Filter to podcast shows only | +> **Filter Pattern**: `EgWKAQ` (base) + filter code + `AWoMEA4QChADEAQQCRAF` (no spelling correction suffix). The filter code encodes the content type (songs=II, albums=IY, artists=Ig, playlists=Io, podcasts=JQ). + **Usage Example** (podcasts): ```swift let body: [String: Any] = [ @@ -850,6 +856,120 @@ if let watchEndpoint = navEndpoint["watchEndpoint"] as? [String: Any], --- +## Parsers Reference + +All parsers are located in `Core/Services/API/Parsers/`. Each parser is responsible for extracting structured data from raw API JSON responses. + +| Parser | File | Input | Output | Used By | +|--------|------|-------|--------|--------| +| `HomeResponseParser` | `HomeResponseParser.swift` | Home/Explore browse response | `HomeResponse` with `[HomeSection]` | `FEmusic_home`, `FEmusic_explore` | +| `SearchResponseParser` | `SearchResponseParser.swift` | Search response | `SearchResponse` with songs, albums, artists, playlists | `search` endpoint | +| `SearchSuggestionsParser` | `SearchSuggestionsParser.swift` | Suggestions response | `[SearchSuggestion]` | `music/get_search_suggestions` | +| `PlaylistParser` | `PlaylistParser.swift` | Playlist/library response | `[Playlist]`, `LibraryContent` | `VL{id}`, `VLLM`, `FEmusic_liked_playlists`, `FEmusic_library_landing` | +| `ArtistParser` | `ArtistParser.swift` | Artist browse response | `ArtistDetail` with songs, albums | `UC{channelId}` | +| `LyricsParser` | `LyricsParser.swift` | Next/lyrics response | `Lyrics` or lyrics browse ID | `next`, `MPLYt{id}` | +| `PodcastParser` | `PodcastParser.swift` | Podcast browse response | `[PodcastSection]`, `PodcastShowDetail` | `FEmusic_podcasts`, `MPSPP{id}` | +| `AccountsListParser` | `AccountsListParser.swift` | Accounts list response | `AccountsListResponse` with `[UserAccount]` | `account/accounts_list` | +| `SongMetadataParser` | `SongMetadataParser.swift` | Next endpoint response | `Song` with full metadata | `next` endpoint | +| `RadioQueueParser` | `RadioQueueParser.swift` | Next endpoint response | `RadioQueueResult` with songs + continuation | Radio/mix playback | +| `ParsingHelpers` | `ParsingHelpers.swift` | Various | Utility functions (stable IDs, text extraction) | All parsers | + +### Parser Patterns + +**Common extraction helpers** (from `ParsingHelpers`): + +```swift +// Extract text from runs array +ParsingHelpers.extractText(from: titleRuns) // -> "Song Title" + +// Generate stable ID for SwiftUI +ParsingHelpers.stableId(title: "Section", components: "item1") // -> deterministic hash + +// Extract thumbnail URL with size preference +ParsingHelpers.extractThumbnailURL(from: thumbnails, preferredSize: 226) +``` + +**Common response structure**: +``` +contents + -> singleColumnBrowseResultsRenderer + -> tabs[0] + -> tabRenderer + -> content + -> sectionListRenderer + -> contents[] <- iterate here for sections +``` + +--- + +## Error Handling + +Kaset uses a unified `YTMusicError` enum for all API-related errors. This enables consistent error handling, user-friendly messages, and retry logic. + +### Error Types + +| Error | When Thrown | Retryable | User Action | +|-------|-------------|-----------|-------------| +| `authExpired` | HTTP 401/403, invalid SAPISIDHASH | ❌ | Sign in again | +| `notAuthenticated` | No cookies available for auth-required endpoint | ❌ | Sign in | +| `networkError(underlying:)` | Connection failed, timeout, DNS failure | ✅ | Check connection | +| `parseError(message:)` | Unexpected JSON structure, missing required fields | ❌ | Report bug | +| `apiError(message:, code:)` | API returned error response | ✅ (5xx only) | Try again | +| `playbackError(message:)` | WebView playback failed, DRM error | ✅ | Try different track | +| `invalidInput(message:)` | Invalid video ID, empty query | ❌ | Fix input | +| `unknown(message:)` | Catch-all for unexpected errors | ✅ | Try again | + +### Error Properties + +```swift +let error: YTMusicError = .networkError(underlying: urlError) + +error.errorDescription // "Network error: The Internet connection appears to be offline." +error.recoverySuggestion // "Check your internet connection and try again." +error.userFriendlyTitle // "Connection Error" +error.userFriendlyMessage // "Unable to connect. Please check your internet connection." +error.requiresReauth // false +error.isRetryable // true +``` + +### Handling in Views + +```swift +// In ViewModel +func load() async { + do { + self.data = try await client.fetchData() + } catch let error as YTMusicError { + if error.requiresReauth { + self.showLoginSheet = true + } else if error.isRetryable { + self.errorMessage = error.userFriendlyMessage + self.showRetryButton = true + } else { + self.errorMessage = error.userFriendlyMessage + } + } +} +``` + +### Retry Logic + +Use `RetryPolicy` for automatic retries with exponential backoff: + +```swift +let result = try await RetryPolicy.execute( + maxAttempts: 3, + initialDelay: .seconds(1), + shouldRetry: { error in + (error as? YTMusicError)?.isRetryable ?? false + } +) { + try await client.fetchData() +} +``` + +--- + ## Implementation Priorities ### Phase 1: High-Impact Features