import Foundation /// HTTP client for the Chad Music REST API. @MainActor @Observable final class ChadMusicAPIClient { static let shared = ChadMusicAPIClient() // MARK: - Configuration var serverURL: String { get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" } set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") } } var isConfigured: Bool { !serverURL.isEmpty && KeychainService.loadAPIKey() != nil } // MARK: - Private @ObservationIgnored private let session: URLSession @ObservationIgnored 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 func fetchStats() async throws -> ChadStats { try await get("api/stats") } func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] { try await get("api/cat/\(category.rawValue)") } func fetchAlbums() async throws -> [ChadAlbum] { try await get("api/cat/album") } func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] { try await get("api/album/\(albumId)/tracks") } 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)) } } 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 let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath return URL(string: base + path) } var authHeaders: [String: String] { guard let key = KeychainService.loadAPIKey() 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 } let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/" return URL(string: normalized) } private func get(_ path: String) async throws -> T { guard let base = baseURL else { throw ChadMusicError.notConfigured } let url = base.appending(path: path) var request = URLRequest(url: url) request.httpMethod = "GET" if let apiKey = KeychainService.loadAPIKey() { request.setValue("Bearer \(apiKey)", 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)" } } }