Files
terebi/Sources/App/YouTubeResolver.swift
T
hl-valdemar 8fb08db6f3 feat: stream YouTube video with coordinated audio and video inputs
Resolve public YouTube URLs through external yt-dlp and play selected streams in the existing native window.

Independent bounded network readers keep one authored timeline. Successful seeks clear FFmpeg's sticky aborted-read AVIO error; manifest seekability uses native format flags rather than a top-level I/O handle.

Verified real YouTube playback, seeking, frame stepping, compact mode, cancellation, and the complete native regression checks.
2026-10-10 23:02:19 +02:00

455 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Dispatch
import Darwin
struct YouTubeStream: Sendable {
let url: String
let headers: String
}
struct YouTubeVideo: Sendable {
let title: String
let video: YouTubeStream
let audio: YouTubeStream?
let duration: Double
let isLive: Bool
let webpageURL: URL
}
private struct YouTubeResolutionError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
enum YouTubeResolver {
fileprivate static let outputLimit = 16 * 1024 * 1024
static func validateURL(_ text: String) throws -> URL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty,
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
let components = URLComponents(string: text),
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
components.user == nil, components.password == nil,
components.port == nil || components.port == (scheme == "https" ? 443 : 80),
let host = components.host?.lowercased() else {
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.")
}
let id: String?
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
if host == "youtu.be", path.count == 2, path[0].isEmpty {
id = String(path[1])
} else if ["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"].contains(host) {
if components.path == "/watch" {
let videoIDs = components.queryItems?.filter { $0.name == "v" } ?? []
id = videoIDs.count == 1 ? videoIDs[0].value : nil
} else if path.count == 3, path[0].isEmpty, ["shorts", "live", "embed"].contains(String(path[1])) {
id = String(path[2])
} else {
id = nil
}
} else {
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
}
guard let id, id.utf8.count == 11,
id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else {
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.")
}
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
}
static func decode(_ data: Data, webpageURL: URL) throws -> YouTubeVideo {
guard data.count <= outputLimit else {
throw YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
}
let metadata: YouTubeMetadata
do {
metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data)
} catch {
throw YouTubeResolutionError(message: "yt-dlp returned malformed video metadata. Update yt-dlp and try again.")
}
guard metadata._type == nil || metadata._type == "video" else {
throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.")
}
guard metadata.live_status != "is_upcoming" else {
throw YouTubeResolutionError(message: "This livestream has not started yet.")
}
let duration = metadata.duration ?? 0
guard duration.isFinite, duration >= 0 else {
throw YouTubeResolutionError(message: "yt-dlp returned an invalid video duration.")
}
let videoFormat: YouTubeMetadata
let audioFormat: YouTubeMetadata?
if let formats = metadata.requested_formats {
if formats.count == 1, formats[0].hasVideo, formats[0].hasAudio {
videoFormat = formats[0]
audioFormat = nil
} else if formats.count == 2,
let video = formats.first(where: { $0.hasVideo && $0.acodec == "none" }),
let audio = formats.first(where: { $0.hasAudio && $0.vcodec == "none" }) {
videoFormat = video
audioFormat = audio
} else {
throw YouTubeResolutionError(message: "yt-dlp did not select a supported video and audio stream pair.")
}
} else {
guard metadata.hasVideo, metadata.hasAudio else {
throw YouTubeResolutionError(message: "No supported combined video and audio stream was returned.")
}
videoFormat = metadata
audioFormat = nil
}
guard videoFormat.dynamic_range == nil || videoFormat.dynamic_range?.uppercased() == "SDR" else {
throw YouTubeResolutionError(message: "This video has no supported SDR stream. HDR playback is not supported.")
}
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
return YouTubeVideo(
title: title?.isEmpty == false ? title! : "YouTube video",
video: video, audio: audio, duration: duration,
isLive: metadata.is_live == true || metadata.live_status == "is_live",
webpageURL: try validateURL(webpageURL.absoluteString)
)
}
static func resolve(_ url: URL, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
try Task.checkCancellation()
let webpageURL = try validateURL(url.absoluteString)
let directories = searchDirectories()
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
?? findExecutable("yt-dlp", directories: directories)
guard let executable else {
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Install it with ‘brew install yt-dlp deno’, then try again.")
}
let runtime: URL?
if let runtimeURL {
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
} else {
runtime = ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
}
guard let runtime,
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Install Deno with ‘brew install deno’. Supported runtimes are Deno, Node, QuickJS, and Bun.")
}
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = directories.joined(separator: ":")
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
let arguments = [
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
"--no-progress", "--color", "never", "--no-check-formats",
"--no-js-runtimes", "--js-runtimes", "\(runtimeName):\(runtime.path)",
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
"--format", "bestvideo[dynamic_range=?SDR]\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(transport)",
"--", webpageURL.absoluteString
]
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
try Task.checkCancellation()
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
throw error
}
let video = try decode(output.stdout, webpageURL: webpageURL)
try Task.checkCancellation()
return video
}
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
}
guard let text = format.url,
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
let components = URLComponents(string: text),
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil,
components.port == nil || (1...65535).contains(components.port!),
let url = components.url else {
throw YouTubeResolutionError(message: "yt-dlp returned an invalid HTTP stream URL.")
}
var headers = ""
for (name, value) in (format.http_headers ?? fallbackHeaders ?? [:]).sorted(by: { $0.key < $1.key }) {
guard !name.isEmpty,
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) || CharacterSet.newlines.contains($0) }) else {
throw YouTubeResolutionError(message: "yt-dlp returned unsafe HTTP stream headers.")
}
headers += "\(name): \(value)\r\n"
}
return YouTubeStream(url: url.absoluteString, headers: headers)
}
private static func searchDirectories() -> [String] {
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
var result: [String] = []
for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) }
return result
}
private static func findExecutable(_ name: String, directories: [String]) -> URL? {
for directory in directories {
let url = URL(fileURLWithPath: directory).appendingPathComponent(name)
if isExecutableFile(url) { return url }
}
return nil
}
private static func isExecutableFile(_ url: URL) -> Bool {
var isDirectory: ObjCBool = false
return url.isFileURL && FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
&& !isDirectory.boolValue && FileManager.default.isExecutableFile(atPath: url.path)
}
private static func executableFile(_ url: URL, name: String) throws -> URL {
guard isExecutableFile(url) else {
throw YouTubeResolutionError(message: "The \(name) executable is missing or is not executable.")
}
return url
}
fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? {
if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") {
return YouTubeResolutionError(message: "YouTube extraction requires current yt-dlp, its EJS components, and a supported JavaScript runtime. Run ‘brew upgrade yt-dlp deno’. For a pip installation, install ‘yt-dlp[default]’ and Deno.")
}
if text.contains("no such option") || text.contains("unrecognized arguments") {
return YouTubeResolutionError(message: "The installed yt-dlp is too old for YouTube streaming. Update it with ‘brew upgrade yt-dlp’.")
}
return nil
}
fileprivate static func extractionError(_ data: Data, status: Int32) -> YouTubeResolutionError {
let text = String(decoding: data, as: UTF8.self).lowercased()
if let error = dependencyError(text) { return error }
// Never include extractor output: it can contain signed URLs or credentials.
if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") {
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
}
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Update yt-dlp and try again.")
}
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.")
}
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; update yt-dlp if the problem persists.")
}
return YouTubeResolutionError(message: "yt-dlp could not resolve this video (exit status \(status)). Update yt-dlp and Deno, then try again.")
}
}
private struct YouTubeMetadata: Decodable {
let _type: String?
let title: String?
let duration: Double?
let is_live: Bool?
let live_status: String?
let url: String?
let `protocol`: String?
let vcodec: String?
let acodec: String?
let dynamic_range: String?
let http_headers: [String: String]?
let requested_formats: [YouTubeMetadata]?
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false }
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false }
}
private struct YouTubeExtractionOutput: Sendable {
let stdout: Data
let stderr: Data
}
// All Process, pipe, and continuation state belongs to this request's serial queue.
private final class YouTubeExtraction: @unchecked Sendable {
private let queue = DispatchQueue(label: "terebi.youtube-extraction", qos: .userInitiated)
private let process = Process()
private let stdoutPipe = Pipe()
private let stderrPipe = Pipe()
private var stdoutSource: (any DispatchSourceRead)?
private var stderrSource: (any DispatchSourceRead)?
private var continuation: CheckedContinuation<YouTubeExtractionOutput, any Error>?
private var stdout = Data()
private var stderr = Data()
private var scratch = [UInt8](repeating: 0, count: 16 * 1024)
private var cancelled = false
private var launched = false
private var exited = false
private var stdoutDone = false
private var stderrDone = false
private var finished = false
private var failure: (any Error)?
init(executable: URL, arguments: [String], environment: [String: String]) {
process.executableURL = executable
process.arguments = arguments
process.environment = environment
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
}
func run() async throws -> YouTubeExtractionOutput {
try await withTaskCancellationHandler {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { continuation in
queue.async { self.start(continuation) }
}
} onCancel: {
self.queue.async { self.cancel() }
}
}
private func start(_ continuation: CheckedContinuation<YouTubeExtractionOutput, any Error>) {
self.continuation = continuation
guard !cancelled else {
finish(.failure(CancellationError()))
return
}
let stdoutFD = stdoutPipe.fileHandleForReading.fileDescriptor
let stderrFD = stderrPipe.fileHandleForReading.fileDescriptor
for descriptor in [stdoutFD, stderrFD] {
let flags = fcntl(descriptor, F_GETFL)
guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) >= 0 else {
finish(.failure(YouTubeResolutionError(message: "Could not create the yt-dlp output pipes.")))
return
}
}
stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutFD, queue: queue)
stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrFD, queue: queue)
stdoutSource?.setEventHandler { [weak self] in self?.drain(isStdout: true) }
stderrSource?.setEventHandler { [weak self] in self?.drain(isStdout: false) }
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutSource?.setCancelHandler { try? stdoutHandle.close() }
stderrSource?.setCancelHandler { try? stderrHandle.close() }
stdoutSource?.resume()
stderrSource?.resume()
process.terminationHandler = { [weak self] _ in
guard let self else { return }
self.queue.async { self.didExit() }
}
do {
try process.run()
launched = true
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
} catch {
finish(.failure(YouTubeResolutionError(message: "Could not launch yt-dlp. Verify that yt-dlp is installed and executable.")))
}
}
private func drain(isStdout: Bool) {
guard !finished, !(isStdout ? stdoutDone : stderrDone) else { return }
let handle = isStdout ? stdoutPipe.fileHandleForReading : stderrPipe.fileHandleForReading
for _ in 0..<64 {
let count = scratch.withUnsafeMutableBytes { Darwin.read(handle.fileDescriptor, $0.baseAddress!, $0.count) }
if count > 0 {
if isStdout {
guard count <= YouTubeResolver.outputLimit - stdout.count else {
failure = YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
stop()
return
}
scratch.withUnsafeBytes { stdout.append($0.bindMemory(to: UInt8.self).baseAddress!, count: count) }
} else {
let retained = min(count, 256 * 1024 - stderr.count)
if retained > 0 {
scratch.withUnsafeBytes { stderr.append($0.bindMemory(to: UInt8.self).baseAddress!, count: retained) }
}
}
} else if count == 0 {
closeStream(isStdout: isStdout)
completeIfReady()
return
} else if errno == EINTR {
continue
} else if errno == EAGAIN || errno == EWOULDBLOCK {
return
} else {
failure = YouTubeResolutionError(message: "Could not read yt-dlp output.")
stop()
return
}
}
// Yield between batches so continuous extractor output cannot starve cancellation.
queue.async { self.drain(isStdout: isStdout) }
}
private func didExit() {
exited = true
drain(isStdout: true)
drain(isStdout: false)
completeIfReady()
}
private func cancel() {
guard !finished else { return }
cancelled = true
failure = CancellationError()
stop()
}
private func stop() {
closeStream(isStdout: true)
closeStream(isStdout: false)
if launched, !exited, process.isRunning {
process.terminate()
queue.asyncAfter(deadline: .now() + .milliseconds(250)) {
if !self.exited, self.process.isRunning {
Darwin.kill(self.process.processIdentifier, SIGKILL)
}
}
}
completeIfReady()
}
private func closeStream(isStdout: Bool) {
if isStdout {
guard !stdoutDone else { return }
stdoutDone = true
if let source = stdoutSource {
source.cancel()
stdoutSource = nil
} else {
try? stdoutPipe.fileHandleForReading.close()
}
} else {
guard !stderrDone else { return }
stderrDone = true
if let source = stderrSource {
source.cancel()
stderrSource = nil
} else {
try? stderrPipe.fileHandleForReading.close()
}
}
}
private func completeIfReady() {
guard !finished, continuation != nil, (!launched || exited), stdoutDone, stderrDone else { return }
if let failure {
finish(.failure(failure))
} else if process.terminationStatus != 0 {
finish(.failure(YouTubeResolver.extractionError(stderr, status: process.terminationStatus)))
} else {
finish(.success(YouTubeExtractionOutput(stdout: stdout, stderr: stderr)))
}
}
private func finish(_ result: Result<YouTubeExtractionOutput, any Error>) {
guard !finished else { return }
finished = true
process.terminationHandler = nil
closeStream(isStdout: true)
closeStream(isStdout: false)
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
let completion = continuation
continuation = nil
completion?.resume(with: result)
}
}