feat: report authoritative playback progress from shared backend
This commit is contained in:
+125
-2
@@ -9,6 +9,9 @@
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <signal.h>
|
||||
#include <sys/resource.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||
@@ -118,7 +121,7 @@ static std::string headersFor(const std::string &path) {
|
||||
|
||||
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) {
|
||||
bool includeHeaders = true, const std::string &mediaURL = {}, double initialPosition = 0) {
|
||||
// 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;
|
||||
@@ -126,7 +129,8 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
|
||||
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};
|
||||
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live,
|
||||
mediaURL.empty() ? nullptr : mediaURL.c_str(), initialPosition};
|
||||
return player_open_network(player, &source);
|
||||
}
|
||||
|
||||
@@ -557,12 +561,131 @@ static void failures(Player *player, const std::string &base) {
|
||||
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
|
||||
}
|
||||
|
||||
static NSArray<NSDictionary *> *progressRecords(const std::string &path) {
|
||||
NSString *text = [NSString stringWithContentsOfFile:[NSString stringWithUTF8String:path.c_str()]
|
||||
encoding:NSUTF8StringEncoding error:nullptr];
|
||||
if (!text) return @[];
|
||||
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
||||
NSMutableArray<NSDictionary *> *records = [NSMutableArray array];
|
||||
for (NSUInteger index = 0; index + 1 < lines.count; ++index) {
|
||||
id record = [NSJSONSerialization JSONObjectWithData:[lines[index] dataUsingEncoding:NSUTF8StringEncoding]
|
||||
options:0 error:nullptr];
|
||||
require([record isKindOfClass:[NSDictionary class]], "native reporting produces complete JSON records");
|
||||
[records addObject:record];
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
static void reporting(const std::string &base, const std::string &fixtures) {
|
||||
char temporary[] = "/tmp/terebi-native-progress-XXXXXX";
|
||||
require(mkdtemp(temporary) != nullptr, "isolated native report directory");
|
||||
const std::string path = std::string(temporary) + "/native.jsonl";
|
||||
const std::string first = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
|
||||
const std::string second = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
char error[512]{};
|
||||
NSString *nativePath = [NSString stringWithUTF8String:path.c_str()];
|
||||
require([@"original" writeToFile:nativePath atomically:NO encoding:NSUTF8StringEncoding error:nullptr],
|
||||
"create existing destination sentinel");
|
||||
require(!player_set_progress_file(player.get(), path.c_str(), error) && error[0] &&
|
||||
player_get_state(player.get()).status == PLAYER_EMPTY,
|
||||
"report initialization error leaves playback state usable");
|
||||
require([[NSString stringWithContentsOfFile:nativePath encoding:NSUTF8StringEncoding error:nullptr]
|
||||
isEqualToString:@"original"], "native initialization cannot overwrite an existing destination");
|
||||
require(unlink(path.c_str()) == 0, "remove only the test-owned sentinel");
|
||||
require(player_set_progress_file(player.get(), path.c_str(), error), "initialize native reporting before playback");
|
||||
require(player_set_volume(player.get(), 0), "mute native reporting fixture");
|
||||
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first, 50),
|
||||
"open canonical source with initial resume intent");
|
||||
until(player.get(), [&](auto state) {
|
||||
auto rows = progressRecords(path);
|
||||
return state.status == PLAYER_PAUSED && !state.seek_pending && rows.count > 0;
|
||||
}, "backend acknowledges initial restoration before report");
|
||||
auto rows = progressRecords(path);
|
||||
require([rows[0][@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]] &&
|
||||
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
|
||||
[rows[0][@"completion_eligible"] boolValue],
|
||||
"initial report is restored native position, not pre-resume zero");
|
||||
require(openNetwork(player.get(), base, "/failure/av.mp4", "", 4, false, true, second),
|
||||
"failed requested identity accepted asynchronously");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_ERROR; }, "failed reported open", true);
|
||||
require(openNetwork(player.get(), base, "/media/audio.wav", "", 4, false, true, first),
|
||||
"audio-only input with canonical identity opens");
|
||||
until(player.get(), [&](auto state) {
|
||||
auto current = progressRecords(path);
|
||||
return state.status == PLAYER_PAUSED && state.has_audio && !state.has_video &&
|
||||
[current.lastObject[@"duration_seconds"] doubleValue] == 4 &&
|
||||
[current.lastObject[@"event"] isEqualToString:@"progress"];
|
||||
}, "audio-only native report");
|
||||
rows = progressRecords(path);
|
||||
require(![rows.lastObject[@"completion_eligible"] boolValue], "actual audio-only input cannot mark watched");
|
||||
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, true, true, first),
|
||||
"live source with known duration opens");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_video; },
|
||||
"reported live input ready");
|
||||
require(player_close(player.get()), "close actual reported live input");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "reported live close");
|
||||
require(openNetwork(player.get(), base, "/stall-open/report-cancelled/streaming.mp4", "", 30,
|
||||
false, true, second, 29),
|
||||
"gated initial resume opens");
|
||||
until(player.get(), [&](auto state) {
|
||||
return state.status == PLAYER_OPENING && gateState(base, "report-cancelled").entered;
|
||||
}, "reported initial open remains unacknowledged");
|
||||
require(player_close(player.get()), "cancel reported open before ready");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "cancelled report open close");
|
||||
require(player_shutdown(player.get(), error), "normal shutdown drains native reporter");
|
||||
rows = progressRecords(path);
|
||||
bool sawLive = false;
|
||||
for (NSDictionary *record in rows) {
|
||||
require([record[@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]],
|
||||
"failed/cancelled opens never report their requested identity");
|
||||
if ([record[@"duration_seconds"] doubleValue] == 120 &&
|
||||
[record[@"position_seconds"] doubleValue] == 0) {
|
||||
sawLive = true;
|
||||
require(![record[@"completion_eligible"] boolValue], "live known-duration records stay ineligible");
|
||||
}
|
||||
}
|
||||
require(sawLive && [rows.lastObject[@"event"] isEqualToString:@"closed"],
|
||||
"native close/quit retain terminal facts for acknowledged media");
|
||||
player.reset();
|
||||
|
||||
const auto failedPath = std::string(temporary) + "/write-failure.jsonl";
|
||||
player.reset(player_create());
|
||||
require(player_set_progress_file(player.get(), failedPath.c_str(), error), "initialize failure-test reporter");
|
||||
require(player_set_volume(player.get(), 0), "mute failure-test playback");
|
||||
struct rlimit previous{}, limited{};
|
||||
require(getrlimit(RLIMIT_FSIZE, &previous) == 0, "read original file-size limit");
|
||||
limited = previous;
|
||||
limited.rlim_cur = 1;
|
||||
auto previousSignal = signal(SIGXFSZ, SIG_IGN);
|
||||
require(previousSignal != SIG_ERR && setrlimit(RLIMIT_FSIZE, &limited) == 0, "force actual reporting write failure");
|
||||
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first),
|
||||
"playback opens with failing output");
|
||||
until(player.get(), [&](auto state) {
|
||||
return state.status == PLAYER_PAUSED && player_get_progress_error(player.get(), error);
|
||||
}, "write error surfaces independently from decoder status");
|
||||
require(setrlimit(RLIMIT_FSIZE, &previous) == 0 && signal(SIGXFSZ, previousSignal) != SIG_ERR,
|
||||
"restore resource limit and signal handler");
|
||||
require(error[0] && player_play(player.get()), "reporting failure does not prevent playback");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_PLAYING && state.position > .1; },
|
||||
"actual playback continues after reporting failure");
|
||||
seekCompleted(player.get(), 5, PLAYER_PLAYING);
|
||||
require(player_open(player.get(), (fixtures + "/audio.wav").c_str()), "failed reporter permits media replacement");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_audio; },
|
||||
"replacement remains functional with disabled reporter");
|
||||
require(!player_shutdown(player.get(), error) && error[0], "final reporting failure remains observable");
|
||||
player.reset();
|
||||
[[NSFileManager defaultManager] removeItemAtPath:[NSString stringWithUTF8String:temporary] error:nullptr];
|
||||
std::puts("PASS native reporting: acknowledged initial resume/audio-only/live eligibility/failed+cancelled opens/final drain/write-failure playback+seek+replacement continuity");
|
||||
}
|
||||
|
||||
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];
|
||||
reporting(base, fixtures);
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user