feat: play resolved Miruro HLS with external VTT and stable episode progress

This commit is contained in:
2026-10-12 19:17:22 +02:00
parent 1f12660936
commit 470d8feec2
23 changed files with 1528 additions and 38 deletions
+96 -1
View File
@@ -1,6 +1,9 @@
#include "../Sources/Core/player_macos.h"
#import <AppKit/AppKit.h>
#import <MetalKit/MetalKit.h>
extern "C" {
#include <libavutil/log.h>
}
#import <dispatch/dispatch.h>
#include <chrono>
#include <cmath>
@@ -135,7 +138,9 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
}
static void closed(Player *player) {
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
// A close submitted from PLAYER_ERROR may still publish the previous error
// until the worker acknowledges it; require the complete clean state below.
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source", true);
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");
@@ -287,6 +292,94 @@ static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
}
static bool openResolved(Player *player, const std::string &base, const std::string &scenario,
bool credentials = true) {
const auto url = base + "/resolved/" + scenario + "/master.m3u8?token=part%2Fone+two%26tail";
const std::string headers = credentials ?
"X-Terebi-Fixture: combined\r\nAuthorization: Bearer resolved-header-secret\r\n" :
"X-Terebi-Fixture: combined\r\n";
PlayerNetworkSource source{url.c_str(), nullptr, headers.c_str(), nullptr,
4, false, "miruro:fixture_opaque-ID:1", 0};
return player_open_network(player, &source);
}
static void resolvedHLS(Player *player, MTKView *view, const std::string &base) {
for (const std::string scenario : {"encrypted", "fmp4", "image-encrypted", "image-fmp4"}) {
require(openResolved(player, base, scenario), "resolved HLS asynchronous open accepted");
require(player_play(player), "resolved HLS play accepted");
auto state = until(player, [](auto s) {
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
}, "resolved HLS audio/video decode");
require(state.has_audio && state.has_video && state.can_seek,
"encrypted/fMP4 HLS provides decoded audio, video and seekability");
require(player_pause(player), "pause resolved HLS");
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "resolved HLS pause");
seekCompleted(player, 2.5, PLAYER_PAUSED);
auto revision = player_get_state(player).revision;
require(player_step_frame(player, 1), "step an actually decoded HLS video frame after seek");
state = until(player, [revision](auto s) {
return s.status == PLAYER_PAUSED && !s.seek_pending && s.revision != revision && s.position > 2.5;
}, "resolved HLS decoded frame acknowledgement");
require(state.position < 2.6, "HLS seek selects the adjacent 24fps decoded frame");
[view draw];
require(player_play(player), "resume resolved HLS");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.65; },
"resolved HLS audio clock resumes after frame step");
seekCompleted(player, 1.25, PLAYER_PLAYING);
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.4; },
"resolved HLS playing backward seek");
require(player_close(player), "close resolved HLS");
closed(player);
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
[[NSString stringWithUTF8String:scenario.c_str()]];
for (NSString *kind in @[@"master", @"variant", @"segment",
scenario.ends_with("encrypted") ? @"key" : @"init"]) {
require([source[kind][@"requests"] unsignedLongLongValue] > 0 &&
[source[kind][@"denied"] unsignedLongLongValue] == 0,
"required headers and unmodified query token reach every HLS resource class");
}
}
for (const std::string scenario : {"redirect-master", "redirect-variant", "redirect-segment",
"redirect-key", "redirect-init", "redirect-same-master",
"external-variant", "external-segment", "external-key", "external-init",
"missing"}) {
require(openResolved(player, base, scenario, scenario != "missing"), "unsafe HLS request accepted asynchronously");
auto state = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
"unsafe HLS transport rejected", true);
require(state.error[0] && std::string(state.error).find("http") == std::string::npos &&
std::string(state.error).find("resolved-header-secret") == std::string::npos,
"application retains meaningful transport error without credentials or URLs");
if (scenario.starts_with("redirect-")) {
const auto kind = scenario == "redirect-same-master" ? "master" : scenario.substr(9);
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
[[NSString stringWithUTF8String:scenario.c_str()]];
require([source[[NSString stringWithUTF8String:kind.c_str()]][@"redirects"] unsignedLongLongValue] > 0,
"redirect isolation actually exercises each HLS redirect resource");
}
if (scenario == "missing") {
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"][@"missing"];
require([source[@"master"][@"denied"] unsignedLongLongValue] > 0,
"fixture explicitly fails a request missing a required credential");
}
require(player_close(player), "close rejected resolved source");
closed(player);
}
NSDictionary *sink = control(base, "/control/resolved-stats")[@"sink"];
require([sink[@"requests"] unsignedLongLongValue] == 0 &&
[sink[@"credentials"] unsignedLongLongValue] == 0,
"neither redirects nor explicit foreign HLS URLs contact the unrelated origin");
require(openNetwork(player, base, "/youtube-redirect/av.mp4", "", 4, false, true,
"https://www.youtube.com/watch?v=AAAAAAAAAAA"),
"YouTube selected source retains ordinary HTTP redirect behavior");
require(player_play(player), "redirected YouTube fixture play");
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .2 && s.has_video && s.has_audio; },
"YouTube transport unchanged by resolved source restrictions");
require(player_close(player), "close redirected YouTube fixture");
closed(player);
std::puts("PASS resolved HLS: AES-128/fMP4 audio+video decode/paused+playing seek/frame step/all resource credentials/token preservation/redirect and foreign-origin isolation/redacted errors/YouTube redirects unchanged");
}
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 && buffered(s); },
@@ -798,6 +891,7 @@ int main(int argc, char **argv) {
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");
av_log_set_level(AV_LOG_TRACE); // Diagnostics must be safe even at maximal FFmpeg verbosity.
[NSApplication sharedApplication];
reporting(base, fixtures);
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
@@ -819,6 +913,7 @@ int main(int argc, char **argv) {
transport(player.get(), view, base, true);
frameSteps(player.get(), view, base);
nativeHLS(player.get(), view, base);
resolvedHLS(player.get(), view, base);
liveAndReplacement(player.get(), base, fixtures);
cachedWindow(player.get(), base);
bufferCoverage(player.get(), base);