Files
yattee/Yattee/Services/Downloads/DownloadManager+Execution.swift
Arkadiusz Fal 13614e7fa0 Fix CFNetwork SIGABRT crash when creating download tasks on invalidated session
The background URLSession could be in an invalid state when downloadTask(with:)
is called, because invalidateAndCancel() is asynchronous internally. This adds
an ObjC exception handler to catch NSExceptions from CFNetwork, nil guards on
the session, and safer session lifecycle management (nil after invalidation,
finishTasksAndInvalidate for cellular toggle).
2026-02-19 17:25:14 +01:00

926 lines
38 KiB
Swift

//
// DownloadManager+Execution.swift
// Yattee
//
// Download execution, progress, completion, and error handling for DownloadManager.
//
import Foundation
import SwiftUI
#if !os(tvOS)
extension DownloadManager {
// MARK: - Download Execution
func startNextDownloadIfNeeded() async {
let currentlyDownloading = activeDownloads.filter { $0.status == .downloading }.count
guard currentlyDownloading < maxConcurrentDownloads else { return }
// Find next queued download
guard let nextIndex = activeDownloads.firstIndex(where: { $0.status == .queued }) else {
return
}
let downloadID = activeDownloads[nextIndex].id
activeDownloads[nextIndex].status = .downloading
activeDownloads[nextIndex].startedAt = Date()
saveDownloads()
await startDownload(for: downloadID)
}
func startDownload(for downloadID: UUID) async {
guard urlSession != nil else {
LoggingService.shared.logDownloadError("URLSession not initialized - call setDownloadSettings() first")
return
}
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
LoggingService.shared.logDownloadError("Download not found in activeDownloads: \(downloadID.uuidString)")
return
}
let download = activeDownloads[index]
LoggingService.shared.logDownload(
"[Downloads] startDownload called",
details: "videoID: \(download.videoID.id), videoProgress: \(download.videoProgress), existingTask: \(videoTasks[downloadID] != nil)"
)
// Start video download if not completed
if download.videoProgress < 1.0 {
startStreamDownload(
downloadID: downloadID,
url: download.streamURL,
phase: .video,
resumeData: download.resumeData,
httpHeaders: download.httpHeaders
)
LoggingService.shared.logDownload("Starting video: \(download.videoID.id)")
}
// Start audio download simultaneously if needed
if let audioURL = download.audioStreamURL, download.audioProgress < 1.0 {
startStreamDownload(
downloadID: downloadID,
url: audioURL,
phase: .audio,
resumeData: download.audioResumeData,
httpHeaders: download.httpHeaders
)
LoggingService.shared.logDownload("Starting audio: \(download.videoID.id)")
}
// Start caption download simultaneously if needed
if let captionURL = download.captionURL, download.captionProgress < 1.0 {
startStreamDownload(
downloadID: downloadID,
url: captionURL,
phase: .caption,
resumeData: nil,
httpHeaders: download.httpHeaders
)
LoggingService.shared.logDownload("Starting caption: \(download.videoID.id)")
}
// Start storyboard download if available and not complete
if download.storyboard != nil, download.storyboardProgress < 1.0 {
startStoryboardDownload(downloadID: downloadID)
}
}
/// Start a download task for a specific stream phase
func startStreamDownload(
downloadID: UUID,
url: URL,
phase: DownloadPhase,
resumeData: Data?,
httpHeaders: [String: String]? = nil
) {
guard urlSession != nil else {
LoggingService.shared.logDownloadError(
"[Downloads] URLSession is nil in startStreamDownload (\(phase))",
error: DownloadError.downloadFailed("URLSession not available - session may be invalidated")
)
handleDownloadError(
downloadID: downloadID,
phase: phase,
error: DownloadError.downloadFailed("URLSession not available")
)
return
}
let task: URLSessionDownloadTask
if let resumeData {
var caughtException: NSException?
var resumeTask: URLSessionDownloadTask?
let success = tryCatchObjCException({
resumeTask = self.urlSession.downloadTask(withResumeData: resumeData)
}, &caughtException)
guard success, let resumeTask else {
LoggingService.shared.logDownloadError(
"[Downloads] NSException creating resume task (\(phase))",
error: DownloadError.downloadFailed("CFNetwork exception: \(caughtException?.reason ?? "unknown")")
)
handleDownloadError(
downloadID: downloadID,
phase: phase,
error: DownloadError.downloadFailed("Failed to create download task: \(caughtException?.reason ?? "unknown")")
)
return
}
task = resumeTask
} else {
// Starting fresh without resumeData - reset progress for this phase
// to avoid jumping when saved progress conflicts with new URLSession progress
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
switch phase {
case .video:
activeDownloads[index].videoProgress = 0
case .audio:
activeDownloads[index].audioProgress = 0
case .caption:
activeDownloads[index].captionProgress = 0
case .storyboard, .thumbnail, .complete:
break
}
recalculateOverallProgress(for: index)
}
var request = URLRequest(url: url)
request.setValue(SettingsManager.currentUserAgent(), forHTTPHeaderField: "User-Agent")
// Add server-provided headers (cookies, referer, etc.)
if let httpHeaders {
for (key, value) in httpHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
}
var caughtException: NSException?
var newTask: URLSessionDownloadTask?
let success = tryCatchObjCException({
newTask = self.urlSession.downloadTask(with: request)
}, &caughtException)
guard success, let newTask else {
LoggingService.shared.logDownloadError(
"[Downloads] NSException creating download task (\(phase))",
error: DownloadError.downloadFailed("CFNetwork exception: \(caughtException?.reason ?? "unknown")")
)
handleDownloadError(
downloadID: downloadID,
phase: phase,
error: DownloadError.downloadFailed("Failed to create download task: \(caughtException?.reason ?? "unknown")")
)
return
}
task = newTask
}
task.taskDescription = "\(downloadID.uuidString):\(phase.rawValue)"
setTaskInfo(downloadID, phase: phase, forTask: task.taskIdentifier)
// Store task in appropriate dictionary
switch phase {
case .video:
videoTasks[downloadID] = task
case .audio:
audioTasks[downloadID] = task
case .caption:
captionTasks[downloadID] = task
case .storyboard, .thumbnail, .complete:
break
}
LoggingService.shared.logDownload(
"[Downloads] Task started (\(phase))",
details: "taskID: \(task.taskIdentifier), URL: \(url.host ?? "unknown")"
)
task.resume()
}
// MARK: - Progress Handling
func handleDownloadProgress(downloadID: UUID, phase: DownloadPhase, bytesWritten: Int64, totalBytes: Int64) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
let previousProgress = download.progress
let now = Date()
// Update phase-specific progress and speed
switch phase {
case .video:
activeDownloads[index].videoTotalBytes = totalBytes
activeDownloads[index].videoDownloadedBytes = bytesWritten
if totalBytes > 0 {
activeDownloads[index].videoProgress = Double(bytesWritten) / Double(totalBytes)
}
// Speed calculation for video
if let lastTime = download.lastSpeedUpdateTime {
let timeDelta = now.timeIntervalSince(lastTime)
if timeDelta >= 0.5 {
let bytesDelta = bytesWritten - download.lastSpeedBytes
let speed = Int64(Double(bytesDelta) / timeDelta)
activeDownloads[index].videoDownloadSpeed = max(0, speed)
activeDownloads[index].downloadSpeed = max(0, speed)
activeDownloads[index].lastSpeedUpdateTime = now
activeDownloads[index].lastSpeedBytes = bytesWritten
}
} else {
activeDownloads[index].lastSpeedUpdateTime = now
activeDownloads[index].lastSpeedBytes = bytesWritten
}
case .audio:
activeDownloads[index].audioTotalBytes = totalBytes
activeDownloads[index].audioDownloadedBytes = bytesWritten
if totalBytes > 0 {
activeDownloads[index].audioProgress = Double(bytesWritten) / Double(totalBytes)
}
// Speed calculation for audio - use separate tracking
let speed = calculateSpeed(currentBytes: bytesWritten, phase: phase, download: download)
activeDownloads[index].audioDownloadSpeed = speed
case .caption:
activeDownloads[index].captionTotalBytes = totalBytes
activeDownloads[index].captionDownloadedBytes = bytesWritten
if totalBytes > 0 {
activeDownloads[index].captionProgress = Double(bytesWritten) / Double(totalBytes)
}
// Speed calculation for caption - use separate tracking
let speed = calculateSpeed(currentBytes: bytesWritten, phase: phase, download: download)
activeDownloads[index].captionDownloadSpeed = speed
case .storyboard, .thumbnail, .complete:
// Storyboard and thumbnail progress are handled separately
break
}
// Calculate combined overall progress
recalculateOverallProgress(for: index)
// Update per-video progress dictionary for efficient thumbnail observation.
// SwiftUI only re-renders views that access this specific video's progress.
let updatedDownload = activeDownloads[index]
downloadProgressByVideo[updatedDownload.videoID] = DownloadProgressInfo(
progress: updatedDownload.progress,
isIndeterminate: updatedDownload.hasIndeterminateProgress
)
// Detect progress reset (indicates download restarted)
let newProgress = activeDownloads[index].progress
if previousProgress > 0.5 && newProgress < 0.1 {
LoggingService.shared.logDownload(
"[Downloads] Progress reset: \(download.videoID.id)",
details: "Was: \(Int(previousProgress * 100))%, Now: \(Int(newProgress * 100))%, Phase: \(phase), bytesWritten: \(bytesWritten), totalBytes: \(totalBytes)"
)
}
}
/// Calculate speed for a stream phase (simplified, updates every call)
func calculateSpeed(currentBytes: Int64, phase: DownloadPhase, download: Download) -> Int64 {
// For audio/caption, we use a simplified speed calculation
// since they run in parallel with video
let previousBytes: Int64
switch phase {
case .audio:
previousBytes = download.audioTotalBytes > 0 ? Int64(download.audioProgress * Double(download.audioTotalBytes)) : 0
case .caption:
previousBytes = download.captionTotalBytes > 0 ? Int64(download.captionProgress * Double(download.captionTotalBytes)) : 0
default:
return 0
}
// Approximate speed based on progress difference (rough estimate)
let delta = currentBytes - previousBytes
return max(0, delta * 2) // Multiply by 2 since updates happen ~every 0.5s
}
/// Recalculate overall progress from all phases
func recalculateOverallProgress(for index: Int) {
let download = activeDownloads[index]
let hasAudio = download.audioStreamURL != nil
let hasCaption = download.captionURL != nil
let hasStoryboard = download.storyboard != nil
// Weights: video ~79%, audio ~19%, caption ~1%, storyboard ~1%
let storyboardWeight: Double = hasStoryboard ? 0.01 : 0.0
let captionWeight: Double = hasCaption ? 0.01 : 0.0
let audioWeight: Double = hasAudio ? 0.19 : 0.0
let videoWeight: Double = 1.0 - audioWeight - captionWeight - storyboardWeight
var overallProgress = download.videoProgress * videoWeight
if hasAudio {
overallProgress += download.audioProgress * audioWeight
}
if hasCaption {
overallProgress += download.captionProgress * captionWeight
}
if hasStoryboard {
overallProgress += download.storyboardProgress * storyboardWeight
}
activeDownloads[index].progress = min(overallProgress, 0.99) // Cap at 99% until truly complete
}
// MARK: - Completion Handling
/// Result of file operations performed on background thread
private struct FileOperationResult {
let success: Bool
let fileName: String?
let fileSize: Int64
let error: Error?
let errorMessage: String?
let contentPreview: String?
}
func handleDownloadCompletion(downloadID: UUID, phase: DownloadPhase, location: URL, expectedBytes: Int64 = 0) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
let minSize = phase == .caption ? Int64(10) : minimumValidFileSize
// Get expected bytes - use header value if available, otherwise use tracked value from progress
let effectiveExpectedBytes: Int64
if expectedBytes > 0 {
effectiveExpectedBytes = expectedBytes
} else {
switch phase {
case .video: effectiveExpectedBytes = download.videoTotalBytes
case .audio: effectiveExpectedBytes = download.audioTotalBytes
case .caption: effectiveExpectedBytes = download.captionTotalBytes
default: effectiveExpectedBytes = 0
}
}
// Generate filename based on phase (pure computation, safe on main thread)
let sanitizedVideoID = download.videoID.videoID
.replacingOccurrences(of: ":", with: "_")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
let fileName: String
switch phase {
case .video:
let fileExtension = download.formatID.isEmpty ? "mp4" : download.formatID
fileName = "\(sanitizedVideoID)_\(download.quality).\(fileExtension)"
case .audio:
let audioExt = download.audioStreamURL?.pathExtension.isEmpty == false
? download.audioStreamURL!.pathExtension
: "m4a"
let langSuffix = download.audioLanguage.map { "_\($0)" } ?? ""
fileName = "\(sanitizedVideoID)_audio\(langSuffix).\(audioExt)"
case .caption:
let langSuffix = download.captionLanguage ?? "unknown"
let captionExt = download.captionURL?.pathExtension.isEmpty == false
? download.captionURL!.pathExtension
: "vtt"
fileName = "\(sanitizedVideoID)_\(langSuffix).\(captionExt)"
case .storyboard, .thumbnail, .complete:
return
}
let destinationURL = downloadsDirectory().appendingPathComponent(fileName)
let videoID = download.videoID.id
// Build URL string for error logging
let urlString: String
switch phase {
case .video: urlString = download.streamURL.absoluteString
case .audio: urlString = download.audioStreamURL?.absoluteString ?? "none"
case .caption: urlString = download.captionURL?.absoluteString ?? "none"
case .storyboard, .thumbnail, .complete: urlString = "n/a"
}
// Move ALL file operations to background thread
Task.detached { [weak self] in
// Capture self at start of closure to satisfy Swift 6 concurrency
let manager = self
let fm = FileManager.default
var result: FileOperationResult
do {
// Check file size
let attrs = try fm.attributesOfItem(atPath: location.path)
let downloadedSize = attrs[.size] as? Int64 ?? 0
if downloadedSize < minSize {
// Read small preview for error logging (max 1KB, only for small error files)
var contentPreview: String?
if downloadedSize < 2048 {
if let data = try? Data(contentsOf: location, options: .mappedIfSafe),
let preview = String(data: data.prefix(300), encoding: .utf8) {
contentPreview = preview.replacingOccurrences(of: "\n", with: " ").prefix(200).description
}
}
try? fm.removeItem(at: location)
result = FileOperationResult(
success: false,
fileName: nil,
fileSize: 0,
error: DownloadError.downloadFailed("Downloaded file is empty or corrupted (\(downloadedSize) bytes)"),
errorMessage: "Got \(downloadedSize) bytes, expected >= \(minSize)",
contentPreview: contentPreview
)
} else if effectiveExpectedBytes > 0 {
// Validate downloaded size against expected size
// If we received less than 90% of expected bytes, the download is incomplete
let minimumAcceptableRatio: Double = 0.90
let actualRatio = Double(downloadedSize) / Double(effectiveExpectedBytes)
if actualRatio < minimumAcceptableRatio {
// Download is incomplete - treat as error for auto-retry
let percentReceived = Int(actualRatio * 100)
try? fm.removeItem(at: location)
result = FileOperationResult(
success: false,
fileName: nil,
fileSize: 0,
error: DownloadError.downloadFailed("Download incomplete: received \(percentReceived)% of expected data"),
errorMessage: "Got \(downloadedSize) bytes, expected \(effectiveExpectedBytes) bytes (\(percentReceived)%)",
contentPreview: nil
)
} else {
// Downloaded size is within acceptable range
// Remove existing file if present
if fm.fileExists(atPath: destinationURL.path) {
try fm.removeItem(at: destinationURL)
}
try fm.moveItem(at: location, to: destinationURL)
// Get final file size
let finalAttrs = try fm.attributesOfItem(atPath: destinationURL.path)
let finalSize = finalAttrs[.size] as? Int64 ?? 0
result = FileOperationResult(
success: true,
fileName: fileName,
fileSize: finalSize,
error: nil,
errorMessage: nil,
contentPreview: nil
)
}
} else {
// Remove existing file if present
if fm.fileExists(atPath: destinationURL.path) {
try fm.removeItem(at: destinationURL)
}
try fm.moveItem(at: location, to: destinationURL)
// Get final file size
let finalAttrs = try fm.attributesOfItem(atPath: destinationURL.path)
let finalSize = finalAttrs[.size] as? Int64 ?? 0
result = FileOperationResult(
success: true,
fileName: fileName,
fileSize: finalSize,
error: nil,
errorMessage: nil,
contentPreview: nil
)
}
} catch {
result = FileOperationResult(
success: false,
fileName: nil,
fileSize: 0,
error: error,
errorMessage: error.localizedDescription,
contentPreview: nil
)
}
// Capture result before entering MainActor to satisfy Swift 6 concurrency
let finalResult = result
// Update state on main thread - guard self before MainActor.run for Swift 6
guard let manager else { return }
await MainActor.run {
manager.handleFileOperationResult(
result: finalResult,
downloadID: downloadID,
phase: phase,
videoID: videoID,
fileName: fileName,
urlString: urlString
)
}
}
}
/// Handle the result of background file operations (runs on main thread)
private func handleFileOperationResult(
result: FileOperationResult,
downloadID: UUID,
phase: DownloadPhase,
videoID: String,
fileName: String,
urlString: String
) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
if result.success, let savedFileName = result.fileName {
// Update the appropriate path and progress based on phase
switch phase {
case .video:
activeDownloads[index].localVideoPath = savedFileName
activeDownloads[index].resumeData = nil
activeDownloads[index].videoProgress = 1.0
activeDownloads[index].videoTotalBytes = result.fileSize
videoTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload("Video saved: \(videoID)", details: savedFileName)
case .audio:
activeDownloads[index].localAudioPath = savedFileName
activeDownloads[index].audioResumeData = nil
activeDownloads[index].audioProgress = 1.0
activeDownloads[index].audioTotalBytes = result.fileSize
audioTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload("Audio saved: \(videoID)", details: savedFileName)
case .caption:
activeDownloads[index].localCaptionPath = savedFileName
activeDownloads[index].captionProgress = 1.0
captionTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload("Caption saved: \(videoID)", details: savedFileName)
case .storyboard, .thumbnail, .complete:
break
}
recalculateOverallProgress(for: index)
saveDownloads()
Task {
await checkAndCompleteDownload(downloadID: downloadID)
}
} else {
// Handle error
if let errorMessage = result.errorMessage {
// Determine if this is an incomplete download or a corrupt/small file
let isIncompleteDownload = errorMessage.contains("expected") && errorMessage.contains("%")
let logCategory = isIncompleteDownload ? "Download incomplete" : "File too small"
LoggingService.shared.logDownloadError(
"[Downloads] \(logCategory) (\(phase)): \(videoID)",
error: DownloadError.downloadFailed(errorMessage)
)
}
LoggingService.shared.logDownload(
"[Downloads] Failed URL (\(phase))",
details: String(urlString.prefix(150))
)
if let preview = result.contentPreview {
LoggingService.shared.logDownloadError("[Downloads] Content preview: \(preview)")
}
handleDownloadError(
downloadID: downloadID,
phase: phase,
error: result.error ?? DownloadError.downloadFailed("Unknown error")
)
}
}
/// Check if all required phases are complete and finalize the download
func checkAndCompleteDownload(downloadID: UUID) async {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
// Check if video is complete
let videoComplete = download.localVideoPath != nil
// Check if audio is complete (or not needed)
let audioComplete = download.audioStreamURL == nil || download.localAudioPath != nil
// Check if caption is complete (or not needed, or skipped due to error)
let captionComplete = download.captionURL == nil ||
download.localCaptionPath != nil ||
download.captionProgress >= 1.0
// Check if storyboard is complete (or not needed, or marked complete via progress)
let storyboardComplete = download.storyboard == nil || download.storyboardProgress >= 1.0
// If video, audio, caption, and storyboard are complete, start thumbnail download
// Thumbnail download is best-effort and won't block completion
if videoComplete && audioComplete && captionComplete && storyboardComplete {
// Check if thumbnail phase needs to be started
if download.downloadPhase != .thumbnail && download.downloadPhase != .complete {
// Start thumbnail download (will call checkAndCompleteDownload again when done)
startThumbnailDownload(downloadID: downloadID)
return
}
// Thumbnail phase is complete (either finished or was started and callback returned)
if download.downloadPhase == .thumbnail {
// Still downloading thumbnails, wait for finalizeThumbnailDownload to call us back
return
}
// All phases complete
await completeMultiFileDownload(downloadID: downloadID)
}
}
/// Complete a multi-file download after all phases are done
func completeMultiFileDownload(downloadID: UUID) async {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
var download = activeDownloads[index]
let baseDir = downloadsDirectory()
// Capture file paths for background calculation
let videoPath = download.localVideoPath
let audioPath = download.localAudioPath
let captionPath = download.localCaptionPath
let storyboardPath = download.localStoryboardPath
let thumbnailPath = download.localThumbnailPath
let channelThumbnailPath = download.localChannelThumbnailPath
// Calculate total bytes on background thread to avoid blocking UI
let totalBytes = await Task.detached {
let fm = FileManager.default
var total: Int64 = 0
if let videoPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(videoPath).path, fileManager: fm)
}
if let audioPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(audioPath).path, fileManager: fm)
}
if let captionPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(captionPath).path, fileManager: fm)
}
if let storyboardPath {
total += Self.directorySizeBackground(at: baseDir.appendingPathComponent(storyboardPath), fileManager: fm)
}
if let thumbnailPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(thumbnailPath).path, fileManager: fm)
}
if let channelThumbnailPath {
total += Self.fileSizeBackground(at: baseDir.appendingPathComponent(channelThumbnailPath).path, fileManager: fm)
}
return total
}.value
// Check if this was a batch download and remove from tracking
let wasBatchDownload = batchDownloadIDs.contains(downloadID)
if wasBatchDownload {
batchDownloadIDs.remove(downloadID)
}
// Update download state on main thread
download.downloadPhase = .complete
download.status = .completed
download.completedAt = Date()
download.progress = 1.0
download.resumeData = nil
download.audioResumeData = nil
download.retryCount = 0
download.totalBytes = totalBytes
download.downloadedBytes = totalBytes
// Move to completed
activeDownloads.remove(at: index)
completedDownloads.insert(download, at: 0)
// Update cached Sets
downloadingVideoIDs.remove(download.videoID)
downloadedVideoIDs.insert(download.videoID)
downloadProgressByVideo.removeValue(forKey: download.videoID)
// Clean up any remaining task references (should already be removed)
videoTasks.removeValue(forKey: downloadID)
audioTasks.removeValue(forKey: downloadID)
captionTasks.removeValue(forKey: downloadID)
storyboardTasks.removeValue(forKey: downloadID)
thumbnailTasks.removeValue(forKey: downloadID)
var details = "Video: \(download.localVideoPath ?? "none")"
if download.localAudioPath != nil { details += ", Audio: \(download.localAudioPath!)" }
if download.localCaptionPath != nil { details += ", Caption: \(download.localCaptionPath!)" }
if download.localStoryboardPath != nil { details += ", Storyboard: \(download.localStoryboardPath!)" }
if download.localThumbnailPath != nil { details += ", Thumbnail: \(download.localThumbnailPath!)" }
LoggingService.shared.logDownload("Completed: \(download.videoID.id)", details: details)
// Only show individual toast for non-batch downloads
if !wasBatchDownload {
if download.warnings.isEmpty {
toastManager?.show(
category: .download,
title: String(localized: "toast.download.completed.title"),
subtitle: download.title,
icon: "checkmark.circle.fill",
iconColor: .green,
autoDismissDelay: 3.0
)
} else {
// Partial success toast with warning
toastManager?.show(
category: .download,
title: String(localized: "toast.download.completedWithWarnings.title"),
subtitle: download.title,
icon: "exclamationmark.triangle.fill",
iconColor: .orange,
autoDismissDelay: 4.0
)
}
}
saveDownloadsImmediately()
await calculateStorageUsed()
await startNextDownloadIfNeeded()
}
// MARK: - Error Handling
func handleDownloadError(downloadID: UUID, phase: DownloadPhase, error: Error) {
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
return
}
let download = activeDownloads[index]
// Remove task for this phase
switch phase {
case .video:
videoTasks.removeValue(forKey: downloadID)
case .audio:
audioTasks.removeValue(forKey: downloadID)
case .caption:
captionTasks.removeValue(forKey: downloadID)
case .storyboard, .thumbnail, .complete:
break
}
// Check if we should retry
if download.retryCount < maxRetryAttempts {
activeDownloads[index].retryCount += 1
// Clear resume data for the failed phase
switch phase {
case .video:
activeDownloads[index].resumeData = nil
case .audio:
activeDownloads[index].audioResumeData = nil
case .caption, .storyboard, .thumbnail, .complete:
break
}
// Build URL string for logging
let retryUrlString: String
switch phase {
case .video: retryUrlString = String(download.streamURL.absoluteString.prefix(100))
case .audio: retryUrlString = String((download.audioStreamURL?.absoluteString ?? "none").prefix(100))
case .caption: retryUrlString = String((download.captionURL?.absoluteString ?? "none").prefix(100))
case .storyboard, .thumbnail, .complete: retryUrlString = "n/a"
}
let delay = retryDelays[min(activeDownloads[index].retryCount - 1, retryDelays.count - 1)]
LoggingService.shared.logDownload(
"[Downloads] Retry \(activeDownloads[index].retryCount)/\(maxRetryAttempts + 1) (\(phase)): \(download.videoID.id)",
details: "Error: \(error.localizedDescription), retrying in \(Int(delay))s"
)
LoggingService.shared.logDownload(
"[Downloads] Retry URL",
details: retryUrlString
)
saveDownloads()
// Schedule retry with delay - only retry the failed phase
Task {
try? await Task.sleep(for: .seconds(delay))
// Verify download still exists and needs retry
guard let currentIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }),
activeDownloads[currentIndex].status == .downloading else {
return
}
let currentDownload = activeDownloads[currentIndex]
// Retry only the failed phase
switch phase {
case .video:
startStreamDownload(
downloadID: downloadID,
url: currentDownload.streamURL,
phase: .video,
resumeData: nil,
httpHeaders: currentDownload.httpHeaders
)
case .audio:
if let audioURL = currentDownload.audioStreamURL {
startStreamDownload(
downloadID: downloadID,
url: audioURL,
phase: .audio,
resumeData: nil,
httpHeaders: currentDownload.httpHeaders
)
}
case .caption:
if let captionURL = currentDownload.captionURL {
startStreamDownload(
downloadID: downloadID,
url: captionURL,
phase: .caption,
resumeData: nil,
httpHeaders: currentDownload.httpHeaders
)
}
case .storyboard:
// Storyboard retry - restart the storyboard download task
startStoryboardDownload(downloadID: downloadID)
case .thumbnail:
// Thumbnail retry - restart the thumbnail download task
startThumbnailDownload(downloadID: downloadID)
case .complete:
break
}
}
} else {
// Max retries exceeded
switch phase {
case .video, .audio:
// Critical phases - fail the entire download
// Remove from batch tracking if this was a batch download
batchDownloadIDs.remove(downloadID)
// Cancel other ongoing tasks for this download
if let task = videoTasks[downloadID] {
task.cancel()
videoTasks.removeValue(forKey: downloadID)
}
if let task = audioTasks[downloadID] {
task.cancel()
audioTasks.removeValue(forKey: downloadID)
}
if let task = captionTasks[downloadID] {
task.cancel()
captionTasks.removeValue(forKey: downloadID)
}
activeDownloads[index].status = .failed
activeDownloads[index].error = "\(phase.rawValue): \(error.localizedDescription)"
LoggingService.shared.logDownloadError(
"Failed after \(maxRetryAttempts) retries (\(phase)): \(download.videoID.id)",
error: error
)
saveDownloads()
Task {
await startNextDownloadIfNeeded()
}
case .caption:
// Non-critical phase - mark as skipped and continue
activeDownloads[index].captionProgress = 1.0 // Mark complete (skipped)
activeDownloads[index].warnings.append("Subtitles failed to download")
captionTasks.removeValue(forKey: downloadID)
LoggingService.shared.logDownload(
"Caption download failed (non-fatal): \(download.videoID.id)",
details: "Error: \(error.localizedDescription)"
)
saveDownloads()
Task {
await checkAndCompleteDownload(downloadID: downloadID)
}
case .storyboard, .thumbnail, .complete:
// Already handled gracefully elsewhere
break
}
}
}
}
#endif