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 {
+2 -2
View File
@@ -19,13 +19,13 @@ enum PlayerCommand {
terebi --help
terebi --default-config
Open one local file, YouTube video, Miruro episode or resolved source and start playback,
Open one local file, yt-dlp-supported video, Miruro episode or resolved source and start playback,
resuming saved video progress. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters.
--new-instance Launch a separate player, leaving existing playback untouched.
--no-config Use built-in settings instead of config.toml on a fresh launch.
--url URL Open a YouTube video or Miruro episode URL.
--url URL Resolve an HTTP(S) video URL with yt-dlp, or open a Miruro episode.
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
--enqueue Append a queue to the running player without interrupting playback;
+4 -3
View File
@@ -6,9 +6,7 @@
#include <arpa/inet.h>
namespace playback {
inline bool validNetworkURL(const char *text) {
if (!text) return false;
const std::string_view url(text);
inline bool validNetworkURL(std::string_view url) {
if (url.size() > 65536) return false;
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
if (!start) return false;
@@ -52,6 +50,9 @@ inline bool validNetworkURL(const char *text) {
}
return port.empty() || number > 0;
}
inline bool validNetworkURL(const char *text) {
return text && validNetworkURL(std::string_view(text));
}
inline bool validHeaders(const char *text) {
if (!text) return true;
const std::string_view headers(text);
+2 -2
View File
@@ -58,8 +58,8 @@ typedef struct PlayerNetworkSource {
const char *audio_headers;
double duration;
bool is_live;
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode>
// identity, never an extracted stream/CDN URL.
// Original HTTP(S) video URL (canonical for YouTube), or stable
// miruro:<base64url media_id>:<positive episode> identity, never an extracted CDN URL.
// NULL/empty means no reporting for this source.
const char *media_url;
// Initial restored position, applied before publishing ready state. Zero
+6 -8
View File
@@ -1,4 +1,5 @@
#include "progress_reporter.hpp"
#include "network_policy.hpp"
#include <cerrno>
#include <charconv>
#include <chrono>
@@ -87,13 +88,7 @@ void ProgressReporter::resume() {
}
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
constexpr std::string_view prefix = "https://www.youtube.com/watch?v=";
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false;
for (char character : url.substr(prefix.size())) {
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false;
}
return true;
return validNetworkURL(url);
}
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
@@ -203,7 +198,10 @@ bool ProgressReporter::emit(const char *event) noexcept {
record += "},\"position_seconds\":";
} else {
record += "\",\"media_url\":\"";
record += mediaURL;
for (char character : mediaURL) {
if (character == '"' || character == '\\') record += '\\';
record += character;
}
record += "\",\"position_seconds\":";
}
appendNumber(record, snapshot.position);