Files

628 lines
24 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 steredbits and Pelagia contributors
// pelagia-play - validation tool: playback of a local file.
// Options, keyboard/gamepad controls and exit codes: see print_usage()
// (pelagia-play --help).
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <string>
#include "api/http_curl.h"
#include "api/jellyfin_client.h"
#include "api/jellyfin_reporter.h"
#include "api/jellyfin_stream.h"
#include "api/track_labels.h"
#include "api/track_selection.h"
#include "platform.h"
#include "player/media_source.h"
#include "player/av_log_bridge.h"
#include "player/network_source.h"
#include "player/playback_monitor.h"
#include "player/player.h"
#include "util/log.h"
namespace {
struct PlatformVideoSink : player::VideoSink {
bool submit(const AVFrame* frame) override {
return platform::video_submit_frame_yuv(
frame->data[0], frame->data[1], frame->data[2], frame->linesize[0],
frame->linesize[1], frame->linesize[2], frame->width, frame->height);
}
};
struct PlatformAudioSink : player::AudioSink {
size_t queue(const void* samples, size_t bytes) override {
return platform::audio_queue(samples, bytes);
}
size_t queued_bytes() override { return platform::audio_queued_bytes(); }
void pause(bool paused) override { platform::audio_pause(paused); }
void flush() override { platform::audio_flush(); }
};
constexpr int kExitOk = 0;
constexpr int kExitFailure = 1; // opening, initialization or playback
constexpr int kExitUsage = 2; // arguments incorrects
void print_usage(std::FILE* out) {
std::fputs(
"Usage: pelagia-play [options] [--] <file>\n"
" pelagia-play [options] --item <id>\n"
"\n"
"Plays a local media file, or an item of a Jellyfin server (transcoded\n"
"H.264 + AAC stream), with ffmpeg decoding and SDL2 rendering and audio.\n"
"\n"
"Options:\n"
" --item <id> plays the Jellyfin item <id> instead of a file; server and\n"
" credentials via JELLYFIN_URL, JELLYFIN_USER, JELLYFIN_PASS\n"
" --resume with --item: resumes at the position saved by the\n"
" server (otherwise, plays from the beginning)\n"
" --stream-auth <mode>\n"
" stream authentication (default: header): header = MediaBrowser\n"
" header only, the token is in no URL; query =\n"
" api_key in the URL; both = both\n"
" --audio <n> with --item: audio track with Jellyfin index <n> (default: the\n"
" server's choice according to the user's profile)\n"
" --subtitle <n|none>\n"
" with --item: subtitles with index <n>, or none (default: the\n"
" server's choice according to the profile). Images (PGS) are burned in by\n"
" the server (transcoded video); text is not displayed by\n"
" this tool (see --burn-subtitles)\n"
" --burn-subtitles\n"
" with --item: makes the server burn in even text subtitles\n"
" --list-tracks with --item: shows the item's tracks and the default choice,\n"
" then exits\n"
" --headless decodes the whole media without rendering or audio output, then\n"
" shows the statistics (frames decoded/dropped,\n"
" real-time factor, simulated A/V drift)\n"
" --verbose detailed logs (debug level)\n"
" -h, --help shows this help and exits\n"
" -- end of options (file whose name starts with '-')\n"
"\n"
"Controls during windowed playback:\n"
" Action Keyboard Gamepad\n"
" Pause / resume Space Start / triangle\n"
" Seek -10 s / +10 s Left arrow / right arrow, , / . L1 / R1, d-pad left / right\n"
" Seek -60 s / +60 s Page Down / Page Up L2 / R2\n"
" Quit Esc, Backspace, Ctrl+Q circle (or close the window)\n"
"\n"
"Without a display or sound card (CI, container):\n"
" SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy pelagia-play <file>\n"
"\n"
"Exit codes: 0 success, 1 opening, playback or server connection\n"
" error, 2 incorrect arguments.\n",
out);
}
struct Options {
bool headless = false;
bool verbose = false;
const char* path = nullptr;
const char* item_id = nullptr;
bool resume = false;
int audio_index = -1; // --audio: -1 = server's choice
bool subtitle_set = false; // --subtitle given
int subtitle_index = -1; // -1: none
bool burn_subtitles = false;
bool list_tracks = false;
// Header only: the token appears in no URL (server logs,
// proxies). Accepted by Jellyfin 12.1 on stream.ts (tested on a real server).
api::StreamAuthMode stream_auth = api::StreamAuthMode::Header;
};
// Returns kExitOk if the arguments are valid; otherwise kExitUsage, with the
// error message already written on stderr. Options are only recognized
// before "--".
int parse_args(int argc, char** argv, Options* opt) {
bool options_ended = false;
for (int i = 1; i < argc; ++i) {
const char* arg = argv[i];
if (!options_ended && std::strcmp(arg, "--") == 0) {
options_ended = true;
} else if (!options_ended && std::strcmp(arg, "--headless") == 0) {
opt->headless = true;
} else if (!options_ended && std::strcmp(arg, "--verbose") == 0) {
opt->verbose = true;
} else if (!options_ended && std::strcmp(arg, "--resume") == 0) {
opt->resume = true;
} else if (!options_ended && std::strcmp(arg, "--item") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
std::fprintf(stderr, "--item expects an item identifier\n");
return kExitUsage;
}
opt->item_id = argv[++i];
} else if (!options_ended && std::strcmp(arg, "--burn-subtitles") == 0) {
opt->burn_subtitles = true;
} else if (!options_ended && std::strcmp(arg, "--list-tracks") == 0) {
opt->list_tracks = true;
} else if (!options_ended && std::strcmp(arg, "--audio") == 0) {
char* end = nullptr;
const long v = i + 1 < argc ? std::strtol(argv[i + 1], &end, 10) : -1;
if (i + 1 >= argc || end == argv[i + 1] || *end != '\0' || v < 0 || v > 1000) {
std::fprintf(stderr, "--audio expects a track index (number)\n");
return kExitUsage;
}
opt->audio_index = static_cast<int>(v);
++i;
} else if (!options_ended && std::strcmp(arg, "--subtitle") == 0) {
if (i + 1 < argc && std::strcmp(argv[i + 1], "none") == 0) {
opt->subtitle_set = true;
opt->subtitle_index = -1;
} else {
char* end = nullptr;
const long v = i + 1 < argc ? std::strtol(argv[i + 1], &end, 10) : -1;
if (i + 1 >= argc || end == argv[i + 1] || *end != '\0' || v < 0 || v > 1000) {
std::fprintf(stderr, "--subtitle expects a track index or none\n");
return kExitUsage;
}
opt->subtitle_set = true;
opt->subtitle_index = static_cast<int>(v);
}
++i;
} else if (!options_ended && std::strcmp(arg, "--stream-auth") == 0) {
if (i + 1 >= argc || !api::parse_stream_auth_mode(argv[i + 1], &opt->stream_auth)) {
std::fprintf(stderr, "--stream-auth expects both, query or header\n");
return kExitUsage;
}
++i;
} else if (!options_ended && arg[0] == '-' && arg[1] != '\0') {
std::fprintf(stderr, "Unknown option: %s\n", arg);
return kExitUsage;
} else if (opt->path) {
std::fprintf(stderr, "Only one file expected (got: '%s' and '%s')\n",
opt->path, arg);
return kExitUsage;
} else {
opt->path = arg;
}
}
if (opt->path && opt->item_id) {
std::fprintf(stderr, "A file and --item are mutually exclusive.\n");
return kExitUsage;
}
if (!opt->path && !opt->item_id) {
std::fprintf(stderr, "No file given (or --item <id>).\n");
return kExitUsage;
}
if (opt->resume && !opt->item_id) {
std::fprintf(stderr, "--resume is only used with --item.\n");
return kExitUsage;
}
if (!opt->item_id && (opt->audio_index >= 0 || opt->subtitle_set || opt->burn_subtitles ||
opt->list_tracks)) {
std::fprintf(stderr, "--audio, --subtitle, --burn-subtitles and --list-tracks are"
"only used with --item.\n");
return kExitUsage;
}
return kExitOk;
}
// Help takes precedence over any other argument error.
bool wants_help(int argc, char** argv) {
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--") == 0) {
return false;
}
if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) {
return true;
}
}
return false;
}
// Max A/V drift: "n/a" when nothing could be compared (no audio track
// or no video), never a misleading 0 ms.
void format_drift(const player::PlayerStats& stats, char* buf, size_t size) {
if (stats.drift_measured) {
std::snprintf(buf, size, "%lld ms", static_cast<long long>(stats.max_drift_ms));
} else {
std::snprintf(buf, size, "n/a");
}
}
void print_headless_stats(const player::PlayerStats& stats) {
const double duration_s = stats.media_duration_ms / 1000.0;
const double wall_s = stats.wall_ms / 1000.0;
const double video_busy_s = stats.video_decode_busy_ms / 1000.0;
const double audio_busy_s = stats.audio_decode_busy_ms / 1000.0;
const double total_factor = wall_s > 0.0 ? duration_s / wall_s : 0.0;
const double video_factor =
video_busy_s > 0.0 ? duration_s / video_busy_s : 0.0;
const double audio_factor =
audio_busy_s > 0.0 ? duration_s / audio_busy_s : 0.0;
std::printf("=== Statistiques headless ===\n");
std::printf("Media duration : %.1f s\n", duration_s);
std::printf("Full decoding : %.2f s (%.1fx real time)\n", wall_s,
total_factor);
std::printf(
"Video : %llu frames decoded, %llu dropped,"
"%.2f s of decoding (%.1fx real time)\n",
static_cast<unsigned long long>(stats.video_frames_decoded),
static_cast<unsigned long long>(stats.video_frames_dropped),
video_busy_s, video_factor);
std::printf(
"Audio : %llu frames decoded, %.2f s of decoding"
"(%.1fx real time)\n",
static_cast<unsigned long long>(stats.audio_frames_decoded),
audio_busy_s, audio_factor);
if (stats.drift_measured) {
std::printf(
"Max A/V drift : %lld ms - SIMULATED (PTS difference in headless mode,"
"not a playback measurement)\n",
static_cast<long long>(stats.max_drift_ms));
} else {
std::printf(
"Max A/V drift : n/a (an audio track and a video track"
"are needed to compare)\n");
}
}
// Everything needed to play a Jellyfin item; lives for the duration of playback.
struct NetworkSession {
api::HttpCurlTransport transport; // appels API
api::HttpCurlTransport control; // DELETE during a seek: short timeouts
api::HttpCurlTransport reporting; // playback reports: short timeouts
std::unique_ptr<api::JellyfinClient> client;
std::unique_ptr<api::JellyfinStreamLocator> locator;
std::unique_ptr<player::NetworkSource> source;
std::unique_ptr<api::PlaybackReporter> reporter;
api::MediaItem item;
int64_t start_ms = 0;
};
int run_headless(player::MediaSource* source, int64_t start_ms) {
player::Player p;
player::Player::Callbacks cb; // no sink: pure decoding
cb.start_position_ms = start_ms;
if (!p.open(source, cb)) {
return kExitFailure;
}
player::PlayerStats stats;
if (!p.run_headless(&stats)) {
LOG_ERROR("Headless decoding failed");
return kExitFailure;
}
print_headless_stats(stats);
return kExitOk;
}
player::PlaybackObservation observe(const player::Player& p, const NetworkSession& net) {
player::PlaybackObservation obs;
obs.now_ms = platform::ticks_ms();
obs.position_ms = p.position_ms();
obs.paused = p.state() == player::PlayerState::Paused;
obs.seek_in_flight = p.seek_in_flight();
obs.session_id = net.source->session_id();
const api::TrackSelection tracks = net.locator->tracks();
obs.has_tracks = !tracks.media_source_id.empty() || tracks.audio_index >= 0;
obs.media_source_id = tracks.media_source_id;
obs.audio_index = tracks.audio_index;
obs.subtitle_index = tracks.subtitle_index;
return obs;
}
void submit_reports(NetworkSession* net, const std::vector<player::PlaybackReport>& reports) {
for (const player::PlaybackReport& r : reports) {
net->reporter->submit(r);
}
}
// non-null net: playback of a Jellyfin item (buffer, resume, reports).
int run_windowed(player::MediaSource* source, int64_t start_ms, const char* label,
NetworkSession* net) {
player::Player p;
PlatformVideoSink video_sink;
PlatformAudioSink audio_sink;
player::Player::Callbacks cb;
cb.video = &video_sink;
cb.audio = &audio_sink;
cb.start_position_ms = start_ms;
if (net) {
cb.buffering = player::BufferingPolicy::network();
cb.deep_buffer = true;
cb.recovery = player::RetryPolicy::network();
cb.data_wait_timeout_ms = player::Player::Callbacks::kNetworkDataWaitMs;
}
if (!p.open(source, cb)) {
return kExitFailure;
}
platform::Config config;
config.window_width = 1280;
config.window_height = 720;
config.window_title = "Pelagia - playback";
if (!platform::init(config)) {
LOG_ERROR("Platform initialization failed");
return kExitFailure;
}
if (p.has_audio()) {
platform::AudioSpec spec; // 48 kHz stereo S16 (player defaults)
if (!platform::audio_open(spec)) {
LOG_ERROR("Audio opening failed");
platform::shutdown();
return kExitFailure;
}
}
p.start();
LOG_INFO("Playing %s (%lld ms)", label,
static_cast<long long>(p.duration_ms()));
uint64_t last_position_log = platform::ticks_ms();
bool running = true;
int status = kExitOk;
player::PlaybackMonitor monitor;
std::vector<player::PlaybackReport> reports;
if (net) {
net->reporter->start();
}
while (running) {
platform::Input in;
while ((in = platform::poll_input()).event != platform::InputEvent::None) {
switch (in.event) {
case platform::InputEvent::Quit:
case platform::InputEvent::Back:
running = false;
break;
case platform::InputEvent::PlayPause:
p.toggle_pause();
break;
case platform::InputEvent::SeekFwd:
case platform::InputEvent::Right:
p.seek_relative(10000);
break;
case platform::InputEvent::SeekBack:
case platform::InputEvent::Left:
p.seek_relative(-10000);
break;
case platform::InputEvent::SeekFwdLong:
p.seek_relative(60000);
break;
case platform::InputEvent::SeekBackLong:
p.seek_relative(-60000);
break;
default:
break;
}
}
const player::Player::TickResult r = p.tick();
if (net) {
reports.clear();
monitor.update(observe(p, *net), &reports);
submit_reports(net, reports);
}
if (r.new_video_frame) {
platform::render_begin();
platform::draw_video(platform::Rect{0, 0, platform::kLogicalWidth,
platform::kLogicalHeight});
platform::render_present();
}
if (r.ended) {
LOG_INFO("End of playback");
break;
}
if (r.failed) {
LOG_ERROR("Playback interrupted: server unreachable");
status = kExitFailure;
break;
}
const uint64_t now = platform::ticks_ms();
if (now - last_position_log >= 1000) {
last_position_log = now;
LOG_DEBUG("Position : %lld / %lld ms",
static_cast<long long>(p.position_ms()),
static_cast<long long>(p.duration_ms()));
}
platform::sleep_ms(2);
}
if (net) {
reports.clear();
monitor.stop(observe(p, *net), &reports);
submit_reports(net, reports);
}
p.stop();
if (net) {
net->reporter->finish(3000);
}
const player::PlayerStats stats = p.stats();
char drift[32];
format_drift(stats, drift, sizeof(drift));
LOG_INFO("Decoded video frames: %llu, dropped: %llu,"
"max A/V drift: %s",
static_cast<unsigned long long>(stats.video_frames_decoded),
static_cast<unsigned long long>(stats.video_frames_dropped), drift);
if (stats.reconnections > 0) {
LOG_INFO("Recoveries after a network drop: %llu",
static_cast<unsigned long long>(stats.reconnections));
}
if (p.has_audio()) {
platform::audio_close();
}
platform::shutdown();
return status;
}
const char* env_or_null(const char* name) {
const char* value = std::getenv(name);
return (value && value[0] != '\0') ? value : nullptr;
}
void log_api_error(const char* prefix, const api::ApiResult& result) {
if (result.http_status > 0) {
LOG_ERROR("%s : %s (HTTP %ld)", prefix, result.message.c_str(), result.http_status);
} else {
LOG_ERROR("%s : %s", prefix, result.message.c_str());
}
}
void print_tracks(const api::MediaSourceInfo& source, const api::TrackSelection& chosen) {
std::printf("Source %s (%s)\n", source.id.c_str(), source.container.c_str());
for (const api::MediaStreamInfo& st : source.streams) {
if (st.kind == api::StreamKind::Video) {
std::printf(" [%d] video %s\n", st.index, st.codec.c_str());
} else if (st.kind == api::StreamKind::Audio) {
std::printf(" [%d] audio %s%s%s\n", st.index, api::audio_label(st).c_str(),
st.is_default ? " (file default)" : "",
st.index == chosen.audio_index ? " <- chosen" : "");
} else {
const bool client = api::subtitle_delivery_for(st) == api::SubtitleDelivery::Client;
std::printf(" [%d] subtitle %s [%s]%s%s\n", st.index, api::subtitle_label(st).c_str(),
client ? "text, rendered by the client" : "burned in by the server",
st.is_default ? " (file default)" : "",
st.index == chosen.subtitle_index ? " <- chosen" : "");
}
}
}
// Playback tracks: the server's choice for the profile (or local fallback), then
// --audio / --subtitle / --burn-subtitles. Writes the summary to the logs.
int choose_tracks(const Options& opt, const NetworkSession& net, api::TrackSelection* out) {
const api::MediaSourceInfo* source = api::find_source(net.item, "");
if (!source) {
LOG_WARN("The server does not return the item's tracks: choice left to the server");
return kExitOk;
}
const api::UserPreferences& prefs = net.client->session().preferences;
api::TrackSelection sel = api::default_selection(*source, prefs);
if (opt.audio_index >= 0) {
const api::MediaStreamInfo* a = api::find_stream(*source, opt.audio_index);
if (!a || a->kind != api::StreamKind::Audio) {
std::fprintf(stderr, "--audio %d: not an audio track of this item (--list-tracks)\n",
opt.audio_index);
return kExitUsage;
}
sel = api::with_audio(*source, prefs, sel, opt.audio_index);
}
if (opt.subtitle_set) {
const api::MediaStreamInfo* st = api::find_stream(*source, opt.subtitle_index);
if (opt.subtitle_index >= 0 && (!st || st->kind != api::StreamKind::Subtitle)) {
std::fprintf(stderr, "--subtitle %d: not a subtitle track of this item"
"(--list-tracks)\n", opt.subtitle_index);
return kExitUsage;
}
sel = api::with_subtitle(*source, sel, opt.subtitle_index);
}
if (opt.burn_subtitles && sel.subtitle_index >= 0) {
sel.subtitle_delivery = api::SubtitleDelivery::Encode;
}
if (opt.list_tracks) {
print_tracks(*source, sel);
}
const api::MediaStreamInfo* a = api::find_stream(*source, sel.audio_index);
const api::MediaStreamInfo* st = api::find_stream(*source, sel.subtitle_index);
LOG_INFO("Tracks: audio %s; subtitles %s", a ? api::audio_label(*a).c_str() : "(server)",
!st ? "none"
: (api::subtitle_label(*st) +
(sel.subtitle_delivery == api::SubtitleDelivery::Encode
? " (burned in by the server: transcoded video)"
: " (text: not displayed by this tool)")).c_str());
*out = sel;
return kExitOk;
}
// Authenticates, reads the item metadata and prepares the network source.
int prepare_network(const Options& opt, NetworkSession* net) {
const char* url = env_or_null("JELLYFIN_URL");
const char* user = env_or_null("JELLYFIN_USER");
const char* pass = env_or_null("JELLYFIN_PASS");
if (!url || !user || !pass) {
std::fprintf(stderr,
"--item requires the JELLYFIN_URL, JELLYFIN_USER and JELLYFIN_PASS variables\n");
return kExitUsage;
}
net->control.set_timeouts(3, 5);
net->reporting.set_timeouts(3, 5);
net->client.reset(new api::JellyfinClient(net->transport, url));
api::ApiResult result = net->client->authenticate(user, pass);
if (!result.ok()) {
log_api_error("Authentication failed", result);
return kExitFailure;
}
result = net->client->fetch_item(opt.item_id, &net->item);
if (!result.ok()) {
log_api_error("Item not found", result);
return kExitFailure;
}
if (net->item.runtime_ticks <= 0) {
LOG_ERROR("Item \"%s\" (%s) is not a playable media", net->item.name.c_str(),
net->item.type.c_str());
if (net->item.type == "Series" || net->item.type == "Season") {
LOG_ERROR("For a series, use the identifier of an episode: pelagia-cli --episodes %s",
net->item.id.c_str());
}
return kExitFailure;
}
api::TrackSelection tracks;
const int tracks_status = choose_tracks(opt, *net, &tracks);
if (tracks_status != kExitOk) {
return tracks_status;
}
net->locator.reset(new api::JellyfinStreamLocator(*net->client, net->control,
net->item.id, opt.stream_auth, tracks));
net->reporter.reset(new api::PlaybackReporter(*net->client, net->reporting, net->item.id,
net->item.media_source_id));
player::NetworkSourceOptions options;
options.duration_ms = net->item.runtime_ticks / 10000;
if (opt.resume) {
net->start_ms = net->item.playback_position_ticks / 10000;
if (net->start_ms > 0) {
LOG_INFO("Resume at %lld:%02lld:%02lld (position saved by the server)",
static_cast<long long>(net->start_ms / 3600000),
static_cast<long long>(net->start_ms / 60000 % 60),
static_cast<long long>(net->start_ms / 1000 % 60));
} else {
LOG_INFO("No resume position saved: playing from the beginning");
}
}
options.start_ms = net->start_ms;
net->source.reset(new player::NetworkSource(net->locator.get(), options));
LOG_INFO("Item \"%s\" (%lld s), stream authentication: %s", net->item.name.c_str(),
static_cast<long long>(options.duration_ms / 1000),
api::stream_auth_mode_name(opt.stream_auth));
return kExitOk;
}
} // namespace
int main(int argc, char** argv) {
if (wants_help(argc, argv)) {
print_usage(stdout);
return kExitOk;
}
Options opt;
if (parse_args(argc, argv, &opt) != kExitOk) {
std::fprintf(stderr, "Try: pelagia-play --help\n");
return kExitUsage;
}
if (opt.verbose) {
util::log_set_level(util::LogLevel::Debug);
}
player::install_ffmpeg_log_bridge(opt.verbose);
if (opt.item_id) {
NetworkSession net;
const int status = prepare_network(opt, &net);
if (status != kExitOk) {
return status;
}
if (opt.list_tracks) {
return kExitOk;
}
return opt.headless ? run_headless(net.source.get(), net.start_ms)
: run_windowed(net.source.get(), net.start_ms, net.item.name.c_str(),
&net);
}
player::FileSource source(opt.path);
return opt.headless ? run_headless(&source, 0)
: run_windowed(&source, 0, opt.path, nullptr);
}