| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- 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<URL, Error>).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 (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)
- }
- 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
- }
- }
|