// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2026 steredbits and Pelagia contributors // Integration tests of network playback against the fake Jellyfin server // (tools/fake_jellyfin_server.py) : vrai HTTP, vrai transcodage ffmpeg. // // The server's test pattern has a luminance of 16 + 5 x T: each test checks the // real position of the displayed content (and not only what the // player believes), which notably detects a transcoding job reused by mistake. // The player clock is simulated and accelerated; network delays are real // but reduced (startup latency ~1 s) to keep the suite fast. #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include } #include "api/http_curl.h" #include "api/jellyfin_client.h" #include "api/jellyfin_reporter.h" #include "api/jellyfin_stream.h" #include "fake_server.h" #include "player/av_log_bridge.h" #include "player/network_source.h" #include "player/playback_monitor.h" #include "player/player.h" #include "test_framework.h" #include "util/log.h" namespace { const char kItem[] = "f00dfeed0000000000000000000000aa"; constexpr long long kTicksPerMs = 10000; struct FakeTime { uint64_t now = 1000; static uint64_t fn(void* ctx) { return static_cast(ctx)->now; } }; int64_t real_ms() { return std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) .count(); } void sleep_real(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } double expected_luma(int64_t media_ms) { return 16.0 + 5.0 * static_cast(media_ms) / 1000.0; } // Mean luminance of the center of each presented frame. struct LumaSink : player::VideoSink { std::atomic frames{0}; std::atomic frames_since_mark{0}; double last_luma = -1; double first_luma_since_mark = -1; bool submit(const AVFrame* f) override { long sum = 0; int n = 0; for (int y = f->height / 4; y < 3 * f->height / 4; y += 4) { for (int x = f->width / 4; x < 3 * f->width / 4; x += 4) { sum += f->data[0][y * f->linesize[0] + x]; ++n; } } last_luma = n > 0 ? static_cast(sum) / n : -1; if (frames_since_mark.fetch_add(1) == 0) { first_luma_since_mark = last_luma; } frames.fetch_add(1); return true; } void mark() { frames_since_mark = 0; first_luma_since_mark = -1; } }; struct Env { testnet::FakeServer server; api::HttpCurlTransport transport; api::HttpCurlTransport control; api::HttpCurlTransport reporting; std::unique_ptr client; api::MediaItem item; }; Env* g_env = nullptr; // Probe limit of the created sources (0 = core default value): 1 MB // reproduces the old probe, which did not find the audio in direct streaming. int64_t g_probe_bytes = 0; testnet::ServerState server_state() { testnet::ServerState st; testnet::parse_state(g_env->server.state(), &st); return st; } void configure(const char* json) { CHECK(g_env->server.post("/__test/config", json)); } void reset_server() { CHECK(g_env->server.post("/__test/reset", "{}")); configure(R"({"startup_delay": 1.0, "stream_auth": "both", "pts_mode": "zero", "readrate": 0, "video_kbps": 0, "late_audio": 0})"); } bool has_delete(const testnet::ServerState& st, const std::string& session) { for (const testnet::DeleteInfo& d : st.deletes) { if (d.play_session_id == session && d.status == 204) { return true; } } return false; } // Network stream: the locator must outlive the source (declared before). struct Stream { std::unique_ptr locator; std::unique_ptr source; Stream(api::StreamAuthMode mode, int64_t start_ms, int read_timeout_ms) { locator.reset(new api::JellyfinStreamLocator(*g_env->client, g_env->control, kItem, mode)); player::NetworkSourceOptions options; options.start_ms = start_ms; options.duration_ms = g_env->item.runtime_ticks / kTicksPerMs; options.open_timeout_ms = 15000; options.read_timeout_ms = read_timeout_ms; options.seek_debounce_ms = 100; if (g_probe_bytes > 0) { options.probe_bytes = g_probe_bytes; } source.reset(new player::NetworkSource(locator.get(), options)); } }; // Thresholds reduced for the tests (the player uses 2 s / 4 s / 250 ms). player::BufferingPolicy test_buffering_policy() { player::BufferingPolicy p; p.start_ms = 1000; p.resume_ms = 1500; p.low_water_ms = 200; return p; } // Playback rig: the player is destroyed (stopped) before the source. struct Rig { FakeTime time; LumaSink sink; int64_t start_ms; Stream stream; player::Player player; // Player clock: accelerated x5 (default) or at real pace, for // scenarios where the server throughput matters (buffering). bool realtime = false; int64_t real_base = 0; int ticks_buffering = 0; int frames_while_buffering = 0; player::RetryPolicy recovery; // disabled unless the test sets it bool failed = false; explicit Rig(api::StreamAuthMode mode = api::StreamAuthMode::Both, int64_t start = 0, int read_timeout_ms = 4000) : start_ms(start), stream(mode, start, read_timeout_ms) {} bool open(bool with_buffering = false) { player::Player::Callbacks cb; cb.video = &sink; cb.time_fn = &FakeTime::fn; cb.time_ctx = &time; cb.start_position_ms = start_ms; if (with_buffering) { cb.buffering = test_buffering_policy(); cb.deep_buffer = true; } cb.recovery = recovery; real_base = real_ms(); return player.open(stream.source.get(), cb) && player.start(); } void step() { const int before = sink.frames; const player::Player::TickResult result = player.tick(); if (result.buffering) { ++ticks_buffering; frames_while_buffering += sink.frames - before; } failed = result.failed; if (realtime) { time.now = 1000 + static_cast(real_ms() - real_base); } else { time.now += 5; } sleep_real(1); } template bool run_until(Pred pred, int max_real_ms = 15000) { const int64_t end = real_ms() + max_real_ms; while (real_ms() < end) { step(); if (pred()) { return true; } } return false; } void run_for(int real_ms_duration) { const int64_t end = real_ms() + real_ms_duration; while (real_ms() < end) { step(); } } }; // --- Scenarios --------------------------------------------------------------- void test_play_from_start() { reset_server(); Rig r; CHECK(r.open()); CHECK(r.run_until([&] { return r.sink.frames >= 50; })); const int64_t pos = r.player.position_ms(); CHECK(std::fabs(r.sink.last_luma - expected_luma(pos)) < 6); const std::string session = r.stream.source->session_id(); testnet::ServerState st = server_state(); CHECK_EQ(st.requests.size(), 1u); if (!st.requests.empty()) { CHECK_EQ(st.requests[0].start_ticks, 0); CHECK(!st.requests[0].reused); CHECK(st.requests[0].auth == "query+header"); CHECK_EQ(st.requests[0].play_session_id.size(), 32u); CHECK(st.requests[0].play_session_id == session); } // End of playback: the source releases the server job. r.player.stop(); r.stream.source.reset(); st = server_state(); CHECK(has_delete(st, session)); CHECK_EQ(st.active_jobs, 0); } void test_seek_forward_and_backward() { reset_server(); Rig r; CHECK(r.open()); CHECK(r.run_until([&] { return r.sink.frames >= 10; })); const std::string first_session = r.stream.source->session_id(); // Avant : 20 s. r.sink.mark(); r.player.seek_to(20000); CHECK(r.player.seek_in_flight()); CHECK_EQ(r.player.position_ms(), 20000); CHECK(r.run_until([&] { return r.sink.frames_since_mark >= 5; })); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(20000)) < 6); CHECK(!r.player.seek_in_flight()); const int64_t pos = r.player.position_ms(); CHECK(pos >= 19900 && pos < 21500); testnet::ServerState st = server_state(); CHECK_EQ(st.requests.size(), 2u); if (st.requests.size() == 2) { CHECK_EQ(st.requests[1].start_ticks, 20000 * kTicksPerMs); CHECK(!st.requests[1].reused); CHECK(st.requests[1].play_session_id != first_session); } CHECK(has_delete(st, first_session)); // old job stopped before the reopening CHECK_EQ(st.active_jobs, 1); // Backwards: 5 s. r.sink.mark(); r.player.seek_to(5000); CHECK(r.run_until([&] { return r.sink.frames_since_mark >= 5; })); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(5000)) < 6); st = server_state(); CHECK_EQ(st.requests.size(), 3u); CHECK_EQ(st.active_jobs, 1); } void test_seek_burst_only_last_counts() { reset_server(); Rig r; CHECK(r.open()); CHECK(r.run_until([&] { return r.sink.frames >= 10; })); const size_t before = server_state().requests.size(); // Key held down: 5 close relative seeks, each starting from the target // of the previous one (and not from the clock, frozen on the old position). r.sink.mark(); int64_t expected = r.player.position_ms(); for (int i = 0; i < 5; ++i) { r.player.seek_relative(3000); expected += 3000; r.player.tick(); sleep_real(20); } CHECK_EQ(r.player.position_ms(), expected); CHECK(r.run_until([&] { return r.sink.frames_since_mark >= 5; })); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(expected)) < 6); const testnet::ServerState st = server_state(); const size_t opened = st.requests.size() - before; CHECK(opened >= 1 && opened <= 2); // burst grouped, not 5 transcodings if (!st.requests.empty()) { CHECK_EQ(st.requests.back().start_ticks, expected * kTicksPerMs); } CHECK_EQ(st.active_jobs, 1); // no orphan job } void test_stop_during_seek() { reset_server(); Rig r; CHECK(r.open()); CHECK(r.run_until([&] { return r.sink.frames >= 10; })); configure(R"({"startup_delay": 3.0})"); r.player.seek_to(30000); sleep_real(600); // request sent, the server "transcodes" const int64_t t0 = real_ms(); r.player.stop(); const int64_t stop_ms = real_ms() - t0; CHECK(stop_ms < 1000); r.stream.source.reset(); const testnet::ServerState st = server_state(); CHECK(!st.requests.empty() && st.requests.back().start_ticks == 30000 * kTicksPerMs); if (!st.requests.empty()) { CHECK(has_delete(st, st.requests.back().play_session_id)); } CHECK_EQ(st.active_jobs, 0); } void test_abort_initial_open() { reset_server(); configure(R"({"startup_delay": 3.0})"); Rig r; std::atomic opened{true}; std::thread opener([&] { opened = r.open(); }); sleep_real(500); const int64_t t0 = real_ms(); r.player.abort_open(); opener.join(); CHECK(real_ms() - t0 < 1000); CHECK(!opened); r.player.stop(); // always before destroying the source r.stream.source.reset(); CHECK_EQ(server_state().active_jobs, 0); } void test_stream_auth_modes() { reset_server(); configure(R"({"stream_auth": "header"})"); { Rig r(api::StreamAuthMode::Header); CHECK(r.open()); CHECK(r.run_until([&] { return r.sink.frames >= 5; })); const testnet::ServerState st = server_state(); CHECK(!st.requests.empty() && st.requests.back().auth == "header"); } { // api_key alone refused by this server: fast failure, no waiting. Rig r(api::StreamAuthMode::Query); const int64_t t0 = real_ms(); CHECK(!r.open()); CHECK(real_ms() - t0 < 3000); } } void test_absolute_pts_after_seek() { reset_server(); configure(R"({"pts_mode": "absolute"})"); Rig r; CHECK(r.open()); CHECK(r.run_until([&] { return r.sink.frames >= 10; })); CHECK(std::fabs(r.sink.last_luma - expected_luma(r.player.position_ms())) < 6); r.sink.mark(); r.player.seek_to(20000); CHECK(r.run_until([&] { return r.sink.frames_since_mark >= 5; })); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(20000)) < 6); const int64_t pos = r.player.position_ms(); CHECK(pos >= 19900 && pos < 21500); } // Start: no frame before the start reserve is built up. void test_startup_buffering() { reset_server(); Rig r; r.realtime = true; int64_t buffered_at_first_frame = -1; CHECK(r.open(true)); CHECK(r.player.is_buffering()); CHECK(r.run_until([&] { if (r.sink.frames > 0 && buffered_at_first_frame < 0) { buffered_at_first_frame = r.player.buffered_ms(); } return r.sink.frames >= 10; })); CHECK(buffered_at_first_frame >= test_buffering_policy().start_ms - 200); CHECK(!r.player.is_buffering()); CHECK_EQ(r.frames_while_buffering, 0); } // The server stops sending for a few seconds (less than the read // timeout): playback is suspended without losing frames, then resumes on the // same connection, at the right place. void test_stall_rebuffers_and_resumes() { reset_server(); configure(R"({"readrate": 1.3})"); // barely faster than real time Rig r; r.realtime = true; CHECK(r.open(true)); CHECK(r.run_until([&] { return r.sink.frames >= 25 && !r.player.is_buffering(); })); r.ticks_buffering = 0; const size_t requests_before = server_state().requests.size(); CHECK(g_env->server.post("/__test/fault", R"({"stall_s": 3.0})")); CHECK(r.run_until([&] { return r.player.is_buffering(); }, 6000)); const int64_t frozen = r.player.position_ms(); const int frames_at_freeze = r.sink.frames; r.run_for(500); CHECK_EQ(r.player.position_ms(), frozen); // clock frozen during the wait CHECK_EQ(r.sink.frames, frames_at_freeze); CHECK(r.run_until([&] { return !r.player.is_buffering() && r.sink.frames > frames_at_freeze + 10; }, 10000)); CHECK(r.ticks_buffering > 0); CHECK_EQ(r.frames_while_buffering, 0); CHECK(std::fabs(r.sink.last_luma - expected_luma(r.player.position_ms())) < 6); CHECK(r.player.stats().video_frames_dropped < 5); CHECK_EQ(server_state().requests.size(), requests_before); // no reconnection } // Stop while playback waits for data: immediate. void test_stop_while_buffering() { reset_server(); configure(R"({"readrate": 1.3})"); Rig r; r.realtime = true; CHECK(r.open(true)); CHECK(r.run_until([&] { return r.sink.frames >= 10 && !r.player.is_buffering(); })); CHECK(g_env->server.post("/__test/fault", R"({"stall_s": 3.0})")); CHECK(r.run_until([&] { return r.player.is_buffering(); }, 6000)); const int64_t t0 = real_ms(); r.player.stop(); CHECK(real_ms() - t0 < 1000); } // Fast recovery for the tests: 100, 200, 400, 800, 1600 ms (the player // utilise 1, 2, 4, 8, 15 s). player::RetryPolicy fast_recovery() { player::RetryPolicy p; p.max_attempts = 5; p.initial_delay_ms = 100; p.max_delay_ms = 1600; return p; } // Playback in progress, at real pace, with buffering and recovery. bool start_resilient(Rig* r, int max_attempts = 5) { r->realtime = true; r->recovery = fast_recovery(); r->recovery.max_attempts = max_attempts; return r->open(true) && r->run_until([&] { return r->sink.frames >= 15 && !r->player.is_buffering(); }); } // Waits for playback to have resumed after a drop (frames again). bool wait_recovered(Rig* r, int max_real_ms) { return r->run_until( [&] { return r->player.stats().reconnections >= 1 && !r->player.is_buffering() && r->sink.frames_since_mark >= 10; }, max_real_ms); } // Abrupt drop (RST): recovery at the playback position, new session, // old job released. void test_drop_reconnects_at_position() { reset_server(); configure(R"({"readrate": 1.3})"); Rig r; CHECK(start_resilient(&r)); const int64_t pos_before = r.player.position_ms(); const std::string session_before = r.stream.source->session_id(); const size_t requests_before = server_state().requests.size(); r.sink.mark(); CHECK(g_env->server.post("/__test/fault", R"({"drop": true})")); CHECK(wait_recovered(&r, 10000)); CHECK_EQ(r.player.stats().reconnections, 1u); CHECK(r.player.state() == player::PlayerState::Playing); CHECK(std::fabs(r.sink.last_luma - expected_luma(r.player.position_ms())) < 6); const testnet::ServerState st = server_state(); CHECK_EQ(st.requests.size(), requests_before + 1); if (!st.requests.empty()) { // Resume where playback was (it went on on the reserve). const long long resume_ms = st.requests.back().start_ticks / kTicksPerMs; CHECK(resume_ms >= pos_before && resume_ms <= pos_before + 3000); CHECK(st.requests.back().play_session_id != session_before); } CHECK(has_delete(st, session_before)); CHECK_EQ(st.active_jobs, 1); } // Server unreachable for 1.5 s (Wi-Fi cut): the attempts fail then the // playback resumes. void test_outage_then_recovery() { reset_server(); configure(R"({"readrate": 1.3})"); Rig r; CHECK(start_resilient(&r)); r.sink.mark(); const int64_t t0 = real_ms(); CHECK(g_env->server.post("/__test/fault", R"({"drop": true, "refuse_s": 1.5})")); CHECK(wait_recovered(&r, 15000)); CHECK(real_ms() - t0 >= 1400); CHECK(r.player.state() == player::PlayerState::Playing); CHECK(std::fabs(r.sink.last_luma - expected_luma(r.player.position_ms())) < 6); } // The server cleanly ends the stream well before the end: drop, not the end. void test_truncated_stream_is_a_cut() { reset_server(); configure(R"({"readrate": 1.3})"); Rig r; CHECK(start_resilient(&r)); r.sink.mark(); CHECK(g_env->server.post("/__test/fault", R"({"truncate": true})")); CHECK(wait_recovered(&r, 10000)); CHECK(r.player.state() == player::PlayerState::Playing); } // No more data, connection open (Wi-Fi without RST): read timeout // exceeded, then resume when the server answers again. void test_stall_beyond_read_timeout() { reset_server(); configure(R"({"readrate": 1.3})"); Rig r(api::StreamAuthMode::Both, 0, /*read_timeout_ms=*/1500); CHECK(start_resilient(&r)); r.sink.mark(); CHECK(g_env->server.post("/__test/fault", R"({"stall_s": 4.0})")); CHECK(wait_recovered(&r, 15000)); CHECK(r.player.state() == player::PlayerState::Playing); } // Server lost: Failed state after the attempts, without blocking; a seek // starts a new attempt once the server is back. void test_permanent_outage_fails_then_seek_retries() { reset_server(); configure(R"({"readrate": 1.3})"); Rig r; CHECK(start_resilient(&r, 3)); // the number of attempts changes nothing here CHECK(g_env->server.post("/__test/fault", R"({"drop": true, "refuse_s": 60})")); CHECK(r.run_until([&] { return r.failed; }, 15000)); CHECK(r.player.state() == player::PlayerState::Failed); CHECK(g_env->server.post("/__test/reset", "{}")); // server back configure(R"({"startup_delay": 1.0, "readrate": 1.3})"); r.sink.mark(); r.player.seek_to(10000); CHECK(r.run_until([&] { return r.sink.frames_since_mark >= 10; }, 10000)); CHECK(r.player.state() == player::PlayerState::Playing); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(10000)) < 6); } // The real end of the movie is not a drop: Ended state, no recovery. void test_natural_end_is_not_a_cut() { reset_server(); Rig r(api::StreamAuthMode::Both, 34000); r.recovery = fast_recovery(); CHECK(r.open(true)); CHECK(r.run_until([&] { return r.player.state() == player::PlayerState::Ended; })); CHECK_EQ(r.player.stats().reconnections, 0u); CHECK_EQ(server_state().requests.size(), 1u); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(34000)) < 6); } // Direct streaming (finding on PS5): the video keeps its original bitrate and the // first audio packet arrives after several MB of video, well beyond a // 1 MB probe ("Could not find codec parameters for stream 1 ... 0 channels"). // Opening and seek must find the audio, never stay on "Seeking...". constexpr const char kDirectStream[] = R"({"video_kbps": 12000, "late_audio": 3.0})"; void test_direct_stream_late_audio_open() { reset_server(); configure(kDirectStream); Rig r; CHECK(r.open(true)); CHECK(r.player.has_audio()); // probe wide enough to find the audio CHECK(r.run_until([&] { return r.sink.frames >= 50 && r.player.stats().audio_frames_decoded >= 20; })); CHECK(!r.player.is_buffering()); } void test_direct_stream_late_audio_seek() { reset_server(); configure(R"({"video_kbps": 12000})"); Rig r; CHECK(r.open(true)); CHECK(r.player.has_audio()); CHECK(r.run_until([&] { return r.sink.frames >= 10; })); // Recoveries after a seek reopen the stream: late audio this time. configure(kDirectStream); const uint64_t audio_before = r.player.stats().audio_frames_decoded; r.sink.mark(); r.player.seek_to(20000); CHECK(r.run_until( [&] { return r.sink.frames_since_mark >= 50 && !r.player.seek_in_flight() && !r.player.is_buffering() && r.player.stats().audio_frames_decoded >= audio_before + 20; }, 25000)); CHECK(r.player.state() == player::PlayerState::Playing); // The fake stream also shifts the audio PTS (+3 s): the clock, set by // the first audio packet, makes the first ~3 seconds of // video be dropped. The content must stay that of the requested position, up to this // offset. CHECK(r.sink.first_luma_since_mark >= expected_luma(19900) && r.sink.first_luma_since_mark <= expected_luma(20000 + 6000)); const int64_t pos = r.player.position_ms(); CHECK(pos >= 19900 && pos < 30000); } // Probe too short (1 MB, audio at 6 MB) on a reopening: the packets of // the audio track without parameters are kept with the first stream's decoder // instead of being dropped (before: the audio never arrived, endless "Seeking..."). // The player's clock fallback and the give-up delay are also tested // in test_player. void test_direct_stream_unresolved_audio_after_seek() { reset_server(); configure(R"({"video_kbps": 12000})"); g_probe_bytes = 1000000; Rig r; g_probe_bytes = 0; CHECK(r.open(true)); CHECK(r.player.has_audio()); // audio near the start: found even with 1 MB CHECK(r.run_until([&] { return r.sink.frames >= 10; })); configure(kDirectStream); const uint64_t audio_before = r.player.stats().audio_frames_decoded; r.sink.mark(); r.player.seek_to(20000); CHECK(r.run_until( [&] { return r.sink.frames_since_mark >= 50 && !r.player.seek_in_flight() && r.player.stats().audio_frames_decoded >= audio_before + 20; }, 25000)); CHECK(r.player.state() == player::PlayerState::Playing); CHECK(r.sink.first_luma_since_mark >= expected_luma(19900)); } // Audio not found from the opening: clear message (log), playback of the // video without sound, no infinite wait. void test_direct_stream_unresolved_audio_at_open() { reset_server(); configure(kDirectStream); g_probe_bytes = 1000000; Rig r; g_probe_bytes = 0; CHECK(r.open(true)); CHECK(!r.player.has_audio()); CHECK(r.player.has_video()); CHECK(r.run_until([&] { return r.sink.frames >= 50; })); CHECK(!r.player.is_buffering()); CHECK(r.player.state() == player::PlayerState::Playing); } // Playback reports driven as in pelagia-play (monitor + sending // thread), on a playback rig. struct Reporting { player::PlaybackMonitor monitor{1000}; // progress every simulated second api::PlaybackReporter reporter; std::vector out; Reporting() : reporter(*g_env->client, g_env->reporting, kItem, g_env->item.media_source_id) { reporter.start(); } player::PlaybackObservation observe(Rig* r) { player::PlaybackObservation obs; obs.now_ms = r->time.now; obs.position_ms = r->player.position_ms(); obs.paused = r->player.state() == player::PlayerState::Paused; obs.seek_in_flight = r->player.seek_in_flight(); obs.session_id = r->stream.source->session_id(); return obs; } void flush() { for (const player::PlaybackReport& report : out) { reporter.submit(report); } out.clear(); } template bool run_until(Rig* r, Pred pred, int max_real_ms = 15000) { return r->run_until( [&] { monitor.update(observe(r), &out); flush(); return pred(); }, max_real_ms); } }; bool body_has(const std::string& body, const std::string& needle) { return body.find(needle) != std::string::npos; } long long body_position_ms(const std::string& body) { const size_t at = body.find("\"PositionTicks\":"); return at == std::string::npos ? -1 : std::atoll(body.c_str() + at + 16) / kTicksPerMs; } // Start, progress, pause/resume, seek, stop: sequence received by the // server; then the saved position allows resuming at the right place. void test_reporting_and_resume() { reset_server(); CHECK(g_env->server.post("/__test/user_data", R"({"PlaybackPositionTicks": 0, "Played": false})")); int64_t stopped_at = 0; { Rig r; Reporting rep; CHECK(r.open()); CHECK(rep.run_until(&r, [&] { return r.sink.frames >= 60; })); // > 2 simulated s r.player.set_paused(true); CHECK(rep.run_until(&r, [&] { return true; })); r.player.set_paused(false); r.sink.mark(); r.player.seek_to(20000); CHECK(rep.run_until(&r, [&] { return r.sink.frames_since_mark >= 10; })); const std::string session_after_seek = r.stream.source->session_id(); rep.monitor.stop(rep.observe(&r), &rep.out); stopped_at = rep.out.empty() ? 0 : rep.out.back().position_ms; rep.flush(); r.player.stop(); CHECK(rep.reporter.finish(3000)); CHECK_EQ(rep.reporter.failed_count(), 0); const testnet::ServerState st = server_state(); CHECK(st.report_kinds.size() >= 5); if (st.report_kinds.size() >= 5) { CHECK(st.report_kinds.front() == "start"); CHECK(st.report_kinds.back() == "stopped"); bool paused_seen = false; bool unpaused_after_pause = false; bool seek_reported = false; for (size_t i = 0; i < st.report_kinds.size(); ++i) { const std::string& body = st.report_bodies[i]; CHECK(body_has(body, std::string("\"ItemId\":\"") + kItem + "\"")); if (st.report_kinds[i] != "progress") { continue; } CHECK(body_has(body, "\"PlayMethod\":\"Transcode\"")); if (body_has(body, "\"IsPaused\":true")) { paused_seen = true; } else if (paused_seen) { unpaused_after_pause = true; } const long long pos = body_position_ms(body); if (pos >= 20000 && pos < 22000 && body_has(body, "\"PlaySessionId\":\"" + session_after_seek + "\"")) { seek_reported = true; // attached to the job of the new stream } } CHECK(paused_seen); CHECK(unpaused_after_pause); CHECK(seek_reported); CHECK(std::llabs(body_position_ms(st.report_bodies.back()) - stopped_at) <= 1); } // Resume position saved (between 5 and 90% of the duration). CHECK(std::llabs(st.resume_position_ticks / kTicksPerMs - stopped_at) <= 1); } // --resume: the resume read from the server opens the stream at the right place. api::MediaItem item; CHECK(g_env->client->fetch_item(kItem, &item).ok()); const int64_t resume_ms = item.playback_position_ticks / kTicksPerMs; CHECK(std::llabs(resume_ms - stopped_at) <= 1); Rig r(api::StreamAuthMode::Both, resume_ms); CHECK(r.open()); CHECK_EQ(r.player.position_ms(), resume_ms); // before the first frame CHECK(r.run_until([&] { return r.sink.frames >= 5; })); CHECK(std::fabs(r.sink.first_luma_since_mark - expected_luma(resume_ms)) < 6); const testnet::ServerState st = server_state(); CHECK(!st.requests.empty() && st.requests.back().start_ticks == resume_ms * kTicksPerMs); } // Redirects the process's stderr to a file for the duration of a scenario: everything // that is written (util/log, ffmpeg, other) is captured, not only what // goes through the logging module. class StderrCapture { public: explicit StderrCapture(const std::string& path) : path_(path) { std::fflush(stderr); saved_ = dup(STDERR_FILENO); const int fd = open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDERR_FILENO); close(fd); } std::string finish() { std::fflush(stderr); dup2(saved_, STDERR_FILENO); close(saved_); return testnet::read_file(path_); } private: std::string path_; int saved_ = -1; }; bool contains(const std::string& haystack, const std::string& needle) { return haystack.find(needle) != std::string::npos; } // The token appears in no output, even with ffmpeg's most // detailed logs (full HTTP requests), in a scenario that opens, // seeks, drops, resumes and reports. void test_token_never_logged() { reset_server(); const std::string token = g_env->client->session().access_token; CHECK_EQ(token.size(), 32u); util::log_set_level(util::LogLevel::Debug); player::install_ffmpeg_log_bridge(true); av_log_set_level(AV_LOG_TRACE); StderrCapture capture(testnet::workdir() + "/token_scenario.log"); { Rig r; r.recovery = fast_recovery(); Reporting rep; CHECK(r.open(true)); CHECK(rep.run_until(&r, [&] { return r.sink.frames >= 15; })); r.sink.mark(); r.player.seek_to(15000); CHECK(rep.run_until(&r, [&] { return r.sink.frames_since_mark >= 5; })); CHECK(g_env->server.post("/__test/fault", R"({"drop": true})")); CHECK(rep.run_until(&r, [&] { return r.player.stats().reconnections >= 1 && r.sink.frames_since_mark >= 15; })); rep.monitor.stop(rep.observe(&r), &rep.out); rep.flush(); r.player.stop(); rep.reporter.finish(3000); } const std::string output = capture.finish(); util::log_set_level(std::getenv("PELAGIA_TEST_VERBOSE") ? util::LogLevel::Debug : util::LogLevel::Warn); av_log_set_level(AV_LOG_WARNING); CHECK(output.size() > 10000); // the detailed logs were indeed produced CHECK(!contains(output, token)); CHECK(!contains(output, token.substr(0, 12))); // Positive control: the URL and the authentication header went through the // logs (otherwise the test would prove nothing), masked. CHECK(contains(output, "api_key=********")); CHECK(contains(output, "Token=\"********\"")); CHECK(contains(output, "[http @")); } // Le vrai binaire : pelagia-play --item --resume --verbose (SDL factice), // resume near the end, reports, clean exit, no token in its output. void test_cli_item_resume() { reset_server(); CHECK(g_env->server.post("/__test/user_data", R"({"PlaybackPositionTicks": 360000000, "Played": false})")); setenv("JELLYFIN_URL", g_env->server.base_url().c_str(), 1); setenv("JELLYFIN_USER", "test", 1); setenv("JELLYFIN_PASS", "test", 1); setenv("SDL_VIDEODRIVER", "dummy", 1); setenv("SDL_AUDIODRIVER", "dummy", 1); const std::string log_path = testnet::workdir() + "/cli_item.log"; const int code = testnet::run_process( {PELAGIA_PLAY_BIN, "--item", kItem, "--resume", "--verbose"}, log_path, 30000); const std::string output = testnet::read_file(log_path); CHECK_EQ(code, 0); CHECK(contains(output, "Resume at 0:00:36")); CHECK(contains(output, "End of playback")); CHECK(!contains(output, g_env->client->session().access_token)); // Default: header only. The token is in no URL (even masked); it // only appears in the header of ffmpeg's HTTP request (debug), masked. CHECK(contains(output, "stream authentication: header")); CHECK(!contains(output, "api_key")); CHECK(contains(output, "Token=\"********\"")); const testnet::ServerState st = server_state(); CHECK(!st.requests.empty() && st.requests.front().auth == "header"); CHECK(!st.requests.empty() && st.requests.front().start_ticks == 36000 * kTicksPerMs); CHECK(!st.report_kinds.empty() && st.report_kinds.front() == "start"); CHECK(!st.report_kinds.empty() && st.report_kinds.back() == "stopped"); CHECK(st.played); // stopped at the end: marked "played", resume cleared CHECK_EQ(st.resume_position_ticks, 0); CHECK_EQ(st.active_jobs, 0); if (code != 0) { std::fprintf(stderr, "--- sortie de pelagia-play ---\n%s\n", output.c_str()); } } // The three stream authentication modes remain available through the CLI // (the default, header, is checked by test_cli_item_resume). void test_cli_stream_auth_modes() { reset_server(); configure(R"({"startup_delay": 0.2})"); // three full playbacks: minimal latency setenv("JELLYFIN_URL", g_env->server.base_url().c_str(), 1); setenv("JELLYFIN_USER", "test", 1); setenv("JELLYFIN_PASS", "test", 1); const std::string log_path = testnet::workdir() + "/cli_auth.log"; for (const char* mode : {"query", "both", "header"}) { CHECK_EQ(testnet::run_process({PELAGIA_PLAY_BIN, "--headless", "--item", kItem, "--stream-auth", mode}, log_path, 30000), 0); } const testnet::ServerState st = server_state(); CHECK_EQ(st.requests.size(), 3u); if (st.requests.size() == 3) { CHECK(st.requests[0].auth == "query"); CHECK(st.requests[1].auth == "query+header"); CHECK(st.requests[2].auth == "header"); } } // pelagia-cli: the identifier of each movie and series is displayed (value of // pelagia-play --item), --episodes lists the episodes with theirs. void test_cli_lists_ids() { reset_server(); setenv("JELLYFIN_URL", g_env->server.base_url().c_str(), 1); setenv("JELLYFIN_USER", "test", 1); setenv("JELLYFIN_PASS", "test", 1); const std::string token = g_env->client->session().access_token; const std::string log_path = testnet::workdir() + "/cli_ids.log"; const std::string series = "f00dfeed0000000000000000000000cc"; CHECK_EQ(testnet::run_process({PELAGIA_CLI_BIN}, log_path, 20000), 0); std::string out = testnet::read_file(log_path); CHECK(contains(out, "Test pattern (2026) [" + std::string(kItem) + "]")); CHECK(contains(out, "Series (1):")); CHECK(contains(out, "Test series (2026) [" + series + "]")); CHECK(contains(out, "pelagia-play --item ")); CHECK(contains(out, "pelagia-cli --episodes ")); CHECK(!contains(out, token)); CHECK(!contains(out, "api_key")); // the example URL does not carry the token CHECK_EQ(testnet::run_process({PELAGIA_CLI_BIN, "--episodes", series}, log_path, 20000), 0); out = testnet::read_file(log_path); CHECK(contains(out, "Episodes (2):")); CHECK(contains(out, "S01E01 Episode 1")); CHECK(contains(out, "S01E02 Episode 2")); CHECK(contains(out, "f00dfeed0000000000000000000000e1")); CHECK(contains(out, "f00dfeed0000000000000000000000e2")); // Errors: unknown series (server) -> 1; incorrect arguments -> 2; help -> 0. CHECK_EQ(testnet::run_process({PELAGIA_CLI_BIN, "--episodes", "inconnue"}, log_path, 20000), 1); CHECK_EQ(testnet::run_process({PELAGIA_CLI_BIN, "--episodes"}, log_path, 20000), 2); CHECK_EQ(testnet::run_process({PELAGIA_CLI_BIN, "--bogus"}, log_path, 20000), 2); CHECK_EQ(testnet::run_process({PELAGIA_CLI_BIN, "--bogus", "--help"}, log_path, 20000), 0); CHECK(contains(testnet::read_file(log_path), "--episodes ")); // pelagia-play --item on a series: message that points to the episodes. setenv("SDL_VIDEODRIVER", "dummy", 1); setenv("SDL_AUDIODRIVER", "dummy", 1); CHECK_EQ(testnet::run_process({PELAGIA_PLAY_BIN, "--headless", "--item", series}, log_path, 20000), 1); out = testnet::read_file(log_path); CHECK(contains(out, "is not a playable media")); CHECK(contains(out, "pelagia-cli --episodes " + series)); } } // namespace // PELAGIA_TEST_ONLY=: only runs that scenario (debugging). #define RUN(test) \ do { \ const char* only = std::getenv("PELAGIA_TEST_ONLY"); \ if (!only || std::strcmp(only, #test) == 0) { \ const int64_t t0 = real_ms(); \ test(); \ std::fprintf(stderr, " %-46s %5.1f s\n", #test, \ (real_ms() - t0) / 1000.0); \ } \ } while (0) int main() { util::log_set_level(std::getenv("PELAGIA_TEST_VERBOSE") ? util::LogLevel::Debug : util::LogLevel::Warn); std::string media; if (!testnet::ensure_test_media(&media)) { std::fprintf(stderr, "python3/ffmpeg unavailable: test skipped\n"); return testnet::kSkip; } Env env; g_env = &env; if (!env.server.start(media, {"--min-resume-duration", "0"})) { CHECK(!"fake server start"); return testfw::test_failures(); } env.control.set_timeouts(3, 5); env.reporting.set_timeouts(3, 5); env.client.reset(new api::JellyfinClient(env.transport, env.server.base_url(), "pelagia-test")); CHECK(env.client->authenticate("test", "test").ok()); CHECK(env.client->fetch_item(kItem, &env.item).ok()); RUN(test_play_from_start); RUN(test_seek_forward_and_backward); RUN(test_seek_burst_only_last_counts); RUN(test_stop_during_seek); RUN(test_abort_initial_open); RUN(test_stream_auth_modes); RUN(test_absolute_pts_after_seek); RUN(test_startup_buffering); RUN(test_stall_rebuffers_and_resumes); RUN(test_stop_while_buffering); RUN(test_drop_reconnects_at_position); RUN(test_outage_then_recovery); RUN(test_truncated_stream_is_a_cut); RUN(test_stall_beyond_read_timeout); RUN(test_permanent_outage_fails_then_seek_retries); RUN(test_natural_end_is_not_a_cut); RUN(test_direct_stream_late_audio_open); RUN(test_direct_stream_late_audio_seek); RUN(test_direct_stream_unresolved_audio_after_seek); RUN(test_direct_stream_unresolved_audio_at_open); RUN(test_reporting_and_resume); RUN(test_token_never_logged); RUN(test_cli_item_resume); RUN(test_cli_stream_auth_modes); RUN(test_cli_lists_ids); return testfw::test_failures(); }