feat: report authoritative playback progress from shared backend

This commit is contained in:
2026-10-11 19:29:35 +02:00
parent a4c38bfa51
commit cc99a2a4f8
14 changed files with 1369 additions and 62 deletions
+95 -6
View File
@@ -1,6 +1,7 @@
#include "player.h"
#include "player_macos.h"
#include "output.hpp"
#include "progress_reporter.hpp"
extern "C" {
#include <libavcodec/avcodec.h>
@@ -85,6 +86,8 @@ struct Command {
double sourceDuration = 0;
bool network = false;
bool live = false;
std::string mediaURL{};
double initialPosition = 0;
};
void copyError(char *destination, const char *message) noexcept {
@@ -223,14 +226,19 @@ struct Player {
PlayerStatus status = PLAYER_EMPTY;
bool previewShown = false;
std::array<char, 512> error{};
std::unique_ptr<playback::ProgressReporter> reporter;
std::thread worker;
Player() : worker([this] { run(); }) {}
~Player() {
stopping.store(true, std::memory_order_relaxed);
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
wake.notify_one();
~Player() { shutdown(); }
void shutdown() {
if (!stopping.exchange(true, std::memory_order_relaxed)) {
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
wake.notify_one();
}
if (worker.joinable()) worker.join();
if (reporter) reporter->finish();
}
bool enqueue(Command command) {
@@ -380,6 +388,33 @@ struct Player {
}
std::memcpy(state.error, error.data(), error.size());
++state.revision;
if (reporter && reportingInputsHealthy()) reporter->observe(state);
}
bool reportingInputsHealthy() const {
for (const auto &input : inputs) if (input && input->error()) return false;
return true;
}
void reportBoundary() {
if (!reporter || !reportingInputsHealthy()) return;
// This is the old worker-owned timeline, even if a new open has already
// replaced the public state with OPENING. Never sample a queued seek target.
PlayerState snapshot{};
snapshot.status = status;
snapshot.duration = duration;
snapshot.has_video = tracks[1].codec != nullptr;
snapshot.can_seek = canSeek;
{
std::lock_guard<std::mutex> lock(mutex);
snapshot.seek_pending = networkSeeksPending() ||
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
return (command.action == Action::Seek || command.action == Action::Step) &&
command.epoch == activeEpoch;
});
}
snapshot.position = status == PLAYER_PLAYING && !snapshot.seek_pending ? clockPosition() : position;
reporter->observe(snapshot);
}
void release() {
@@ -420,6 +455,7 @@ struct Player {
// Preserve messages that point into one of this object's own state buffers.
std::array<char, 512> saved{};
copyError(saved.data(), message);
if (reporter) reporter->closeMedia();
release();
error = saved;
status = PLAYER_ERROR;
@@ -519,6 +555,8 @@ struct Player {
}
void open(const Command &command) {
reportBoundary();
if (reporter) reporter->begin(command.mediaURL, command.live);
release();
network = command.network;
status = PLAYER_OPENING;
@@ -627,6 +665,8 @@ struct Player {
}
}
status = PLAYER_PAUSED;
if (command.initialPosition > 0 && command.initialPosition < duration && canSeek)
seek(command.initialPosition, false);
publish();
}
@@ -868,13 +908,23 @@ struct Player {
void execute(const Command &command) {
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
activeEpoch = command.epoch;
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
// Local seeks can finish without a pending publication. Preserve
// the transition so their acknowledgement still emits a record.
std::lock_guard<std::mutex> lock(mutex);
reporter->observe(state);
}
switch (command.action) {
case Action::Open: open(command); break;
case Action::Play: start(); break;
case Action::Pause: pause(); break;
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
case Action::Step: step(command.direction); break;
case Action::Close: release(); break;
case Action::Close:
reportBoundary();
if (reporter) reporter->closeMedia();
release();
break;
}
publish();
}
@@ -1294,7 +1344,11 @@ struct Player {
try { fail("Unexpected playback failure"); } catch (...) {}
}
}
try { release(); } catch (...) {}
try {
reportBoundary();
if (reporter) reporter->closeMedia();
release();
} catch (...) {}
}
};
@@ -1317,12 +1371,45 @@ extern "C" {
Player *player_create(void) {
try { return new Player; } catch (...) { return nullptr; }
}
bool player_set_progress_file(Player *player, const char *path, char *error) {
if (error) error[0] = '\0';
try {
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
std::lock_guard<std::mutex> lock(player->mutex);
if (player->reporter || player->stopping.load(std::memory_order_relaxed) ||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
throw std::runtime_error("Progress reporting must be initialized once, before opening media");
player->reporter = std::make_unique<playback::ProgressReporter>(path);
return true;
} catch (const std::exception &failure) {
if (error) copyError(error, failure.what());
} catch (...) {
if (error) copyError(error, "Could not initialize progress reporting");
}
return false;
}
bool player_get_progress_error(Player *player, char *error) {
if (error) error[0] = '\0';
if (!player || !player->reporter) return false;
const auto failure = player->reporter->error();
if (failure.empty()) return false;
if (error) copyError(error, failure.c_str());
return true;
}
bool player_shutdown(Player *player, char *progress_error) {
if (progress_error) progress_error[0] = '\0';
if (!player) return false;
player->shutdown();
return !player_get_progress_error(player, progress_error);
}
bool player_open(Player *player, const char *path) {
if (!path || !*path) return false;
return submit(player, [=] { return player->enqueue({Action::Open, 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)) ||
!validNetworkURL(source->video_url) ||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
@@ -1334,6 +1421,8 @@ bool player_open_network(Player *player, const PlayerNetworkSource *source) {
command.sourceDuration = source->duration;
command.network = true;
command.live = source->is_live;
command.mediaURL = source->media_url ? source->media_url : "";
command.initialPosition = source->initial_position;
return player->enqueue(std::move(command));
});
}
+13
View File
@@ -37,6 +37,13 @@ typedef struct PlayerState {
// Playback commands are asynchronous. False means invalid input or a full command queue.
// Paths are copied before returning. All other calls require a live handle.
Player *player_create(void);
// Opt-in reporting must be initialized before opening media. Creates a new 0600
// file exclusively; errors never change playback state. Error buffers hold 512 bytes.
bool player_set_progress_file(Player *player, const char *path, char *error);
bool player_get_progress_error(Player *player, char *error);
// Stops the worker and drains final reporting, allowing errors to be shown before
// destroying the handle. Idempotent; do not call from an audio callback.
bool player_shutdown(Player *player, char *progress_error);
typedef struct PlayerNetworkSource {
// Primary video or combined audio/video HTTP(S) URL; audio_url is optional.
const char *video_url;
@@ -46,6 +53,12 @@ typedef struct PlayerNetworkSource {
const char *audio_headers;
double duration;
bool is_live;
// Canonical YouTube webpage identity, never an extracted stream URL.
// NULL/empty means no reporting for this source.
const char *media_url;
// Initial restored position, applied before publishing ready state. Zero
// starts at the beginning; out-of-duration bookmarks also start at zero.
double initial_position;
} PlayerNetworkSource;
bool player_open(Player *player, const char *path);
// Streams directly; never downloads or muxes the entire media file before opening.
+234
View File
@@ -0,0 +1,234 @@
#include "progress_reporter.hpp"
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <limits>
#include <stdexcept>
#include <sys/stat.h>
#include <unistd.h>
#include <utility>
namespace playback {
namespace {
double monotonicSeconds() {
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
void appendNumber(std::string &record, double value) {
char buffer[64];
auto result = std::to_chars(buffer, buffer + sizeof(buffer), value,
std::chars_format::general, std::numeric_limits<double>::max_digits10);
if (result.ec != std::errc{}) throw std::runtime_error("Could not encode progress timestamp");
record.append(buffer, result.ptr);
}
}
ProgressReporter::ProgressReporter(const std::string &path, std::function<double()> clock)
: clock(clock ? std::move(clock) : monotonicSeconds) {
if (path.empty() || path.find('\0') != std::string::npos)
throw std::invalid_argument("The progress file path must be nonempty and contain no NUL bytes");
descriptor = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
if (descriptor < 0) {
const int code = errno;
throw std::runtime_error("Could not exclusively create progress file '" + path + "': " + std::strerror(code));
}
try {
struct stat info{};
if (::fstat(descriptor, &info) != 0)
throw std::runtime_error(std::string("Could not inspect progress file: ") + std::strerror(errno));
if (!S_ISREG(info.st_mode) || info.st_uid != ::geteuid() || info.st_nlink != 1)
throw std::runtime_error("The new progress file must be a regular file privately owned by this user");
// Creation can only remove owner permissions through umask; restore exactly 0600.
if (::fchmod(descriptor, 0600) != 0)
throw std::runtime_error(std::string("Could not make progress file private: ") + std::strerror(errno));
if (::fstat(descriptor, &info) != 0)
throw std::runtime_error(std::string("Could not verify progress file permissions: ") + std::strerror(errno));
if ((info.st_mode & 07777) != 0600)
throw std::runtime_error("The progress file must have exactly owner read/write permissions (0600)");
writer = std::thread([this] { writeRecords(); });
} catch (...) {
::close(descriptor);
descriptor = -1;
throw;
}
}
ProgressReporter::~ProgressReporter() { finish(); }
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
constexpr std::string_view prefix = "https://www.youtube.com/watch?v=";
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false;
for (char character : url.substr(prefix.size())) {
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false;
}
return true;
}
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");
return;
}
try {
if (mediaURL != url) {
closeMedia();
mediaURL = url;
}
live = isLive;
awaitingReady = true;
seekPending = false;
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("Could not begin playback progress reporting");
}
}
void ProgressReporter::observe(const PlayerState &state) noexcept {
if (finished || mediaURL.empty() || disabled.load(std::memory_order_acquire)) return;
if (state.seek_pending) { seekPending = true; return; }
if (state.status != PLAYER_PAUSED && state.status != PLAYER_PLAYING && state.status != PLAYER_ENDED) return;
if (!std::isfinite(state.position) || state.position < 0) return;
// A later bookmark reset must not replace the native end-of-stream snapshot.
if (hasSnapshot && snapshot.status == PLAYER_ENDED && state.status == PLAYER_ENDED &&
!awaitingReady && !seekPending) return;
const bool paused = hasSnapshot && snapshot.status == PLAYER_PLAYING && state.status == PLAYER_PAUSED;
const double duration = std::isfinite(state.duration) && state.duration > 0 ? state.duration : 0;
const bool eligible = !live && state.has_video && state.can_seek && duration > 0;
const bool crossing = !thresholdReported && eligible && state.position / duration >= .95;
const bool immediate = awaitingReady || seekPending || paused || crossing || state.status == PLAYER_ENDED;
snapshot = {state.position == 0 ? 0 : state.position, duration, state.status, eligible};
hasSnapshot = true;
awaitingReady = false;
seekPending = false;
if (!immediate && state.status != PLAYER_PLAYING) return;
try {
const double now = clock();
if (!std::isfinite(now)) {
disable("The playback progress clock returned a non-finite value");
return;
}
if (immediate || now - lastRecordAt >= 5) {
if (emit(state.status == PLAYER_ENDED ? "ended" : "progress")) {
lastRecordAt = now;
if (crossing) thresholdReported = true;
}
}
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("Could not sample the playback progress clock");
}
}
void ProgressReporter::closeMedia() noexcept {
if (mediaURL.empty()) return;
if (hasSnapshot && !disabled.load(std::memory_order_acquire)) emit("closed");
mediaURL.clear();
hasSnapshot = false;
awaitingReady = false;
seekPending = false;
thresholdReported = false;
lastRecordAt = 0;
}
bool ProgressReporter::emit(const char *event) noexcept {
try {
std::string record;
record.reserve(256);
record += "{\"version\":1,\"event\":\"";
record += event;
record += "\",\"media_url\":\"";
record += mediaURL;
record += "\",\"position_seconds\":";
appendNumber(record, snapshot.position);
record += ",\"duration_seconds\":";
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
else record += "null";
record += ",\"completion_eligible\":";
record += snapshot.eligible ? "true}\n" : "false}\n";
{
std::lock_guard lock(mutex);
if (stopping || disabled.load(std::memory_order_relaxed)) return false;
records.push_back(std::move(record));
}
wake.notify_one();
return true;
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("Could not encode or queue playback progress");
}
return false;
}
void ProgressReporter::disable(const char *message, int osError) noexcept {
std::lock_guard lock(mutex);
if (!failure[0]) {
if (osError) std::snprintf(failure.data(), failure.size(), "%s: %s", message, std::strerror(osError));
else std::snprintf(failure.data(), failure.size(), "%s", message);
if (!failure[0]) std::snprintf(failure.data(), failure.size(), "Playback progress reporting failed");
}
disabled.store(true, std::memory_order_release);
records.clear();
wake.notify_all();
}
std::string ProgressReporter::error() const {
std::lock_guard lock(mutex);
return failure.data();
}
void ProgressReporter::writeRecords() noexcept {
try {
for (;;) {
std::string record;
{
std::unique_lock lock(mutex);
wake.wait(lock, [this] { return stopping || disabled.load(std::memory_order_relaxed) || !records.empty(); });
if (disabled.load(std::memory_order_relaxed) || (stopping && records.empty())) break;
record = std::move(records.front());
records.pop_front();
}
std::size_t written = 0;
while (written < record.size() && !disabled.load(std::memory_order_acquire)) {
const auto count = ::write(descriptor, record.data() + written, record.size() - written);
if (count > 0) written += static_cast<std::size_t>(count);
else if (count < 0 && errno == EINTR) continue;
else {
const int code = count < 0 ? errno : EIO;
disable("Could not write playback progress file", code);
break;
}
}
if (disabled.load(std::memory_order_acquire)) break;
}
} catch (const std::exception &exception) {
disable(exception.what());
} catch (...) {
disable("The playback progress writer failed");
}
if (::close(descriptor) != 0) disable("Could not close playback progress file", errno);
descriptor = -1;
}
void ProgressReporter::finish() noexcept {
if (finished) return;
closeMedia();
finished = true;
{
std::lock_guard lock(mutex);
stopping = true;
}
wake.notify_all();
if (writer.joinable()) writer.join();
}
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include "player.h"
#include <array>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <string_view>
#include <thread>
namespace playback {
// The decoder owns media/snapshot methods; the writer owns the file after construction.
class ProgressReporter {
public:
explicit ProgressReporter(const std::string &path, std::function<double()> clock = {});
~ProgressReporter();
ProgressReporter(const ProgressReporter &) = delete;
ProgressReporter &operator=(const ProgressReporter &) = delete;
static bool validMediaURL(std::string_view url) noexcept;
void begin(const std::string &mediaURL, bool live = false) noexcept;
void observe(const PlayerState &state) noexcept;
void closeMedia() noexcept;
// Call after the decoder joins; drains queued records and joins the writer.
void finish() noexcept;
std::string error() const;
private:
struct Snapshot {
double position = 0;
double duration = 0;
PlayerStatus status = PLAYER_EMPTY;
bool eligible = false;
} snapshot;
std::string mediaURL;
std::function<double()> clock;
bool live = false;
bool hasSnapshot = false;
bool awaitingReady = false;
bool seekPending = false;
bool thresholdReported = false;
bool finished = false;
double lastRecordAt = 0;
int descriptor = -1;
std::thread writer;
mutable std::mutex mutex;
std::condition_variable wake;
std::deque<std::string> records;
std::array<char, 512> failure{};
std::atomic<bool> disabled{false};
bool stopping = false;
bool emit(const char *event) noexcept;
void disable(const char *message, int osError = 0) noexcept;
void writeRecords() noexcept;
};
}