|
|
|
|
@@ -1,5 +1,8 @@
|
|
|
|
|
import Foundation
|
|
|
|
|
import Observation
|
|
|
|
|
#if canImport(UIKit)
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
struct DownloadedTrack: Codable, Hashable {
|
|
|
|
|
let ino: String
|
|
|
|
|
@@ -66,29 +69,111 @@ struct DownloadedItem: Codable, Hashable {
|
|
|
|
|
|
|
|
|
|
@Observable
|
|
|
|
|
@MainActor
|
|
|
|
|
final class DownloadManager: @unchecked Sendable {
|
|
|
|
|
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] = [:]
|
|
|
|
|
/// Bytes received for the currently in-flight track per downloadKey.
|
|
|
|
|
/// Reset between tracks; cleared when the download finishes/cancels.
|
|
|
|
|
/// 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] = [:]
|
|
|
|
|
/// Current track index (0-based) per active download. Set at the start of
|
|
|
|
|
/// each track iteration, cleared via defer when the download exits.
|
|
|
|
|
private var currentTrackIndex: [String: Int] = [:]
|
|
|
|
|
/// Total number of tracks per active download.
|
|
|
|
|
/// 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 activeTasks: [String: Task<Void, Never>] = [:]
|
|
|
|
|
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
|
|
|
|
|
// Tells iOS this is interactive (vs. opportunistic bulk transfer) so
|
|
|
|
|
// it scheduling gives us closer-to-foreground throughput. On `.background`
|
|
|
|
|
// sessions this is mostly advisory but still respected for prioritization.
|
|
|
|
|
config.networkServiceType = .responsiveData
|
|
|
|
|
// Allow up to 6 parallel HTTPS connections to the ABS host — matches the
|
|
|
|
|
// default for foreground sessions and helps multi-track books download
|
|
|
|
|
// faster instead of being serialized to 1 connection.
|
|
|
|
|
config.httpMaximumConnectionsPerHost = 6
|
|
|
|
|
#else
|
|
|
|
|
let config = URLSessionConfiguration.default
|
|
|
|
|
#endif
|
|
|
|
|
config.allowsCellularAccess = true
|
|
|
|
|
config.waitsForConnectivity = true
|
|
|
|
|
config.timeoutIntervalForRequest = 60
|
|
|
|
|
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 {
|
|
|
|
|
@@ -115,29 +200,6 @@ final class DownloadManager: @unchecked Sendable {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Called from the URL session delegate (any queue) to update in-flight bytes
|
|
|
|
|
/// for a currently downloading track.
|
|
|
|
|
nonisolated func _updateInFlightBytes(_ bytes: Int64, for key: String) {
|
|
|
|
|
Task { @MainActor [self] in
|
|
|
|
|
self.inFlightBytes[key] = bytes
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Called from the URL session delegate with the fraction (0…1) of the
|
|
|
|
|
/// currently downloading track. Combines with the completed-track count to
|
|
|
|
|
/// drive a smooth overall progress ring, even for single-track downloads.
|
|
|
|
|
nonisolated func _reportTrackByteFraction(_ fraction: Double, for downloadKey: String) {
|
|
|
|
|
Task { @MainActor [self] in
|
|
|
|
|
guard let idx = self.currentTrackIndex[downloadKey],
|
|
|
|
|
let total = self.totalTrackCount[downloadKey],
|
|
|
|
|
total > 0 else { return }
|
|
|
|
|
let overall = (Double(idx) + max(0, min(1, fraction))) / Double(total)
|
|
|
|
|
// Clamp below 1.0 so `performDownload` is the only place that
|
|
|
|
|
// transitions to `.downloaded` after the final track is persisted.
|
|
|
|
|
self.states[downloadKey] = .downloading(progress: min(0.999, overall))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
@@ -153,11 +215,17 @@ final class DownloadManager: @unchecked Sendable {
|
|
|
|
|
/// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
|
|
|
|
|
func startDownload(item: LibraryItem) {
|
|
|
|
|
let key = item.downloadKey
|
|
|
|
|
guard activeTasks[key] == nil else { return }
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
let task = Task { @MainActor [weak self] in
|
|
|
|
|
Task { @MainActor [weak self] in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
var workItem = item
|
|
|
|
|
|
|
|
|
|
@@ -167,28 +235,120 @@ final class DownloadManager: @unchecked Sendable {
|
|
|
|
|
} catch {
|
|
|
|
|
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
|
|
|
|
self.pendingItems.removeValue(forKey: key)
|
|
|
|
|
self.activeTasks[key] = nil
|
|
|
|
|
#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)
|
|
|
|
|
self.activeTasks[key] = nil
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
self.handleDownloadFailure(itemTitle: workItem.title, downloadKey: key)
|
|
|
|
|
#endif
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await self.performDownload(workItem: workItem, downloadKey: key)
|
|
|
|
|
self.activeTasks[key] = nil
|
|
|
|
|
self.enqueueDownload(workItem: workItem, downloadKey: key)
|
|
|
|
|
}
|
|
|
|
|
activeTasks[key] = task
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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) {
|
|
|
|
|
activeTasks[downloadKey]?.cancel()
|
|
|
|
|
activeTasks[downloadKey] = nil
|
|
|
|
|
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) {
|
|
|
|
|
@@ -234,142 +394,164 @@ final class DownloadManager: @unchecked Sendable {
|
|
|
|
|
return "\(itemId)/\(fileName)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
|
|
|
|
|
defer {
|
|
|
|
|
pendingItems.removeValue(forKey: downloadKey)
|
|
|
|
|
inFlightBytes.removeValue(forKey: downloadKey)
|
|
|
|
|
currentTrackIndex.removeValue(forKey: downloadKey)
|
|
|
|
|
totalTrackCount.removeValue(forKey: downloadKey)
|
|
|
|
|
#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) }
|
|
|
|
|
}
|
|
|
|
|
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
|
|
|
|
do {
|
|
|
|
|
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
|
|
|
|
} catch {
|
|
|
|
|
states[downloadKey] = .failed(message: error.localizedDescription)
|
|
|
|
|
}
|
|
|
|
|
#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] ?? [:]
|
|
|
|
|
|
|
|
|
|
var tracks: [DownloadedTrack] = []
|
|
|
|
|
let total = max(workItem.audioFiles.count, 1)
|
|
|
|
|
totalTrackCount[downloadKey] = total
|
|
|
|
|
|
|
|
|
|
for (idx, file) in workItem.audioFiles.enumerated() {
|
|
|
|
|
if Task.isCancelled {
|
|
|
|
|
states[downloadKey] = .notDownloaded
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
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) }
|
|
|
|
|
currentTrackIndex[downloadKey] = idx
|
|
|
|
|
|
|
|
|
|
let tempURL: URL
|
|
|
|
|
do {
|
|
|
|
|
tempURL = try await downloadWithRetry(request: request, filename: file.filename, downloadKey: downloadKey)
|
|
|
|
|
} catch is CancellationError {
|
|
|
|
|
states[downloadKey] = .notDownloaded
|
|
|
|
|
return
|
|
|
|
|
} catch {
|
|
|
|
|
states[downloadKey] = .failed(message: error.localizedDescription)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ext = file.ext.isEmpty ? "mp3" : file.ext
|
|
|
|
|
let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
|
|
|
|
|
let dest = itemDir.appendingPathComponent(destName)
|
|
|
|
|
do {
|
|
|
|
|
try? FileManager.default.removeItem(at: dest)
|
|
|
|
|
try FileManager.default.moveItem(at: tempURL, to: dest)
|
|
|
|
|
} catch {
|
|
|
|
|
states[downloadKey] = .failed(message: error.localizedDescription)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tracks.append(DownloadedTrack(
|
|
|
|
|
ino: file.ino,
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
|
|
|
|
|
durationSeconds: file.durationSeconds
|
|
|
|
|
))
|
|
|
|
|
states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
|
|
|
|
|
// Track is on disk now (folderSize picks it up). Clear the in-flight
|
|
|
|
|
// counter so the next track's bytes don't double-count.
|
|
|
|
|
inFlightBytes[downloadKey] = 0
|
|
|
|
|
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: tracks
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Downloads with up to `maxAttempts` retries and resume-data support so a brief
|
|
|
|
|
/// network dropout picks up where it left off. Uses a classic URLSessionDownloadTask
|
|
|
|
|
/// with explicit delegate (wrapped in a continuation) so we reliably get
|
|
|
|
|
/// `didWriteData` progress callbacks — the async `download(for:delegate:)` API
|
|
|
|
|
/// often doesn't fire them.
|
|
|
|
|
private func downloadWithRetry(request: URLRequest, filename: String, downloadKey: String, maxAttempts: Int = 5) async throws -> URL {
|
|
|
|
|
var resumeData: Data? = nil
|
|
|
|
|
var lastError: Error = URLError(.unknown)
|
|
|
|
|
/// 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() }
|
|
|
|
|
|
|
|
|
|
for attempt in 0..<maxAttempts {
|
|
|
|
|
try Task.checkCancellation()
|
|
|
|
|
do {
|
|
|
|
|
let (tempURL, response) = try await streamingDownload(
|
|
|
|
|
request: request,
|
|
|
|
|
resumeData: resumeData,
|
|
|
|
|
downloadKey: downloadKey
|
|
|
|
|
)
|
|
|
|
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
|
|
|
try? FileManager.default.removeItem(at: tempURL)
|
|
|
|
|
throw URLError(.badServerResponse)
|
|
|
|
|
}
|
|
|
|
|
return tempURL
|
|
|
|
|
} catch is CancellationError {
|
|
|
|
|
throw CancellationError()
|
|
|
|
|
} catch let error as NSError {
|
|
|
|
|
resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
|
|
|
|
lastError = error
|
|
|
|
|
if attempt < maxAttempts - 1 {
|
|
|
|
|
// Exponential backoff: 1 s, 2 s, 4 s, 8 s …
|
|
|
|
|
let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
|
|
|
|
|
try await Task.sleep(nanoseconds: delay)
|
|
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw lastError
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Runs a single download attempt via URLSessionDownloadTask, reporting byte
|
|
|
|
|
/// progress to `inFlightBytes` and moving the system-temp file to a stable
|
|
|
|
|
/// location before returning.
|
|
|
|
|
private func streamingDownload(
|
|
|
|
|
request: URLRequest,
|
|
|
|
|
resumeData: Data?,
|
|
|
|
|
downloadKey: String
|
|
|
|
|
) async throws -> (URL, URLResponse) {
|
|
|
|
|
let session = client.session
|
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
|
|
|
let delegate = DownloadProgressDelegate(
|
|
|
|
|
manager: self,
|
|
|
|
|
downloadKey: downloadKey,
|
|
|
|
|
continuation: continuation
|
|
|
|
|
)
|
|
|
|
|
let task: URLSessionDownloadTask
|
|
|
|
|
if let resumeData {
|
|
|
|
|
task = session.downloadTask(withResumeData: resumeData)
|
|
|
|
|
} else {
|
|
|
|
|
task = session.downloadTask(with: request)
|
|
|
|
|
}
|
|
|
|
|
task.delegate = delegate
|
|
|
|
|
task.resume()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func loadIndex() {
|
|
|
|
|
@@ -401,83 +583,214 @@ final class DownloadManager: @unchecked Sendable {
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Per-task delegate for a single `URLSessionDownloadTask`. Forwards live byte
|
|
|
|
|
/// progress to the manager and bridges the delegate callbacks back to async/await
|
|
|
|
|
/// via a checked continuation. The system deletes the `didFinishDownloadingTo`
|
|
|
|
|
/// temp URL immediately after the callback returns, so we move it to a stable
|
|
|
|
|
/// location before resuming.
|
|
|
|
|
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
|
|
|
|
private let manager: DownloadManager
|
|
|
|
|
private let downloadKey: String
|
|
|
|
|
private let continuation: CheckedContinuation<(URL, URLResponse), Error>
|
|
|
|
|
private var stableTempURL: URL?
|
|
|
|
|
private var didResume = false
|
|
|
|
|
private let lock = NSLock()
|
|
|
|
|
// MARK: - Queued unit
|
|
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
manager: DownloadManager,
|
|
|
|
|
downloadKey: String,
|
|
|
|
|
continuation: CheckedContinuation<(URL, URLResponse), Error>
|
|
|
|
|
) {
|
|
|
|
|
self.manager = manager
|
|
|
|
|
self.downloadKey = downloadKey
|
|
|
|
|
self.continuation = continuation
|
|
|
|
|
super.init()
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func resumeOnce(with result: Result<(URL, URLResponse), Error>) {
|
|
|
|
|
lock.lock(); defer { lock.unlock() }
|
|
|
|
|
guard !didResume else { return }
|
|
|
|
|
didResume = true
|
|
|
|
|
continuation.resume(with: result)
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func urlSession(
|
|
|
|
|
// MARK: - URLSessionDownloadDelegate
|
|
|
|
|
|
|
|
|
|
extension DownloadManager: URLSessionDownloadDelegate {
|
|
|
|
|
|
|
|
|
|
nonisolated func urlSession(
|
|
|
|
|
_ session: URLSession,
|
|
|
|
|
downloadTask: URLSessionDownloadTask,
|
|
|
|
|
didWriteData bytesWritten: Int64,
|
|
|
|
|
totalBytesWritten: Int64,
|
|
|
|
|
totalBytesExpectedToWrite: Int64
|
|
|
|
|
) {
|
|
|
|
|
manager._updateInFlightBytes(totalBytesWritten, for: downloadKey)
|
|
|
|
|
if totalBytesExpectedToWrite > 0 {
|
|
|
|
|
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
|
|
|
manager._reportTrackByteFraction(fraction, for: downloadKey)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func urlSession(
|
|
|
|
|
nonisolated func urlSession(
|
|
|
|
|
_ session: URLSession,
|
|
|
|
|
downloadTask: URLSessionDownloadTask,
|
|
|
|
|
didFinishDownloadingTo location: URL
|
|
|
|
|
) {
|
|
|
|
|
// Move out of the system-temp folder before this delegate method returns
|
|
|
|
|
// (otherwise the OS deletes it).
|
|
|
|
|
let target = FileManager.default.temporaryDirectory
|
|
|
|
|
.appendingPathComponent(UUID().uuidString + ".tmp")
|
|
|
|
|
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.moveItem(at: location, to: target)
|
|
|
|
|
stableTempURL = target
|
|
|
|
|
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
|
|
|
|
try? FileManager.default.removeItem(at: destURL)
|
|
|
|
|
try FileManager.default.moveItem(at: location, to: destURL)
|
|
|
|
|
} catch {
|
|
|
|
|
stableTempURL = nil
|
|
|
|
|
// didCompleteWithError will follow; bail out of completion reporting.
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
Task { @MainActor [weak self] in
|
|
|
|
|
self?.handleTrackCompleted(meta: meta)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func urlSession(
|
|
|
|
|
nonisolated func urlSession(
|
|
|
|
|
_ session: URLSession,
|
|
|
|
|
task: URLSessionTask,
|
|
|
|
|
didCompleteWithError error: Error?
|
|
|
|
|
) {
|
|
|
|
|
if let error {
|
|
|
|
|
resumeOnce(with: .failure(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 url = stableTempURL, let response = task.response else {
|
|
|
|
|
resumeOnce(with: .failure(URLError(.cannotCreateFile)))
|
|
|
|
|
guard let meta = TaskMeta.decode(task.taskDescription) else {
|
|
|
|
|
Task { @MainActor [weak self] in self?.handleTaskAborted() }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resumeOnce(with: .success((url, response)))
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|