feat: report authoritative playback progress from shared backend
This commit is contained in:
+92
-14
@@ -5,7 +5,8 @@ import TerebiConfiguration
|
||||
@MainActor
|
||||
final class PlayerModel: ObservableObject {
|
||||
static var startupConfiguration = PlayerConfiguration()
|
||||
static let shared = PlayerModel(configuration: startupConfiguration)
|
||||
static var startupProgressFile: String?
|
||||
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
|
||||
|
||||
@Published private(set) var state = PlayerState()
|
||||
@Published private(set) var fileName: String?
|
||||
@@ -80,8 +81,16 @@ final class PlayerModel: ObservableObject {
|
||||
private var resumePlaying = true
|
||||
private var lastSavedPosition: Double?
|
||||
private var systemPlayback: SystemPlaybackControls?
|
||||
private(set) var progressInitializationError: String?
|
||||
private var reportingEnabled = false
|
||||
private var reportingErrorShown = false
|
||||
private var reportingErrorBuffer = [CChar]()
|
||||
private var reportingErrorMessage: String {
|
||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||
progressFile: String? = nil,
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
keyBindings = configuration.keys
|
||||
@@ -99,6 +108,15 @@ final class PlayerModel: ObservableObject {
|
||||
videoView = nil
|
||||
commandError = "Could not create the media player."
|
||||
}
|
||||
if let progressFile {
|
||||
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||||
if let player {
|
||||
reportingEnabled = progressFile.withCString { player_set_progress_file(player, $0, &reportingErrorBuffer) }
|
||||
if !reportingEnabled { progressInitializationError = reportingErrorMessage }
|
||||
} else {
|
||||
progressInitializationError = "Could not create the media player to initialize progress reporting."
|
||||
}
|
||||
}
|
||||
compactWindow.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
if !self.compactWindow.isTransitioning {
|
||||
@@ -288,20 +306,25 @@ final class PlayerModel: ObservableObject {
|
||||
guard let handle else { return }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
let accepted = video.video.url.withCString { videoURL in
|
||||
video.video.headers.withCString { videoHeaders in
|
||||
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
||||
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
||||
video_headers: videoHeaders, audio_headers: audioHeaders,
|
||||
duration: video.duration, is_live: video.isLive)
|
||||
return player_open_network(handle, &source)
|
||||
}
|
||||
if let audio = video.audio {
|
||||
return audio.url.withCString { audioURL in
|
||||
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
|
||||
let initialPosition = video.isLive ? 0 :
|
||||
(restoring.map { $0.position ?? 0 } ?? playbackProgress.position(for: video.webpageURL) ?? 0)
|
||||
let accepted = video.webpageURL.absoluteString.withCString { mediaURL in
|
||||
video.video.url.withCString { videoURL in
|
||||
video.video.headers.withCString { videoHeaders in
|
||||
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
||||
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
||||
video_headers: videoHeaders, audio_headers: audioHeaders,
|
||||
duration: video.duration, is_live: video.isLive,
|
||||
media_url: mediaURL, initial_position: initialPosition)
|
||||
return player_open_network(handle, &source)
|
||||
}
|
||||
if let audio = video.audio {
|
||||
return audio.url.withCString { audioURL in
|
||||
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
|
||||
}
|
||||
}
|
||||
return enqueue(audioURL: nil, audioHeaders: nil)
|
||||
}
|
||||
return enqueue(audioURL: nil, audioHeaders: nil)
|
||||
}
|
||||
}
|
||||
guard accepted else {
|
||||
@@ -338,7 +361,11 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
progressURL = url
|
||||
if let restoring {
|
||||
if !url.isFileURL {
|
||||
// Network restoration is part of the backend open transaction, so
|
||||
// reporting cannot observe a pre-resume position as initial state.
|
||||
resumePosition = nil
|
||||
} else if let restoring {
|
||||
resumePosition = restoring.position
|
||||
} else {
|
||||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||||
@@ -729,6 +756,11 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func poll() {
|
||||
guard let handle else { return }
|
||||
if reportingEnabled && !reportingErrorShown {
|
||||
if player_get_progress_error(handle, &reportingErrorBuffer) {
|
||||
showReportingError(reportingErrorMessage)
|
||||
}
|
||||
}
|
||||
applyState(player_get_state(handle))
|
||||
}
|
||||
|
||||
@@ -798,6 +830,21 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: height, automatic: true)
|
||||
}
|
||||
|
||||
private func showReportingError(_ message: String, terminating: Bool = false) {
|
||||
guard !reportingErrorShown else { return }
|
||||
reportingErrorShown = true
|
||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Playback progress reporting failed"
|
||||
alert.informativeText = message + "\n\nReporting has been disabled. Playback is unaffected."
|
||||
if let window, !terminating {
|
||||
alert.beginSheetModal(for: window)
|
||||
} else {
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
@@ -809,6 +856,11 @@ final class PlayerModel: ObservableObject {
|
||||
hideDeadline = nil
|
||||
NSCursor.setHiddenUntilMouseMoves(false)
|
||||
guard let player = handle else { return }
|
||||
if reportingEnabled {
|
||||
if !player_shutdown(player, &reportingErrorBuffer) {
|
||||
showReportingError(reportingErrorMessage, terminating: true)
|
||||
}
|
||||
}
|
||||
handle = nil
|
||||
player_destroy(player)
|
||||
}
|
||||
@@ -1526,5 +1578,31 @@ public func terebiAppMain() {
|
||||
alert.runModal()
|
||||
exit(78)
|
||||
}
|
||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||
if let index = arguments.firstIndex(of: "--progress-file") {
|
||||
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
|
||||
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Could not initialize playback progress reporting"
|
||||
alert.informativeText = "--progress-file requires one absolute filesystem path."
|
||||
alert.runModal()
|
||||
exit(73)
|
||||
}
|
||||
PlayerModel.startupProgressFile = arguments[index + 1]
|
||||
_ = NSApplication.shared
|
||||
if let error = PlayerModel.shared.progressInitializationError {
|
||||
PlayerModel.shared.shutdown()
|
||||
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Could not initialize playback progress reporting"
|
||||
alert.informativeText = error
|
||||
alert.runModal()
|
||||
exit(73)
|
||||
}
|
||||
}
|
||||
TerebiApp.main()
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import Foundation
|
||||
enum PlayerCommand {
|
||||
case help
|
||||
case printDefaultConfig
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool)
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
||||
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
||||
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
@@ -19,6 +20,8 @@ enum PlayerCommand {
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video URL.
|
||||
--progress-file PATH Write JSONL playback facts to a new private file; requires
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
-h, --help Show this help without launching the player.
|
||||
--default-config Print the complete built-in TOML without launching.
|
||||
@@ -30,6 +33,23 @@ enum PlayerCommand {
|
||||
|
||||
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
||||
var inputs = arguments
|
||||
var progressFile: URL?
|
||||
var index = 0
|
||||
while index < inputs.count && inputs[index] != "--" {
|
||||
if inputs[index] != "--progress-file" {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file may only be supplied once.") }
|
||||
guard index + 1 < inputs.count, !inputs[index + 1].isEmpty,
|
||||
!inputs[index + 1].contains("\0"), !inputs[index + 1].hasPrefix("--") else {
|
||||
throw ArgumentError("--progress-file requires a filesystem path.")
|
||||
}
|
||||
let base = directory.hasDirectoryPath ? directory :
|
||||
URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
progressFile = URL(fileURLWithPath: inputs[index + 1], relativeTo: base).absoluteURL.standardizedFileURL
|
||||
inputs.removeSubrange(index...index + 1)
|
||||
}
|
||||
var newInstance = false
|
||||
var noConfig = false
|
||||
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
||||
@@ -42,12 +62,24 @@ enum PlayerCommand {
|
||||
}
|
||||
inputs.removeFirst()
|
||||
}
|
||||
if inputs.isEmpty { return .launch(nil, newInstance: newInstance, noConfig: noConfig) }
|
||||
if inputs == ["--help"] || inputs == ["-h"] { return .help }
|
||||
if inputs == ["--default-config"] { return .printDefaultConfig }
|
||||
func launch(_ url: URL?) throws -> PlayerCommand {
|
||||
guard progressFile == nil || newInstance else {
|
||||
throw ArgumentError("--progress-file requires --new-instance.")
|
||||
}
|
||||
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
if inputs.isEmpty { return try launch(nil) }
|
||||
if inputs == ["--help"] || inputs == ["-h"] {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") }
|
||||
return .help
|
||||
}
|
||||
if inputs == ["--default-config"] {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
|
||||
return .printDefaultConfig
|
||||
}
|
||||
if inputs.first == "--url" {
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance, noConfig: noConfig)
|
||||
return try launch(YouTubeResolver.validateURL(inputs[1]))
|
||||
}
|
||||
let filenameOnly = inputs.first == "--"
|
||||
if filenameOnly {
|
||||
@@ -67,7 +99,7 @@ enum PlayerCommand {
|
||||
}
|
||||
file = url
|
||||
} else {
|
||||
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance, noConfig: noConfig)
|
||||
return try launch(YouTubeResolver.validateURL(input))
|
||||
}
|
||||
} else {
|
||||
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
@@ -77,7 +109,7 @@ enum PlayerCommand {
|
||||
FileManager.default.isReadableFile(atPath: file.path) else {
|
||||
throw ArgumentError("File does not exist or is not a readable regular file.")
|
||||
}
|
||||
return .launch(file.standardizedFileURL, newInstance: newInstance, noConfig: noConfig)
|
||||
return try launch(file.standardizedFileURL)
|
||||
}
|
||||
|
||||
private struct ArgumentError: LocalizedError {
|
||||
|
||||
@@ -17,7 +17,16 @@ enum TerebiCLI {
|
||||
print(PlayerCommand.usage)
|
||||
case .printDefaultConfig:
|
||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||
case .launch(let url, let newInstance, let noConfig):
|
||||
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||
if let progressFile {
|
||||
var existing = stat()
|
||||
guard lstat(progressFile.path, &existing) != 0 else {
|
||||
fail("Progress file already exists: \(progressFile.path). Supply a nonexistent destination.", code: 73)
|
||||
}
|
||||
guard errno == ENOENT else {
|
||||
fail("Could not inspect progress file \(progressFile.path): \(String(cString: strerror(errno))).", code: 73)
|
||||
}
|
||||
}
|
||||
let configURL = PlayerConfiguration.fileURL(environment: ProcessInfo.processInfo.environment,
|
||||
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
||||
if !noConfig {
|
||||
@@ -39,6 +48,9 @@ enum TerebiCLI {
|
||||
configuration.createsNewApplicationInstance = newInstance
|
||||
// LaunchServices does not inherit the invoking shell's XDG environment.
|
||||
configuration.arguments = noConfig ? ["--no-config"] : ["--config-file", configURL.absoluteString]
|
||||
if let progressFile {
|
||||
configuration.arguments += ["--progress-file", progressFile.path]
|
||||
}
|
||||
if newInstance, let url {
|
||||
configuration.arguments += ["--open-media", url.absoluteString]
|
||||
}
|
||||
|
||||
+95
-6
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user