Files
yattee/Yattee/Services/Downloads/DownloadManager+URLSession.swift
Arkadiusz Fal 433e57e68d Recover downloads from an invalidated URLSession (#975)
Toggling cellular downloads recreated the background session with the
same identifier before the old one finished invalidating, and nothing
ever replaced a dead session, so every download threw "Task created in
a session that has been invalidated" until relaunch. Wait for the old
session's invalidation before creating the new one, rebuild the session
when the delegate reports the live one invalid, and recreate it once
when task creation hits the invalidated exception.
2026-10-19 22:31:52 +02:00

222 lines
9.3 KiB
Swift

//
// DownloadManager+URLSession.swift
// Yattee
//
// URLSessionDownloadDelegate conformance for DownloadManager.
//
import Foundation
#if !os(tvOS)
// MARK: - URLSessionDownloadDelegate
extension DownloadManager: URLSessionDownloadDelegate {
/// Parse task description to extract downloadID and phase
/// Format: "UUID:phase" e.g., "550e8400-e29b-41d4-a716-446655440000:video"
private nonisolated func parseTaskDescription(_ description: String?) -> (downloadID: UUID, phase: DownloadPhase)? {
guard let description else { return nil }
let parts = description.split(separator: ":")
guard parts.count == 2,
let uuid = UUID(uuidString: String(parts[0])),
let phase = DownloadPhase(rawValue: String(parts[1])) else {
// Fallback: try parsing as just UUID for backward compatibility
if let uuid = UUID(uuidString: description) {
return (uuid, .video)
}
return nil
}
return (uuid, phase)
}
nonisolated func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Try task ID storage first, then fallback to task description
let taskInfo = getTaskInfo(forTask: downloadTask.taskIdentifier) ??
parseTaskDescription(downloadTask.taskDescription)
guard let taskInfo else {
Task { @MainActor in
LoggingService.shared.logDownloadError("Download completed but no download ID found for task \(downloadTask.taskIdentifier)")
}
return
}
// Log file size at completion with expected size info
let fileSize = (try? FileManager.default.attributesOfItem(atPath: location.path)[.size] as? Int64) ?? 0
let expectedSizeFromCountBytes = downloadTask.countOfBytesExpectedToReceive
Task { @MainActor in
var details = "taskID: \(downloadTask.taskIdentifier), fileSize: \(fileSize) bytes"
if expectedSizeFromCountBytes > 0 && expectedSizeFromCountBytes != NSURLSessionTransferSizeUnknown {
let ratio = expectedSizeFromCountBytes > 0 ? Double(fileSize) / Double(expectedSizeFromCountBytes) * 100 : 0
details += ", expected: \(expectedSizeFromCountBytes) bytes (\(Int(ratio))%)"
}
LoggingService.shared.logDownload(
"[Downloads] didFinishDownloadingTo (\(taskInfo.phase))",
details: details
)
}
// Extract expected content length from response headers
// Check both Content-Length and X-Expected-Content-Length (used by Yattee Server)
var expectedBytes: Int64 = 0
if let httpResponse = downloadTask.response as? HTTPURLResponse {
let statusCode = httpResponse.statusCode
Task { @MainActor in
LoggingService.shared.logDownload(
"[Downloads] HTTP \(statusCode) (\(taskInfo.phase))",
details: "URL: \(httpResponse.url?.host ?? "unknown")"
)
}
// Check for non-success status codes
if statusCode < 200 || statusCode >= 300 {
Task { @MainActor in
LoggingService.shared.logDownloadError(
"[Downloads] Server error HTTP \(statusCode) (\(taskInfo.phase))"
)
self.handleDownloadError(
downloadID: taskInfo.downloadID,
phase: taskInfo.phase,
error: DownloadError.downloadFailed("Server returned HTTP \(statusCode)")
)
}
return
}
// Extract expected size from headers
// X-Expected-Content-Length is used by Yattee Server when Content-Length is unavailable
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let size = Int64(contentLength), size > 0 {
expectedBytes = size
} else if let expectedSize = httpResponse.value(forHTTPHeaderField: "X-Expected-Content-Length"),
let size = Int64(expectedSize), size > 0 {
expectedBytes = size
}
}
// Copy file to temp location since the original will be deleted
let originalExtension = location.pathExtension.isEmpty ? "tmp" : location.pathExtension
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + "." + originalExtension)
do {
try FileManager.default.copyItem(at: location, to: tempURL)
let capturedExpectedBytes = expectedBytes
Task { @MainActor in
self.handleDownloadCompletion(downloadID: taskInfo.downloadID, phase: taskInfo.phase, location: tempURL, expectedBytes: capturedExpectedBytes)
}
} catch {
Task { @MainActor in
LoggingService.shared.logDownloadError("Failed to copy downloaded file", error: error)
self.handleDownloadError(downloadID: taskInfo.downloadID, phase: taskInfo.phase, error: error)
}
}
}
nonisolated func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let taskInfo = getTaskInfo(forTask: downloadTask.taskIdentifier) ??
parseTaskDescription(downloadTask.taskDescription)
guard let taskInfo else { return }
// EARLY THROTTLE: Skip ALL processing if update is too soon (saves CPU)
// This check must be FIRST to avoid unnecessary work on 100s of callbacks/sec
let now = Date()
let lastUpdate = lastProgressUpdateStorage.read { $0[taskInfo.downloadID] } ?? .distantPast
guard now.timeIntervalSince(lastUpdate) >= 0.3 else { return }
lastProgressUpdateStorage.write { $0[taskInfo.downloadID] = now }
// Only process updates that pass the throttle check
let taskID = downloadTask.taskIdentifier
let prevBytes = previousBytesStorage.read { $0[taskID] } ?? 0
if prevBytes > totalBytesWritten + 100_000 {
// Progress went backwards significantly (reset detected)
Task { @MainActor in
LoggingService.shared.logDownload(
"[Downloads] URLSession progress reset detected",
details: "taskID: \(taskID), prev: \(prevBytes), now: \(totalBytesWritten)"
)
}
}
previousBytesStorage.write { $0[taskID] = totalBytesWritten }
// Determine total bytes - use X-Expected-Content-Length header if Content-Length is unknown
let effectiveTotalBytes: Int64
if totalBytesExpectedToWrite == NSURLSessionTransferSizeUnknown,
let response = downloadTask.response as? HTTPURLResponse,
let expectedSize = response.value(forHTTPHeaderField: "X-Expected-Content-Length"),
let size = Int64(expectedSize) {
effectiveTotalBytes = size
} else {
effectiveTotalBytes = totalBytesExpectedToWrite
}
Task { @MainActor in
self.handleDownloadProgress(
downloadID: taskInfo.downloadID,
phase: taskInfo.phase,
bytesWritten: totalBytesWritten,
totalBytes: effectiveTotalBytes
)
}
}
nonisolated func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error = error {
let taskInfo = getTaskInfo(forTask: task.taskIdentifier) ??
parseTaskDescription(task.taskDescription)
guard let taskInfo else {
Task { @MainActor in
LoggingService.shared.logDownloadError("Download task failed with error but no download ID found")
}
return
}
// Check if this was a cancellation with resume data
let nsError = error as NSError
if nsError.code == NSURLErrorCancelled {
Task { @MainActor in
LoggingService.shared.logDownload(
"[Downloads] Task cancelled (\(taskInfo.phase))",
details: "taskID: \(task.taskIdentifier)"
)
}
return
}
Task { @MainActor in
LoggingService.shared.logDownloadError("Download task failed (\(taskInfo.phase))", error: error)
self.handleDownloadError(downloadID: taskInfo.downloadID, phase: taskInfo.phase, error: error)
}
}
}
nonisolated func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
Task { @MainActor in
self.handleSessionInvalidated(session, error: error)
}
}
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// Called when all background tasks are complete
// Could be used to update UI or show notification
}
}
#endif