feat: resolve HTTP video URLs with bundled yt-dlp
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user