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? 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) { 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) } }