ChadMusicAPIClient.swift 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import Foundation
  2. /// HTTP client for the Chad Music REST API.
  3. @MainActor
  4. @Observable
  5. final class ChadMusicAPIClient {
  6. static let shared = ChadMusicAPIClient()
  7. // MARK: - Configuration
  8. var serverURL: String {
  9. get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" }
  10. set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") }
  11. }
  12. var isConfigured: Bool {
  13. !serverURL.isEmpty && KeychainService.loadAPIKey() != nil
  14. }
  15. // MARK: - Private
  16. @ObservationIgnored private let session: URLSession
  17. @ObservationIgnored private let decoder: JSONDecoder
  18. init() {
  19. let config = URLSessionConfiguration.default
  20. config.timeoutIntervalForRequest = 15
  21. config.timeoutIntervalForResource = 60
  22. self.session = URLSession(configuration: config)
  23. self.decoder = JSONDecoder()
  24. }
  25. // MARK: - API Methods
  26. func fetchStats() async throws -> ChadStats {
  27. try await get("api/stats")
  28. }
  29. func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] {
  30. try await get("api/cat/\(category.rawValue)")
  31. }
  32. func fetchAlbums() async throws -> [ChadAlbum] {
  33. try await get("api/cat/album")
  34. }
  35. func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] {
  36. try await get("api/album/\(albumId)/tracks")
  37. }
  38. func testConnection() async -> Result<ChadStats, ChadMusicError> {
  39. do {
  40. let stats = try await fetchStats()
  41. return .success(stats)
  42. } catch let error as ChadMusicError {
  43. return .failure(error)
  44. } catch {
  45. return .failure(.networkError(error))
  46. }
  47. }
  48. func streamURL(for trackPath: String) -> URL? {
  49. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  50. guard !trimmed.isEmpty else { return nil }
  51. let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed
  52. let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath
  53. return URL(string: base + path)
  54. }
  55. var authHeaders: [String: String] {
  56. guard let key = KeychainService.loadAPIKey() else { return [:] }
  57. return ["Authorization": "Bearer \(key)"]
  58. }
  59. // MARK: - Private Helpers
  60. private var baseURL: URL? {
  61. let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  62. guard !trimmed.isEmpty else { return nil }
  63. let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
  64. return URL(string: normalized)
  65. }
  66. private func get<T: Decodable>(_ path: String) async throws -> T {
  67. guard let base = baseURL else {
  68. throw ChadMusicError.notConfigured
  69. }
  70. let url = base.appending(path: path)
  71. var request = URLRequest(url: url)
  72. request.httpMethod = "GET"
  73. if let apiKey = KeychainService.loadAPIKey() {
  74. request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
  75. }
  76. let (data, response) = try await session.data(for: request)
  77. guard let httpResponse = response as? HTTPURLResponse else {
  78. throw ChadMusicError.invalidResponse
  79. }
  80. switch httpResponse.statusCode {
  81. case 200..<300:
  82. do {
  83. return try decoder.decode(T.self, from: data)
  84. } catch {
  85. throw ChadMusicError.decodingFailed(error)
  86. }
  87. case 401:
  88. throw ChadMusicError.unauthorized
  89. case 403:
  90. throw ChadMusicError.forbidden
  91. case 404:
  92. throw ChadMusicError.notFound(path)
  93. default:
  94. throw ChadMusicError.httpError(httpResponse.statusCode)
  95. }
  96. }
  97. }
  98. // MARK: - Errors
  99. enum ChadMusicError: LocalizedError {
  100. case notConfigured
  101. case unauthorized
  102. case forbidden
  103. case notFound(String)
  104. case httpError(Int)
  105. case invalidResponse
  106. case decodingFailed(Error)
  107. case networkError(Error)
  108. var errorDescription: String? {
  109. switch self {
  110. case .notConfigured:
  111. "Chad Music server not configured. Set the server URL and API key in Settings."
  112. case .unauthorized:
  113. "Invalid API key (401 Unauthorized)."
  114. case .forbidden:
  115. "Access denied (403 Forbidden)."
  116. case .notFound(let path):
  117. "Endpoint not found: \(path)"
  118. case .httpError(let code):
  119. "Server returned HTTP \(code)."
  120. case .invalidResponse:
  121. "Invalid server response."
  122. case .decodingFailed(let error):
  123. "Failed to decode response: \(error.localizedDescription)"
  124. case .networkError(let error):
  125. "Network error: \(error.localizedDescription)"
  126. }
  127. }
  128. }