import Foundation /// HTTP client for the Chad Music REST API. /// Handles auth headers, URL composition, and JSON decoding. @MainActor @Observable final class ChadMusicAPIClient { /// Shared instance — reuse across views to avoid repeated Keychain/UserDefaults reads. static let shared = ChadMusicAPIClient() // MARK: - Configuration /// Server base URL (e.g., "https://music.tailnet.ts.net"). var serverURL: String { get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" } set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") } } /// API key for authentication — reads from Keychain via ChadMusicCredentials. private var apiKey: String? { ChadMusicCredentials.shared.apiKey } /// Whether the client is configured (has URL + API key). var isConfigured: Bool { !serverURL.isEmpty && apiKey != nil } // MARK: - Private private let session: URLSession private let decoder: JSONDecoder init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 60 self.session = URLSession(configuration: config) self.decoder = JSONDecoder() } // MARK: - API Methods /// Fetch library statistics. func fetchStats() async throws -> ChadStats { try await get("api/stats") } /// Fetch entries for a category (albums, artists, genres, etc.). func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] { try await get("api/cat/\(category.rawValue)") } /// Fetch albums — /api/cat/album returns full ChadAlbum objects, not ChadCategory items. func fetchAlbums() async throws -> [ChadAlbum] { try await get("api/cat/album") } /// Fetch albums filtered by a category value (e.g., artist name, genre, year). func fetchAlbums(filteredBy category: String, value: String) async throws -> [ChadAlbum] { let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value return try await get("api/cat/album?\(category)=\(encoded)") } /// Fetch tracks for an album. func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] { try await get("api/album/\(albumId)/tracks") } /// Test connection — tries to fetch stats and returns success/failure. func testConnection() async -> Result { do { let stats = try await fetchStats() return .success(stats) } catch let error as ChadMusicError { return .failure(error) } catch { return .failure(.networkError(error)) } } /// Build a full streaming URL for a track, suitable for AVURLAsset. /// The trackPath from the API is already percent-encoded — don't re-encode it. func streamURL(for trackPath: String) -> URL? { let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed // trackPath starts with "/" from the API — just concatenate let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath return URL(string: base + path) } /// The auth headers dict for AVURLAsset (AVURLAssetHTTPHeaderFieldsKey). var authHeaders: [String: String] { guard let key = apiKey else { return [:] } return ["Authorization": "Bearer \(key)"] } // MARK: - Private Helpers private var baseURL: URL? { let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } // Ensure trailing slash for proper path joining let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/" return URL(string: normalized) } private func get(_ path: String) async throws -> T { let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw ChadMusicError.notConfigured } let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/" guard let url = URL(string: base + path) else { throw ChadMusicError.notConfigured } var request = URLRequest(url: url) request.httpMethod = "GET" // Auth header if let key = apiKey { request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ChadMusicError.invalidResponse } switch httpResponse.statusCode { case 200..<300: do { return try decoder.decode(T.self, from: data) } catch { throw ChadMusicError.decodingFailed(error) } case 401: throw ChadMusicError.unauthorized case 403: throw ChadMusicError.forbidden case 404: throw ChadMusicError.notFound(path) default: throw ChadMusicError.httpError(httpResponse.statusCode) } } } // MARK: - Errors enum ChadMusicError: LocalizedError { case notConfigured case unauthorized case forbidden case notFound(String) case httpError(Int) case invalidResponse case decodingFailed(Error) case networkError(Error) var errorDescription: String? { switch self { case .notConfigured: "Chad Music server not configured. Set the server URL and API key in Settings." case .unauthorized: "Invalid API key (401 Unauthorized)." case .forbidden: "Access denied (403 Forbidden)." case .notFound(let path): "Endpoint not found: \(path)" case .httpError(let code): "Server returned HTTP \(code)." case .invalidResponse: "Invalid server response." case .decodingFailed(let error): "Failed to decode response: \(error.localizedDescription)" case .networkError(let error): "Network error: \(error.localizedDescription)" } } }