| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- 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<ChadStats, ChadMusicError> {
- 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<T: Decodable>(_ 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)"
- }
- }
- }
|