import Foundation /// Downloads cloud tracks to a local directory for DAW export. /// Mirrors the UploadService pattern — URLSession-based, auth headers, progress tracking. enum DownloadService { // MARK: - Errors enum DownloadError: LocalizedError { case noStreamPath case invalidURL case invalidResponse case emptyFile case httpError(Int) var errorDescription: String? { switch self { case .noStreamPath: "Track has no cloud stream path" case .invalidURL: "Could not build download URL" case .invalidResponse: "Invalid server response" case .emptyFile: "Downloaded file is empty" case .httpError(let code): "Server error (HTTP \(code))" } } } // MARK: - Batch Result /// Result of a batch download operation. struct BatchResult { /// Successfully downloaded files, mapped by Track ID to local URL. let downloaded: [UUID: URL] /// Tracks that failed to download. let failures: [(title: String, error: String)] } // MARK: - Single Download /// Download a single cloud track to a local directory. /// - Returns: The local file URL of the downloaded file. static func download( track: Track, streamURL: URL, authHeaders: [String: String], to directory: URL ) async throws -> URL { let fm = FileManager.default try fm.createDirectory(at: directory, withIntermediateDirectories: true) var request = URLRequest(url: streamURL) request.timeoutInterval = 300 for (key, value) in authHeaders { request.setValue(value, forHTTPHeaderField: key) } let (tempURL, response) = try await URLSession.shared.download(for: request) guard let http = response as? HTTPURLResponse else { throw DownloadError.invalidResponse } guard (200..<300).contains(http.statusCode) else { throw DownloadError.httpError(http.statusCode) } // File extension from cloudStreamPath, fallback to Content-Type let ext = fileExtension( fromPath: track.cloudStreamPath, contentType: http.value(forHTTPHeaderField: "Content-Type") ) let safeName = safeFileName(for: track) let destName = ext.isEmpty ? safeName : "\(safeName).\(ext)" let destURL = directory.appendingPathComponent(destName) if fm.fileExists(atPath: destURL.path) { try fm.removeItem(at: destURL) } try fm.moveItem(at: tempURL, to: destURL) // Validate non-zero size let attrs = try fm.attributesOfItem(atPath: destURL.path) guard let size = attrs[.size] as? Int64, size > 0 else { try? fm.removeItem(at: destURL) throw DownloadError.emptyFile } return destURL } // MARK: - Batch Download /// Download multiple cloud tracks with bounded concurrency. static func downloadBatch( tracks: [(track: Track, streamURL: URL)], authHeaders: [String: String], to directory: URL, maxConcurrency: Int = 3, onProgress: @MainActor @Sendable (Int, Int) -> Void ) async -> BatchResult { var downloaded: [UUID: URL] = [:] var failures: [(title: String, error: String)] = [] var completed = 0 await withTaskGroup(of: (UUID, String, Result).self) { group in var index = 0 // Seed initial batch up to maxConcurrency while index < min(maxConcurrency, tracks.count) { let item = tracks[index] index += 1 group.addTask { do { let url = try await download( track: item.track, streamURL: item.streamURL, authHeaders: authHeaders, to: directory ) return (item.track.id, item.track.title, .success(url)) } catch { return (item.track.id, item.track.title, .failure(error)) } } } // As each completes, add the next while let result = await group.next() { completed += 1 await onProgress(completed, tracks.count) switch result.2 { case .success(let url): downloaded[result.0] = url case .failure(let error): failures.append((title: result.1, error: error.localizedDescription)) } if index < tracks.count { let item = tracks[index] index += 1 group.addTask { do { let url = try await download( track: item.track, streamURL: item.streamURL, authHeaders: authHeaders, to: directory ) return (item.track.id, item.track.title, .success(url)) } catch { return (item.track.id, item.track.title, .failure(error)) } } } } } return BatchResult(downloaded: downloaded, failures: failures) } // MARK: - Private Helpers /// Extract file extension from the cloud stream path, falling back to Content-Type. static func fileExtension(fromPath path: String?, contentType: String?) -> String { // Try stream path first (e.g. "/music/Artist/Album/track.flac") if let path { let url = URL(string: path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path) if let ext = url?.pathExtension, !ext.isEmpty { return ext.lowercased() } // Fallback: split by "." manually if let lastDot = path.lastIndex(of: ".") { let ext = String(path[path.index(after: lastDot)...]) if !ext.isEmpty && !ext.contains("/") { return ext.lowercased() } } } // Fall back to Content-Type header if let ct = contentType?.lowercased() { if ct.contains("flac") { return "flac" } if ct.contains("mpeg") { return "mp3" } if ct.contains("wav") { return "wav" } if ct.contains("aiff") { return "aiff" } if ct.contains("ogg") { return "ogg" } if ct.contains("mp4") || ct.contains("m4a") { return "m4a" } } return "" } /// Create a safe filename from track metadata. static func safeFileName(for track: Track) -> String { let base: String if !track.artist.isEmpty { base = "\(track.artist) - \(track.title)" } else { base = track.title } return base .replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: "-") .replacingOccurrences(of: "\\", with: "-") .replacingOccurrences(of: "\"", with: "'") } // MARK: - Persistent Download (Offline Cache) /// Directory for persistent cloud track downloads. static var persistentStorageDirectory: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("MixBoard/CloudTracks", isDirectory: true) } /// Download a cloud track to persistent local storage for offline playback. /// Updates the track's `localCachePath` and `downloadState` on completion. @MainActor static func downloadPersistent( track: Track, apiClient: ChadMusicAPIClient, onProgress: @escaping @Sendable (Double) -> Void = { _ in } ) async throws -> URL { guard let streamPath = track.cloudStreamPath else { throw DownloadError.noStreamPath } guard let streamURL = apiClient.streamURL(for: streamPath) else { throw DownloadError.invalidURL } track.downloadState = .downloading do { let directory = persistentStorageDirectory let fm = FileManager.default try fm.createDirectory(at: directory, withIntermediateDirectories: true) // Use cloudTrackId for filename uniqueness let ext = fileExtension(fromPath: streamPath, contentType: nil) let fileName: String if let trackId = track.cloudTrackId { fileName = ext.isEmpty ? trackId : "\(trackId).\(ext)" } else { let safe = safeFileName(for: track) fileName = ext.isEmpty ? safe : "\(safe).\(ext)" } var request = URLRequest(url: streamURL) request.timeoutInterval = 600 for (key, value) in apiClient.authHeaders { request.setValue(value, forHTTPHeaderField: key) } let result = try await ProgressDownloader.download( request: request, onProgress: onProgress ) let tempURL = result.fileURL let http = result.response guard (200..<300).contains(http.statusCode) else { throw DownloadError.httpError(http.statusCode) } let destURL = directory.appendingPathComponent(fileName) if fm.fileExists(atPath: destURL.path) { try fm.removeItem(at: destURL) } try fm.moveItem(at: tempURL, to: destURL) // Validate non-zero size let attrs = try fm.attributesOfItem(atPath: destURL.path) guard let size = attrs[.size] as? Int64, size > 0 else { try? fm.removeItem(at: destURL) throw DownloadError.emptyFile } track.localCachePath = destURL.path track.downloadState = .downloaded return destURL } catch { track.downloadState = .error throw error } } /// Remove a persistent download for a cloud track. Deletes the local file and resets state. @MainActor static func removeDownload(track: Track) { if let cachePath = track.localCachePath { try? FileManager.default.removeItem(atPath: cachePath) } track.localCachePath = nil track.downloadState = .none } }