feat: stream YouTube video with coordinated audio and video inputs
Resolve public YouTube URLs through external yt-dlp and play selected streams in the existing native window. Independent bounded network readers keep one authored timeline. Successful seeks clear FFmpeg's sticky aborted-read AVIO error; manifest seekability uses native format flags rather than a top-level I/O handle. Verified real YouTube playback, seeking, frame stepping, compact mode, cancellation, and the complete native regression checks.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
#include "../Sources/Core/player_macos.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
#import <dispatch/dispatch.h>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||
}
|
||||
|
||||
// Only these deadlines use elapsed time: a stuck command/read must fail the check,
|
||||
// not hang CI. Playback assertions use fixture presentation timestamps and events.
|
||||
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
|
||||
const char *scenario, bool expectError = false) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto state = player_get_state(player);
|
||||
if (state.status == PLAYER_ERROR && !expectError) {
|
||||
std::fprintf(stderr, "FAIL %s: %s\n", scenario, state.error);
|
||||
std::exit(1);
|
||||
}
|
||||
if (ready(state)) return state;
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
|
||||
}
|
||||
auto state = player_get_state(player);
|
||||
std::fprintf(stderr, "FAIL %s: command deadline (status=%d position=%.4f duration=%.4f pending=%d buffering=%d)\n",
|
||||
scenario, state.status, state.position, state.duration, state.seek_pending, state.buffering);
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
static NSDictionary *control(const std::string &base, const std::string &path) {
|
||||
NSString *address = [NSString stringWithUTF8String:(base + path).c_str()];
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:address]];
|
||||
request.timeoutInterval = 5;
|
||||
request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
|
||||
dispatch_semaphore_t done = dispatch_semaphore_create(0);
|
||||
__block NSData *body = nil;
|
||||
__block NSHTTPURLResponse *response = nil;
|
||||
__block NSError *failure = nil;
|
||||
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request
|
||||
completionHandler:^(NSData *data, NSURLResponse *result, NSError *error) {
|
||||
body = data;
|
||||
response = (NSHTTPURLResponse *)result;
|
||||
failure = error;
|
||||
dispatch_semaphore_signal(done);
|
||||
}];
|
||||
[task resume];
|
||||
if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 6 * NSEC_PER_SEC)) != 0) {
|
||||
[task cancel];
|
||||
require(false, "loopback control request deadline");
|
||||
}
|
||||
require(failure == nil && response.statusCode == 200 && body != nil, "loopback control endpoint responds");
|
||||
id json = [NSJSONSerialization JSONObjectWithData:body options:0 error:nullptr];
|
||||
require([json isKindOfClass:[NSDictionary class]], "loopback control response is a dictionary");
|
||||
return (NSDictionary *)json;
|
||||
}
|
||||
|
||||
struct GateState {
|
||||
bool entered, released;
|
||||
uint64_t waiting, requests, ranges, bytes, total;
|
||||
};
|
||||
|
||||
static GateState gateState(const std::string &base, const std::string &token) {
|
||||
NSDictionary *state = control(base, "/control/status/" + token);
|
||||
return {[state[@"entered"] boolValue], [state[@"released"] boolValue],
|
||||
[state[@"waiting"] unsignedLongLongValue], [state[@"requests"] unsignedLongLongValue],
|
||||
[state[@"ranges"] unsignedLongLongValue], [state[@"bytes"] unsignedLongLongValue],
|
||||
[state[@"total"] unsignedLongLongValue]};
|
||||
}
|
||||
|
||||
static void stillBlocked(const std::string &base, const std::string &token) {
|
||||
auto gate = gateState(base, token);
|
||||
require(gate.entered && !gate.released && gate.waiting > 0,
|
||||
"command completed while original server read remained blocked");
|
||||
}
|
||||
|
||||
static std::string headersFor(const std::string &path) {
|
||||
const char *kind = path.find("audio.wav") != std::string::npos ||
|
||||
path.find("frame-step-audio.m4a") != std::string::npos ? "audio" :
|
||||
path.find("video.mp4") != std::string::npos ? "video" : "combined";
|
||||
return std::string("X-Terebi-Fixture: ") + kind + "\r\n";
|
||||
}
|
||||
|
||||
static bool openNetwork(Player *player, const std::string &base, const std::string &videoPath,
|
||||
const std::string &audioPath = {}, double duration = 4, bool live = false,
|
||||
bool includeHeaders = true) {
|
||||
// Locals intentionally die immediately after the call: the asynchronous engine
|
||||
// must own both selected URLs and both selected HTTP-header blocks.
|
||||
auto video = base + videoPath;
|
||||
auto audio = audioPath.empty() ? std::string{} : base + audioPath;
|
||||
auto videoHeaders = includeHeaders ? headersFor(videoPath) : std::string{};
|
||||
auto audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
|
||||
PlayerNetworkSource source{video.c_str(), audio.empty() ? nullptr : audio.c_str(),
|
||||
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live};
|
||||
return player_open_network(player, &source);
|
||||
}
|
||||
|
||||
static void closed(Player *player) {
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
|
||||
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
|
||||
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
|
||||
"close clears all network/media/seek/error state");
|
||||
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network close");
|
||||
}
|
||||
|
||||
static PlayerState seekCompleted(Player *player, double target, PlayerStatus status) {
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_seek(player, target), "network seek accepted");
|
||||
auto pending = player_get_state(player);
|
||||
require(pending.seek_pending || std::abs(pending.position - target) < .03,
|
||||
"network seek remains pending until target is authoritative");
|
||||
return until(player, [=](auto s) {
|
||||
return !s.seek_pending && s.revision != revision && s.status == status &&
|
||||
std::abs(s.position - target) < (status == PLAYER_PLAYING ? .2 : .03);
|
||||
}, "network seek acknowledgement");
|
||||
}
|
||||
|
||||
static void transport(Player *player, MTKView *view, const std::string &base, bool split) {
|
||||
require(openNetwork(player, base, split ? "/media/video.mp4" : "/media/av.mp4",
|
||||
split ? "/media/audio.wav" : ""), "network open accepted");
|
||||
require(player_play(player), "play queues during asynchronous network open");
|
||||
auto playing = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .15; },
|
||||
"queued network autoplay");
|
||||
require(playing.has_audio && playing.has_video && playing.can_seek && !playing.seek_pending,
|
||||
"combined and split inputs select both tracks and expose seekability");
|
||||
require(playing.duration >= 3.9 && playing.duration <= 4.2, "network media duration preserves source timing");
|
||||
require(std::abs(playing.video_aspect_ratio - 16.0 / 9) < .0001, "network video keeps display aspect");
|
||||
require(std::abs(player_get_volume(player) - .37f) < .00001f, "volume survives network open");
|
||||
[view draw];
|
||||
require(player_pause(player), "pause streaming playback");
|
||||
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "network pause");
|
||||
// An acknowledged external request is an event barrier, not a sleep pretending
|
||||
// the audio callback or command queue must have completed by now.
|
||||
control(base, "/control/stats");
|
||||
require(std::abs(player_get_state(player).position - paused.position) < .0001,
|
||||
"paused streaming audio clock does not advance");
|
||||
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
||||
[view draw];
|
||||
require(player_seek(player, .8) && player_seek(player, 1.7), "queued seeks accepted on both inputs");
|
||||
auto pending = player_get_state(player);
|
||||
require(pending.seek_pending || std::abs(pending.position - 1.7) < .03,
|
||||
"earlier seek cannot acknowledge final split-input seek");
|
||||
until(player, [](auto s) {
|
||||
return !s.seek_pending && s.status == PLAYER_PAUSED && std::abs(s.position - 1.7) < .03;
|
||||
}, "queued final network seek");
|
||||
require(player_play(player), "resume both network inputs");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.8; }, "resume after split seek");
|
||||
seekCompleted(player, 3.2, PLAYER_PLAYING);
|
||||
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "drain streaming media");
|
||||
require(ended.position >= 3.9 && ended.position < 4.2, "network EOF drains decoded audio and video");
|
||||
require(player_play(player), "network replay accepted");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1 && s.position < 1; },
|
||||
"network replay restarts both streams");
|
||||
require(player_close(player), "close playing network source");
|
||||
closed(player);
|
||||
std::puts(split ? "PASS split stream-index-0 inputs: headers/autoplay/duration/pause/paused+playing seek/acknowledgement/EOF/replay"
|
||||
: "PASS combined network: headers/autoplay/duration/pause/paused+playing seek/acknowledgement/EOF/replay");
|
||||
}
|
||||
|
||||
static PlayerState step(Player *player, int direction, double pts) {
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_step_frame(player, direction), "split-source adjacent frame step accepted");
|
||||
auto state = until(player, [revision](auto s) {
|
||||
return !s.seek_pending && s.revision != revision && s.status == PLAYER_PAUSED;
|
||||
}, "split frame-step acknowledgement");
|
||||
if (std::abs(state.position - pts) >= .0001) {
|
||||
std::fprintf(stderr, "FAIL split frame PTS: expected %.4f, got %.4f\n", pts, state.position);
|
||||
std::exit(1);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
static void frameSteps(Player *player, MTKView *view, const std::string &base) {
|
||||
constexpr double pts[] = {0, .1, .2, .3, .4, .65, .9, 1.15, 1.4, 1.5, 1.6, 1.7};
|
||||
require(openNetwork(player, base, "/media/frame-step-video.mp4", "/media/audio.wav"),
|
||||
"video-only VFR plus independent audio open");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED && s.has_audio && s.has_video; }, "split VFR open");
|
||||
require(!player_step_frame(player, 0) && !player_step_frame(player, 2), "invalid step directions rejected");
|
||||
step(player, -1, 0);
|
||||
for (unsigned i = 1; i < sizeof(pts) / sizeof(pts[0]); ++i) {
|
||||
step(player, 1, pts[i]);
|
||||
[view draw];
|
||||
}
|
||||
step(player, 1, 1.7);
|
||||
for (int i = 10; i >= 0; --i) step(player, -1, pts[i]);
|
||||
step(player, -1, 0);
|
||||
seekCompleted(player, .72, PLAYER_PAUSED);
|
||||
step(player, 1, 1.15); // The .72 seek previews .9, never the preceding keyframe.
|
||||
step(player, -1, .9);
|
||||
step(player, -1, .65);
|
||||
require(player_play(player), "resume frame-stepped split audio/video");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.3; },
|
||||
"audio rebuild resumes authoritative stepped timestamp");
|
||||
require(player_pause(player), "pause resumed split VFR timeline");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause split VFR after resume");
|
||||
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
||||
require(player_play(player), "play past independent video EOF");
|
||||
auto beyondVideo = until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.2; },
|
||||
"audio continues after independent video EOF");
|
||||
require(beyondVideo.position < 3.9, "video EOF does not end longer audio source");
|
||||
auto ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer audio input drains");
|
||||
require(ended.position >= 3.9 && ended.position < 4.2, "split EOF follows last audio sample");
|
||||
require(player_close(player), "close split stepped source");
|
||||
closed(player);
|
||||
std::puts("PASS split VFR/B-frames: exact bidirectional steps/boundaries/non-keyframe seek/audio resume/independent video EOF");
|
||||
|
||||
require(openNetwork(player, base, "/media/video.mp4", "/media/frame-step-audio.m4a"),
|
||||
"long video plus short independent audio open");
|
||||
require(player_play(player), "queued play with shorter audio");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "asymmetric split autoplay");
|
||||
seekCompleted(player, 1.5, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.3; },
|
||||
"video continues after independent audio EOF");
|
||||
require(player_pause(player), "pause in independent audio tail");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause longer video after audio EOF");
|
||||
seekCompleted(player, 3.2, PLAYER_PAUSED);
|
||||
require(player_play(player), "resume beyond shorter separate audio endpoint");
|
||||
ended = until(player, [](auto s) { return s.status == PLAYER_ENDED; }, "longer video input drains");
|
||||
require(std::abs(ended.position - 4) < .03, "audio EOF does not truncate video presentation duration");
|
||||
require(player_close(player), "close asymmetric split source");
|
||||
closed(player);
|
||||
std::puts("PASS independent audio EOF: video presentation continues and final duration is retained");
|
||||
}
|
||||
|
||||
static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
require(openNetwork(player, base, "/media/network-vod.m3u8"), "recorded native HLS open");
|
||||
require(player_play(player), "recorded HLS autoplay");
|
||||
auto playing = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
|
||||
}, "recorded HLS playback");
|
||||
require(playing.has_audio && playing.has_video && playing.can_seek,
|
||||
"recorded HLS owns its I/O but supports timestamp seeking");
|
||||
require(player_pause(player), "pause native HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "native HLS pause");
|
||||
seekCompleted(player, 2.5, PLAYER_PAUSED);
|
||||
require(player_play(player), "resume native HLS seek");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.6; },
|
||||
"native HLS seek resumes actual audio/video clock");
|
||||
[view draw];
|
||||
require(player_close(player), "close native HLS");
|
||||
closed(player);
|
||||
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
|
||||
}
|
||||
|
||||
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
|
||||
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "live-flag open");
|
||||
require(!live.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1) &&
|
||||
!player_step_frame(player, -1), "live source rejects seeks and both frame-step directions");
|
||||
require(player_open(player, (fixtures + "/video.mp4").c_str()), "ordinary local source replaces network live source");
|
||||
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "local replacement");
|
||||
require(local.can_seek && !local.buffering && local.has_video && !local.has_audio && !local.seek_pending,
|
||||
"local replacement restores seekability and clears network flags");
|
||||
require(std::abs(player_get_volume(player) - .37f) < .00001f, "local replacement keeps volume");
|
||||
seekCompleted(player, 1.5, PLAYER_PAUSED);
|
||||
require(player_close(player), "close local replacement");
|
||||
closed(player);
|
||||
require(openNetwork(player, base, "/no-range/streaming.mp4", "", 30), "non-range HTTP open");
|
||||
auto noRange = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "non-seekable HTTP open");
|
||||
require(!noRange.can_seek && !player_seek(player, 1) && !player_step_frame(player, 1),
|
||||
"HTTP input without byte ranges rejects seek and step even without live metadata");
|
||||
require(player_play(player), "non-seekable network source still plays");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "non-seekable playback");
|
||||
require(player_close(player), "close non-seekable HTTP source");
|
||||
closed(player);
|
||||
std::puts("PASS live/non-range seek rejection/local replacement/volume/state reset");
|
||||
}
|
||||
|
||||
static void blockedOpening(Player *player, const std::string &base, const std::string &token) {
|
||||
require(openNetwork(player, base, "/stall-open/" + token + "/av.mp4"), "open deliberately stalled HTTP response");
|
||||
until(player, [&](auto s) {
|
||||
auto gate = gateState(base, token);
|
||||
return s.status == PLAYER_OPENING && gate.entered && gate.waiting > 0 && !gate.released;
|
||||
}, "opening response-body read underway");
|
||||
}
|
||||
|
||||
static void blockedPlayback(Player *player, const std::string &base, const std::string &token,
|
||||
bool splitAudio = false) {
|
||||
auto stalled = "/stall/" + token + "/streaming.mp4";
|
||||
require(openNetwork(player, base, splitAudio ? "/media/streaming.mp4" : stalled,
|
||||
splitAudio ? stalled : "", 30), "open bounded streaming prefix");
|
||||
require(player_play(player), "queue play before gated source opens");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; },
|
||||
"stream starts before entire file arrives");
|
||||
until(player, [&](auto s) {
|
||||
auto gate = gateState(base, token);
|
||||
return s.status == PLAYER_PLAYING && s.buffering && gate.entered && gate.waiting > 0 && !gate.released;
|
||||
}, "real demux read blocks at server gate");
|
||||
auto gate = gateState(base, token);
|
||||
require(gate.bytes < gate.total && gate.total > 131072, "engine streams playable prefix without whole-file download");
|
||||
}
|
||||
|
||||
static void cancellation(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
for (bool splitAudio : {false, true}) {
|
||||
const std::string token = splitAudio ? "split-playing-seek" : "pause-seek";
|
||||
blockedPlayback(player, base, token, splitAudio);
|
||||
if (!splitAudio) {
|
||||
require(player_pause(player), "pause while actual network read is blocked");
|
||||
auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "pause does not wait for network read");
|
||||
stillBlocked(base, token);
|
||||
require(std::abs(player_get_state(player).position - paused.position) < .0001, "buffered pause freezes native clock");
|
||||
}
|
||||
seekCompleted(player, 28, splitAudio ? PLAYER_PLAYING : PLAYER_PAUSED);
|
||||
stillBlocked(base, token);
|
||||
auto afterSeek = gateState(base, token);
|
||||
require(afterSeek.requests > 1 && afterSeek.ranges > 1, "seek replaces stalled read with an actual new byte-range request");
|
||||
require(player_play(player), "resume at seek target with stale read still blocked");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && !s.seek_pending && s.position > 28.1; },
|
||||
"resume after stalled-read seek");
|
||||
stillBlocked(base, token);
|
||||
require(player_close(player), "close resumed formerly stalled source");
|
||||
closed(player);
|
||||
std::puts(splitAudio
|
||||
? "PASS stalled secondary audio reader: split playing seek/both-input acknowledgement/new range/resume without server release"
|
||||
: "PASS stalled demux read: buffering/bounded prefix/pause/paused seek acknowledgement/new range/resume without server release");
|
||||
}
|
||||
|
||||
blockedOpening(player, base, "opening-close");
|
||||
require(player_close(player), "close cancels opening read");
|
||||
closed(player);
|
||||
stillBlocked(base, "opening-close");
|
||||
blockedOpening(player, base, "opening-replace");
|
||||
require(player_open(player, (fixtures + "/audio.wav").c_str()), "local open replaces blocked network open");
|
||||
auto local = until(player, [](auto s) { return s.status == PLAYER_PAUSED && s.has_audio && !s.has_video; },
|
||||
"local replacement cancels blocked opening read");
|
||||
require(local.can_seek && !local.buffering && !local.seek_pending && local.position == 0,
|
||||
"stale opening read cannot publish over replacement timeline");
|
||||
stillBlocked(base, "opening-replace");
|
||||
require(player_close(player), "close audio-only replacement");
|
||||
closed(player);
|
||||
blockedPlayback(player, base, "playing-close");
|
||||
require(player_close(player), "close cancels blocked playing read");
|
||||
closed(player);
|
||||
stillBlocked(base, "playing-close");
|
||||
blockedPlayback(player, base, "playing-replace");
|
||||
require(openNetwork(player, base, "/media/video.mp4", "/media/audio.wav"),
|
||||
"new network open replaces blocked playing read");
|
||||
auto replacement = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PAUSED && s.has_audio && s.has_video && !s.buffering;
|
||||
},
|
||||
"network replacement cancels blocked playing read");
|
||||
require(replacement.position == 0 && !replacement.buffering && !replacement.seek_pending && replacement.can_seek,
|
||||
"replacement ignores stale stream data and buffering state");
|
||||
stillBlocked(base, "playing-replace");
|
||||
require(player_close(player), "close split network replacement");
|
||||
closed(player);
|
||||
std::puts("PASS stalled opening+playing reads: close/local+network replacement cancel without server release");
|
||||
}
|
||||
|
||||
static void failures(Player *player, const std::string &base) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, false, false), "header-denied request accepted asynchronously");
|
||||
auto denied = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "required header denial", true);
|
||||
require(denied.error[0] && !denied.has_audio && !denied.has_video && !denied.can_seek &&
|
||||
!denied.buffering && !denied.seek_pending, "failed network open clears media and reports useful error");
|
||||
NSDictionary *stats = control(base, "/control/stats");
|
||||
require([stats[@"av.mp4"][@"denied"] unsignedLongLongValue] > 0,
|
||||
"fixture server actually rejected request without selected-format header");
|
||||
require(openNetwork(player, base, "/failure/av.mp4"), "controlled failed network open accepted");
|
||||
auto failed = until(player, [](auto s) { return s.status == PLAYER_ERROR; }, "HTTP failure", true);
|
||||
require(failed.error[0] && !failed.buffering && !failed.can_seek, "HTTP failure has explicit error and cleared flags");
|
||||
require(openNetwork(player, base, "/media/video.mp4", "/media/audio.wav"), "recover from real HTTP failures");
|
||||
require(player_play(player), "recovered split source autoplay");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .1; }, "failed network recovery");
|
||||
require(player_close(player), "close recovered network source");
|
||||
closed(player);
|
||||
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@autoreleasepool {
|
||||
require(argc == 3, "loopback base URL and local fixture directory required");
|
||||
std::string base = argv[1], fixtures = argv[2];
|
||||
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
||||
[NSApplication sharedApplication];
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
require(bool(player), "player creation");
|
||||
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
|
||||
void *native = player_macos_make_video_view(player.get());
|
||||
require(native != nullptr, "actual Metal renderer initializes");
|
||||
MTKView *view = (__bridge_transfer MTKView *)native;
|
||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-2000, -2000, 320, 180)
|
||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
window.contentView = view;
|
||||
[window orderBack:nil];
|
||||
transport(player.get(), view, base, false);
|
||||
transport(player.get(), view, base, true);
|
||||
frameSteps(player.get(), view, base);
|
||||
nativeHLS(player.get(), view, base);
|
||||
liveAndReplacement(player.get(), base, fixtures);
|
||||
cancellation(player.get(), base, fixtures);
|
||||
failures(player.get(), base);
|
||||
blockedPlayback(player.get(), base, "destroy-playing");
|
||||
player.reset(); // Python's process deadline catches a join waiting for server unblock.
|
||||
stillBlocked(base, "destroy-playing");
|
||||
[view draw]; // The native view is allowed to outlive its cancelled engine.
|
||||
[window orderOut:nil];
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> opening(player_create(), player_destroy);
|
||||
require(bool(opening), "second player creation for opening cancellation");
|
||||
blockedOpening(opening.get(), base, "destroy-opening");
|
||||
opening.reset();
|
||||
stillBlocked(base, "destroy-opening");
|
||||
std::puts("PASS stalled opening+playing destruction: joins without server release/native view lifetime");
|
||||
}
|
||||
}
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Range-capable loopback fixtures for the real native network playback check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
|
||||
class _Gate:
|
||||
def __init__(self, total: int):
|
||||
self.total = total
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.claimed = False
|
||||
self.waiting = 0
|
||||
self.requests = 0
|
||||
self.ranges = 0
|
||||
self.bytes = 0
|
||||
|
||||
|
||||
class StreamingServer:
|
||||
"""Context-managed server; safe to reuse for actual resolver/playback smoke checks."""
|
||||
|
||||
headers = {
|
||||
"av.mp4": "combined",
|
||||
"streaming.mp4": "combined",
|
||||
"frame-step.mp4": "combined",
|
||||
"video.mp4": "video",
|
||||
"frame-step-video.mp4": "video",
|
||||
"audio.wav": "audio",
|
||||
"frame-step-audio.m4a": "audio",
|
||||
"network-vod.m3u8": "combined",
|
||||
**{f"network-vod-{index}.ts": "combined" for index in range(4)},
|
||||
}
|
||||
|
||||
def __init__(self, fixtures: str | Path):
|
||||
self.fixtures = Path(fixtures).resolve()
|
||||
self.lock = threading.Lock()
|
||||
self.gates: dict[str, _Gate] = {}
|
||||
self.stats: dict[str, dict[str, int]] = {}
|
||||
self.stopping = threading.Event()
|
||||
owner = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass # Media URLs/headers never appear in logs.
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
owner._get(self)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
pass # The engine intentionally cancels active media connections.
|
||||
|
||||
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.http.daemon_threads = True
|
||||
self.thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
||||
self.base_url = f"http://127.0.0.1:{self.http.server_port}"
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
self.stopping.set()
|
||||
with self.lock:
|
||||
for gate in self.gates.values():
|
||||
gate.release.set()
|
||||
self.http.shutdown()
|
||||
self.http.server_close()
|
||||
self.thread.join()
|
||||
|
||||
@staticmethod
|
||||
def _reply(request, status: int, body: bytes, content_type="application/json"):
|
||||
request.send_response(status)
|
||||
request.send_header("Content-Type", content_type)
|
||||
request.send_header("Content-Length", str(len(body)))
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.wfile.write(body)
|
||||
request.close_connection = True
|
||||
|
||||
@staticmethod
|
||||
def _range(value: str | None, size: int):
|
||||
if value is None:
|
||||
return 0, size - 1
|
||||
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value)
|
||||
if not match or not any(match.groups()):
|
||||
return None
|
||||
first, last = match.groups()
|
||||
if not first:
|
||||
suffix = int(last)
|
||||
return (max(0, size - suffix), size - 1) if suffix else None
|
||||
start = int(first)
|
||||
end = min(int(last), size - 1) if last else size - 1
|
||||
return (start, end) if 0 <= start <= end < size else None
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
if parts == ["control", "stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps(self.stats).encode()
|
||||
self._reply(request, 200, body)
|
||||
return
|
||||
if len(parts) == 3 and parts[0] == "control" and parts[1] in ("status", "release"):
|
||||
with self.lock:
|
||||
gate = self.gates.get(parts[2])
|
||||
if gate is None:
|
||||
body = {"entered": False, "released": False, "waiting": 0, "requests": 0,
|
||||
"ranges": 0, "bytes": 0, "total": 0}
|
||||
else:
|
||||
if parts[1] == "release":
|
||||
gate.release.set()
|
||||
body = {"entered": gate.entered.is_set(), "released": gate.release.is_set(),
|
||||
"waiting": gate.waiting, "requests": gate.requests, "ranges": gate.ranges,
|
||||
"bytes": gate.bytes, "total": gate.total}
|
||||
self._reply(request, 200, json.dumps(body).encode())
|
||||
return
|
||||
|
||||
mode = parts[0]
|
||||
token = None
|
||||
if len(parts) == 3 and mode in ("stall", "stall-open"):
|
||||
token, name = parts[1:]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
|
||||
self._reply(request, 400, b"Invalid gate token", "text/plain")
|
||||
return
|
||||
elif len(parts) == 2 and mode in ("media", "no-range", "failure"):
|
||||
name = parts[1]
|
||||
else:
|
||||
self._reply(request, 404, b"Unknown fixture endpoint", "text/plain")
|
||||
return
|
||||
if name not in self.headers or not (self.fixtures / name).is_file():
|
||||
self._reply(request, 404, b"Fixture not found", "text/plain")
|
||||
return
|
||||
with self.lock:
|
||||
stats = self.stats.setdefault(name, {"requests": 0, "ranges": 0, "bytes": 0, "denied": 0})
|
||||
stats["requests"] += 1
|
||||
if request.headers.get("X-Terebi-Fixture") != self.headers[name]:
|
||||
stats["denied"] += 1
|
||||
denied = True
|
||||
else:
|
||||
denied = False
|
||||
if denied:
|
||||
self._reply(request, 403, b"Required fixture header missing or incorrect", "text/plain")
|
||||
return
|
||||
if mode == "failure":
|
||||
self._reply(request, 503, b"Controlled fixture extraction failure", "text/plain")
|
||||
return
|
||||
|
||||
path = self.fixtures / name
|
||||
size = path.stat().st_size
|
||||
range_header = request.headers.get("Range") if mode != "no-range" else None
|
||||
byte_range = self._range(range_header, size)
|
||||
if byte_range is None:
|
||||
request.send_response(416)
|
||||
request.send_header("Content-Range", f"bytes */{size}")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
start, end = byte_range
|
||||
gate = None
|
||||
gate_at = 0
|
||||
should_gate = False
|
||||
if token is not None:
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
gate_at = int(query.get("after", ["131072"])[0]) if mode == "stall" else start
|
||||
except ValueError:
|
||||
self._reply(request, 400, b"Invalid gate offset", "text/plain")
|
||||
return
|
||||
with self.lock:
|
||||
gate = self.gates.setdefault(token, _Gate(size))
|
||||
gate.requests += 1
|
||||
gate.ranges += int(range_header is not None)
|
||||
# Only the original prefix connection is gated: a seek can create a new
|
||||
# range request while the old read is still blocked and never released.
|
||||
should_gate = not gate.claimed and start <= gate_at <= end
|
||||
if should_gate:
|
||||
gate.claimed = True
|
||||
with self.lock:
|
||||
stats["ranges"] += int(range_header is not None)
|
||||
request.send_response(206 if range_header is not None else 200)
|
||||
request.send_header("Accept-Ranges", "none" if mode == "no-range" else "bytes")
|
||||
content_types = {".wav": "audio/wav", ".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t"}
|
||||
request.send_header("Content-Type", content_types.get(path.suffix, "video/mp4"))
|
||||
request.send_header("Content-Length", str(end - start + 1))
|
||||
if range_header is not None:
|
||||
request.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.wfile.flush()
|
||||
request.close_connection = True
|
||||
cursor = start
|
||||
with path.open("rb") as media:
|
||||
media.seek(start)
|
||||
while cursor <= end and not self.stopping.is_set():
|
||||
if should_gate and cursor == gate_at:
|
||||
with self.lock:
|
||||
gate.waiting += 1
|
||||
gate.entered.set()
|
||||
try:
|
||||
gate.release.wait() # No timer silently unblocks a cancellation check.
|
||||
finally:
|
||||
with self.lock:
|
||||
gate.waiting -= 1
|
||||
should_gate = False
|
||||
amount = min(16384, end - cursor + 1)
|
||||
if should_gate:
|
||||
amount = min(amount, gate_at - cursor)
|
||||
chunk = media.read(amount)
|
||||
if not chunk:
|
||||
break
|
||||
request.wfile.write(chunk)
|
||||
request.wfile.flush()
|
||||
cursor += len(chunk)
|
||||
with self.lock:
|
||||
stats["bytes"] += len(chunk)
|
||||
if gate is not None:
|
||||
gate.bytes += len(chunk)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("fixtures", type=Path)
|
||||
args = parser.parse_args()
|
||||
required = ("av.mp4", "video.mp4", "audio.wav", "frame-step-video.mp4",
|
||||
"frame-step-audio.m4a", "streaming.mp4", "network-vod.m3u8")
|
||||
missing = [name for name in required if not (args.fixtures / name).is_file()]
|
||||
if missing:
|
||||
parser.error("Missing streaming fixtures: " + ", ".join(missing))
|
||||
with StreamingServer(args.fixtures) as server:
|
||||
try:
|
||||
result = subprocess.run([str(args.binary.resolve()), server.base_url,
|
||||
str(args.fixtures.resolve())], timeout=180, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("FAIL: native network check exceeded its infrastructure deadline")
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,388 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct YouTubeResolverChecks {
|
||||
struct Failure: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
}
|
||||
|
||||
static let page = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
static let splitJSON = #"""
|
||||
{
|
||||
"title": "Separate streams", "duration": 19.25, "is_live": false,
|
||||
"http_headers": {"Referer": "https://www.youtube.com/", "User-Agent": "metadata-agent"},
|
||||
"requested_formats": [
|
||||
{"url": "https://media.example/video?token=video", "vcodec": "av01.0.04M.08", "acodec": "none", "dynamic_range": "SDR", "http_headers": {"User-Agent": "video-agent", "X-Input": "video"}},
|
||||
{"url": "https://media.example/audio?token=audio", "vcodec": "none", "acodec": "opus"}
|
||||
]
|
||||
}
|
||||
"""#
|
||||
static let combinedJSON = #"""
|
||||
{"title": "Combined stream", "duration": 12, "is_live": false,
|
||||
"url": "https://media.example/combined", "vcodec": "h264", "acodec": "aac",
|
||||
"http_headers": {"User-Agent": "combined-agent"}}
|
||||
"""#
|
||||
|
||||
static func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
|
||||
if try condition() == false { throw Failure(description: "FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func reject(_ message: String, _ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch is Failure { throw Failure(description: "FAIL: \(message) assertion failed") }
|
||||
catch { return }
|
||||
throw Failure(description: "FAIL: \(message) was accepted")
|
||||
}
|
||||
|
||||
static func decode(_ json: String) throws -> YouTubeVideo {
|
||||
try YouTubeResolver.decode(Data(json.utf8), webpageURL: page)
|
||||
}
|
||||
|
||||
static func decode(_ object: [String: Any]) throws -> YouTubeVideo {
|
||||
try YouTubeResolver.decode(JSONSerialization.data(withJSONObject: object), webpageURL: page)
|
||||
}
|
||||
|
||||
static func fixture(_ json: String) throws -> [String: Any] {
|
||||
try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
|
||||
}
|
||||
|
||||
static func headerLines(_ text: String) throws -> Set<String> {
|
||||
try require(text.isEmpty || text.hasSuffix("\r\n"), "headers terminate with CRLF")
|
||||
let lines = text.components(separatedBy: "\r\n").filter { !$0.isEmpty }
|
||||
try require(lines.allSatisfy { !$0.contains("\r") && !$0.contains("\n") },
|
||||
"headers contain no bare CR or LF")
|
||||
return Set(lines)
|
||||
}
|
||||
|
||||
static func videoURLs() throws {
|
||||
let valid = [
|
||||
"https://youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVRw&list=PLignored&t=3",
|
||||
"https://m.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://music.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtu.be/jNQXAC9IVRw?si=share",
|
||||
"https://www.youtube.com/shorts/jNQXAC9IVRw",
|
||||
"https://www.youtube.com/live/jNQXAC9IVRw",
|
||||
"https://www.youtube.com/embed/jNQXAC9IVRw"
|
||||
]
|
||||
for input in valid {
|
||||
let url = try YouTubeResolver.validateURL(input)
|
||||
try require(url == page, "supported video URLs identify the same video")
|
||||
}
|
||||
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",
|
||||
"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"
|
||||
]
|
||||
for input in hostile {
|
||||
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
|
||||
}
|
||||
let asciiID = try YouTubeResolver.validateURL("https://youtu.be/Abc_123-XYZ")
|
||||
try require(asciiID.absoluteString.hasSuffix("v=Abc_123-XYZ"), "video IDs retain case, underscore and hyphen")
|
||||
}
|
||||
|
||||
static func metadata() throws {
|
||||
let split = try decode(splitJSON)
|
||||
try require(split.title == "Separate streams" && split.duration == 19.25 && !split.isLive,
|
||||
"split streams preserve title, fractional duration and recorded status")
|
||||
try require(split.webpageURL == page, "resolved media retains its webpage URL")
|
||||
try require(split.video.url == "https://media.example/video?token=video"
|
||||
&& split.audio?.url == "https://media.example/audio?token=audio",
|
||||
"video and audio inputs remain distinct and preserve signed query strings")
|
||||
let videoHeaders = try headerLines(split.video.headers)
|
||||
let audioHeaders = try headerLines(split.audio!.headers)
|
||||
try require(videoHeaders == ["User-Agent: video-agent", "X-Input: video"],
|
||||
"selected video headers take precedence as a complete map")
|
||||
try require(audioHeaders == ["Referer: https://www.youtube.com/", "User-Agent: metadata-agent"],
|
||||
"audio without selected headers inherits metadata headers")
|
||||
|
||||
var splitObject = try fixture(splitJSON)
|
||||
var formats = splitObject["requested_formats"] as! [[String: Any]]
|
||||
formats.reverse()
|
||||
formats[0]["http_headers"] = ["X-Input": "audio"]
|
||||
formats[1]["http_headers"] = NSNull()
|
||||
splitObject["requested_formats"] = formats
|
||||
let reversed = try decode(splitObject)
|
||||
let reversedVideoHeaders = try headerLines(reversed.video.headers)
|
||||
let reversedAudioHeaders = try headerLines(reversed.audio!.headers)
|
||||
try require(reversed.video.url == split.video.url && reversed.audio?.url == split.audio?.url,
|
||||
"stream roles depend on codecs rather than array order")
|
||||
try require(reversedVideoHeaders == audioHeaders && reversedAudioHeaders == ["X-Input: audio"],
|
||||
"each input independently selects its own headers or metadata fallback")
|
||||
|
||||
let combined = try decode(combinedJSON)
|
||||
try require(combined.video.url == "https://media.example/combined" && combined.audio == nil,
|
||||
"combined fallback uses one input, not duplicated audio")
|
||||
try require(combined.title == "Combined stream" && combined.duration == 12 && !combined.isLive,
|
||||
"combined metadata is preserved")
|
||||
let combinedHeaders = try headerLines(combined.video.headers)
|
||||
try require(combinedHeaders == ["User-Agent: combined-agent"], "combined input carries headers")
|
||||
|
||||
var selectedCombined = try fixture(combinedJSON)
|
||||
selectedCombined.removeValue(forKey: "url")
|
||||
selectedCombined["requested_formats"] = [
|
||||
["url": "https://media.example/selected-combined", "vcodec": "h264", "acodec": "aac",
|
||||
"http_headers": ["X-Input": "combined"]]
|
||||
]
|
||||
let selected = try decode(selectedCombined)
|
||||
let selectedHeaders = try headerLines(selected.video.headers)
|
||||
try require(selected.audio == nil && selected.video.url == "https://media.example/selected-combined"
|
||||
&& selectedHeaders == ["X-Input: combined"],
|
||||
"a selected combined format is one playable input with its own headers")
|
||||
|
||||
var hls = try fixture(combinedJSON)
|
||||
hls["url"] = "https://media.example/live.m3u8"
|
||||
hls["protocol"] = "m3u8_native"
|
||||
let playlist = try decode(hls)
|
||||
try require(playlist.video.url == "https://media.example/live.m3u8" && playlist.audio == nil,
|
||||
"native HLS remains a usable streaming playlist, not a downloaded file")
|
||||
|
||||
var live = try fixture(combinedJSON)
|
||||
live["is_live"] = true
|
||||
live["duration"] = NSNull()
|
||||
let directLive = try decode(live)
|
||||
try require(directLive.isLive && directLive.duration == 0, "live stream permits unknown duration")
|
||||
live.removeValue(forKey: "is_live")
|
||||
live.removeValue(forKey: "duration")
|
||||
live["live_status"] = "is_live"
|
||||
try require(try decode(live).isLive, "live_status identifies live broadcasts")
|
||||
live["live_status"] = "was_live"
|
||||
try require(!(try decode(live)).isLive, "completed live broadcasts are recorded media")
|
||||
live["live_status"] = "is_upcoming"
|
||||
try reject("upcoming broadcast with no playable live content") { _ = try decode(live) }
|
||||
|
||||
var emptyHeaders = try fixture(combinedJSON)
|
||||
emptyHeaders.removeValue(forKey: "http_headers")
|
||||
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
|
||||
}
|
||||
|
||||
static func malformedMetadata() throws {
|
||||
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) }
|
||||
}
|
||||
try reject("nonfinite duration") {
|
||||
_ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#)
|
||||
}
|
||||
let combined = try fixture(combinedJSON)
|
||||
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) }
|
||||
}
|
||||
for url in ["", "https://", "file:///tmp/video", "ftp://media.example/video",
|
||||
"data:video/mp4;base64,AA==", "https://media.example/video\r\nX-Injected: yes"] {
|
||||
var object = combined
|
||||
object["url"] = url
|
||||
try reject("invalid or unsafe stream URL") { _ = try decode(object) }
|
||||
}
|
||||
for duration in [-1, "19", true] as [Any] {
|
||||
var object = combined
|
||||
object["duration"] = duration
|
||||
try reject("invalid duration metadata") { _ = try decode(object) }
|
||||
}
|
||||
for key in ["is_live", "title"] {
|
||||
var object = combined
|
||||
object[key] = ["malformed"]
|
||||
try reject("malformed \(key) metadata") { _ = try decode(object) }
|
||||
}
|
||||
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) }
|
||||
}
|
||||
for transport in ["http_dash_segments", "mhtml", "rtmp"] {
|
||||
var object = combined
|
||||
object["protocol"] = transport
|
||||
try reject("fragment assembly or unsupported transport is not a whole playable input") {
|
||||
_ = try decode(object)
|
||||
}
|
||||
}
|
||||
|
||||
let split = try fixture(splitJSON)
|
||||
let formats = split["requested_formats"] as! [[String: Any]]
|
||||
for malformed in ["bad", [formats[0]], [formats[1]], [formats[0], formats[0]],
|
||||
[formats[0], formats[1], formats[1]]] as [Any] {
|
||||
var object = split
|
||||
object["requested_formats"] = malformed
|
||||
try reject("malformed or ambiguous split stream selection") { _ = try decode(object) }
|
||||
}
|
||||
var missingAudio = split
|
||||
var badFormats = formats
|
||||
badFormats[1].removeValue(forKey: "url")
|
||||
missingAudio["requested_formats"] = badFormats
|
||||
try reject("missing separate audio URL is not silently downgraded") { _ = try decode(missingAudio) }
|
||||
|
||||
let badHeaders: [Any] = [
|
||||
"not a header map", ["User-Agent": 5], ["Bad Name": "value"], ["Bad:Name": "value"],
|
||||
["Bad\r\nName": "value"], ["": "value"], ["X-Header": "a\r\nInjected: yes"],
|
||||
["X-Header": "a\nb"], ["X-Header": "a\rb"], ["X-Header": "a\u{0}b"],
|
||||
["X-Header": "a\t b"], ["X-Header": "a\u{7f}b"]
|
||||
]
|
||||
for headers in badHeaders {
|
||||
var object = combined
|
||||
object["http_headers"] = headers
|
||||
try reject("unsafe or malformed metadata headers") { _ = try decode(object) }
|
||||
var perInput = split
|
||||
var selected = formats
|
||||
selected[1]["http_headers"] = headers
|
||||
perInput["requested_formats"] = selected
|
||||
try reject("unsafe or malformed selected audio headers") { _ = try decode(perInput) }
|
||||
}
|
||||
}
|
||||
|
||||
struct Extractor: Sendable {
|
||||
let executable: URL
|
||||
let directory: URL
|
||||
|
||||
init(root: URL, name: String, json: String = YouTubeResolverChecks.splitJSON,
|
||||
body: String = #"/bin/cat "$base/response.json""#) throws {
|
||||
directory = root.appendingPathComponent(name, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
executable = directory.appendingPathComponent("fake-extractor")
|
||||
let script = "#!/bin/sh\nset -eu\nbase=${0%/*}\n: > \"$base/started\"\n" + body + "\n"
|
||||
try Data(script.utf8).write(to: executable)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path)
|
||||
try Data(json.utf8).write(to: directory.appendingPathComponent("response.json"))
|
||||
}
|
||||
|
||||
func marker(_ name: String) -> URL { directory.appendingPathComponent(name) }
|
||||
}
|
||||
|
||||
static func awaitMarker(_ extractor: Extractor, _ name: String = "started") async throws {
|
||||
for _ in 0..<400 {
|
||||
if FileManager.default.fileExists(atPath: extractor.marker(name).path) { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
throw Failure(description: "FAIL: extractor did not reach \(name) within the isolated check deadline")
|
||||
}
|
||||
|
||||
static func cancelled(_ task: Task<YouTubeVideo, Error>, _ message: String) async throws {
|
||||
do { _ = try await task.value }
|
||||
catch is CancellationError { return }
|
||||
catch { throw Failure(description: "FAIL: \(message) returned an extraction error instead of cancellation") }
|
||||
throw Failure(description: "FAIL: \(message) returned media instead of cancellation")
|
||||
}
|
||||
|
||||
static func subprocesses(root: URL, runtime: URL) async throws {
|
||||
let successful = try Extractor(root: root, name: "successful split")
|
||||
let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime)
|
||||
try require(result.video.url == "https://media.example/video?token=video"
|
||||
&& result.audio?.url == "https://media.example/audio?token=audio"
|
||||
&& result.title == "Separate streams" && result.duration == 19.25,
|
||||
"actual subprocess output becomes usable separate media inputs")
|
||||
|
||||
let denied = try Extractor(root: root, name: "denied", body: #"""
|
||||
/bin/cat "$base/response.json"
|
||||
printf '%s\n' 'ERROR: Sign in to confirm your age: https://media.example/private?secret=not-for-errors' >&2
|
||||
exit 17
|
||||
"""#)
|
||||
do {
|
||||
_ = try await YouTubeResolver.resolve(page, executableURL: denied.executable, runtimeURL: runtime)
|
||||
throw Failure(description: "FAIL: failed extractor returned media despite its exit status")
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = String(describing: error) + error.localizedDescription
|
||||
try require(text.lowercased().contains("sign-in") || text.lowercased().contains("sign in"),
|
||||
"extractor stderr produces an actionable access error")
|
||||
try require(!text.contains("not-for-errors") && !text.contains("media.example"),
|
||||
"extractor error does not leak signed media URLs or credentials")
|
||||
}
|
||||
|
||||
let noisy = try Extractor(root: root, name: "pipe boundaries", body: #"""
|
||||
/bin/dd if=/dev/zero bs=1024 count=512 2>/dev/null | /usr/bin/tr '\000' x >&2
|
||||
/bin/dd if=/dev/zero bs=1024 count=512 2>/dev/null | /usr/bin/tr '\000' ' '
|
||||
/bin/cat "$base/response.json"
|
||||
"""#)
|
||||
let drained = try await YouTubeResolver.resolve(page, executableURL: noisy.executable, runtimeURL: runtime)
|
||||
try require(drained.audio?.url == result.audio?.url && drained.video.url == result.video.url,
|
||||
"stderr beyond its capture boundary and stdout beyond pipe capacity drain without deadlock")
|
||||
|
||||
let oversized = try Extractor(root: root, name: "oversized stdout", body: #"""
|
||||
/bin/dd if=/dev/zero bs=1024 count=17408 2>/dev/null | /usr/bin/tr '\000' ' '
|
||||
/bin/cat "$base/response.json"
|
||||
"""#)
|
||||
do {
|
||||
_ = try await YouTubeResolver.resolve(page, executableURL: oversized.executable, runtimeURL: runtime)
|
||||
throw Failure(description: "FAIL: oversized extractor output was accepted")
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = (String(describing: error) + error.localizedDescription).lowercased()
|
||||
try require(text.contains("output") || text.contains("limit") || text.contains("large") || text.contains("metadata"),
|
||||
"bounded stdout rejection explains the output limit")
|
||||
}
|
||||
|
||||
let beforeLaunch = try Extractor(root: root, name: "already cancelled")
|
||||
let preCancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await YouTubeResolver.resolve(page, executableURL: beforeLaunch.executable, runtimeURL: runtime)
|
||||
}
|
||||
try await cancelled(preCancelled, "cancellation before launch")
|
||||
try require(!FileManager.default.fileExists(atPath: beforeLaunch.marker("started").path),
|
||||
"a pre-cancelled request never starts the dependency")
|
||||
|
||||
let waitingScript = #"""
|
||||
trap '' TERM INT
|
||||
printf '%s\n' "$$" > "$base/pid"
|
||||
: > "$base/ready"
|
||||
while [ ! -f "$base/release" ]; do /bin/sleep 0.01; done
|
||||
/bin/cat "$base/response.json"
|
||||
"""#
|
||||
let old = try Extractor(root: root, name: "old request", body: waitingScript)
|
||||
let newer = try Extractor(root: root, name: "new request", json: combinedJSON, body: waitingScript)
|
||||
let oldTask = Task { try await YouTubeResolver.resolve(page, executableURL: old.executable, runtimeURL: runtime) }
|
||||
defer { oldTask.cancel() }
|
||||
try await awaitMarker(old, "ready")
|
||||
let newTask = Task { try await YouTubeResolver.resolve(page, executableURL: newer.executable, runtimeURL: runtime) }
|
||||
defer { newTask.cancel() }
|
||||
try await awaitMarker(newer, "ready")
|
||||
oldTask.cancel()
|
||||
try await cancelled(oldTask, "in-flight cancellation")
|
||||
let oldPID = try String(contentsOf: old.marker("pid"), encoding: .utf8)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try require(pid_t(oldPID).map { Darwin.kill($0, 0) == -1 && errno == ESRCH } == true,
|
||||
"cancelled extractor is reaped even when it ignores termination")
|
||||
oldTask.cancel()
|
||||
try Data().write(to: newer.marker("release"))
|
||||
let newResult = try await newTask.value
|
||||
try require(newResult.title == "Combined stream" && newResult.audio == nil
|
||||
&& newResult.video.url == "https://media.example/combined",
|
||||
"cancelling an old request cannot terminate or replace a newer resolution")
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
// A broken pipe/cancellation implementation must fail, not leave this check hanging.
|
||||
let watchdog = Task {
|
||||
try await Task.sleep(for: .seconds(30))
|
||||
fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline")
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi resolver checks '\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let runtime = root.appendingPathComponent("deno")
|
||||
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
||||
try videoURLs()
|
||||
try metadata()
|
||||
try malformedMetadata()
|
||||
try await subprocesses(root: root, runtime: runtime)
|
||||
print("YouTube resolver checks passed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user