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 track's local file. Cancels any in-progress upload first. func startUpload(track: Track, apiClient: ChadMusicAPIClient) { cancel() track.uploadState = .uploading uploadTask = Task { await performUpload(track: track, fileURL: URL(fileURLWithPath: track.filePath), apiClient: apiClient) } } /// Start uploading a file from a URL (file picker / drag-and-drop, no Track object). func startUpload(fileURL: URL, apiClient: ChadMusicAPIClient) { cancel() uploadTask = Task { await performUpload(track: nil, fileURL: fileURL, apiClient: apiClient) } } /// Upload a batch of tracks sequentially. func uploadBatch(tracks: [Track], apiClient: ChadMusicAPIClient) { cancel() uploadTask = Task { for track in tracks { guard !Task.isCancelled else { break } guard track.uploadState != .uploaded else { continue } guard track.hasLocalFile, FileManager.default.fileExists(atPath: track.filePath) else { continue } track.uploadState = .uploading await performUpload(track: track, fileURL: URL(fileURLWithPath: track.filePath), 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(track: Track?, fileURL: URL, apiClient: ChadMusicAPIClient) async { guard apiClient.isConfigured else { state = .error("Chad Music not configured") track?.uploadState = .error 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)") track?.uploadState = .error 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") track?.uploadState = .error 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") track?.uploadState = .error 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 ) track?.uploadState = .uploaded case 401: state = .error("Unauthorized — check your API key") track?.uploadState = .error case 413: state = .error("File too large (max 200 MB)") track?.uploadState = .error default: let result = try? JSONDecoder().decode(UploadResult.self, from: data) state = .error( result?.message ?? "Server error (HTTP \(http.statusCode))" ) track?.uploadState = .error } } catch is CancellationError { // User cancelled — state already reset by cancel() if track?.uploadState == .uploading { track?.uploadState = .none } } catch { if !Task.isCancelled { if (error as NSError).code == NSURLErrorCancelled { if track?.uploadState == .uploading { track?.uploadState = .none } } else { state = .error(error.localizedDescription) track?.uploadState = .error } } else { if track?.uploadState == .uploading { track?.uploadState = .none } } } } // 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 } } }