mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
215
Yattee/Services/Downloads/DownloadManager+URLSession.swift
Normal file
215
Yattee/Services/Downloads/DownloadManager+URLSession.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user