feat: resolve HTTP video URLs with bundled yt-dlp

This commit is contained in:
2026-10-22 17:05:29 +02:00
parent 6a4afc9f0e
commit b132bab393
14 changed files with 121 additions and 79 deletions
+21 -18
View File
@@ -30,12 +30,13 @@ enum YouTubeResolver {
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),
var 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.")
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)
@@ -51,11 +52,12 @@ enum YouTubeResolver {
id = nil
}
} else {
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
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 {
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.")
components.scheme = scheme
return components.url!
}
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
}
@@ -112,7 +114,7 @@ enum YouTubeResolver {
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.")
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 {
@@ -129,7 +131,7 @@ enum YouTubeResolver {
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",
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),
@@ -151,7 +153,7 @@ enum YouTubeResolver {
?? 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.")
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
}
let runtime: URL?
if let runtimeURL {
@@ -162,12 +164,12 @@ enum YouTubeResolver {
}
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.")
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 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",
@@ -257,7 +259,7 @@ enum YouTubeResolver {
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 YouTubeResolutionError(message: "The bundled yt-dlp is too old for streaming. Upgrade terebi or rebuild its bundled helpers.")
}
return nil
}
@@ -267,16 +269,16 @@ enum YouTubeResolver {
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.")
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: "YouTube did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
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 YouTube video is unavailable or restricted in your region.")
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 YouTube. Check your network connection and try again; upgrade terebi if the problem persists.")
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.")
}
@@ -298,8 +300,9 @@ private struct YouTubeMetadata: Decodable {
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 }
// 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 {