ChadMusicAPIClient.swift 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import Foundation
  2. /// HTTP client for the Chad Music REST API.
  3. /// Handles auth headers, URL composition, and JSON decoding.
  4. @MainActor
  5. @Observable
  6. final class ChadMusicAPIClient {
  7. /// Shared instance — reuse across views to avoid repeated Keychain/UserDefaults reads.
  8. static let shared = ChadMusicAPIClient()
  9. // MARK: - Configuration
  10. /// Server base URL (e.g., "https://music.tailnet.ts.net").
  11. var serverURL: String {
  12. get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" }
  13. set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") }
  14. }
  15. /// API key for authentication.
  16. private var apiKey: String? {
  17. let key = UserDefaults.standard.string(forKey: "chadMusic.apiKey")
  18. return (key?.isEmpty ?? true) ? nil : key
  19. }
  20. /// Whether the client is configured (has URL + API key).
  21. var isConfigured: Bool {
  22. !serverURL.isEmpty && apiKey != nil
  23. }
  24. // MARK: - Private
  25. private let session: URLSession
  26. private let decoder: JSONDecoder
  27. init() {
  28. let config = URLSessionConfiguration.default
  29. config.timeoutIntervalForRequest = 15
  30. config.timeoutIntervalForResource = 60
  31. self.session = URLSession(configuration: config)
  32. self.decoder = JSONDecoder()
  33. }
  34. // MARK: - API Methods
  35. /// Fetch library statistics.
  36. func fetchStats() async throws -> ChadStats {
  37. try await get("api/stats")
  38. }
  39. /// Fetch entries for a category (albums, artists, genres, etc.).
  40. func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] {
  41. try await get("api/cat/\(category.rawValue)")
  42. }
  43. /// Fetch albums — /api/cat/album returns full ChadAlbum objects, not ChadCategory items.
  44. func fetchAlbums() async throws -> [ChadAlbum] {
  45. try await get("api/cat/album")
  46. }
  47. /// Fetch albums filtered by a category value (e.g., artist name, genre, year).
  48. func fetchAlbums(filteredBy category: String, value: String) async throws -> [ChadAlbum] {
  49. let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
  50. return try await get("api/cat/album?\(category)=\(encoded)")
  51. }
  52. /// Fetch tracks for an album.
  53. func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] {
  54. try await get("api/album/\(albumId)/tracks")
  55. }
  56. /// Test connection — tries to fetch stats and returns success/failure.
  57. func testConnection() async -> Result<ChadStats, ChadMusicError> {
  58. do {
  59. let stats = try await fetchStats()
  60. return .success(stats)
  61. } catch let error as ChadMusicError {
  62. return .failure(error)
  63. } catch {
  64. return .failure(.networkError(error))
  65. }
  66. }
  67. /// Build a full streaming URL for a track, suitable for AVURLAsset.
  68. /// The trackPath from the API is already percent-encoded — don't re-encode it.
  69. func streamURL(for trackPath: String) -> URL? {
  70. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  71. guard !trimmed.isEmpty else { return nil }
  72. let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed
  73. // trackPath starts with "/" from the API — just concatenate
  74. let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath
  75. return URL(string: base + path)
  76. }
  77. /// The auth headers dict for AVURLAsset (AVURLAssetHTTPHeaderFieldsKey).
  78. var authHeaders: [String: String] {
  79. guard let key = apiKey else { return [:] }
  80. return ["Authorization": "Bearer \(key)"]
  81. }
  82. // MARK: - Private Helpers
  83. private var baseURL: URL? {
  84. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  85. guard !trimmed.isEmpty else { return nil }
  86. // Ensure trailing slash for proper path joining
  87. let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
  88. return URL(string: normalized)
  89. }
  90. private func get<T: Decodable>(_ path: String) async throws -> T {
  91. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  92. guard !trimmed.isEmpty else {
  93. throw ChadMusicError.notConfigured
  94. }
  95. let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
  96. guard let url = URL(string: base + path) else {
  97. throw ChadMusicError.notConfigured
  98. }
  99. var request = URLRequest(url: url)
  100. request.httpMethod = "GET"
  101. // Auth header
  102. if let key = apiKey {
  103. request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
  104. }
  105. let (data, response) = try await session.data(for: request)
  106. guard let httpResponse = response as? HTTPURLResponse else {
  107. throw ChadMusicError.invalidResponse
  108. }
  109. switch httpResponse.statusCode {
  110. case 200..<300:
  111. do {
  112. return try decoder.decode(T.self, from: data)
  113. } catch {
  114. throw ChadMusicError.decodingFailed(error)
  115. }
  116. case 401:
  117. throw ChadMusicError.unauthorized
  118. case 403:
  119. throw ChadMusicError.forbidden
  120. case 404:
  121. throw ChadMusicError.notFound(path)
  122. default:
  123. throw ChadMusicError.httpError(httpResponse.statusCode)
  124. }
  125. }
  126. }
  127. // MARK: - Errors
  128. enum ChadMusicError: LocalizedError {
  129. case notConfigured
  130. case unauthorized
  131. case forbidden
  132. case notFound(String)
  133. case httpError(Int)
  134. case invalidResponse
  135. case decodingFailed(Error)
  136. case networkError(Error)
  137. var errorDescription: String? {
  138. switch self {
  139. case .notConfigured:
  140. "Chad Music server not configured. Set the server URL and API key in Settings."
  141. case .unauthorized:
  142. "Invalid API key (401 Unauthorized)."
  143. case .forbidden:
  144. "Access denied (403 Forbidden)."
  145. case .notFound(let path):
  146. "Endpoint not found: \(path)"
  147. case .httpError(let code):
  148. "Server returned HTTP \(code)."
  149. case .invalidResponse:
  150. "Invalid server response."
  151. case .decodingFailed(let error):
  152. "Failed to decode response: \(error.localizedDescription)"
  153. case .networkError(let error):
  154. "Network error: \(error.localizedDescription)"
  155. }
  156. }
  157. }