| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- import Foundation
- import UniformTypeIdentifiers
- /// Handles uploading local audio files to the Chad Music server.
- /// Uses URLSession upload task with progress tracking via delegate.
- ///
- /// @unchecked Sendable: Required because self is passed as URLSessionTaskDelegate
- /// across actor boundaries. Safe because delegate methods only dispatch back to
- /// MainActor via Task to update observable properties.
- @MainActor
- @Observable
- final class UploadService: NSObject, @unchecked Sendable {
- static let shared = UploadService()
- // MARK: - State
- enum State: Equatable {
- case idle
- case uploading(fileName: String)
- case success(tracksAdded: Int, albumsUpdated: Int)
- case error(String)
- }
- var state: State = .idle
- var progress: Double = 0.0
- // MARK: - Allowed File Types
- static let allowedTypes: [UTType] = {
- var types: [UTType] = [.mp3, .wav, .aiff, .mpeg4Audio]
- if let flac = UTType(filenameExtension: "flac") {
- types.append(flac)
- }
- if let ogg = UTType(filenameExtension: "ogg") {
- types.append(ogg)
- }
- return types
- }()
- // MARK: - Private
- @ObservationIgnored private var uploadTask: Task<Void, Never>?
- // MARK: - Public API
- /// Start uploading a file. Cancels any in-progress upload first.
- func startUpload(fileURL: URL, apiClient: ChadMusicAPIClient) {
- cancel()
- uploadTask = Task {
- await performUpload(fileURL: fileURL, apiClient: apiClient)
- }
- }
- /// Cancel the current upload.
- func cancel() {
- uploadTask?.cancel()
- uploadTask = nil
- state = .idle
- progress = 0.0
- }
- /// Reset state after success/error dismissal.
- func dismiss() {
- state = .idle
- progress = 0.0
- }
- // MARK: - Upload Implementation
- private func performUpload(fileURL: URL, apiClient: ChadMusicAPIClient) async {
- guard apiClient.isConfigured else {
- state = .error("Chad Music not configured")
- return
- }
- let fileName = fileURL.lastPathComponent
- state = .uploading(fileName: fileName)
- progress = 0.0
- guard let contentType = Self.contentType(for: fileURL) else {
- state = .error("Unsupported format: .\(fileURL.pathExtension)")
- return
- }
- // Build request URL
- let base = apiClient.serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
- let normalized = base.hasSuffix("/") ? base : base + "/"
- guard let url = URL(string: normalized + "api/upload") else {
- state = .error("Invalid server URL")
- return
- }
- var request = URLRequest(url: url)
- request.httpMethod = "PUT"
- request.timeoutInterval = 600
- for (key, value) in apiClient.authHeaders {
- request.setValue(value, forHTTPHeaderField: key)
- }
- request.setValue(contentType, forHTTPHeaderField: "Content-Type")
- // Sanitize filename for HTTP header (strip CR/LF to prevent header injection)
- let safeFileName = fileName
- .replacingOccurrences(of: "\r", with: "")
- .replacingOccurrences(of: "\n", with: "")
- request.setValue(safeFileName, forHTTPHeaderField: "X-Filename")
- do {
- // Security-scoped access for files from NSOpenPanel
- let accessing = fileURL.startAccessingSecurityScopedResource()
- defer { if accessing { fileURL.stopAccessingSecurityScopedResource() } }
- let config = URLSessionConfiguration.default
- config.timeoutIntervalForResource = 600
- let session = URLSession(configuration: config)
- defer { session.finishTasksAndInvalidate() }
- let (data, response) = try await session.upload(
- for: request, fromFile: fileURL, delegate: self
- )
- guard !Task.isCancelled else { return }
- guard let http = response as? HTTPURLResponse else {
- state = .error("Invalid server response")
- return
- }
- switch http.statusCode {
- case 200..<300:
- let result = try? JSONDecoder().decode(UploadResult.self, from: data)
- state = .success(
- tracksAdded: result?.tracksAdded ?? 0,
- albumsUpdated: result?.albumsUpdated ?? 0
- )
- case 401:
- state = .error("Unauthorized — check your API key")
- case 413:
- state = .error("File too large (max 200 MB)")
- default:
- let result = try? JSONDecoder().decode(UploadResult.self, from: data)
- state = .error(
- result?.message ?? "Server error (HTTP \(http.statusCode))"
- )
- }
- } catch is CancellationError {
- // User cancelled — state already reset by cancel()
- } catch {
- if !Task.isCancelled {
- if (error as NSError).code == NSURLErrorCancelled {
- // URLSession cancellation
- } else {
- state = .error(error.localizedDescription)
- }
- }
- }
- }
- // MARK: - Response Model
- private struct UploadResult: Decodable {
- let status: String
- let tracksAdded: Int?
- let albumsUpdated: Int?
- let message: String?
- enum CodingKeys: String, CodingKey {
- case status
- case tracksAdded = "tracks_added"
- case albumsUpdated = "albums_updated"
- case message
- }
- }
- // MARK: - Content Type Mapping
- private static func contentType(for url: URL) -> String? {
- switch url.pathExtension.lowercased() {
- case "mp3": "audio/mpeg"
- case "flac": "audio/flac"
- case "wav": "audio/wav"
- case "aiff", "aif": "audio/aiff"
- case "m4a", "aac": "audio/mp4"
- case "ogg": "audio/ogg"
- default: nil
- }
- }
- }
- // MARK: - URLSessionTaskDelegate (progress tracking)
- extension UploadService: URLSessionTaskDelegate {
- nonisolated func urlSession(
- _ session: URLSession,
- task: URLSessionTask,
- didSendBodyData bytesSent: Int64,
- totalBytesSent: Int64,
- totalBytesExpectedToSend: Int64
- ) {
- guard totalBytesExpectedToSend > 0 else { return }
- let p = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
- Task { @MainActor in
- self.progress = p
- }
- }
- }
|