feat: play resolved Miruro HLS with external VTT and stable episode progress
This commit is contained in:
+76
-8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user