UploadService.swift 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import Foundation
  2. import UniformTypeIdentifiers
  3. /// Handles uploading local audio files to the Chad Music server.
  4. /// Uses URLSession upload task with progress tracking via delegate.
  5. ///
  6. /// @unchecked Sendable: Required because self is passed as URLSessionTaskDelegate
  7. /// across actor boundaries. Safe because delegate methods only dispatch back to
  8. /// MainActor via Task to update observable properties.
  9. @MainActor
  10. @Observable
  11. final class UploadService: NSObject, @unchecked Sendable {
  12. static let shared = UploadService()
  13. // MARK: - State
  14. enum State: Equatable {
  15. case idle
  16. case uploading(fileName: String)
  17. case success(tracksAdded: Int, albumsUpdated: Int)
  18. case error(String)
  19. }
  20. var state: State = .idle
  21. var progress: Double = 0.0
  22. // MARK: - Allowed File Types
  23. static let allowedTypes: [UTType] = {
  24. var types: [UTType] = [.mp3, .wav, .aiff, .mpeg4Audio]
  25. if let flac = UTType(filenameExtension: "flac") {
  26. types.append(flac)
  27. }
  28. if let ogg = UTType(filenameExtension: "ogg") {
  29. types.append(ogg)
  30. }
  31. return types
  32. }()
  33. // MARK: - Private
  34. @ObservationIgnored private var uploadTask: Task<Void, Never>?
  35. // MARK: - Public API
  36. /// Start uploading a file. Cancels any in-progress upload first.
  37. func startUpload(fileURL: URL, apiClient: ChadMusicAPIClient) {
  38. cancel()
  39. uploadTask = Task {
  40. await performUpload(fileURL: fileURL, apiClient: apiClient)
  41. }
  42. }
  43. /// Cancel the current upload.
  44. func cancel() {
  45. uploadTask?.cancel()
  46. uploadTask = nil
  47. state = .idle
  48. progress = 0.0
  49. }
  50. /// Reset state after success/error dismissal.
  51. func dismiss() {
  52. state = .idle
  53. progress = 0.0
  54. }
  55. // MARK: - Upload Implementation
  56. private func performUpload(fileURL: URL, apiClient: ChadMusicAPIClient) async {
  57. guard apiClient.isConfigured else {
  58. state = .error("Chad Music not configured")
  59. return
  60. }
  61. let fileName = fileURL.lastPathComponent
  62. state = .uploading(fileName: fileName)
  63. progress = 0.0
  64. guard let contentType = Self.contentType(for: fileURL) else {
  65. state = .error("Unsupported format: .\(fileURL.pathExtension)")
  66. return
  67. }
  68. // Build request URL
  69. let base = apiClient.serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  70. let normalized = base.hasSuffix("/") ? base : base + "/"
  71. guard let url = URL(string: normalized + "api/upload") else {
  72. state = .error("Invalid server URL")
  73. return
  74. }
  75. var request = URLRequest(url: url)
  76. request.httpMethod = "PUT"
  77. request.timeoutInterval = 600
  78. for (key, value) in apiClient.authHeaders {
  79. request.setValue(value, forHTTPHeaderField: key)
  80. }
  81. request.setValue(contentType, forHTTPHeaderField: "Content-Type")
  82. // Sanitize filename for HTTP header (strip CR/LF to prevent header injection)
  83. let safeFileName = fileName
  84. .replacingOccurrences(of: "\r", with: "")
  85. .replacingOccurrences(of: "\n", with: "")
  86. request.setValue(safeFileName, forHTTPHeaderField: "X-Filename")
  87. do {
  88. // Security-scoped access for files from NSOpenPanel
  89. let accessing = fileURL.startAccessingSecurityScopedResource()
  90. defer { if accessing { fileURL.stopAccessingSecurityScopedResource() } }
  91. let config = URLSessionConfiguration.default
  92. config.timeoutIntervalForResource = 600
  93. let session = URLSession(configuration: config)
  94. defer { session.finishTasksAndInvalidate() }
  95. let (data, response) = try await session.upload(
  96. for: request, fromFile: fileURL, delegate: self
  97. )
  98. guard !Task.isCancelled else { return }
  99. guard let http = response as? HTTPURLResponse else {
  100. state = .error("Invalid server response")
  101. return
  102. }
  103. switch http.statusCode {
  104. case 200..<300:
  105. let result = try? JSONDecoder().decode(UploadResult.self, from: data)
  106. state = .success(
  107. tracksAdded: result?.tracksAdded ?? 0,
  108. albumsUpdated: result?.albumsUpdated ?? 0
  109. )
  110. case 401:
  111. state = .error("Unauthorized — check your API key")
  112. case 413:
  113. state = .error("File too large (max 200 MB)")
  114. default:
  115. let result = try? JSONDecoder().decode(UploadResult.self, from: data)
  116. state = .error(
  117. result?.message ?? "Server error (HTTP \(http.statusCode))"
  118. )
  119. }
  120. } catch is CancellationError {
  121. // User cancelled — state already reset by cancel()
  122. } catch {
  123. if !Task.isCancelled {
  124. if (error as NSError).code == NSURLErrorCancelled {
  125. // URLSession cancellation
  126. } else {
  127. state = .error(error.localizedDescription)
  128. }
  129. }
  130. }
  131. }
  132. // MARK: - Response Model
  133. private struct UploadResult: Decodable {
  134. let status: String
  135. let tracksAdded: Int?
  136. let albumsUpdated: Int?
  137. let message: String?
  138. enum CodingKeys: String, CodingKey {
  139. case status
  140. case tracksAdded = "tracks_added"
  141. case albumsUpdated = "albums_updated"
  142. case message
  143. }
  144. }
  145. // MARK: - Content Type Mapping
  146. private static func contentType(for url: URL) -> String? {
  147. switch url.pathExtension.lowercased() {
  148. case "mp3": "audio/mpeg"
  149. case "flac": "audio/flac"
  150. case "wav": "audio/wav"
  151. case "aiff", "aif": "audio/aiff"
  152. case "m4a", "aac": "audio/mp4"
  153. case "ogg": "audio/ogg"
  154. default: nil
  155. }
  156. }
  157. }
  158. // MARK: - URLSessionTaskDelegate (progress tracking)
  159. extension UploadService: URLSessionTaskDelegate {
  160. nonisolated func urlSession(
  161. _ session: URLSession,
  162. task: URLSessionTask,
  163. didSendBodyData bytesSent: Int64,
  164. totalBytesSent: Int64,
  165. totalBytesExpectedToSend: Int64
  166. ) {
  167. guard totalBytesExpectedToSend > 0 else { return }
  168. let p = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
  169. Task { @MainActor in
  170. self.progress = p
  171. }
  172. }
  173. }