Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift
Scarriffle a17a61ad9a Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library
- Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library
- Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail
- Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished
- Add AirPlay picker in PlayerBar
- Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup
- Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s
- Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id)
- Bump CFBundleVersion to 8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 08:54:43 +02:00

800 lines
33 KiB
Swift

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 -> 01).
/// 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
}