4576080955
The sidebar scheduler waited for playback and used full stream extraction for titles. Use independent utility-priority title requests, native YouTube oEmbed, and metadata-only extraction for generic sites. Preserve foreground title precedence and stale row protection. Verified: make check-youtube check-queue check-resolved check-adaptive check-stream all.
589 lines
29 KiB
Swift
589 lines
29 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) }),
|
|
var components = URLComponents(string: text),
|
|
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
|
components.user == nil, components.password == nil,
|
|
components.port == nil || (1...65535).contains(components.port!),
|
|
let host = components.host?.lowercased(), !host.isEmpty,
|
|
let url = components.url, url.absoluteString.utf8.count <= 65536 else {
|
|
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS video URL without embedded credentials.")
|
|
}
|
|
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 {
|
|
id = nil
|
|
}
|
|
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 {
|
|
components.scheme = scheme
|
|
return components.url!
|
|
}
|
|
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: "yt-dlp 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! : "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
|
|
)
|
|
}
|
|
|
|
// --skip-download/-j still perform extraction; --flat-playlist only skips playlist
|
|
// entries. YouTube's oEmbed endpoint avoids format and JavaScript challenge work.
|
|
static func resolveTitle(_ url: URL, executableURL: URL? = nil,
|
|
configuration: URLSessionConfiguration? = nil) async throws -> String? {
|
|
try Task.checkCancellation()
|
|
let webpageURL = try validateURL(url.absoluteString)
|
|
if webpageURL.host == "www.youtube.com", webpageURL.path == "/watch",
|
|
URLComponents(url: webpageURL, resolvingAgainstBaseURL: false)?.queryItems?.first?.name == "v" {
|
|
var endpoint = URLComponents(string: "https://www.youtube.com/oembed")!
|
|
endpoint.queryItems = [URLQueryItem(name: "url", value: webpageURL.absoluteString),
|
|
URLQueryItem(name: "format", value: "json")]
|
|
let configuration = configuration ?? .ephemeral
|
|
configuration.timeoutIntervalForRequest = 15
|
|
configuration.timeoutIntervalForResource = 20
|
|
configuration.httpShouldSetCookies = false
|
|
configuration.httpCookieStorage = nil
|
|
configuration.urlCache = nil
|
|
let session = URLSession(configuration: configuration, delegate: YouTubeTitleRedirectPolicy(), delegateQueue: nil)
|
|
defer { session.invalidateAndCancel() }
|
|
let (bytes, response) = try await session.bytes(from: endpoint.url!)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
|
http.mimeType == "application/json", response.expectedContentLength <= 65536 else {
|
|
throw YouTubeResolutionError(message: "The optional video title was unavailable.")
|
|
}
|
|
var data = Data()
|
|
for try await byte in bytes {
|
|
try Task.checkCancellation()
|
|
guard data.count < 65536 else {
|
|
throw YouTubeResolutionError(message: "The optional video title was too large.")
|
|
}
|
|
data.append(byte)
|
|
}
|
|
try Task.checkCancellation()
|
|
return try decodeTitle(data)
|
|
}
|
|
let (executable, directories) = try extractorExecutable(executableURL)
|
|
var environment = ProcessInfo.processInfo.environment
|
|
environment["PATH"] = directories.joined(separator: ":")
|
|
// Generic sites may need site-specific extraction, but no playback format,
|
|
// runtime, stream validation or native media load is required for their title.
|
|
let arguments = [
|
|
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
|
"--no-playlist", "--flat-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
|
"--no-progress", "--color", "never", "--no-check-formats", "--ignore-no-formats-error",
|
|
"--no-js-runtimes", "--socket-timeout", "15", "--extractor-retries", "0", "--retries", "0",
|
|
"--", webpageURL.absoluteString
|
|
]
|
|
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
|
try Task.checkCancellation()
|
|
return try decodeTitle(output.stdout)
|
|
}
|
|
|
|
static func decodeTitle(_ data: Data) throws -> String? {
|
|
struct Metadata: Decodable { let title: String?; let _type: String? }
|
|
guard data.count <= outputLimit else {
|
|
throw YouTubeResolutionError(message: "The optional video title was too large.")
|
|
}
|
|
let metadata = try JSONDecoder().decode(Metadata.self, from: data)
|
|
guard metadata._type == nil || ["video", "url", "url_transparent"].contains(metadata._type!) else {
|
|
throw YouTubeResolutionError(message: "The optional video title did not identify a single video.")
|
|
}
|
|
guard let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!title.isEmpty else { return nil }
|
|
guard title.utf8.count <= 16384,
|
|
!title.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
|
|
throw YouTubeResolutionError(message: "The optional video title was invalid.")
|
|
}
|
|
return title
|
|
}
|
|
|
|
private static func extractorExecutable(_ override: URL?) throws -> (URL, [String]) {
|
|
let bundle = Bundle.main.bundleURL
|
|
let directories = bundle.pathExtension == "app"
|
|
? [bundle.appendingPathComponent("Contents/MacOS").path]
|
|
: searchDirectories()
|
|
let executable = try override.map { try executableFile($0, name: "yt-dlp") }
|
|
?? bundledExecutable("yt-dlp")
|
|
?? findExecutable("yt-dlp", directories: directories)
|
|
guard let executable else {
|
|
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
|
|
}
|
|
return (executable, directories)
|
|
}
|
|
|
|
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 (executable, directories) = try extractorExecutable(executableURL)
|
|
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: "Stream 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 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: "This site 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: "yt-dlp 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 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 the video site. 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 final class YouTubeTitleRedirectPolicy: NSObject, URLSessionTaskDelegate {
|
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
|
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
|
completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
|
|
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]?
|
|
|
|
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
|
|
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
|
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
|
}
|
|
|
|
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
|
|
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]) {
|
|
let background = Task.currentPriority <= .utility
|
|
queue = DispatchQueue(label: "terebi.youtube-extraction", qos: background ? .utility : .userInitiated)
|
|
process.qualityOfService = background ? .utility : .userInitiated
|
|
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)
|
|
}
|
|
}
|