feat: resolve HTTP video URLs with bundled yt-dlp
This commit is contained in:
@@ -36,6 +36,10 @@ struct PlayerCommandChecks {
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
||||
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
||||
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
||||
require(try opened([generic.absoluteString]) == generic &&
|
||||
opened(["--url", generic.absoluteString]) == generic,
|
||||
"positional and explicit third-party URLs reach extraction unchanged")
|
||||
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
|
||||
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"independent local launches preserve relative paths and spaces")
|
||||
@@ -171,6 +175,12 @@ struct PlayerCommandChecks {
|
||||
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
||||
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
||||
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
||||
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
|
||||
let genericQueue = try StreamQueue.load(from: queueFile)
|
||||
require(genericQueue.entries[0].url == generic &&
|
||||
genericQueue.entries[0].identity == generic.absoluteString &&
|
||||
!genericQueue.entries[0].label.contains("YouTube"),
|
||||
"third-party queues retain page identity without a YouTube label")
|
||||
let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport]
|
||||
let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
|
||||
"progress_file": youtubeReport]
|
||||
@@ -183,7 +193,6 @@ struct PlayerCommandChecks {
|
||||
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]],
|
||||
["version": 1, "items": [entries[0], ["url": canonical.absoluteString,
|
||||
"progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]],
|
||||
["version": 1, "items": [["url": "https://cdn.example/expired.m3u8", "progress_file": youtubeReport]]],
|
||||
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]],
|
||||
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]]
|
||||
]
|
||||
@@ -221,7 +230,7 @@ struct PlayerCommandChecks {
|
||||
|
||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
|
||||
["file://remote-host/video.mp4"], ["ftp://example.com/video.mp4"],
|
||||
["--new-instance", "--new-instance"], ["--new-instance", "--url"],
|
||||
["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
|
||||
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
||||
|
||||
@@ -55,7 +55,7 @@ static NSArray<NSDictionary *> *records(const std::string &path) {
|
||||
"schema version is numeric one");
|
||||
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
||||
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity");
|
||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
||||
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
||||
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
||||
@@ -240,13 +240,26 @@ int main(int argc, char **argv) {
|
||||
for (NSUInteger i = 0; i < rows.count; ++i)
|
||||
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
|
||||
|
||||
require(ProgressReporter::validMediaURL(firstURL) &&
|
||||
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted");
|
||||
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA",
|
||||
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
||||
const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
|
||||
require(ProgressReporter::validMediaURL(genericURL) &&
|
||||
ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
|
||||
"HTTP(S) page identities are accepted beyond YouTube");
|
||||
for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
|
||||
"https://", "https://user:secret@example.com/video", "https://example.com:0/video",
|
||||
"https://example.com:65536/video", "https://example.com/video\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "unsafe page identities are rejected");
|
||||
const auto genericPath = root + "/generic.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(genericPath, [] { return 0.; });
|
||||
reporter.begin(genericURL);
|
||||
reporter.observe(sample(PLAYER_PLAYING, 25));
|
||||
reporter.finish();
|
||||
require(reporter.error().empty(), "generic page progress drains successfully");
|
||||
}
|
||||
rows = records(genericPath);
|
||||
require(rows.count == 2, "generic page has initial and terminal snapshots");
|
||||
expect(rows[0], @"progress", genericURL, 25, true);
|
||||
expect(rows[1], @"closed", genericURL, 25, true);
|
||||
|
||||
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
|
||||
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
||||
|
||||
@@ -52,9 +52,12 @@ struct StreamResolverChecks {
|
||||
throw Failure(message: "YouTube URL classification")
|
||||
}
|
||||
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
|
||||
let other = "https://vimeo.com/76979871?quality=original"
|
||||
guard case .youtube(let generic) = try StreamURL.parse(other) else {
|
||||
throw Failure(message: "other HTTP(S) sites route through yt-dlp")
|
||||
}
|
||||
try require(generic.absoluteString == other, "other sites retain their URL and query")
|
||||
for invalid in [
|
||||
"https://example.com/video", "https://www.miruro.to/watch?id=21&ep=1",
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv.evil.example"),
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
|
||||
page.replacingOccurrences(of: "https://", with: "http://"),
|
||||
page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
|
||||
@@ -70,7 +73,7 @@ struct StreamResolverChecks {
|
||||
page + "&ep=2", page + "&provider=bee", page + "#secret",
|
||||
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
|
||||
page.replacingOccurrences(of: "?ep", with: "/?ep"),
|
||||
"https://youtu.be/invalid", "file:///secret"
|
||||
"file:///secret"
|
||||
] {
|
||||
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
|
||||
}
|
||||
|
||||
@@ -70,20 +70,20 @@ struct YouTubeResolverChecks {
|
||||
let url = try YouTubeResolver.validateURL(input)
|
||||
try require(url == page, "supported video URLs identify the same video")
|
||||
}
|
||||
for input in ["https://vimeo.com/76979871", "https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"http://127.0.0.1:8080/video.mp4?token=kept#fragment",
|
||||
"https://www.youtube.com/playlist?list=PLexample"] {
|
||||
try require(try YouTubeResolver.validateURL(input).absoluteString == input,
|
||||
"HTTP(S) URLs outside canonical YouTube video forms reach yt-dlp unchanged")
|
||||
}
|
||||
let hostile = [
|
||||
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
|
||||
"https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"ftp://example.com/video", "https://", "https://example.com:0/video",
|
||||
"https://example.com:65536/video", "https://example.com/video\ninjected",
|
||||
"https://youtube.com@evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://www.youtube.com/playlist?list=PLexample",
|
||||
"https://www.youtube.com/watch?list=PLexample",
|
||||
"https://www.youtube.com/watch?v=short",
|
||||
"https://youtu.be/jNQXAC9IVRwextra", "https://youtu.be/jNQXAC9IVR!",
|
||||
"https://www.youtube.com/shorts/", "https://youtu.be/jNQXAC9IVRw/extra",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVR%0A",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVR%C3%A9"
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||
]
|
||||
for input in hostile {
|
||||
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
|
||||
@@ -202,6 +202,9 @@ struct YouTubeResolverChecks {
|
||||
}
|
||||
|
||||
static func malformedMetadata() throws {
|
||||
var collection = try fixture(combinedJSON)
|
||||
collection["_type"] = "playlist"
|
||||
try reject("collection result instead of a single video") { _ = try decode(collection) }
|
||||
try reject("non-JSON extractor output") { _ = try decode("not JSON") }
|
||||
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
|
||||
try reject("missing playable stream metadata") { _ = try decode(json) }
|
||||
@@ -210,10 +213,11 @@ struct YouTubeResolverChecks {
|
||||
_ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#)
|
||||
}
|
||||
let combined = try fixture(combinedJSON)
|
||||
var missingURL = combined
|
||||
missingURL.removeValue(forKey: "url")
|
||||
try reject("missing combined stream URL") { _ = try decode(missingURL) }
|
||||
for key in ["url", "vcodec", "acodec"] {
|
||||
var object = combined
|
||||
object.removeValue(forKey: key)
|
||||
try reject("missing required combined \(key)") { _ = try decode(object) }
|
||||
object[key] = 17
|
||||
try reject("wrong type for combined \(key)") { _ = try decode(object) }
|
||||
}
|
||||
@@ -236,11 +240,9 @@ struct YouTubeResolverChecks {
|
||||
var hdr = combined
|
||||
hdr["dynamic_range"] = "HDR10"
|
||||
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
|
||||
for value in ["none", NSNull()] as [Any] {
|
||||
var object = combined
|
||||
object["vcodec"] = value
|
||||
try reject("combined input without video codec") { _ = try decode(object) }
|
||||
}
|
||||
var audioOnly = combined
|
||||
audioOnly["vcodec"] = "none"
|
||||
try reject("explicitly audio-only result") { _ = try decode(audioOnly) }
|
||||
for transport in ["http_dash_segments", "mhtml", "rtmp"] {
|
||||
var object = combined
|
||||
object["protocol"] = transport
|
||||
@@ -444,6 +446,18 @@ struct YouTubeResolverChecks {
|
||||
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
|
||||
let lower = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime)
|
||||
try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K")
|
||||
let genericPage = URL(string: "https://video.example/watch?id=123")!
|
||||
let generic: [String: Any] = [
|
||||
"id": "123", "title": "Generic HTML5 video", "webpage_url": genericPage.absoluteString,
|
||||
"extractor": "generic", "extractor_key": "Generic",
|
||||
"formats": [["format_id": "0", "url": "https://media.example/unknown.mp4",
|
||||
"ext": "mp4", "protocol": "https"]]
|
||||
]
|
||||
try JSONSerialization.data(withJSONObject: generic).write(to: extractor.marker("response.json"))
|
||||
let unknown = try await YouTubeResolver.resolve(genericPage, executableURL: extractor.executable, runtimeURL: runtime)
|
||||
try require(unknown.video.url == "https://media.example/unknown.mp4" &&
|
||||
unknown.audio == nil && unknown.height == nil && unknown.webpageURL == genericPage,
|
||||
"generic HTML5 video without codec or height hints remains playable and retains page identity")
|
||||
}
|
||||
|
||||
static func bundledHelpers(root: URL) throws {
|
||||
|
||||
Reference in New Issue
Block a user