import Foundation import Observation #if canImport(UIKit) import UIKit #endif struct DownloadedTrack: Codable, Hashable { let ino: String let filename: String let localPath: String // relative to AppPaths.downloadsDirectory let durationSeconds: Double enum CodingKeys: String, CodingKey { case ino, filename, localPath, durationSeconds } init(ino: String, filename: String, localPath: String, durationSeconds: Double) { self.ino = ino self.filename = filename self.localPath = localPath self.durationSeconds = durationSeconds } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) ino = try c.decode(String.self, forKey: .ino) filename = try c.decode(String.self, forKey: .filename) localPath = try c.decode(String.self, forKey: .localPath) durationSeconds = try c.decodeIfPresent(Double.self, forKey: .durationSeconds) ?? 0 } } struct DownloadedItem: Codable, Hashable { let itemId: String var episodeId: String? let title: String let author: String let durationSeconds: Double let tracks: [DownloadedTrack] enum CodingKeys: String, CodingKey { case itemId, episodeId, title, author, durationSeconds, tracks } init(itemId: String, episodeId: String? = nil, title: String, author: String, durationSeconds: Double, tracks: [DownloadedTrack]) { self.itemId = itemId self.episodeId = episodeId self.title = title self.author = author self.durationSeconds = durationSeconds self.tracks = tracks } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) itemId = try c.decode(String.self, forKey: .itemId) episodeId = try c.decodeIfPresent(String.self, forKey: .episodeId) title = try c.decode(String.self, forKey: .title) author = try c.decode(String.self, forKey: .author) durationSeconds = try c.decode(Double.self, forKey: .durationSeconds) tracks = try c.decode([DownloadedTrack].self, forKey: .tracks) } var downloadKey: String { if let episodeId { return "\(itemId)|\(episodeId)" } return itemId } } @Observable @MainActor final class DownloadManager: NSObject, @unchecked Sendable { private let client: ABSClient /// Keyed by downloadKey (itemId or "itemId|episodeId"). private(set) var states: [String: DownloadState] = [:] private(set) var downloadedItems: [String: DownloadedItem] = [:] /// Items currently being downloaded (removed on completion or cancellation). /// Persisted so the list survives an app kill while a background download /// is still in flight. private(set) var pendingItems: [String: LibraryItem] = [:] /// Per-download in-flight bytes for the currently active track(s). Background /// session may have multiple parallel tasks; we sum the writes per key. private(set) var inFlightBytes: [String: Int64] = [:] /// Completed tracks per active download, keyed by track index. Persisted so /// we can stitch the final DownloadedItem together if the app was killed. private var completedTracks: [String: [Int: DownloadedTrack]] = [:] /// Expected total track count per active download (persisted). private var totalTrackCount: [String: Int] = [:] /// In-flight byte counts per track (downloadKey -> trackIndex -> bytesWritten). /// Background sessions may run several tasks for the same download in /// parallel; we keep per-track counts so the sum is accurate even when /// multiple tracks are mid-flight. private var perTrackBytes: [String: [Int: Int64]] = [:] /// In-flight fractions per track (downloadKey -> trackIndex -> 0…1). /// Drives a smooth progress ring even for single-track audiobooks where /// `completedTracks` stays at 0 for the entire download. private var perTrackFractions: [String: [Int: Double]] = [:] /// Global FIFO queue of waiting tasks across ALL downloads. When the user /// has parallel mode off, only `maxConcurrentDownloads` tasks run at any /// time globally — so starting Book 2 while Book 1 is in flight leaves /// Book 2 entirely queued until Book 1 finishes. private var globalQueue: [QueuedUnit] = [] /// Number of URLSession tasks we've handed off to the OS and that haven't /// reported completion yet. Compared against `maxConcurrentDownloads` to /// decide whether to drain the queue. private var activeTaskCount: Int = 0 /// Per-track URLRequest cache — used so we can rebuild a download task /// even after the queue has shifted. private var pendingRequests: [String: [Int: URLRequest]] = [:] private var maxConcurrentDownloads: Int { let parallel = UserDefaults.standard.object(forKey: "downloadsParallel") as? Bool ?? true return parallel ? Int.max : 1 } private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") } private var pendingFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-pending.json") } #if os(iOS) private let activityManager = DownloadActivityManager() /// Set when a download fails while the app is in the foreground. /// The UI observes this to show an in-app alert; cleared on dismissal. var downloadFailureAlertTitle: String? = nil #endif /// Stashed by the AppDelegate when iOS wakes the app to handle a completed /// background download. Called from `urlSessionDidFinishEvents`. nonisolated(unsafe) static var pendingBackgroundCompletion: (() -> Void)? /// Last MainActor-progress-update timestamp per URLSession task identifier /// (in mach nanoseconds). Used to throttle `didWriteData` callbacks — /// accessed only from URLSession's serial delegate queue so it's safe. nonisolated(unsafe) static var lastProgressNs: [Int: UInt64] = [:] /// Background URLSession used for all download tasks. On iOS this keeps /// downloads alive when the app is suspended or terminated; on macOS it /// behaves like a normal session. Set in `init` after `super.init()` /// because `self` is needed as the delegate; @ObservationIgnored because /// the @Observable macro can't synthesize accessors for IUO properties. @ObservationIgnored private var downloadSession: URLSession! private func makeDownloadSession() -> URLSession { let identifier = "com.local.ABS-Client.bgDownloads" #if os(iOS) let config = URLSessionConfiguration.background(withIdentifier: identifier) config.sessionSendsLaunchEvents = true config.isDiscretionary = false config.networkServiceType = .responsiveData config.httpMaximumConnectionsPerHost = 6 #else // macOS hat keinen Background-Session-Daemon mit Auto-Retry — // gleiche Pool-/Service-Settings wie iOS, sonst hängt sich ein // parallel laufender Download bei geteilter Bandbreite zu lange ohne // Bytes auf und failed mit Timeout. let config = URLSessionConfiguration.default config.networkServiceType = .responsiveData config.httpMaximumConnectionsPerHost = 6 #endif config.allowsCellularAccess = true config.waitsForConnectivity = true // 300s statt 60s: bei zwei parallelen großen Tracks kann eine Task // temporär für deutlich mehr als 60s ohne Bytes bleiben, besonders // auf macOS-Foreground-Sessions ohne iOS-Background-Retry-Logik. config.timeoutIntervalForRequest = 300 config.timeoutIntervalForResource = 60 * 60 * 24 // 24h — big audiobooks return URLSession(configuration: config, delegate: self, delegateQueue: nil) } init(client: ABSClient) { self.client = client super.init() // Created here (not as a property initializer) so the URLSession can // pass `self` as its delegate and reconnect to any tasks left running // by a previous app launch. self.downloadSession = makeDownloadSession() try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true) loadIndex() loadPending() } func state(for downloadKey: String) -> DownloadState { states[downloadKey] ?? .notDownloaded } func isDownloaded(downloadKey: String) -> Bool { if case .downloaded = state(for: downloadKey) { return true } return false } func downloadedBytes(for downloadKey: String) -> Int64 { if let item = downloadedItems[downloadKey] { return item.tracks.reduce(Int64(0)) { sum, track in let url = AppPaths.downloadsDirectory.appendingPathComponent(track.localPath) return sum + fileSize(at: url) } } if let pending = pendingItems[downloadKey] { let onDisk = folderSize(at: AppPaths.downloadsDirectory.appendingPathComponent(pending.id)) let inFlight = inFlightBytes[downloadKey] ?? 0 return onDisk + inFlight } return 0 } private func fileSize(at url: URL) -> Int64 { guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 } if let n = attrs[.size] as? NSNumber { return n.int64Value } if let i = attrs[.size] as? Int { return Int64(i) } return 0 } func localTrackURLs(for downloadKey: String) -> [URL]? { guard let item = downloadedItems[downloadKey] else { return nil } return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) } } /// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile). func startDownload(item: LibraryItem) { let key = item.downloadKey // Already downloaded or in progress — no-op if downloadedItems[key] != nil { return } if case .downloading = states[key] ?? .notDownloaded { return } states[key] = .downloading(progress: 0) pendingItems[key] = item #if os(iOS) activityManager.startActivity(downloadKey: key, itemTitle: item.title) #endif Task { @MainActor [weak self] in guard let self else { return } var workItem = item if !workItem.isPodcast && workItem.audioFiles.isEmpty { do { workItem = try await self.client.fetchItemDetail(itemId: item.id) } catch { self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)") self.pendingItems.removeValue(forKey: key) #if os(iOS) self.handleDownloadFailure(itemTitle: item.title, downloadKey: key) #endif return } } if workItem.audioFiles.isEmpty { self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.") self.pendingItems.removeValue(forKey: key) #if os(iOS) self.handleDownloadFailure(itemTitle: workItem.title, downloadKey: key) #endif return } self.enqueueDownload(workItem: workItem, downloadKey: key) } } /// Builds the URLRequest + TaskMeta pairs for every audio file of `workItem` /// and appends them to the global FIFO queue. Then `drainQueue` starts as /// many as the current concurrency limit allows — in sequential mode that's /// 1, so Book 2's tracks wait until Book 1 fully finishes. private func enqueueDownload(workItem: LibraryItem, downloadKey: String) { let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId) try? FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) pendingItems[downloadKey] = workItem totalTrackCount[downloadKey] = workItem.audioFiles.count completedTracks[downloadKey] = [:] persistPending() let allowCellular = UserDefaults.standard.object(forKey: "downloadsOverMobile") as? Bool ?? true var requestMap: [Int: URLRequest] = [:] var units: [QueuedUnit] = [] for (idx, file) in workItem.audioFiles.enumerated() { guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue } var request = URLRequest(url: url) for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) } request.allowsCellularAccess = allowCellular let ext = file.ext.isEmpty ? "mp3" : file.ext let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)" let meta = TaskMeta( downloadKey: downloadKey, trackIndex: idx, itemId: workItem.id, episodeId: workItem.episodeId, destName: destName, ino: file.ino, filename: file.filename, durationSeconds: file.durationSeconds ) requestMap[idx] = request units.append(QueuedUnit(meta: meta, request: request)) } pendingRequests[downloadKey] = requestMap globalQueue.append(contentsOf: units) drainQueue() } /// Starts as many queued tasks as the current concurrency limit allows. /// Reads the user setting fresh each call so a toggle takes effect on the /// next slot that opens up. private func drainQueue() { let limit = maxConcurrentDownloads while activeTaskCount < limit, !globalQueue.isEmpty { let next = globalQueue.removeFirst() activeTaskCount += 1 startTask(meta: next.meta, request: next.request) } } private func startTask(meta: TaskMeta, request: URLRequest) { let task = downloadSession.downloadTask(with: request) task.taskDescription = meta.encoded() task.resume() } /// Called when a task is cancelled (via our cancel/delete or the OS) and /// no track-completion or track-failure handler will fire for it. Keeps /// `activeTaskCount` accurate so the next queued task can start. fileprivate func handleTaskAborted() { activeTaskCount = max(0, activeTaskCount - 1) drainQueue() } func cancel(downloadKey: String) { states[downloadKey] = .notDownloaded pendingItems.removeValue(forKey: downloadKey) inFlightBytes.removeValue(forKey: downloadKey) perTrackBytes.removeValue(forKey: downloadKey) perTrackFractions.removeValue(forKey: downloadKey) completedTracks.removeValue(forKey: downloadKey) totalTrackCount.removeValue(forKey: downloadKey) pendingRequests.removeValue(forKey: downloadKey) // Drop any queued (not-yet-started) tasks for this download. globalQueue.removeAll { $0.meta.downloadKey == downloadKey } persistPending() #if os(iOS) activityManager.endActivity(downloadKey: downloadKey) #endif // Cancel all in-flight URLSession tasks for this download (async). downloadSession.getAllTasks { tasks in for task in tasks { if let meta = TaskMeta.decode(task.taskDescription), meta.downloadKey == downloadKey { task.cancel() } } } } func delete(downloadKey: String) { pendingItems.removeValue(forKey: downloadKey) cancel(downloadKey: downloadKey) if let item = downloadedItems[downloadKey] { let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId) try? FileManager.default.removeItem(at: dir) if item.episodeId != nil { let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId) if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty { try? FileManager.default.removeItem(at: parent) } } } downloadedItems.removeValue(forKey: downloadKey) states[downloadKey] = .notDownloaded persistIndex() } private func folderSize(at url: URL) -> Int64 { guard let enumerator = FileManager.default.enumerator( atPath: url.path ) else { return 0 } var total: Int64 = 0 for case let relPath as String in enumerator { let fileURL = url.appendingPathComponent(relPath) total += fileSize(at: fileURL) } return total } private func directoryURL(itemId: String, episodeId: String?) -> URL { var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true) if let episodeId { dir = dir.appendingPathComponent(episodeId, isDirectory: true) } return dir } private func relativePath(itemId: String, episodeId: String?, fileName: String) -> String { if let episodeId { return "\(itemId)/\(episodeId)/\(fileName)" } return "\(itemId)/\(fileName)" } #if os(iOS) /// Ends the Live Activity and routes the failure to either an in-app alert /// (foreground) or a local notification (background). private func handleDownloadFailure(itemTitle: String, downloadKey: String) { activityManager.endActivity(downloadKey: downloadKey) if UIApplication.shared.applicationState == .active { // Only set if no alert is already pending — prevents a second // concurrent failure from overwriting an unread message. if downloadFailureAlertTitle == nil { downloadFailureAlertTitle = itemTitle } } else { Task { await NotificationManager.shared.sendDownloadFailedNotification(itemTitle: itemTitle) } } } #endif // MARK: - Background-session bookkeeping /// Sums per-task in-flight byte counts (the background session may have /// several tasks for the same download) and recomputes the overall progress /// ring. The progress includes already-completed tracks PLUS the fraction /// of each in-flight track — without this, single-track audiobooks stay /// at 0 % until the whole file is on disk. fileprivate func applyTaskProgress(meta: TaskMeta, totalBytesWritten: Int64, expectedBytes: Int64) { perTrackBytes[meta.downloadKey, default: [:]][meta.trackIndex] = totalBytesWritten if expectedBytes > 0 { let fraction = min(1, max(0, Double(totalBytesWritten) / Double(expectedBytes))) perTrackFractions[meta.downloadKey, default: [:]][meta.trackIndex] = fraction } let trackSum = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0 inFlightBytes[meta.downloadKey] = trackSum if let total = totalTrackCount[meta.downloadKey], total > 0 { let completed = Double(completedTracks[meta.downloadKey]?.count ?? 0) let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0 let progress = min(0.999, (completed + inflightSum) / Double(total)) states[meta.downloadKey] = .downloading(progress: progress) #if os(iOS) activityManager.updateActivity( downloadKey: meta.downloadKey, progress: progress, bytesDownloaded: downloadedBytes(for: meta.downloadKey) ) #endif } } /// Called when a single track completes successfully (its file has already /// been moved to the final destination by the delegate). Records the track, /// decrements the global active-task counter, and asks the queue to drain — /// in sequential mode that frees the single slot for the next queued track. fileprivate func handleTrackCompleted(meta: TaskMeta) { activeTaskCount = max(0, activeTaskCount - 1) // Track is now on disk — clear its in-flight byte/fraction counters so // we don't double-count (folderSize and completedTracks already include // this track). perTrackBytes[meta.downloadKey]?[meta.trackIndex] = 0 perTrackFractions[meta.downloadKey]?[meta.trackIndex] = nil let inFlight = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0 inFlightBytes[meta.downloadKey] = inFlight let track = DownloadedTrack( ino: meta.ino, filename: meta.filename, localPath: relativePath(itemId: meta.itemId, episodeId: meta.episodeId, fileName: meta.destName), durationSeconds: meta.durationSeconds ) completedTracks[meta.downloadKey, default: [:]][meta.trackIndex] = track persistPending() guard let total = totalTrackCount[meta.downloadKey], let workItem = pendingItems[meta.downloadKey] else { drainQueue() return } let completed = completedTracks[meta.downloadKey] ?? [:] if completed.count == total { finalizeDownload(workItem: workItem, downloadKey: meta.downloadKey, completed: completed) } else { let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0 let progress = min(0.999, (Double(completed.count) + inflightSum) / Double(total)) states[meta.downloadKey] = .downloading(progress: progress) #if os(iOS) activityManager.updateActivity( downloadKey: meta.downloadKey, progress: progress, bytesDownloaded: downloadedBytes(for: meta.downloadKey) ) #endif } drainQueue() } private func finalizeDownload(workItem: LibraryItem, downloadKey: String, completed: [Int: DownloadedTrack]) { let sortedTracks = completed.sorted(by: { $0.key < $1.key }).map { $0.value } let downloaded = DownloadedItem( itemId: workItem.id, episodeId: workItem.episodeId, title: workItem.title, author: workItem.author, durationSeconds: workItem.durationSeconds, tracks: sortedTracks ) downloadedItems[downloadKey] = downloaded states[downloadKey] = .downloaded pendingItems.removeValue(forKey: downloadKey) inFlightBytes.removeValue(forKey: downloadKey) perTrackBytes.removeValue(forKey: downloadKey) perTrackFractions.removeValue(forKey: downloadKey) completedTracks.removeValue(forKey: downloadKey) totalTrackCount.removeValue(forKey: downloadKey) pendingRequests.removeValue(forKey: downloadKey) #if os(iOS) activityManager.endActivity(downloadKey: downloadKey) #endif persistIndex() persistPending() } /// Called by the delegate when a track fails irrecoverably. iOS's background /// URLSession already retries transient network errors; if we still end up /// here, the user will need to retry manually. fileprivate func handleTrackFailed(meta: TaskMeta, errorMessage: String) { activeTaskCount = max(0, activeTaskCount - 1) defer { drainQueue() } // Only react once per download — if we already failed/cancelled the // overall download, ignore further per-track failures. guard pendingItems[meta.downloadKey] != nil else { return } let title = pendingItems[meta.downloadKey]?.title ?? "Download" states[meta.downloadKey] = .failed(message: errorMessage) pendingItems.removeValue(forKey: meta.downloadKey) perTrackBytes.removeValue(forKey: meta.downloadKey) perTrackFractions.removeValue(forKey: meta.downloadKey) completedTracks.removeValue(forKey: meta.downloadKey) totalTrackCount.removeValue(forKey: meta.downloadKey) inFlightBytes.removeValue(forKey: meta.downloadKey) pendingRequests.removeValue(forKey: meta.downloadKey) globalQueue.removeAll { $0.meta.downloadKey == meta.downloadKey } persistPending() #if os(iOS) handleDownloadFailure(itemTitle: title, downloadKey: meta.downloadKey) #endif // Cancel any sibling tasks for the same download so we don't keep // pulling bytes for a download we've already given up on. let cancelKey = meta.downloadKey downloadSession.getAllTasks { tasks in for task in tasks { if let m = TaskMeta.decode(task.taskDescription), m.downloadKey == cancelKey { task.cancel() } } } } private func loadIndex() { guard let data = try? Data(contentsOf: indexFile), let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return } var rekeyed: [String: DownloadedItem] = [:] for (_, item) in decoded { if item.tracks.isEmpty { continue } rekeyed[item.downloadKey] = item } downloadedItems = rekeyed for k in rekeyed.keys { states[k] = .downloaded } for (oldKey, item) in decoded where item.tracks.isEmpty { let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey) try? FileManager.default.removeItem(at: dir) } if rekeyed.count != decoded.count { persistIndex() } } private func persistIndex() { do { let data = try JSONEncoder().encode(downloadedItems) try data.write(to: indexFile, options: .atomic) } catch { // non-fatal } } // MARK: - Pending-download persistence /// Snapshot written to disk so that a background download surviving an app /// kill can be picked up again on next launch. private struct PendingSnapshot: Codable { var pendingItems: [String: LibraryItem] var completedTracks: [String: [Int: DownloadedTrack]] var totalTrackCount: [String: Int] } private func persistPending() { let snapshot = PendingSnapshot( pendingItems: pendingItems, completedTracks: completedTracks, totalTrackCount: totalTrackCount ) do { let data = try JSONEncoder().encode(snapshot) try data.write(to: pendingFile, options: .atomic) } catch { // non-fatal } } private func loadPending() { guard let data = try? Data(contentsOf: pendingFile), let snapshot = try? JSONDecoder().decode(PendingSnapshot.self, from: data) else { return } pendingItems = snapshot.pendingItems completedTracks = snapshot.completedTracks totalTrackCount = snapshot.totalTrackCount // Restore states from progress (won't be `.downloaded` since persistIndex // is the only path that flips that). for (key, item) in pendingItems { if let total = totalTrackCount[key], total > 0 { let done = Double(completedTracks[key]?.count ?? 0) states[key] = .downloading(progress: min(0.999, done / Double(total))) } else { states[key] = .downloading(progress: 0) } _ = item // referenced for clarity } } } // MARK: - Queued unit /// One entry in `DownloadManager.globalQueue`. Holds everything needed to spin /// up the URLSessionDownloadTask later, including the prepared URLRequest with /// its bearer headers and cellular-allowance flag already applied. fileprivate struct QueuedUnit { let meta: TaskMeta let request: URLRequest } // MARK: - Task metadata /// Encoded into `URLSessionTask.taskDescription` so the delegate has everything /// it needs to route bytes back to the right download / track on the iOS /// background queue (which has no access to MainActor state). /// /// Uses a hand-rolled tab-separated encoding rather than `Codable` so we don't /// need a nonisolated conformance — the project's /// `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` would otherwise pin Encodable /// to MainActor, which can't be called from the URLSession delegate queue. fileprivate struct TaskMeta: Sendable { let downloadKey: String let trackIndex: Int let itemId: String let episodeId: String? let destName: String let ino: String let filename: String let durationSeconds: Double private nonisolated static let separator = "\u{1F}" // ASCII unit separator — won't appear in IDs/filenames nonisolated func encoded() -> String { let parts: [String] = [ downloadKey, String(trackIndex), itemId, episodeId ?? "", destName, ino, filename, String(durationSeconds) ] return parts.joined(separator: Self.separator) } nonisolated static func decode(_ description: String?) -> TaskMeta? { guard let description else { return nil } let parts = description.components(separatedBy: separator) guard parts.count == 8, let trackIndex = Int(parts[1]), let durationSeconds = Double(parts[7]) else { return nil } return TaskMeta( downloadKey: parts[0], trackIndex: trackIndex, itemId: parts[2], episodeId: parts[3].isEmpty ? nil : parts[3], destName: parts[4], ino: parts[5], filename: parts[6], durationSeconds: durationSeconds ) } } // MARK: - URLSessionDownloadDelegate extension DownloadManager: URLSessionDownloadDelegate { nonisolated func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return } // Throttle: only hop to MainActor at most every ~250 ms per task. The // OS fires didWriteData many times a second; uncapped this floods // SwiftUI's diff loop and starves the main thread (which slows the // download too, since URLSession shares the same process). let now = DispatchTime.now().uptimeNanoseconds let lastNs = Self.lastProgressNs[downloadTask.taskIdentifier] ?? 0 if now - lastNs < 250_000_000 { return } Self.lastProgressNs[downloadTask.taskIdentifier] = now let expected = totalBytesExpectedToWrite Task { @MainActor [weak self] in self?.applyTaskProgress(meta: meta, totalBytesWritten: totalBytesWritten, expectedBytes: expected) } } nonisolated func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return } // Must move synchronously here — the OS removes `location` immediately // after this method returns. let downloadsDir = AppPaths.downloadsDirectory let itemDir: URL = { var dir = downloadsDir.appendingPathComponent(meta.itemId, isDirectory: true) if let episodeId = meta.episodeId { dir = dir.appendingPathComponent(episodeId, isDirectory: true) } return dir }() let destURL = itemDir.appendingPathComponent(meta.destName) do { try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) try? FileManager.default.removeItem(at: destURL) try FileManager.default.moveItem(at: location, to: destURL) } catch { // didCompleteWithError will follow; bail out of completion reporting. return } Task { @MainActor [weak self] in self?.handleTrackCompleted(meta: meta) } } nonisolated func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { // Free the throttle slot for this task identifier — tasks come and go, // and this dict would otherwise grow without bound. Self.lastProgressNs.removeValue(forKey: task.taskIdentifier) // Success path: `didFinishDownloadingTo` already fired and will decrement // the active-task counter via `handleTrackCompleted`. guard let error else { return } let nsError = error as NSError if nsError.code == NSURLErrorCancelled { // We initiated cancellation (cancel/delete/handleTrackFailed). The // active counter still needs to come down so the next queued task // can start. Task { @MainActor [weak self] in self?.handleTaskAborted() } return } guard let meta = TaskMeta.decode(task.taskDescription) else { Task { @MainActor [weak self] in self?.handleTaskAborted() } return } let message = error.localizedDescription Task { @MainActor [weak self] in self?.handleTrackFailed(meta: meta, errorMessage: message) } } #if os(iOS) nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { Task { @MainActor in let handler = DownloadManager.pendingBackgroundCompletion DownloadManager.pendingBackgroundCompletion = nil handler?() } } #endif }