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
+4
View File
@@ -17,6 +17,7 @@ extern "C" {
#include <mutex>
#include <new>
#include <stdexcept>
#include <string>
#include <thread>
namespace playback {
@@ -49,6 +50,9 @@ class NetworkInput {
public:
static constexpr double aheadSeconds = 60, behindSeconds = 30;
AVFormatContext *format = nullptr;
// Resolved sources bind credentials to their original origin for all HLS I/O.
std::string headerOrigin;
std::string requestHeaders;
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
+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;
+2 -1
View File
@@ -53,7 +53,8 @@ typedef struct PlayerNetworkSource {
const char *audio_headers;
double duration;
bool is_live;
// Canonical YouTube webpage identity, never an extracted stream URL.
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode>
// identity, never an extracted stream/CDN URL.
// NULL/empty means no reporting for this source.
const char *media_url;
// Initial restored position, applied before publishing ready state. Zero
+36 -6
View File
@@ -69,11 +69,31 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
return true;
}
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
if (validMediaURL(identity)) return true;
constexpr std::string_view prefix = "miruro:";
if (identity.substr(0, prefix.size()) != prefix) return false;
const auto separator = identity.find(':', prefix.size());
if (separator == std::string_view::npos) return false;
const auto id = identity.substr(prefix.size(), separator - prefix.size());
if (id.empty() || id.size() > 512) return false;
for (char c : id) {
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
}
const auto episode = identity.substr(separator + 1);
if (episode.empty() || episode.front() < '1' || episode.front() > '9') return false;
for (char c : episode) if (c < '0' || c > '9') return false;
int value = 0;
const auto parsed = std::from_chars(episode.data(), episode.data() + episode.size(), value);
return parsed.ec == std::errc{} && parsed.ptr == episode.data() + episode.size() && value > 0;
}
void ProgressReporter::begin(const std::string &url, bool isLive) noexcept {
if (finished || disabled.load(std::memory_order_acquire)) return;
if (url.empty()) { closeMedia(); return; }
if (!validMediaURL(url)) {
disable("Progress media URL is not a canonical YouTube watch URL");
if (!validMediaIdentity(url)) {
disable("Progress media identity is invalid");
return;
}
try {
@@ -144,11 +164,21 @@ bool ProgressReporter::emit(const char *event) noexcept {
try {
std::string record;
record.reserve(256);
record += "{\"version\":1,\"event\":\"";
const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0;
record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
record += event;
record += "\",\"media_url\":\"";
record += mediaURL;
record += "\",\"position_seconds\":";
if (miruro) {
const auto separator = mediaURL.find(':', 7);
record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\"";
record.append(mediaURL, 7, separator - 7);
record += "\",\"episode\":";
record.append(mediaURL, separator + 1, std::string::npos);
record += "},\"position_seconds\":";
} else {
record += "\",\"media_url\":\"";
record += mediaURL;
record += "\",\"position_seconds\":";
}
appendNumber(record, snapshot.position);
record += ",\"duration_seconds\":";
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
+1
View File
@@ -20,6 +20,7 @@ public:
ProgressReporter &operator=(const ProgressReporter &) = delete;
static bool validMediaURL(std::string_view url) noexcept;
static bool validMediaIdentity(std::string_view identity) noexcept;
void begin(const std::string &mediaURL, bool live = false) noexcept;
void observe(const PlayerState &state) noexcept;
void closeMedia() noexcept;