| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- 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.
- private var apiKey: String? {
- let key = UserDefaults.standard.string(forKey: "chadMusic.apiKey")
- return (key?.isEmpty ?? true) ? nil : key
- }
- /// 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<ChadStats, ChadMusicError> {
- 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<T: Decodable>(_ 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)"
- }
- }
- }
|