|
|
|
@@ -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 {
|
|
|
|
|