import Foundation import Dispatch import Darwin struct YouTubeStream: Sendable { let url: String let headers: String } struct YouTubeChapter: Sendable { let start: Double let end: Double let title: 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] let chapters: [YouTubeChapter] func chapterBoundaries(duration: Double) -> [Double] { guard duration.isFinite, duration > 0 else { return [] } var boundaries = Set() for chapter in chapters { guard chapter.start.isFinite, chapter.end.isFinite, chapter.start >= 0, chapter.end > chapter.start, chapter.end <= duration else { continue } if chapter.start > 0 { boundaries.insert(chapter.start) } if chapter.end < duration { boundaries.insert(chapter.end) } } return boundaries.sorted() } func chapterTitle(at time: Double, duration: Double) -> String? { guard duration.isFinite, duration > 0 else { return nil } let title = chapters.first { $0.start >= 0 && $0.end <= duration && $0.start <= time && time < $0.end }?.title?.trimmingCharacters(in: .whitespacesAndNewlines) return title?.isEmpty == false ? title : nil } } 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, chapters: metadata.chapters?.values ?? [] ) } // --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]" var 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)", ] if webpageURL.host == "www.youtube.com" { arguments.append("--force-ipv4") } arguments.append("--") arguments.append(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]? let chapters: YouTubeChapters? // 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 YouTubeChapters: Decodable { let values: [YouTubeChapter] private enum CodingKeys: String, CodingKey { case start_time, end_time, title } init(from decoder: Decoder) { guard var rows = try? decoder.unkeyedContainer() else { values = [] return } var chapters: [YouTubeChapter] = [] while !rows.isAtEnd { guard let rowDecoder = try? rows.superDecoder() else { break } guard let row = try? rowDecoder.container(keyedBy: CodingKeys.self), let start = try? row.decode(Double.self, forKey: .start_time), let end = try? row.decode(Double.self, forKey: .end_time) else { continue } chapters.append(YouTubeChapter(start: start, end: end, title: try? row.decode(String.self, forKey: .title))) } values = chapters } } 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? 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) { 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) { 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) } }