ChadMusicAPIClient.swift 6.3 KB

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