Files
yattee/Yattee/Services/Downloads/DownloadManager.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

1213 lines
47 KiB
Swift

//
// DownloadManager.swift
// Yattee
//
// Manages video downloads with background support.
//
import Foundation
import SwiftUI
#if !os(tvOS)
/// Per-video download progress for efficient SwiftUI observation.
/// Views can observe individual video progress without triggering re-renders for all videos.
struct DownloadProgressInfo: Equatable {
var progress: Double // 0.0 to 1.0
var isIndeterminate: Bool // true if total size unknown
}
/// Manages video downloads with background session support.
@Observable
@MainActor
final class DownloadManager: NSObject {
// MARK: - Properties
/// Active downloads (queued, downloading, paused).
var activeDownloads: [Download] = []
/// Completed downloads.
var completedDownloads: [Download] = []
/// Cached set of downloaded video IDs for O(1) lookup.
var downloadedVideoIDs: Set<VideoID> = []
/// Cached set of downloading (active) video IDs for O(1) lookup.
var downloadingVideoIDs: Set<VideoID> = []
/// Per-video download progress for efficient thumbnail observation.
/// SwiftUI's @Observable tracks dictionary access per-key, so views only
/// re-render when their specific video's progress changes (not all videos).
var downloadProgressByVideo: [VideoID: DownloadProgressInfo] = [:]
/// Total storage used by downloads in bytes.
var storageUsed: Int64 = 0
/// Maximum concurrent downloads (reads from settings, defaults to 2).
var maxConcurrentDownloads: Int {
downloadSettings?.maxConcurrentDownloads ?? 2
}
/// IDs of downloads that are part of the current batch (suppresses individual toasts).
/// Downloads are added when enqueued and removed when completed, failed, or cancelled.
var batchDownloadIDs: Set<UUID> = []
/// Retry delays between attempts (3 delays = 4 total attempts).
/// Matches playback retry timing for consistency.
let retryDelays: [TimeInterval] = [1, 3, 5]
/// Maximum retry attempts (derived from retryDelays).
var maxRetryAttempts: Int { retryDelays.count }
/// Minimum valid file size in bytes (files smaller than this are considered failed).
let minimumValidFileSize: Int64 = 1024 // 1 KB
var urlSession: URLSession!
/// Tracks video download tasks by download ID
var videoTasks: [UUID: URLSessionDownloadTask] = [:]
/// Tracks audio download tasks by download ID
var audioTasks: [UUID: URLSessionDownloadTask] = [:]
/// Tracks caption download tasks by download ID
var captionTasks: [UUID: URLSessionDownloadTask] = [:]
/// Tracks active storyboard download tasks by download ID
var storyboardTasks: [UUID: Task<Void, Never>] = [:]
/// Tracks active thumbnail download tasks by download ID
var thumbnailTasks: [UUID: Task<Void, Never>] = [:]
/// Thread-safe storage for mapping task identifiers to (downloadID, phase).
/// Accessed from URLSession delegate callbacks which run on arbitrary threads.
let taskIDStorage = LockedStorage<[Int: (downloadID: UUID, phase: DownloadPhase)]>([:])
/// Track previous bytes to detect resets at URLSession level
let previousBytesStorage = LockedStorage<[Int: Int64]>([:])
/// Track last progress update time per download to throttle UI updates (0.3s interval)
let lastProgressUpdateStorage = LockedStorage<[UUID: Date]>([:])
let fileManager = FileManager.default
/// Cached downloads directory URL (created once on first access)
private static var _cachedDownloadsDirectory: URL?
weak var toastManager: ToastManager?
weak var downloadSettings: DownloadSettings?
/// Debounced save task to prevent excessive JSON encoding
var saveTask: Task<Void, Never>?
/// Thread-safe setter for task ID mapping.
nonisolated func setTaskInfo(_ downloadID: UUID, phase: DownloadPhase, forTask taskID: Int) {
taskIDStorage.write { $0[taskID] = (downloadID, phase) }
}
/// Thread-safe getter for task ID mapping.
nonisolated func getTaskInfo(forTask taskID: Int) -> (downloadID: UUID, phase: DownloadPhase)? {
taskIDStorage.read { $0[taskID] }
}
// MARK: - Initialization
override init() {
super.init()
loadDownloads()
// Note: Session setup and resumeInterruptedDownloads are deferred to setDownloadSettings()
// to ensure we have the correct cellular access setting before creating the session.
}
private func setupSession() {
let config = URLSessionConfiguration.background(
withIdentifier: AppIdentifiers.downloadSession
)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
#if os(iOS)
let allowCellular = downloadSettings?.allowCellularDownloads ?? false
config.allowsCellularAccess = allowCellular
LoggingService.shared.logDownload(
"[Downloads] Session setup",
details: "allowsCellularAccess: \(allowCellular), hasSettings: \(downloadSettings != nil)"
)
#else
config.allowsCellularAccess = true
#endif
urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
}
private func resumeInterruptedDownloads() {
// Resume any downloads that were in progress when app was terminated
Task {
for download in activeDownloads where download.status == .downloading {
await startDownload(for: download.id)
}
}
}
func setToastManager(_ manager: ToastManager) {
self.toastManager = manager
}
func setDownloadSettings(_ settings: DownloadSettings) {
let isInitialSetup = self.downloadSettings == nil
self.downloadSettings = settings
// Invalidate old session before creating new one with correct settings
urlSession?.invalidateAndCancel()
urlSession = nil
setupSession()
// Resume interrupted downloads only on initial setup
if isInitialSetup {
resumeInterruptedDownloads()
}
}
#if os(iOS)
/// Refreshes the URLSession configuration when cellular settings change.
/// Call this after the user toggles the "Allow Downloads on Cellular" setting.
func refreshCellularAccessSetting() {
Task {
await recreateSessionWithNewCellularSetting()
}
}
/// Recreates the URLSession with updated cellular access setting.
/// Properly pauses active downloads, invalidates the old session, and resumes downloads.
private func recreateSessionWithNewCellularSetting() async {
let allowCellular = downloadSettings?.allowCellularDownloads ?? false
LoggingService.shared.logDownload(
"[Downloads] Cellular setting changed",
details: "allowCellular: \(allowCellular)"
)
// 1. Collect downloads that are currently downloading
let downloadingIDs = activeDownloads
.filter { $0.status == .downloading }
.map { $0.id }
// 2. Pause all active downloads (saves resume data)
for downloadID in downloadingIDs {
if let download = activeDownloads.first(where: { $0.id == downloadID }) {
await pause(download)
}
}
// 3. Invalidate the old session (use finishTasksAndInvalidate since downloads are already paused)
urlSession?.finishTasksAndInvalidate()
urlSession = nil
// 4. Create new session with updated cellular config
setupSession()
LoggingService.shared.logDownload(
"[Downloads] Session recreated",
details: "Migrating \(downloadingIDs.count) downloads"
)
// 5. Mark paused downloads as queued so they restart
for downloadID in downloadingIDs {
if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
activeDownloads[index].status = .queued
}
}
saveDownloads()
// 6. Restart downloads on new session
await startNextDownloadIfNeeded()
}
#endif
// MARK: - Auto Download
/// Automatically enqueue a video for download using the preferred quality setting.
/// This fetches streams, selects the best match, and starts the download without user interaction.
///
/// - Parameters:
/// - video: The video to download
/// - preferredQuality: The maximum quality to download
/// - preferredAudioLanguage: Preferred audio language code (from playback settings)
/// - preferredSubtitlesLanguage: Preferred subtitles language code (from playback settings)
/// - includeSubtitles: Whether to include subtitles
/// - contentService: The content service to fetch streams
/// - instance: The instance to fetch from
/// - suppressStartToast: Whether to suppress the "download started" toast (for batch downloads)
func autoEnqueue(
_ video: Video,
preferredQuality: DownloadQuality,
preferredAudioLanguage: String?,
preferredSubtitlesLanguage: String?,
includeSubtitles: Bool,
contentService: ContentService,
instance: Instance,
suppressStartToast: Bool = false
) async throws {
// Fetch streams and captions
let fetchedVideo: Video
let streams: [Stream]
let captions: [Caption]
let storyboards: [Storyboard]
if case .extracted(_, let originalURL) = video.id.source {
// Extracted videos need re-extraction via /api/v1/extract
let result = try await contentService.extractURL(originalURL, instance: instance)
fetchedVideo = result.video
streams = result.streams
captions = result.captions
storyboards = []
} else {
let result = try await contentService.videoWithProxyStreamsAndCaptionsAndStoryboards(
id: video.id.videoID,
instance: instance
)
fetchedVideo = result.video
streams = result.streams
captions = result.captions
storyboards = result.storyboards
}
// Filter and select video stream
let videoStream = selectBestVideoStream(
from: streams,
maxQuality: preferredQuality
)
guard let videoStream else {
throw DownloadError.noStreamAvailable
}
// Select audio stream if needed (for video-only streams)
var audioStream: Stream?
if videoStream.isVideoOnly {
audioStream = selectBestAudioStream(
from: streams,
preferredLanguage: preferredAudioLanguage
)
}
// Select caption if enabled
var caption: Caption?
if includeSubtitles, let preferredSubtitlesLanguage {
caption = selectBestCaption(
from: captions,
preferredLanguage: preferredSubtitlesLanguage
)
}
// Get storyboard
let storyboard = storyboards.highest()
// Enqueue the download
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate
try await enqueue(
fetchedVideo,
quality: videoStream.qualityLabel,
formatID: videoStream.format,
streamURL: videoStream.url,
audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
captionURL: caption?.url,
audioLanguage: audioStream?.audioLanguage,
captionLanguage: caption?.languageCode,
httpHeaders: videoStream.httpHeaders,
storyboard: storyboard,
dislikeCount: nil,
videoCodec: videoStream.videoCodec,
audioCodec: audioCodec,
videoBitrate: videoStream.bitrate,
audioBitrate: audioBitrate,
suppressStartToast: suppressStartToast
)
}
/// Automatically enqueue a media source video (SMB/WebDAV/local) for download.
/// Unlike autoEnqueue, this doesn't make API calls - it uses the direct file URL.
///
/// - For SMB files: Downloads using libsmbclient (not URLSession) since URLSession doesn't support smb:// URLs.
/// - For local folder files: Copies the file to the downloads directory.
/// - For WebDAV files: Uses URLSession with HTTP/HTTPS (existing approach works).
func autoEnqueueMediaSource(
_ video: Video,
mediaSourcesManager: MediaSourcesManager,
webDAVClient: WebDAVClient,
smbClient: SMBClient
) async throws {
guard case .extracted(_, let originalURL) = video.id.source else {
throw DownloadError.noStreamAvailable
}
// For SMB files: download using SMBClient (not URLSession)
// URLSession doesn't support smb:// URLs - it only supports HTTP/HTTPS
if video.isFromSMB {
guard let sourceID = video.mediaSourceID,
let filePath = video.mediaSourceFilePath,
let source = mediaSourcesManager.sources.first(where: { $0.id == sourceID }) else {
throw DownloadError.noStreamAvailable
}
let password = mediaSourcesManager.password(for: source)
LoggingService.shared.logDownload(
"[Downloads] Starting SMB download",
details: "video: \(video.title), path: \(filePath)"
)
// Download using libsmbclient
let (localURL, fileSize) = try await smbClient.downloadFileToDownloads(
filePath: filePath,
source: source,
password: password,
downloadsDirectory: downloadsDirectory()
)
// Create completed download record
try await createCompletedDownload(
video: video,
localURL: localURL,
fileSize: fileSize
)
return
}
// For local folder files: copy to downloads directory
if video.isFromLocalFolder {
LoggingService.shared.logDownload(
"[Downloads] Copying local file",
details: "video: \(video.title), url: \(originalURL.path)"
)
let localURL = try copyLocalFileToDownloads(from: originalURL)
let fileSize = self.fileSize(at: localURL.path)
try await createCompletedDownload(
video: video,
localURL: localURL,
fileSize: fileSize
)
return
}
// For WebDAV: use URLSession (HTTP-based, existing code works)
var authHeaders: [String: String]?
if video.isFromWebDAV,
let sourceID = video.mediaSourceID,
let source = mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
let password = mediaSourcesManager.password(for: source)
authHeaders = await webDAVClient.authHeaders(for: source, password: password)
}
let fileExtension = originalURL.pathExtension.lowercased()
try await enqueue(
video,
quality: "Original",
formatID: fileExtension.isEmpty ? "video" : fileExtension,
streamURL: originalURL,
httpHeaders: authHeaders,
audioCodec: "aac" // Mark as muxed since local files typically have both tracks
)
}
// MARK: - Batch Download
/// Result of a batch download operation.
struct BatchDownloadResult: Sendable {
let successCount: Int
let skippedCount: Int
let failedVideos: [(title: String, error: String)]
}
/// Batch enqueue multiple videos for download.
///
/// This method processes videos sequentially to avoid overwhelming the API.
/// It skips videos that are already downloaded or downloading, and reports
/// errors via the `onError` callback which can pause execution for user input.
///
/// - Parameters:
/// - videos: The videos to download
/// - preferredQuality: Maximum quality to download
/// - preferredAudioLanguage: Preferred audio language code
/// - preferredSubtitlesLanguage: Preferred subtitles language code
/// - includeSubtitles: Whether to include subtitles
/// - contentService: The content service to fetch streams
/// - instance: The instance to fetch from
/// - onProgress: Called after each video with (current, total)
/// - onError: Called when a video fails, returns true to continue or false to stop
/// - Returns: Summary of the batch operation
func batchAutoEnqueue(
videos: [Video],
preferredQuality: DownloadQuality,
preferredAudioLanguage: String?,
preferredSubtitlesLanguage: String?,
includeSubtitles: Bool,
contentService: ContentService,
instance: Instance,
onProgress: @escaping @Sendable (Int, Int) async -> Void,
onError: @escaping @Sendable (Video, Error) async -> Bool,
onEnqueued: (@Sendable (UUID) async -> Void)? = nil
) async -> BatchDownloadResult {
var successCount = 0
var skippedCount = 0
var failedVideos: [(title: String, error: String)] = []
for (index, video) in videos.enumerated() {
// Report progress
await onProgress(index + 1, videos.count)
// Skip if already downloaded or downloading
if downloadedVideoIDs.contains(video.id) || downloadingVideoIDs.contains(video.id) {
skippedCount += 1
LoggingService.shared.logDownload(
"[Batch] Skipped: \(video.id.id)",
details: "Already downloaded or downloading"
)
continue
}
do {
try await autoEnqueue(
video,
preferredQuality: preferredQuality,
preferredAudioLanguage: preferredAudioLanguage,
preferredSubtitlesLanguage: preferredSubtitlesLanguage,
includeSubtitles: includeSubtitles,
contentService: contentService,
instance: instance,
suppressStartToast: true
)
// Report the download ID that was just created
if let download = activeDownloads.first(where: { $0.videoID == video.id }) {
await onEnqueued?(download.id)
}
successCount += 1
} catch {
LoggingService.shared.logDownload(
"[Batch] Failed: \(video.id.id)",
details: error.localizedDescription
)
failedVideos.append((title: video.title, error: error.localizedDescription))
// Ask user if they want to continue
let shouldContinue = await onError(video, error)
if !shouldContinue {
LoggingService.shared.logDownload(
"[Batch] Stopped by user",
details: "After \(index + 1) of \(videos.count) videos"
)
break
}
}
}
LoggingService.shared.logDownload(
"[Batch] Complete",
details: "Success: \(successCount), Skipped: \(skippedCount), Failed: \(failedVideos.count)"
)
return BatchDownloadResult(
successCount: successCount,
skippedCount: skippedCount,
failedVideos: failedVideos
)
}
// MARK: - Stream Selection Helpers
/// Selects the best video stream up to the specified quality.
/// Respects device hardware capabilities when selecting codecs.
private func selectBestVideoStream(
from streams: [Stream],
maxQuality: DownloadQuality
) -> Stream? {
let maxRes = maxQuality.maxResolution
// Filter to downloadable video streams (exclude HLS/DASH)
let videoStreams = streams
.filter { !$0.isAudioOnly && $0.resolution != nil }
.filter {
let format = StreamFormat.detect(from: $0)
return format != .hls && format != .dash
}
.sorted { s1, s2 in
// Sort by resolution (higher first), then by codec priority
let res1 = s1.resolution ?? .p360
let res2 = s2.resolution ?? .p360
if res1 != res2 { return res1 > res2 }
// Prefer muxed streams
if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
// Then by codec quality (respecting hardware capabilities)
return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
}
// If maxRes is nil (best quality), return the highest quality stream
guard let maxRes else {
return videoStreams.first
}
// Find the best stream that doesn't exceed maxRes
if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
return stream
}
// Fallback: return the lowest quality stream if all exceed maxRes
return videoStreams.last
}
/// Selects the best audio stream for the preferred language.
private func selectBestAudioStream(
from streams: [Stream],
preferredLanguage: String?
) -> Stream? {
let audioStreams = streams.filter { $0.isAudioOnly }
if let preferred = preferredLanguage {
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
return match
}
}
// Fallback to original audio or first available
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
return original
}
return audioStreams.first
}
/// Selects the best caption for the preferred language.
private func selectBestCaption(
from captions: [Caption],
preferredLanguage: String
) -> Caption? {
// Prefer exact match, then prefix match, then base language match
if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
return exact
}
if let prefix = captions.first(where: { $0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage }) {
return prefix
}
return nil
}
// MARK: - Queue Management
/// Add a video to the download queue.
func enqueue(
_ video: Video,
quality: String,
formatID: String,
streamURL: URL,
audioStreamURL: URL? = nil,
captionURL: URL? = nil,
audioLanguage: String? = nil,
captionLanguage: String? = nil,
httpHeaders: [String: String]? = nil,
storyboard: Storyboard? = nil,
dislikeCount: Int? = nil,
priority: DownloadPriority = .normal,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoBitrate: Int? = nil,
audioBitrate: Int? = nil,
suppressStartToast: Bool = false
) async throws {
// Check if already downloading
guard !activeDownloads.contains(where: { $0.videoID == video.id }) else {
throw DownloadError.alreadyDownloading
}
// Check if already downloaded
guard !completedDownloads.contains(where: { $0.videoID == video.id }) else {
throw DownloadError.alreadyDownloaded
}
let download = Download(
video: video,
quality: quality,
formatID: formatID,
streamURL: streamURL,
audioStreamURL: audioStreamURL,
captionURL: captionURL,
audioLanguage: audioLanguage,
captionLanguage: captionLanguage,
httpHeaders: httpHeaders,
storyboard: storyboard,
dislikeCount: dislikeCount,
priority: priority,
videoCodec: videoCodec,
audioCodec: audioCodec,
videoBitrate: videoBitrate,
audioBitrate: audioBitrate
)
activeDownloads.append(download)
downloadingVideoIDs.insert(download.videoID)
downloadProgressByVideo[download.videoID] = DownloadProgressInfo(progress: 0, isIndeterminate: true)
sortQueue()
saveDownloads()
var details = "Quality: \(quality)"
if audioStreamURL != nil { details += ", Audio: \(audioLanguage ?? "default")" }
if captionURL != nil { details += ", Caption: \(captionLanguage ?? "unknown")" }
if storyboard != nil { details += ", Storyboard: \(storyboard!.storyboardCount) sheets" }
LoggingService.shared.logDownload("Queued: \(download.videoID.id)", details: details)
if !suppressStartToast {
toastManager?.show(
category: .download,
title: String(localized: "toast.download.started.title"),
subtitle: video.title,
icon: "arrow.down.circle",
iconColor: .blue,
autoDismissDelay: 2.0
)
}
// Start download if under concurrent limit
await startNextDownloadIfNeeded()
}
/// Pause a download.
func pause(_ download: Download) async {
guard let index = activeDownloads.firstIndex(where: { $0.id == download.id }) else {
return
}
// Cancel all active tasks with resume data
if let task = videoTasks[download.id] {
let resumeData = await task.cancelByProducingResumeData()
if let idx = activeDownloads.firstIndex(where: { $0.id == download.id }) {
activeDownloads[idx].resumeData = resumeData
}
videoTasks.removeValue(forKey: download.id)
}
if let task = audioTasks[download.id] {
let resumeData = await task.cancelByProducingResumeData()
if let idx = activeDownloads.firstIndex(where: { $0.id == download.id }) {
activeDownloads[idx].audioResumeData = resumeData
}
audioTasks.removeValue(forKey: download.id)
}
if let task = captionTasks[download.id] {
task.cancel()
captionTasks.removeValue(forKey: download.id)
}
if let task = storyboardTasks[download.id] {
task.cancel()
storyboardTasks.removeValue(forKey: download.id)
}
if let task = thumbnailTasks[download.id] {
task.cancel()
thumbnailTasks.removeValue(forKey: download.id)
}
activeDownloads[index].status = .paused
saveDownloads()
LoggingService.shared.logDownload("Paused: \(download.videoID.id)")
}
/// Resume a paused download.
func resume(_ download: Download) async {
guard let index = activeDownloads.firstIndex(where: { $0.id == download.id }),
activeDownloads[index].status == .paused || activeDownloads[index].status == .failed else {
return
}
activeDownloads[index].status = .queued
activeDownloads[index].error = nil
activeDownloads[index].retryCount = 0 // Reset retry count on manual resume
saveDownloads()
LoggingService.shared.logDownload("Resumed: \(download.videoID.id)")
await startNextDownloadIfNeeded()
}
/// Cancel and remove a download.
func cancel(_ download: Download) async {
// Cancel all tasks
if let task = videoTasks[download.id] {
task.cancel()
videoTasks.removeValue(forKey: download.id)
}
if let task = audioTasks[download.id] {
task.cancel()
audioTasks.removeValue(forKey: download.id)
}
if let task = captionTasks[download.id] {
task.cancel()
captionTasks.removeValue(forKey: download.id)
}
if let task = storyboardTasks[download.id] {
task.cancel()
storyboardTasks.removeValue(forKey: download.id)
}
if let task = thumbnailTasks[download.id] {
task.cancel()
thumbnailTasks.removeValue(forKey: download.id)
}
activeDownloads.removeAll { $0.id == download.id }
downloadingVideoIDs.remove(download.videoID)
downloadProgressByVideo.removeValue(forKey: download.videoID)
// Remove from batch tracking if this was a batch download
batchDownloadIDs.remove(download.id)
// Clean up partial files
if let path = download.localVideoPath {
try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
}
if let path = download.localAudioPath {
try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
}
if let path = download.localCaptionPath {
try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
}
if let path = download.localStoryboardPath {
try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
}
if let path = download.localThumbnailPath {
try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
}
if let path = download.localChannelThumbnailPath {
try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
}
saveDownloads()
LoggingService.shared.logDownload("Cancelled: \(download.videoID.id)")
// Start next queued download
await startNextDownloadIfNeeded()
}
/// Delete a completed download.
func delete(_ download: Download) async {
completedDownloads.removeAll { $0.id == download.id }
downloadedVideoIDs.remove(download.videoID)
// Delete all associated files (video, audio, caption)
var deletedFiles: [String] = []
if let videoPath = download.localVideoPath {
let videoURL = downloadsDirectory().appendingPathComponent(videoPath)
if fileManager.fileExists(atPath: videoURL.path) {
try? fileManager.removeItem(at: videoURL)
deletedFiles.append("video: \(videoPath)")
}
}
if let audioPath = download.localAudioPath {
let audioURL = downloadsDirectory().appendingPathComponent(audioPath)
if fileManager.fileExists(atPath: audioURL.path) {
try? fileManager.removeItem(at: audioURL)
deletedFiles.append("audio: \(audioPath)")
}
}
if let captionPath = download.localCaptionPath {
let captionURL = downloadsDirectory().appendingPathComponent(captionPath)
if fileManager.fileExists(atPath: captionURL.path) {
try? fileManager.removeItem(at: captionURL)
deletedFiles.append("caption: \(captionPath)")
}
}
if let storyboardPath = download.localStoryboardPath {
let storyboardURL = downloadsDirectory().appendingPathComponent(storyboardPath)
if fileManager.fileExists(atPath: storyboardURL.path) {
try? fileManager.removeItem(at: storyboardURL)
deletedFiles.append("storyboard: \(storyboardPath)")
}
}
if let thumbnailPath = download.localThumbnailPath {
let thumbnailURL = downloadsDirectory().appendingPathComponent(thumbnailPath)
if fileManager.fileExists(atPath: thumbnailURL.path) {
try? fileManager.removeItem(at: thumbnailURL)
deletedFiles.append("thumbnail: \(thumbnailPath)")
}
}
if let channelThumbnailPath = download.localChannelThumbnailPath {
let channelThumbnailURL = downloadsDirectory().appendingPathComponent(channelThumbnailPath)
if fileManager.fileExists(atPath: channelThumbnailURL.path) {
try? fileManager.removeItem(at: channelThumbnailURL)
deletedFiles.append("channelThumbnail: \(channelThumbnailPath)")
}
}
await calculateStorageUsed()
saveDownloads()
LoggingService.shared.logDownload("Deleted: \(download.videoID.id)", details: deletedFiles.joined(separator: ", "))
}
/// Move a download in the queue.
func moveInQueue(_ download: Download, to position: Int) async {
guard let currentIndex = activeDownloads.firstIndex(where: { $0.id == download.id }) else {
return
}
let item = activeDownloads.remove(at: currentIndex)
let targetIndex = min(max(0, position), activeDownloads.count)
activeDownloads.insert(item, at: targetIndex)
saveDownloads()
}
// MARK: - Batch Operations
/// Pause all active downloads.
func pauseAll() async {
for download in activeDownloads where download.status == .downloading {
await pause(download)
}
}
/// Resume all paused downloads.
func resumeAll() async {
for download in activeDownloads where download.status == .paused {
await resume(download)
}
}
/// Delete all completed downloads.
func deleteAllCompleted() async {
for download in completedDownloads {
await delete(download)
}
}
/// Delete downloads for videos that have been watched.
/// - Parameter watchedVideoIDs: Set of video IDs (strings) that are considered watched.
func deleteWatchedDownloads(watchedVideoIDs: Set<String>) async {
let watchedDownloads = completedDownloads.filter {
watchedVideoIDs.contains($0.videoID.videoID)
}
for download in watchedDownloads {
await delete(download)
}
}
// MARK: - Queries
/// Check if a video is downloaded using cached Set for O(1) lookup.
func isDownloaded(_ videoID: VideoID) -> Bool {
downloadedVideoIDs.contains(videoID)
}
/// Check if a video is currently downloading using cached Set for O(1) lookup.
func isDownloading(_ videoID: VideoID) -> Bool {
downloadingVideoIDs.contains(videoID)
}
/// Get the download for a video if it exists.
func download(for videoID: VideoID) -> Download? {
completedDownloads.first { $0.videoID == videoID } ??
activeDownloads.first { $0.videoID == videoID }
}
/// Get the local file URL for a completed download.
func localURL(for videoID: VideoID) -> URL? {
guard let download = completedDownloads.first(where: { $0.videoID == videoID }),
let fileName = download.localVideoPath else {
return nil
}
return downloadsDirectory().appendingPathComponent(fileName)
}
/// Resolve the full file URL for a download's local path.
func resolveLocalURL(for download: Download) -> URL? {
guard let fileName = download.localVideoPath else {
return nil
}
return downloadsDirectory().appendingPathComponent(fileName)
}
/// Creates a Video and Stream for playing a downloaded video.
/// Returns nil if the download doesn't have a valid local file.
/// Also returns the stored dislike count, audio stream, and caption URL if available.
func videoAndStream(for download: Download) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? {
guard let fileURL = resolveLocalURL(for: download) else {
LoggingService.shared.warning("[Downloads] resolveLocalURL returned nil for \(download.videoID), localVideoPath=\(download.localVideoPath ?? "nil")", category: .downloads)
return nil
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
LoggingService.shared.warning("[Downloads] Local file does not exist at \(fileURL.path) for \(download.videoID)", category: .downloads)
return nil
}
let video = Video(
id: download.videoID,
title: download.title,
description: download.description,
author: Author(
id: download.channelID,
name: download.channelName,
thumbnailURL: download.channelThumbnailURL,
subscriberCount: download.channelSubscriberCount
),
duration: download.duration,
publishedAt: download.publishedAt,
publishedText: download.publishedText,
viewCount: download.viewCount,
likeCount: download.likeCount,
thumbnails: download.thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
// Determine video format from file extension
let format = fileURL.pathExtension.isEmpty ? "mp4" : fileURL.pathExtension
let mimeType = format == "webm" ? "video/webm" : "video/mp4"
// Parse resolution from quality string (e.g., "1080p" -> StreamResolution)
let resolution = StreamResolution(heightLabel: download.quality)
// If no separate audio file, this is a muxed stream - use stored or default audioCodec
let isMuxedDownload = download.localAudioPath == nil
let streamAudioCodec = isMuxedDownload ? (download.audioCodec ?? "aac") : nil
let stream = Stream(
url: fileURL,
resolution: resolution,
format: format,
videoCodec: download.videoCodec,
audioCodec: streamAudioCodec,
bitrate: download.videoBitrate,
fileSize: download.videoTotalBytes > 0 ? download.videoTotalBytes : nil,
isLive: false,
mimeType: mimeType
)
// Create audio stream if we have a separate audio file
var audioStream: Stream?
if let audioPath = download.localAudioPath {
let audioURL = downloadsDirectory().appendingPathComponent(audioPath)
if FileManager.default.fileExists(atPath: audioURL.path) {
let audioFormat = audioURL.pathExtension.isEmpty ? "m4a" : audioURL.pathExtension
let audioMimeType = audioFormat == "webm" ? "audio/webm" : "audio/mp4"
audioStream = Stream(
url: audioURL,
resolution: nil,
format: audioFormat,
audioCodec: download.audioCodec,
bitrate: download.audioBitrate,
fileSize: download.audioTotalBytes > 0 ? download.audioTotalBytes : nil,
isAudioOnly: true,
isLive: false,
mimeType: audioMimeType,
audioLanguage: download.audioLanguage
)
}
}
// Get caption URL if we have a caption file
var captionURL: URL?
if let captionPath = download.localCaptionPath {
let url = downloadsDirectory().appendingPathComponent(captionPath)
if FileManager.default.fileExists(atPath: url.path) {
captionURL = url
}
}
return (video, stream, audioStream, captionURL, download.dislikeCount)
}
/// Creates a Video and Stream for playing a downloaded video by video ID.
/// Returns nil if the video is not downloaded or the file doesn't exist.
/// Also returns the stored dislike count, audio stream, and caption URL if available.
func videoAndStream(for videoID: VideoID) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? {
guard let download = completedDownloads.first(where: { $0.videoID == videoID }) else {
return nil
}
return videoAndStream(for: download)
}
// MARK: - Private Helpers
func sortQueue() {
activeDownloads.sort { $0.priority.rawValue > $1.priority.rawValue }
}
func fileSize(at path: String) -> Int64 {
do {
let attrs = try fileManager.attributesOfItem(atPath: path)
return attrs[.size] as? Int64 ?? 0
} catch {
return 0
}
}
/// Returns the downloads directory URL (cached for performance).
func downloadsDirectory() -> URL {
if let cached = Self._cachedDownloadsDirectory {
return cached
}
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let downloadsURL = documentsURL.appendingPathComponent("Downloads", isDirectory: true)
if !fileManager.fileExists(atPath: downloadsURL.path) {
try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true)
}
Self._cachedDownloadsDirectory = downloadsURL
return downloadsURL
}
// MARK: - Media Source Download Helpers
/// Creates a completed download record for a file already downloaded to local storage.
/// Used for SMB/local files that bypass URLSession.
private func createCompletedDownload(
video: Video,
localURL: URL,
fileSize: Int64
) async throws {
// Check if already downloaded
guard !completedDownloads.contains(where: { $0.videoID == video.id }) else {
throw DownloadError.alreadyDownloaded
}
// Check if already downloading
guard !activeDownloads.contains(where: { $0.videoID == video.id }) else {
throw DownloadError.alreadyDownloading
}
let relativePath = localURL.lastPathComponent
let fileExtension = localURL.pathExtension.lowercased()
var download = Download(
video: video,
quality: "Original",
formatID: fileExtension.isEmpty ? "video" : fileExtension,
streamURL: localURL, // Not used for completed downloads but required by init
audioCodec: "aac" // Mark as muxed since local files typically have both tracks
)
download.status = .completed
download.localVideoPath = relativePath
download.videoTotalBytes = fileSize
download.videoDownloadedBytes = fileSize
download.totalBytes = fileSize
download.downloadedBytes = fileSize
download.completedAt = Date()
completedDownloads.append(download)
downloadedVideoIDs.insert(video.id)
await calculateStorageUsed()
saveDownloads()
LoggingService.shared.logDownload(
"[Downloads] Created completed download record",
details: "video: \(video.title), path: \(relativePath), size: \(fileSize)"
)
// Note: Media source downloads (SMB/local) complete synchronously during enqueue,
// so they won't be in batchDownloadIDs by the time this runs.
// The check is kept for consistency and future-proofing.
if !batchDownloadIDs.contains(download.id) {
toastManager?.show(
category: .download,
title: String(localized: "toast.download.completed.title"),
subtitle: video.title,
icon: "checkmark.circle.fill",
iconColor: .green,
autoDismissDelay: 2.0
)
}
}
/// Copies a local file to the downloads directory.
/// Generates a unique filename if the destination already exists.
private func copyLocalFileToDownloads(from sourceURL: URL) throws -> URL {
let fileName = sourceURL.lastPathComponent
var destURL = downloadsDirectory().appendingPathComponent(fileName)
// Handle name collision by appending number
destURL = uniqueDestinationURL(for: destURL)
try fileManager.copyItem(at: sourceURL, to: destURL)
LoggingService.shared.logDownload(
"[Downloads] Copied local file",
details: "from: \(sourceURL.path), to: \(destURL.path)"
)
return destURL
}
/// Generates a unique file URL by appending numbers if the file already exists.
private func uniqueDestinationURL(for url: URL) -> URL {
guard fileManager.fileExists(atPath: url.path) else {
return url
}
let directory = url.deletingLastPathComponent()
let baseName = url.deletingPathExtension().lastPathComponent
let fileExtension = url.pathExtension
var counter = 1
var newURL = url
while fileManager.fileExists(atPath: newURL.path) {
let newName = fileExtension.isEmpty
? "\(baseName) (\(counter))"
: "\(baseName) (\(counter)).\(fileExtension)"
newURL = directory.appendingPathComponent(newName)
counter += 1
}
return newURL
}
}
#else
// tvOS stub - downloads not supported
@Observable
@MainActor
final class DownloadManager {
private(set) var activeDownloads: [Download] = []
private(set) var completedDownloads: [Download] = []
private(set) var storageUsed: Int64 = 0
var maxConcurrentDownloads: Int { 2 }
func enqueue(_ video: Video, quality: String, formatID: String, streamURL: URL, audioStreamURL: URL? = nil, captionURL: URL? = nil, audioLanguage: String? = nil, captionLanguage: String? = nil, httpHeaders: [String: String]? = nil, storyboard: Storyboard? = nil, dislikeCount: Int? = nil, priority: DownloadPriority = .normal, videoCodec: String? = nil, audioCodec: String? = nil, videoBitrate: Int? = nil, audioBitrate: Int? = nil, suppressStartToast: Bool = false) async throws {
throw DownloadError.notSupported
}
func autoEnqueue(_ video: Video, preferredQuality: DownloadQuality, preferredAudioLanguage: String?, preferredSubtitlesLanguage: String?, includeSubtitles: Bool, contentService: ContentService, instance: Instance, suppressStartToast: Bool = false) async throws {
throw DownloadError.notSupported
}
func autoEnqueueMediaSource(_ video: Video, mediaSourcesManager: MediaSourcesManager, webDAVClient: WebDAVClient, smbClient: SMBClient) async throws {
throw DownloadError.notSupported
}
func pause(_ download: Download) async {}
func resume(_ download: Download) async {}
func cancel(_ download: Download) async {}
func delete(_ download: Download) async {}
func moveInQueue(_ download: Download, to position: Int) async {}
func pauseAll() async {}
func resumeAll() async {}
func deleteAllCompleted() async {}
func calculateStorageUsed() async -> Int64 { 0 }
func getAvailableStorage() -> Int64 { 0 }
func deleteWatchedDownloads(watchedVideoIDs: Set<String>) async {}
func isDownloaded(_ videoID: VideoID) -> Bool { false }
func isDownloading(_ videoID: VideoID) -> Bool { false }
func download(for videoID: VideoID) -> Download? { nil }
func localURL(for videoID: VideoID) -> URL? { nil }
func resolveLocalURL(for download: Download) -> URL? { nil }
func videoAndStream(for download: Download) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { nil }
func videoAndStream(for videoID: VideoID) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { nil }
func setToastManager(_ manager: ToastManager) {}
func setDownloadSettings(_ settings: DownloadSettings) {}
func downloadsDirectory() -> URL { URL(fileURLWithPath: NSTemporaryDirectory()) }
}
#endif