UploadService.swift 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  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 track's local file. Cancels any in-progress upload first.
  37. func startUpload(track: Track, apiClient: ChadMusicAPIClient) {
  38. cancel()
  39. track.uploadState = .uploading
  40. uploadTask = Task {
  41. await performUpload(track: track, fileURL: URL(fileURLWithPath: track.filePath), apiClient: apiClient)
  42. }
  43. }
  44. /// Start uploading a file from a URL (file picker / drag-and-drop, no Track object).
  45. func startUpload(fileURL: URL, apiClient: ChadMusicAPIClient) {
  46. cancel()
  47. uploadTask = Task {
  48. await performUpload(track: nil, fileURL: fileURL, apiClient: apiClient)
  49. }
  50. }
  51. /// Upload a batch of tracks sequentially.
  52. func uploadBatch(tracks: [Track], apiClient: ChadMusicAPIClient) {
  53. cancel()
  54. uploadTask = Task {
  55. for track in tracks {
  56. guard !Task.isCancelled else { break }
  57. guard track.uploadState != .uploaded else { continue }
  58. guard track.hasLocalFile, FileManager.default.fileExists(atPath: track.filePath) else { continue }
  59. track.uploadState = .uploading
  60. await performUpload(track: track, fileURL: URL(fileURLWithPath: track.filePath), apiClient: apiClient)
  61. }
  62. }
  63. }
  64. /// Cancel the current upload.
  65. func cancel() {
  66. uploadTask?.cancel()
  67. uploadTask = nil
  68. state = .idle
  69. progress = 0.0
  70. }
  71. /// Reset state after success/error dismissal.
  72. func dismiss() {
  73. state = .idle
  74. progress = 0.0
  75. }
  76. // MARK: - Upload Implementation
  77. private func performUpload(track: Track?, fileURL: URL, apiClient: ChadMusicAPIClient) async {
  78. guard apiClient.isConfigured else {
  79. state = .error("Chad Music not configured")
  80. track?.uploadState = .error
  81. return
  82. }
  83. let fileName = fileURL.lastPathComponent
  84. state = .uploading(fileName: fileName)
  85. progress = 0.0
  86. guard let contentType = Self.contentType(for: fileURL) else {
  87. state = .error("Unsupported format: .\(fileURL.pathExtension)")
  88. track?.uploadState = .error
  89. return
  90. }
  91. // Build request URL
  92. let base = apiClient.serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
  93. let normalized = base.hasSuffix("/") ? base : base + "/"
  94. guard let url = URL(string: normalized + "api/upload") else {
  95. state = .error("Invalid server URL")
  96. track?.uploadState = .error
  97. return
  98. }
  99. var request = URLRequest(url: url)
  100. request.httpMethod = "PUT"
  101. request.timeoutInterval = 600
  102. for (key, value) in apiClient.authHeaders {
  103. request.setValue(value, forHTTPHeaderField: key)
  104. }
  105. request.setValue(contentType, forHTTPHeaderField: "Content-Type")
  106. // Sanitize filename for HTTP header (strip CR/LF to prevent header injection)
  107. let safeFileName = fileName
  108. .replacingOccurrences(of: "\r", with: "")
  109. .replacingOccurrences(of: "\n", with: "")
  110. request.setValue(safeFileName, forHTTPHeaderField: "X-Filename")
  111. do {
  112. // Security-scoped access for files from NSOpenPanel
  113. let accessing = fileURL.startAccessingSecurityScopedResource()
  114. defer { if accessing { fileURL.stopAccessingSecurityScopedResource() } }
  115. let config = URLSessionConfiguration.default
  116. config.timeoutIntervalForResource = 600
  117. let session = URLSession(configuration: config)
  118. defer { session.finishTasksAndInvalidate() }
  119. let (data, response) = try await session.upload(
  120. for: request, fromFile: fileURL, delegate: self
  121. )
  122. guard !Task.isCancelled else { return }
  123. guard let http = response as? HTTPURLResponse else {
  124. state = .error("Invalid server response")
  125. track?.uploadState = .error
  126. return
  127. }
  128. switch http.statusCode {
  129. case 200..<300:
  130. let result = try? JSONDecoder().decode(UploadResult.self, from: data)
  131. state = .success(
  132. tracksAdded: result?.tracksAdded ?? 0,
  133. albumsUpdated: result?.albumsUpdated ?? 0
  134. )
  135. track?.uploadState = .uploaded
  136. case 401:
  137. state = .error("Unauthorized — check your API key")
  138. track?.uploadState = .error
  139. case 413:
  140. state = .error("File too large (max 200 MB)")
  141. track?.uploadState = .error
  142. default:
  143. let result = try? JSONDecoder().decode(UploadResult.self, from: data)
  144. state = .error(
  145. result?.message ?? "Server error (HTTP \(http.statusCode))"
  146. )
  147. track?.uploadState = .error
  148. }
  149. } catch is CancellationError {
  150. // User cancelled — state already reset by cancel()
  151. if track?.uploadState == .uploading { track?.uploadState = .none }
  152. } catch {
  153. if !Task.isCancelled {
  154. if (error as NSError).code == NSURLErrorCancelled {
  155. if track?.uploadState == .uploading { track?.uploadState = .none }
  156. } else {
  157. state = .error(error.localizedDescription)
  158. track?.uploadState = .error
  159. }
  160. } else {
  161. if track?.uploadState == .uploading { track?.uploadState = .none }
  162. }
  163. }
  164. }
  165. // MARK: - Response Model
  166. private struct UploadResult: Decodable {
  167. let status: String
  168. let tracksAdded: Int?
  169. let albumsUpdated: Int?
  170. let message: String?
  171. enum CodingKeys: String, CodingKey {
  172. case status
  173. case tracksAdded = "tracks_added"
  174. case albumsUpdated = "albums_updated"
  175. case message
  176. }
  177. }
  178. // MARK: - Content Type Mapping
  179. private static func contentType(for url: URL) -> String? {
  180. switch url.pathExtension.lowercased() {
  181. case "mp3": "audio/mpeg"
  182. case "flac": "audio/flac"
  183. case "wav": "audio/wav"
  184. case "aiff", "aif": "audio/aiff"
  185. case "m4a", "aac": "audio/mp4"
  186. case "ogg": "audio/ogg"
  187. default: nil
  188. }
  189. }
  190. }
  191. // MARK: - URLSessionTaskDelegate (progress tracking)
  192. extension UploadService: URLSessionTaskDelegate {
  193. nonisolated func urlSession(
  194. _ session: URLSession,
  195. task: URLSessionTask,
  196. didSendBodyData bytesSent: Int64,
  197. totalBytesSent: Int64,
  198. totalBytesExpectedToSend: Int64
  199. ) {
  200. guard totalBytesExpectedToSend > 0 else { return }
  201. let p = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
  202. Task { @MainActor in
  203. self.progress = p
  204. }
  205. }
  206. }