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
+1 -1
View File
@@ -64,7 +64,7 @@ struct StreamQueue: Sendable {
case .youtube(let canonical):
url = canonical
identity = canonical.absoluteString
label = "YouTube · " + (URLComponents(url: canonical, resolvingAgainstBaseURL: false)?.queryItems?.first?.value ?? "Video")
label = canonical.absoluteString
case .miruro(let mediaID, let episode):
url = URL(string: item.url)!
identity = "miruro:\(mediaID):\(episode)"
+3 -2
View File
@@ -11,8 +11,9 @@ enum StreamURL: Sendable {
static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.")
let url = try YouTubeResolver.validateURL(text)
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
guard !text.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
}), let components = URLComponents(string: text),
+4 -4
View File
@@ -572,7 +572,7 @@ final class PlayerModel: ObservableObject {
cancelStreamResolution()
if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
commandError = "Could not pause the current media to open the stream. Please try again."
revealControls()
return
}
@@ -620,10 +620,10 @@ final class PlayerModel: ObservableObject {
automaticQuality.extractionFailed(at: qualityNow)
return
}
commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again."
commandError = error?.localizedDescription ?? "Could not queue the video to open. Please try again."
if change.isQuality && change.playing, let handle, !player_play(handle) {
commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ??
"Could not queue the YouTube video or resume the previous playback. Please try again."
"Could not queue the video or resume the previous playback. Please try again."
}
revealControls()
}
@@ -3450,7 +3450,7 @@ private struct PlayerContent: View {
model.openFiles(urls)
} else {
guard urls.count == 1, let url = urls.first,
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false }
(try? StreamURL.parse(url.absoluteString)) != nil else { return false }
model.open(url)
}
return true
+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 {