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:
875
Yattee/Services/Downloads/DownloadManager+Execution.swift
Normal file
875
Yattee/Services/Downloads/DownloadManager+Execution.swift
Normal 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
|
||||
Reference in New Issue
Block a user