mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
1211 lines
47 KiB
Swift
1211 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()
|
|
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
|
|
urlSession.invalidateAndCancel()
|
|
|
|
// 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
|