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
+76 -8
View File
@@ -18,6 +18,7 @@ extern "C" {
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <deque>
@@ -167,6 +168,53 @@ bool validHeaders(const char *text) {
}
return true;
}
std::string networkOrigin(const char *text) {
if (!validNetworkURL(text)) return {};
std::string_view url(text);
const auto start = url.starts_with("https://") ? 8u : 7u;
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
for (char &value : authority)
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
else {
// Canonicalize explicit default ports and leading zeroes.
authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
}
return std::string(url.substr(0, start)) + authority;
}
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
int flags, AVDictionary **options) noexcept {
try {
const auto &input = *static_cast<playback::NetworkInput *>(format->opaque);
// crypto forwards options to its underlying HTTP protocol; do not unwrap the
// actual URL, since FFmpeg must still decrypt AES-128 segments itself.
const char *resource = std::strncmp(url, "crypto+", 7) == 0 ||
std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
if (networkOrigin(resource) != input.headerOrigin) return AVERROR(EACCES);
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
if (result >= 0) result = av_dict_set(options, "headers", input.requestHeaders.c_str(), 0);
// FFmpeg 9's HTTP redirect loop retains custom headers. Reject every redirect
// before any second request, including same-origin redirects, rather than leak.
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
if (result < 0) return result;
return avio_open2(pb, url, flags, &format->interrupt_callback, options);
} catch (const std::bad_alloc &) {
return AVERROR(ENOMEM);
} catch (...) {
return AVERROR(EINVAL);
}
}
void redactedFFmpegLog(void *, int level, const char *, va_list) {
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
// FFmpeg diagnostics can interpolate signed URLs, cookies, keys and headers,
// including at error level. Player::check still exposes operation + AVERROR.
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg error (private details redacted)\n" :
"FFmpeg warning (private details redacted)\n", stderr);
}
}
struct Player {
@@ -521,12 +569,19 @@ struct Player {
steadyAnchor = Clock::now();
}
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) {
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers,
bool live, bool resolved) {
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
auto &input = *inputs[slot];
input.format = avformat_alloc_context();
if (!input.format) throw std::bad_alloc();
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
if (resolved) {
input.headerOrigin = networkOrigin(url.c_str());
input.requestHeaders = headers;
input.format->opaque = &input;
input.format->io_open = resolvedIOOpen;
}
AVDictionary *options = nullptr;
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
// Leave room for socket-timeout recovery within the reader's total 15-second deadline.
@@ -542,6 +597,12 @@ struct Player {
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_total_max", "5", 0);
// Leave short-seek read-ahead disabled: cache misses must escape stalled responses.
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
if (result >= 0 && resolved) result = av_dict_set(&options, "http_persistent", "0", 0);
// HLS persistent requests bypass io_open (and its origin/header policy).
if (result >= 0 && resolved) result = av_dict_set(&options, "http_multiple", "0", 0);
// Resolved HLS may name genuine TS/fMP4 resources as images. Probe their
// content instead of trusting suffixes; io_open still enforces every URL.
if (result >= 0 && resolved) result = av_dict_set(&options, "extension_picky", "0", 0);
if (result < 0) {
av_dict_free(&options);
check(result, "Configuring network input");
@@ -569,7 +630,8 @@ struct Player {
status = PLAYER_OPENING;
publish();
if (network) {
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live);
const bool resolved = command.mediaURL.starts_with("miruro:");
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
format = primary->format;
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
if (command.audioURL.empty()) {
@@ -577,7 +639,7 @@ struct Player {
} else {
if (!tracks[1].codec)
throw std::runtime_error("The selected video stream contains no playable video");
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live);
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live, resolved);
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
if (!tracks[0].codec)
throw std::runtime_error("The selected audio stream contains no playable audio");
@@ -629,10 +691,12 @@ struct Player {
}
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
// Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks.
// HLS time seeks reopen segments; byte-seek support on the initial
// manifest response says nothing about its demuxer's seek capability.
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
(context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 :
(context->iformat->flags & AVFMT_NOFILE) != 0);
(context->iformat == av_find_input_format("hls") ||
(context->iformat->flags & AVFMT_NOFILE) != 0 ||
(context->pb && (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0));
}
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
origin = std::isfinite(first) ? first : 0;
@@ -1376,7 +1440,11 @@ bool submit(Player *player, Function function) noexcept {
extern "C" {
Player *player_create(void) {
try { return new Player; } catch (...) { return nullptr; }
try {
static std::once_flag logging;
std::call_once(logging, [] { av_log_set_callback(redactedFFmpegLog); });
return new Player;
} catch (...) { return nullptr; }
}
bool player_set_progress_file(Player *player, const char *path, char *error) {
if (error) error[0] = '\0';
@@ -1416,7 +1484,7 @@ bool player_open(Player *player, const char *path) {
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
!std::isfinite(source->initial_position) || source->initial_position < 0 ||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaURL(source->media_url)) ||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaIdentity(source->media_url)) ||
!validNetworkURL(source->video_url) ||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;