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? // 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 } } }