Fixes #30 — Downloaded videos include audio via adaptive-stream muxing

YouTubeKit returns stream URLs only; its own README punts on the
progressive-vs-adaptive problem. Most modern YouTube videos above 360p
serve video and audio as separate DASH tracks, so the previous "any MP4"
fallback silently grabbed a video-only track and the downloaded file had
no audio.

VideoDownloadService now resolves a DownloadPlan up front. Progressive
MP4 (when one exists) still streams straight to the final destination.
Otherwise the service downloads the best MP4 video track + best M4A
audio track to temp files, then combines them into a single MP4 via
AVMutableComposition + AVAssetExportSession. Passthrough preset first
(lossless container rewrite) with a fallback to highestQuality if the
codec combination requires re-encoding. Temp files are cleaned up on
both success and failure.

DownloadStatus replaces the bare Double progress so the UI can show
per-phase labels (Video %, Audio %, Finalizing…). Muxing progress is
rendered as an indeterminate spinner since AVAssetExportSession's
progress property doesn't cross actor boundaries cleanly.

Also retires the deprecated Stream.subtype comparison in favor of
Stream.fileExtension, matching YouTubeKit's current API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-23 07:00:16 -05:00
parent 57f945a4d3
commit 0a099c3fc9
2 changed files with 236 additions and 68 deletions
@@ -1,41 +1,57 @@
import Foundation import Foundation
import SwiftData import SwiftData
import SharedModels import SharedModels
import AVFoundation
import YouTubeKit 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 /// Two-path strategy since YouTube phased out high-quality progressive streams:
/// to persist the MP4 under the app's documents directory. Metadata is /// 1. **Progressive** single MP4 with audio+video combined (rare, 360p).
/// recorded in SwiftData via `DownloadedVideo` so the app knows what's on /// Download directly to the final path.
/// disk across launches. /// 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 /// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
/// will break when YouTube changes their internal format. When it does, the /// will break when YouTube changes their internal format. Streaming (iframe
/// service throws `DownloadError.extractionFailed` and the UI should fall /// embed elsewhere) keeps working regardless.
/// back to streaming (phase 2) which remains available.
@MainActor @MainActor
@Observable @Observable
final class VideoDownloadService { 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 { enum DownloadError: Error, LocalizedError {
case extractionFailed(String) case extractionFailed(String)
case noSuitableStream case noSuitableStream
case downloadFailed(String) case downloadFailed(String)
case muxFailed(String)
case fileWriteFailed(String) case fileWriteFailed(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .extractionFailed(let why): "Could not extract video: \(why)" 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 .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)" case .fileWriteFailed(let why): "Could not save video: \(why)"
} }
} }
} }
/// In-flight downloads by videoId. Progress is Double in [0, 1]. /// In-flight downloads by videoId, with a phase label and progress.
var activeDownloads: [String: Double] = [:] var activeDownloads: [String: DownloadStatus] = [:]
static let shared = VideoDownloadService() static let shared = VideoDownloadService()
@@ -46,6 +62,10 @@ final class VideoDownloadService {
return docs.appendingPathComponent("videos", isDirectory: true) return docs.appendingPathComponent("videos", isDirectory: true)
} }
private static var tempDirectory: URL {
FileManager.default.temporaryDirectory
}
private static func ensureDirectory() throws { private static func ensureDirectory() throws {
let url = videosDirectory let url = videosDirectory
if !FileManager.default.fileExists(atPath: url.path) { if !FileManager.default.fileExists(atPath: url.path) {
@@ -57,64 +77,93 @@ final class VideoDownloadService {
videosDirectory.appendingPathComponent("\(videoId).mp4") 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. /// True if a downloaded MP4 exists for this videoId.
static func isDownloaded(videoId: String) -> Bool { static func isDownloaded(videoId: String) -> Bool {
FileManager.default.fileExists(atPath: fileURL(for: videoId).path) 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( func download(
videoId: String, videoId: String,
title: String, title: String,
into modelContext: ModelContext into modelContext: ModelContext
) async throws { ) async throws {
guard !activeDownloads.keys.contains(videoId) else { return } guard activeDownloads[videoId] == nil else { return }
activeDownloads[videoId] = 0 activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
defer { activeDownloads.removeValue(forKey: videoId) } defer { activeDownloads.removeValue(forKey: videoId) }
try Self.ensureDirectory() try Self.ensureDirectory()
// 1. Resolve stream URL via YouTubeKit. Run off the main actor because let destURL = Self.fileURL(for: videoId)
// YouTubeKit.YouTube isn't Sendable and does synchronous work we don't
// want blocking UI. // Resolve streams once; decide progressive vs adaptive.
let streamURL: URL let plan: DownloadPlan
do { do {
streamURL = try await Self.resolveStreamURL(videoId: videoId) plan = try await Self.resolvePlan(videoId: videoId)
} catch let e as DownloadError { } catch let e as DownloadError {
throw e throw e
} catch { } catch {
throw DownloadError.extractionFailed(error.localizedDescription) throw DownloadError.extractionFailed(error.localizedDescription)
} }
// 2. Download the stream to disk with progress tracking. switch plan {
let destURL = Self.fileURL(for: videoId) case .progressive(let url):
do { activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
let (tempURL, response) = try await URLSession.shared.download( try await downloadStream(from: url, to: destURL) { [weak self] p in
for: URLRequest(url: streamURL), Task { @MainActor [weak self] in
delegate: DownloadProgressDelegate { [weak self] progress in self?.activeDownloads[videoId] = DownloadStatus(
Task { @MainActor in progress: p,
self?.activeDownloads[videoId] = progress 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) }
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 (055% 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 (5580%)
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 { } catch let e as DownloadError {
throw e throw e
} catch { } catch {
throw DownloadError.downloadFailed(error.localizedDescription) throw DownloadError.muxFailed(error.localizedDescription)
}
} }
// 3. Record in SwiftData. // Record in SwiftData.
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path) let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
let byteCount = (attrs?[.size] as? Int) ?? 0 let byteCount = (attrs?[.size] as? Int) ?? 0
let entry = DownloadedVideo( 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. /// Total bytes used by all downloads.
static func totalBytesUsed() -> Int { static func totalBytesUsed() -> Int {
let url = videosDirectory let url = videosDirectory
@@ -168,14 +203,142 @@ final class VideoDownloadService {
return acc + size 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 // MARK: - URLSession progress delegate
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate { private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
let onProgress: (Double) -> Void let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping (Double) -> Void) { init(onProgress: @escaping @Sendable (Double) -> Void) {
self.onProgress = onProgress self.onProgress = onProgress
} }
@@ -187,8 +350,7 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
totalBytesExpectedToWrite: Int64 totalBytesExpectedToWrite: Int64
) { ) {
guard totalBytesExpectedToWrite > 0 else { return } guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
onProgress(progress)
} }
func urlSession( func urlSession(
@@ -196,6 +358,6 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
downloadTask: URLSessionDownloadTask, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL 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.
} }
} }
@@ -27,12 +27,12 @@ struct VideoActionsButtonRow: View {
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId)) self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
} }
private var activeProgress: Double? { private var activeStatus: VideoDownloadService.DownloadStatus? {
downloadService.activeDownloads[video.videoId] downloadService.activeDownloads[video.videoId]
} }
private var isDownloading: Bool { private var isDownloading: Bool {
activeProgress != nil activeStatus != nil
} }
var body: some View { var body: some View {
@@ -103,13 +103,19 @@ struct VideoActionsButtonRow: View {
@ViewBuilder @ViewBuilder
private var downloadButton: some View { private var downloadButton: some View {
// Single slot whose role flips through the download lifecycle: // Single slot whose role flips through the download lifecycle:
// Download progress (disabled) Delete. // Download progress/label (disabled) Delete.
if isDownloading, let progress = activeProgress { if let status = activeStatus {
Button {} label: { Button {} label: {
HStack(spacing: 6) { HStack(spacing: 6) {
if let progress = status.progress {
ProgressView(value: progress).frame(width: 40) ProgressView(value: progress).frame(width: 40)
Text("\(Int(progress * 100))%") } else {
ProgressView().controlSize(.small)
}
Text(status.label)
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.7)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }