Files
yattee/Yattee/Services/Downloads/DownloadManager+URLSession.swift
2026-02-08 18:33:56 +01:00

216 lines
9.1 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 urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// Called when all background tasks are complete
// Could be used to update UI or show notification
}
}
#endif