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 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 (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 {
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.
}
}
@@ -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)
}