DownloadService.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import Foundation
  2. /// Downloads cloud tracks to a local directory for DAW export.
  3. /// Mirrors the UploadService pattern — URLSession-based, auth headers, progress tracking.
  4. enum DownloadService {
  5. // MARK: - Errors
  6. enum DownloadError: LocalizedError {
  7. case noStreamPath
  8. case invalidURL
  9. case invalidResponse
  10. case emptyFile
  11. case httpError(Int)
  12. var errorDescription: String? {
  13. switch self {
  14. case .noStreamPath: "Track has no cloud stream path"
  15. case .invalidURL: "Could not build download URL"
  16. case .invalidResponse: "Invalid server response"
  17. case .emptyFile: "Downloaded file is empty"
  18. case .httpError(let code): "Server error (HTTP \(code))"
  19. }
  20. }
  21. }
  22. // MARK: - Batch Result
  23. /// Result of a batch download operation.
  24. struct BatchResult {
  25. /// Successfully downloaded files, mapped by Track ID to local URL.
  26. let downloaded: [UUID: URL]
  27. /// Tracks that failed to download.
  28. let failures: [(title: String, error: String)]
  29. }
  30. // MARK: - Single Download
  31. /// Download a single cloud track to a local directory.
  32. /// - Returns: The local file URL of the downloaded file.
  33. static func download(
  34. track: Track,
  35. streamURL: URL,
  36. authHeaders: [String: String],
  37. to directory: URL
  38. ) async throws -> URL {
  39. let fm = FileManager.default
  40. try fm.createDirectory(at: directory, withIntermediateDirectories: true)
  41. var request = URLRequest(url: streamURL)
  42. request.timeoutInterval = 300
  43. for (key, value) in authHeaders {
  44. request.setValue(value, forHTTPHeaderField: key)
  45. }
  46. let (tempURL, response) = try await URLSession.shared.download(for: request)
  47. guard let http = response as? HTTPURLResponse else {
  48. throw DownloadError.invalidResponse
  49. }
  50. guard (200..<300).contains(http.statusCode) else {
  51. throw DownloadError.httpError(http.statusCode)
  52. }
  53. // File extension from cloudStreamPath, fallback to Content-Type
  54. let ext = fileExtension(
  55. fromPath: track.cloudStreamPath,
  56. contentType: http.value(forHTTPHeaderField: "Content-Type")
  57. )
  58. let safeName = safeFileName(for: track)
  59. let destName = ext.isEmpty ? safeName : "\(safeName).\(ext)"
  60. let destURL = directory.appendingPathComponent(destName)
  61. if fm.fileExists(atPath: destURL.path) {
  62. try fm.removeItem(at: destURL)
  63. }
  64. try fm.moveItem(at: tempURL, to: destURL)
  65. // Validate non-zero size
  66. let attrs = try fm.attributesOfItem(atPath: destURL.path)
  67. guard let size = attrs[.size] as? Int64, size > 0 else {
  68. try? fm.removeItem(at: destURL)
  69. throw DownloadError.emptyFile
  70. }
  71. return destURL
  72. }
  73. // MARK: - Batch Download
  74. /// Download multiple cloud tracks with bounded concurrency.
  75. static func downloadBatch(
  76. tracks: [(track: Track, streamURL: URL)],
  77. authHeaders: [String: String],
  78. to directory: URL,
  79. maxConcurrency: Int = 3,
  80. onProgress: @MainActor @Sendable (Int, Int) -> Void
  81. ) async -> BatchResult {
  82. var downloaded: [UUID: URL] = [:]
  83. var failures: [(title: String, error: String)] = []
  84. var completed = 0
  85. await withTaskGroup(of: (UUID, String, Result<URL, Error>).self) { group in
  86. var index = 0
  87. // Seed initial batch up to maxConcurrency
  88. while index < min(maxConcurrency, tracks.count) {
  89. let item = tracks[index]
  90. index += 1
  91. group.addTask {
  92. do {
  93. let url = try await download(
  94. track: item.track,
  95. streamURL: item.streamURL,
  96. authHeaders: authHeaders,
  97. to: directory
  98. )
  99. return (item.track.id, item.track.title, .success(url))
  100. } catch {
  101. return (item.track.id, item.track.title, .failure(error))
  102. }
  103. }
  104. }
  105. // As each completes, add the next
  106. while let result = await group.next() {
  107. completed += 1
  108. await onProgress(completed, tracks.count)
  109. switch result.2 {
  110. case .success(let url):
  111. downloaded[result.0] = url
  112. case .failure(let error):
  113. failures.append((title: result.1, error: error.localizedDescription))
  114. }
  115. if index < tracks.count {
  116. let item = tracks[index]
  117. index += 1
  118. group.addTask {
  119. do {
  120. let url = try await download(
  121. track: item.track,
  122. streamURL: item.streamURL,
  123. authHeaders: authHeaders,
  124. to: directory
  125. )
  126. return (item.track.id, item.track.title, .success(url))
  127. } catch {
  128. return (item.track.id, item.track.title, .failure(error))
  129. }
  130. }
  131. }
  132. }
  133. }
  134. return BatchResult(downloaded: downloaded, failures: failures)
  135. }
  136. // MARK: - Private Helpers
  137. /// Extract file extension from the cloud stream path, falling back to Content-Type.
  138. static func fileExtension(fromPath path: String?, contentType: String?) -> String {
  139. // Try stream path first (e.g. "/music/Artist/Album/track.flac")
  140. if let path {
  141. let url = URL(string: path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path)
  142. if let ext = url?.pathExtension, !ext.isEmpty {
  143. return ext.lowercased()
  144. }
  145. // Fallback: split by "." manually
  146. if let lastDot = path.lastIndex(of: ".") {
  147. let ext = String(path[path.index(after: lastDot)...])
  148. if !ext.isEmpty && !ext.contains("/") {
  149. return ext.lowercased()
  150. }
  151. }
  152. }
  153. // Fall back to Content-Type header
  154. if let ct = contentType?.lowercased() {
  155. if ct.contains("flac") { return "flac" }
  156. if ct.contains("mpeg") { return "mp3" }
  157. if ct.contains("wav") { return "wav" }
  158. if ct.contains("aiff") { return "aiff" }
  159. if ct.contains("ogg") { return "ogg" }
  160. if ct.contains("mp4") || ct.contains("m4a") { return "m4a" }
  161. }
  162. return ""
  163. }
  164. /// Create a safe filename from track metadata.
  165. static func safeFileName(for track: Track) -> String {
  166. let base: String
  167. if !track.artist.isEmpty {
  168. base = "\(track.artist) - \(track.title)"
  169. } else {
  170. base = track.title
  171. }
  172. return base
  173. .replacingOccurrences(of: "/", with: "-")
  174. .replacingOccurrences(of: ":", with: "-")
  175. .replacingOccurrences(of: "\\", with: "-")
  176. .replacingOccurrences(of: "\"", with: "'")
  177. }
  178. // MARK: - Persistent Download (Offline Cache)
  179. /// Directory for persistent cloud track downloads.
  180. static var persistentStorageDirectory: URL {
  181. let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
  182. return appSupport.appendingPathComponent("MixBoard/CloudTracks", isDirectory: true)
  183. }
  184. /// Download a cloud track to persistent local storage for offline playback.
  185. /// Updates the track's `localCachePath` and `downloadState` on completion.
  186. @MainActor
  187. static func downloadPersistent(
  188. track: Track,
  189. apiClient: ChadMusicAPIClient,
  190. onProgress: @escaping @Sendable (Double) -> Void = { _ in }
  191. ) async throws -> URL {
  192. guard let streamPath = track.cloudStreamPath else {
  193. throw DownloadError.noStreamPath
  194. }
  195. guard let streamURL = apiClient.streamURL(for: streamPath) else {
  196. throw DownloadError.invalidURL
  197. }
  198. track.downloadState = .downloading
  199. do {
  200. let directory = persistentStorageDirectory
  201. let fm = FileManager.default
  202. try fm.createDirectory(at: directory, withIntermediateDirectories: true)
  203. // Use cloudTrackId for filename uniqueness
  204. let ext = fileExtension(fromPath: streamPath, contentType: nil)
  205. let fileName: String
  206. if let trackId = track.cloudTrackId {
  207. fileName = ext.isEmpty ? trackId : "\(trackId).\(ext)"
  208. } else {
  209. let safe = safeFileName(for: track)
  210. fileName = ext.isEmpty ? safe : "\(safe).\(ext)"
  211. }
  212. var request = URLRequest(url: streamURL)
  213. request.timeoutInterval = 600
  214. for (key, value) in apiClient.authHeaders {
  215. request.setValue(value, forHTTPHeaderField: key)
  216. }
  217. let (tempURL, response) = try await URLSession.shared.download(for: request)
  218. guard let http = response as? HTTPURLResponse else {
  219. throw DownloadError.invalidResponse
  220. }
  221. guard (200..<300).contains(http.statusCode) else {
  222. throw DownloadError.httpError(http.statusCode)
  223. }
  224. let destURL = directory.appendingPathComponent(fileName)
  225. if fm.fileExists(atPath: destURL.path) {
  226. try fm.removeItem(at: destURL)
  227. }
  228. try fm.moveItem(at: tempURL, to: destURL)
  229. // Validate non-zero size
  230. let attrs = try fm.attributesOfItem(atPath: destURL.path)
  231. guard let size = attrs[.size] as? Int64, size > 0 else {
  232. try? fm.removeItem(at: destURL)
  233. throw DownloadError.emptyFile
  234. }
  235. track.localCachePath = destURL.path
  236. track.downloadState = .downloaded
  237. return destURL
  238. } catch {
  239. track.downloadState = .error
  240. throw error
  241. }
  242. }
  243. /// Remove a persistent download for a cloud track. Deletes the local file and resets state.
  244. @MainActor
  245. static func removeDownload(track: Track) {
  246. if let cachePath = track.localCachePath {
  247. try? FileManager.default.removeItem(atPath: cachePath)
  248. }
  249. track.localCachePath = nil
  250. track.downloadState = .none
  251. }
  252. }