31c4759c1d
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.
500 lines
24 KiB
Swift
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)
|
|
}
|
|
}
|