Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,875 @@
//
// 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
) {
let task: URLSessionDownloadTask
if let resumeData {
task = urlSession.downloadTask(withResumeData: resumeData)
} 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)
}
}
task = urlSession.downloadTask(with: request)
}
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