diff --git a/Conjuga/Conjuga/Services/VideoDownloadService.swift b/Conjuga/Conjuga/Services/VideoDownloadService.swift index d3b5f5e..e2b73cd 100644 --- a/Conjuga/Conjuga/Services/VideoDownloadService.swift +++ b/Conjuga/Conjuga/Services/VideoDownloadService.swift @@ -1,41 +1,57 @@ import Foundation import SwiftData import SharedModels +import AVFoundation import YouTubeKit -/// Downloads YouTube videos for offline viewing (Issue #21, phase 3). +/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30). /// -/// Uses YouTubeKit to resolve stream URLs, then a `URLSession` download task -/// to persist the MP4 under the app's documents directory. Metadata is -/// recorded in SwiftData via `DownloadedVideo` so the app knows what's on -/// disk across launches. +/// Two-path strategy since YouTube phased out high-quality progressive streams: +/// 1. **Progressive** — single MP4 with audio+video combined (rare, ≤360p). +/// Download directly to the final path. +/// 2. **Adaptive** — DASH: separate video + audio tracks. Download each to +/// temp files, then mux with AVAssetExportSession (`.passthrough` preset, +/// no re-encoding) into the final MP4. +/// +/// YouTubeKit returns stream URLs only; combining tracks is the app's job. +/// See the library's README Example 3 for the progressive-only pattern. /// /// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and -/// will break when YouTube changes their internal format. When it does, the -/// service throws `DownloadError.extractionFailed` and the UI should fall -/// back to streaming (phase 2) which remains available. +/// will break when YouTube changes their internal format. Streaming (iframe +/// embed elsewhere) keeps working regardless. @MainActor @Observable final class VideoDownloadService { + // MARK: - Types + + /// Per-download state surfaced to the UI. `progress == nil` means show an + /// indeterminate spinner (e.g. muxing phase). + struct DownloadStatus: Equatable, Sendable { + var progress: Double? + var label: String + } + enum DownloadError: Error, LocalizedError { case extractionFailed(String) case noSuitableStream case downloadFailed(String) + case muxFailed(String) case fileWriteFailed(String) var errorDescription: String? { switch self { case .extractionFailed(let why): "Could not extract video: \(why)" - case .noSuitableStream: "No downloadable stream found for this video." + case .noSuitableStream: "No downloadable video+audio streams found for this video." case .downloadFailed(let why): "Download failed: \(why)" + case .muxFailed(let why): "Could not combine audio and video: \(why)" case .fileWriteFailed(let why): "Could not save video: \(why)" } } } - /// In-flight downloads by videoId. Progress is Double in [0, 1]. - var activeDownloads: [String: Double] = [:] + /// In-flight downloads by videoId, with a phase label and progress. + var activeDownloads: [String: DownloadStatus] = [:] static let shared = VideoDownloadService() @@ -46,6 +62,10 @@ final class VideoDownloadService { return docs.appendingPathComponent("videos", isDirectory: true) } + private static var tempDirectory: URL { + FileManager.default.temporaryDirectory + } + private static func ensureDirectory() throws { let url = videosDirectory if !FileManager.default.fileExists(atPath: url.path) { @@ -57,64 +77,93 @@ final class VideoDownloadService { videosDirectory.appendingPathComponent("\(videoId).mp4") } + private static func tempURL(videoId: String, kind: String, ext: String) -> URL { + tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)") + } + /// True if a downloaded MP4 exists for this videoId. static func isDownloaded(videoId: String) -> Bool { FileManager.default.fileExists(atPath: fileURL(for: videoId).path) } - // MARK: - Download + // MARK: - Public download entry point - /// Downloads a YouTube video to local storage and records it in SwiftData. - /// Throws on any failure. Caller is responsible for showing errors. func download( videoId: String, title: String, into modelContext: ModelContext ) async throws { - guard !activeDownloads.keys.contains(videoId) else { return } - activeDownloads[videoId] = 0 + guard activeDownloads[videoId] == nil else { return } + activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…") defer { activeDownloads.removeValue(forKey: videoId) } try Self.ensureDirectory() - // 1. Resolve stream URL via YouTubeKit. Run off the main actor because - // YouTubeKit.YouTube isn't Sendable and does synchronous work we don't - // want blocking UI. - let streamURL: URL + let destURL = Self.fileURL(for: videoId) + + // Resolve streams once; decide progressive vs adaptive. + let plan: DownloadPlan do { - streamURL = try await Self.resolveStreamURL(videoId: videoId) + plan = try await Self.resolvePlan(videoId: videoId) } catch let e as DownloadError { throw e } catch { throw DownloadError.extractionFailed(error.localizedDescription) } - // 2. Download the stream to disk with progress tracking. - let destURL = Self.fileURL(for: videoId) - do { - let (tempURL, response) = try await URLSession.shared.download( - for: URLRequest(url: streamURL), - delegate: DownloadProgressDelegate { [weak self] progress in - Task { @MainActor in - self?.activeDownloads[videoId] = progress - } + switch plan { + case .progressive(let url): + activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…") + try await downloadStream(from: url, to: destURL) { [weak self] p in + Task { @MainActor [weak self] in + self?.activeDownloads[videoId] = DownloadStatus( + progress: p, + label: "Downloading \(Int(p * 100))%" + ) } - ) - _ = response - - // Move the temp file to our persistent location (atomic). - if FileManager.default.fileExists(atPath: destURL.path) { - try FileManager.default.removeItem(at: destURL) } - try FileManager.default.moveItem(at: tempURL, to: destURL) - } catch let e as DownloadError { - throw e - } catch { - throw DownloadError.downloadFailed(error.localizedDescription) + + case .adaptive(let videoURL, let audioURL): + let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4") + let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a") + defer { + try? FileManager.default.removeItem(at: tempVideo) + try? FileManager.default.removeItem(at: tempAudio) + } + + // Phase 1 of 3: video track (0–55% of overall progress) + try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in + Task { @MainActor [weak self] in + self?.activeDownloads[videoId] = DownloadStatus( + progress: p * 0.55, + label: "Video \(Int(p * 100))%" + ) + } + } + + // Phase 2 of 3: audio track (55–80%) + try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in + Task { @MainActor [weak self] in + self?.activeDownloads[videoId] = DownloadStatus( + progress: 0.55 + p * 0.25, + label: "Audio \(Int(p * 100))%" + ) + } + } + + // Phase 3 of 3: mux (indeterminate) + activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…") + do { + try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL) + } catch let e as DownloadError { + throw e + } catch { + throw DownloadError.muxFailed(error.localizedDescription) + } } - // 3. Record in SwiftData. + // Record in SwiftData. let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path) let byteCount = (attrs?[.size] as? Int) ?? 0 let entry = DownloadedVideo( @@ -143,20 +192,6 @@ final class VideoDownloadService { } } - /// Resolves the best progressive-MP4 stream URL for a YouTube videoId. - /// Runs off the main actor because `YouTube` isn't Sendable. - nonisolated private static func resolveStreamURL(videoId: String) async throws -> URL { - let youtube = YouTube(videoID: videoId) - let streams = try await youtube.streams - let candidate = streams - .filter { $0.isProgressive && $0.subtype == "mp4" } - .sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) } - .first - ?? streams.filter({ $0.subtype == "mp4" }).first - guard let stream = candidate else { throw DownloadError.noSuitableStream } - return stream.url - } - /// Total bytes used by all downloads. static func totalBytesUsed() -> Int { let url = videosDirectory @@ -168,14 +203,142 @@ final class VideoDownloadService { return acc + size } } + + // MARK: - Stream resolution + + private enum DownloadPlan { + case progressive(URL) + case adaptive(video: URL, audio: URL) + } + + /// Picks progressive MP4 if available (single download); otherwise the + /// best adaptive MP4 video + M4A audio pair for later muxing. + nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan { + let youtube = YouTube(videoID: videoId) + let streams = try await youtube.streams + + // 1. Progressive path — uses the library's own recommended filter chain. + if let progressive = streams + .filterVideoAndAudio() + .filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable }) + .highestResolutionStream() { + return .progressive(progressive.url) + } + + // 2. Adaptive path — highest-resolution MP4 video track + highest-bitrate M4A audio. + let videoStream = streams + .filterVideoOnly() + .filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable } + .highestResolutionStream() + let audioStream = streams + .filterAudioOnly() + .filter { + ($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable + } + .highestAudioBitrateStream() + + if let v = videoStream, let a = audioStream { + return .adaptive(video: v.url, audio: a.url) + } + + throw DownloadError.noSuitableStream + } + + // MARK: - Download helper + + nonisolated private func downloadStream( + from url: URL, + to dest: URL, + onProgress: @escaping @Sendable (Double) -> Void + ) async throws { + let delegate = DownloadProgressDelegate(onProgress: onProgress) + do { + let (tempURL, _) = try await URLSession.shared.download( + for: URLRequest(url: url), + delegate: delegate + ) + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.moveItem(at: tempURL, to: dest) + } catch { + throw DownloadError.downloadFailed(error.localizedDescription) + } + } + + // MARK: - Muxing + + /// Combines a video-only file and an audio-only file into a single MP4. + /// Uses `.passthrough` preset — no re-encoding, just container rewrite. + /// Falls back to `.highestQuality` (which re-encodes) if passthrough + /// rejects the composition due to codec incompatibility. + nonisolated private static func mux( + videoURL: URL, + audioURL: URL, + to outputURL: URL + ) async throws { + let videoAsset = AVURLAsset(url: videoURL) + let audioAsset = AVURLAsset(url: audioURL) + + let videoTracks = try await videoAsset.loadTracks(withMediaType: .video) + let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio) + + guard let srcVideo = videoTracks.first, + let srcAudio = audioTracks.first else { + throw DownloadError.muxFailed("Downloaded streams missing expected tracks") + } + + let composition = AVMutableComposition() + guard let compVideo = composition.addMutableTrack( + withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid + ), + let compAudio = composition.addMutableTrack( + withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid + ) else { + throw DownloadError.muxFailed("Could not create composition tracks") + } + + let videoDuration = try await videoAsset.load(.duration) + let audioDuration = try await audioAsset.load(.duration) + let duration = CMTimeMinimum(videoDuration, audioDuration) + let range = CMTimeRange(start: .zero, duration: duration) + + try compVideo.insertTimeRange(range, of: srcVideo, at: .zero) + try compAudio.insertTimeRange(range, of: srcAudio, at: .zero) + + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + + // Try passthrough (no re-encode) first; fall back to quality export on failure. + let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality] + var lastError: Error? + + for preset in presets { + guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else { + continue + } + do { + try await exporter.export(to: outputURL, as: .mp4) + return + } catch { + lastError = error + // Clean up partial output before retrying with next preset. + try? FileManager.default.removeItem(at: outputURL) + continue + } + } + + throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure") + } } // MARK: - URLSession progress delegate -private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate { - let onProgress: (Double) -> Void +private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + let onProgress: @Sendable (Double) -> Void - init(onProgress: @escaping (Double) -> Void) { + init(onProgress: @escaping @Sendable (Double) -> Void) { self.onProgress = onProgress } @@ -187,8 +350,7 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega totalBytesExpectedToWrite: Int64 ) { guard totalBytesExpectedToWrite > 0 else { return } - let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) - onProgress(progress) + onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) } func urlSession( @@ -196,6 +358,6 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { - // Not used — `URLSession.download(for:delegate:)` already returns the temp URL. + // Not used — `URLSession.download(for:delegate:)` returns the temp URL directly. } } diff --git a/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift b/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift index 628243d..b95305a 100644 --- a/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift +++ b/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift @@ -27,12 +27,12 @@ struct VideoActionsButtonRow: View { self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId)) } - private var activeProgress: Double? { + private var activeStatus: VideoDownloadService.DownloadStatus? { downloadService.activeDownloads[video.videoId] } private var isDownloading: Bool { - activeProgress != nil + activeStatus != nil } var body: some View { @@ -103,13 +103,19 @@ struct VideoActionsButtonRow: View { @ViewBuilder private var downloadButton: some View { // Single slot whose role flips through the download lifecycle: - // Download → progress (disabled) → Delete. - if isDownloading, let progress = activeProgress { + // Download → progress/label (disabled) → Delete. + if let status = activeStatus { Button {} label: { HStack(spacing: 6) { - ProgressView(value: progress).frame(width: 40) - Text("\(Int(progress * 100))%") + if let progress = status.progress { + ProgressView(value: progress).frame(width: 40) + } else { + ProgressView().controlSize(.small) + } + Text(status.label) .font(.caption.monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.7) } .frame(maxWidth: .infinity) }