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.
This commit is contained in:
2026-10-10 22:18:24 +02:00
parent 25d674d84d
commit 8fb08db6f3
10 changed files with 2451 additions and 181 deletions
+151 -28
View File
@@ -10,6 +10,8 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
@Published private(set) var pendingRevision: UInt64?
@Published private(set) var resolvingYouTube = false
@Published private(set) var isLiveStream = false
@Published var seekPosition = 0.0
@Published var isSeeking = false
@Published private(set) var volume = 1.0
@@ -40,6 +42,8 @@ final class PlayerModel: ObservableObject {
private var lastAudibleVolume = 1.0
private var needsVideoFit = false
private var seekPending = false
private var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID?
private init() {
let player = player_create()
@@ -58,20 +62,20 @@ final class PlayerModel: ObservableObject {
}
}
var isBusy: Bool { pendingRevision != nil || state.status == PLAYER_OPENING }
var isBusy: Bool { resolvingYouTube || pendingRevision != nil || state.status == PLAYER_OPENING }
var canTogglePlayback: Bool {
handle != nil && !isBusy &&
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
}
var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && !isSeeking }
var canOpen: Bool { handle != nil && !isBusy }
var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY }
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
var canOpen: Bool { handle != nil }
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
var errorText: String? {
if let commandError { return commandError }
guard state.status == PLAYER_ERROR else { return nil }
guard !resolvingYouTube, state.status == PLAYER_ERROR else { return nil }
var error = state.error
return withUnsafeBytes(of: &error) { bytes in
let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
@@ -91,13 +95,120 @@ final class PlayerModel: ObservableObject {
open(url)
}
func open(_ url: URL) {
guard canOpen, let handle else { return }
guard url.isFileURL else {
commandError = "Only local media files are supported."
func openYouTubePanel() {
guard canOpen else { return }
revealControls()
let alert = NSAlert()
alert.messageText = "Open YouTube Video"
alert.informativeText = "Paste a YouTube video URL."
alert.addButton(withTitle: "Open")
alert.addButton(withTitle: "Cancel")
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
field.placeholderString = "https://www.youtube.com/watch?v=…"
field.setAccessibilityLabel("YouTube video URL")
alert.accessoryView = field
alert.window.initialFirstResponder = field
guard alert.runModal() == .alertFirstButtonReturn else { return }
openYouTube(field.stringValue)
}
func openYouTube(_ text: String) {
guard let handle else { return }
cancelYouTubeResolution()
let url: URL
do {
url = try YouTubeResolver.validateURL(text)
} catch {
commandError = error.localizedDescription
revealControls()
return
}
if !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls()
return
}
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
commandError = nil
revealControls()
youtubeTask = Task { [weak self] in
do {
let video = try await YouTubeResolver.resolve(url)
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
self.youtubeTask = nil
self.youtubeRequest = nil
self.resolvingYouTube = false
self.openResolvedYouTube(video)
} catch {
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
self.youtubeTask = nil
self.youtubeRequest = nil
self.resolvingYouTube = false
self.commandError = error.localizedDescription
self.revealControls()
}
}
}
private func cancelYouTubeResolution() {
youtubeRequest = nil
youtubeTask?.cancel()
youtubeTask = nil
resolvingYouTube = false
}
private func openResolvedYouTube(_ video: YouTubeVideo) {
guard let handle else { return }
let revision = player_get_state(handle).revision
let accepted = video.video.url.withCString { videoURL in
video.video.headers.withCString { videoHeaders in
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
video_headers: videoHeaders, audio_headers: audioHeaders,
duration: video.duration, is_live: video.isLive)
return player_open_network(handle, &source)
}
if let audio = video.audio {
return audio.url.withCString { audioURL in
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
}
}
return enqueue(audioURL: nil, audioHeaders: nil)
}
}
guard accepted else {
commandError = "Could not queue the YouTube video to open. Please try again."
revealControls()
return
}
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive)
}
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false) {
guard let handle else { return }
pendingRevision = revision
fileName = title
fileIcon = icon
isLiveStream = live
needsVideoFit = true
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
isSeeking = false
seekPending = false
seekPosition = 0
window?.representedURL = url
window?.title = title
revealControls()
}
func open(_ url: URL) {
guard canOpen, let handle else { return }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
}
cancelYouTubeResolution()
let revision = player_get_state(handle).revision
let accepted = url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false }
@@ -107,17 +218,8 @@ final class PlayerModel: ObservableObject {
commandError = "Could not queue the file to open. Please try again."
return
}
pendingRevision = revision
fileName = url.lastPathComponent
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
needsVideoFit = true
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
isSeeking = false
seekPending = false
seekPosition = 0
window?.representedURL = url
window?.title = url.lastPathComponent
revealControls()
didAcceptOpen(revision: revision, title: url.lastPathComponent,
icon: NSWorkspace.shared.icon(forFile: url.path), url: url)
}
func togglePlayback() {
@@ -139,6 +241,7 @@ final class PlayerModel: ObservableObject {
func closeFile() {
guard let handle, canClose else { return }
cancelYouTubeResolution()
guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again."
return
@@ -146,6 +249,7 @@ final class PlayerModel: ObservableObject {
compactWindow.restore(resumeFullScreen: false)
fileName = nil
fileIcon = nil
isLiveStream = false
needsVideoFit = false
window?.aspectRatio = .zero
commandError = nil
@@ -339,6 +443,7 @@ final class PlayerModel: ObservableObject {
}
func shutdown() {
cancelYouTubeResolution()
compactWindow.restore(resumeFullScreen: false)
timer?.invalidate()
timer = nil
@@ -558,6 +663,7 @@ private struct PlayerContent: View {
private var actionsMenu: some View {
Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
Button("Close File", action: model.closeFile).disabled(!model.canClose)
Divider()
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
@@ -651,13 +757,16 @@ private struct PlayerContent: View {
showsRemainingTime.toggle()
model.revealControls()
} label: {
Text(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration))
Text(model.isLiveStream ? "LIVE" :
(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration)))
.frame(minWidth: 32, alignment: .leading)
}
.buttonStyle(.plain)
.help(showsRemainingTime ? "Show Duration" : "Show Remaining Time")
.accessibilityLabel(showsRemainingTime ? "Remaining Time" : "Duration")
.accessibilityValue(time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
.disabled(model.isLiveStream)
.help(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Show Duration" : "Show Remaining Time"))
.accessibilityLabel(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Remaining Time" : "Duration"))
.accessibilityValue(model.isLiveStream ? "Live" :
time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
}
.font(.system(size: 11).monospacedDigit())
.padding(.horizontal, 16)
@@ -707,7 +816,8 @@ private struct PlayerContent: View {
} else if model.isBusy {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
Text("Opening…").font(.callout).foregroundStyle(.secondary)
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
.font(.callout).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.fileName == nil {
@@ -715,9 +825,11 @@ private struct PlayerContent: View {
Image(systemName: "play.rectangle").font(.system(size: 56, weight: .ultraLight))
.foregroundStyle(.white.opacity(0.65))
Text("terebi").font(.system(size: 22, weight: .medium))
Text("Drop a media file here").font(.callout).foregroundStyle(.secondary)
Text("Drop a media file or YouTube link here").font(.callout).foregroundStyle(.secondary)
Button("Open File…", action: model.openPanel)
.buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel)
.buttonStyle(.bordered).disabled(!model.canOpen)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if !model.state.has_video && model.state.has_audio {
@@ -729,6 +841,13 @@ private struct PlayerContent: View {
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if model.state.buffering && !model.isBusy && model.errorText == nil {
ProgressView("Buffering…")
.padding(12)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
.allowsHitTesting(false)
}
if model.isCompact {
Text(model.fileName ?? "terebi")
@@ -782,7 +901,8 @@ private struct PlayerContent: View {
}
}
.dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, let url = urls.first, url.isFileURL else { return false }
guard model.canOpen, let url = urls.first else { return false }
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
model.open(url)
return true
} isTargeted: {
@@ -974,6 +1094,9 @@ struct TerebiApp: App {
CommandGroup(replacing: .newItem) {
Button("Open File…", action: model.openPanel).keyboardShortcut("o")
.disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel)
.keyboardShortcut("o", modifiers: [.command, .shift])
.disabled(!model.canOpen)
Button("Close File", action: model.closeFile)
.keyboardShortcut("w", modifiers: [.command, .shift])
.disabled(!model.canClose)
+454
View File
@@ -0,0 +1,454 @@
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)
}
}