Files
terebi/Sources/App/YouTubeResolver.swift
T
hl-valdemar 31c4759c1d build: bundle playback and YouTube dependencies
Build pinned LGPL playback libraries and package standalone YouTube helpers inside the signed native app. Keep the CLI inside the app and relocate private library links for installation without runtime Homebrew dependencies.

Record adaptive fixture completion in its EXIT trap: cancellation can exit with status 143 before the TERM trap writes its marker, while native pause and quality preservation remain correct.
2026-10-12 01:52:51 +02:00

500 lines
24 KiB
Swift

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
let height: Int?
let availableHeights: [Int]
}
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, height: Int? = nil) 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: "The bundled yt-dlp returned malformed video metadata. Upgrade terebi or rebuild its bundled helpers 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.")
}
guard videoFormat.height == nil || videoFormat.height! > 0 else {
throw YouTubeResolutionError(message: "yt-dlp returned an invalid video resolution.")
}
if let height {
guard height > 0, videoFormat.height == height else {
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
}
} else if let selectedHeight = videoFormat.height, selectedHeight > 1080 {
throw YouTubeResolutionError(message: "YouTube did not provide a supported stream at or below 1080p.")
}
let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? [])
let hasSeparateAudio = formats.contains {
$0.hasAudio && !$0.hasVideo && (try? stream($0, fallbackHeaders: metadata.http_headers)) != nil
}
let availableHeights = Set(formats.compactMap { format -> Int? in
guard format.hasVideo, format.hasAudio || hasSeparateAudio,
format.dynamic_range == nil || format.dynamic_range?.uppercased() == "SDR",
let height = format.height, height > 0,
(try? stream(format, fallbackHeaders: metadata.http_headers)) != nil else { return nil }
return height
}).sorted(by: >)
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),
height: videoFormat.height, availableHeights: availableHeights
)
}
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
try Task.checkCancellation()
let webpageURL = try validateURL(url.absoluteString)
guard height == nil || height! > 0 else {
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
}
let bundle = Bundle.main.bundleURL
let directories = bundle.pathExtension == "app"
? [bundle.appendingPathComponent("Contents/MacOS").path]
: searchDirectories()
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories)
guard let executable else {
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
}
let runtime: URL?
if let runtimeURL {
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
} else {
runtime = try bundledExecutable("deno")
?? ["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. Rebuild or reinstall terebi with its bundled Deno helper.")
}
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = directories.joined(separator: ":")
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
let resolution = height.map { "[height=\($0)]" } ?? "[height<=1080]"
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-sort", "res", "--format-sort-force",
"--format", "bestvideo*[dynamic_range=?SDR]\(resolution)\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(resolution)\(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, height: height)
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)
}
// A missing bundled helper is an installation error, never permission to use PATH.
static func bundledExecutable(_ name: String, bundleURL: URL = Bundle.main.bundleURL) throws -> URL? {
guard bundleURL.pathExtension == "app" else { return nil }
let url = bundleURL.appendingPathComponent("Contents/MacOS").appendingPathComponent(name)
guard isExecutableFile(url) else {
throw YouTubeResolutionError(message: "This app's bundled \(name) helper is missing or is not executable. Rebuild or reinstall the complete terebi app.")
}
return url
}
private static func searchDirectories() -> [String] {
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
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: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.")
}
if text.contains("no such option") || text.contains("unrecognized arguments") {
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for YouTube streaming. Upgrade terebi or rebuild its bundled helpers.")
}
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. Upgrade terebi or rebuild its bundled helpers 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; upgrade terebi if the problem persists.")
}
return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, 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 height: Int?
let formats: [YouTubeMetadata]?
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)
}
}