mirror of
https://github.com/LowganDev/PlayfinPS4.git
synced 2026-10-18 09:01:00 +02:00
206 lines
9.1 KiB
C++
206 lines
9.1 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "jellyfin/models.hpp"
|
|
#include "player/seek.hpp"
|
|
#include "player/session.hpp"
|
|
#include "player/tracks.hpp"
|
|
#include "support/files.hpp"
|
|
|
|
using namespace playfin;
|
|
using test::readFixture;
|
|
|
|
namespace {
|
|
|
|
jellyfin::Session server() {
|
|
jellyfin::Session s;
|
|
s.baseUrl = "https://h:8920";
|
|
s.token = "tok-demo";
|
|
return s;
|
|
}
|
|
|
|
jellyfin::PlaybackInfo fixture(const char* name) {
|
|
return jellyfin::parsePlaybackInfo(readFixture(std::string("jellyfin/") + name)).value();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("stream choice: direct play of the first source") {
|
|
const auto choice = player::chooseStream(server(), "e-1", fixture("playbackinfo_direct.json"));
|
|
REQUIRE(choice.ok());
|
|
CHECK_FALSE(choice.value().transcoding);
|
|
CHECK(choice.value().url ==
|
|
"https://h:8920/Videos/e-1/stream?static=true&mediaSourceId=ms-1&playSessionId=ps-direct-1");
|
|
CHECK(choice.value().mediaSourceId == "ms-1");
|
|
REQUIRE(choice.value().externalSubtitles.size() == 1);
|
|
CHECK(choice.value().externalSubtitles[0].index == 4);
|
|
CHECK(choice.value().externalSubtitles[0].url == "https://h:8920/Videos/e-1/ms-1/Subtitles/4/0/Stream.srt");
|
|
}
|
|
|
|
TEST_CASE("stream choice: a transcode keeps the server's URL without its api key") {
|
|
const auto choice = player::chooseStream(server(), "m-transcode", fixture("playbackinfo_transcode.json"));
|
|
REQUIRE(choice.ok());
|
|
CHECK(choice.value().transcoding);
|
|
CHECK(choice.value().url.rfind("https://h:8920/videos/m-transcode/master.m3u8?", 0) == 0);
|
|
CHECK(choice.value().url.find("tok-demo") == std::string::npos);
|
|
CHECK(choice.value().url.find("api_key") == std::string::npos);
|
|
CHECK(choice.value().playSessionId == "ps-tc-1");
|
|
}
|
|
|
|
TEST_CASE("stream choice: nothing playable is an error") {
|
|
jellyfin::PlaybackInfo none;
|
|
CHECK_FALSE(player::chooseStream(server(), "x", none).ok());
|
|
jellyfin::PlaybackInfo refused = fixture("playbackinfo_direct.json");
|
|
refused.errorCode = "NoCompatibleStream";
|
|
CHECK_FALSE(player::chooseStream(server(), "x", refused).ok());
|
|
jellyfin::PlaybackInfo stuck = fixture("playbackinfo_direct.json");
|
|
stuck.sources[0].supportsDirectPlay = false;
|
|
stuck.sources[0].transcodingUrl.clear();
|
|
CHECK_FALSE(player::chooseStream(server(), "x", stuck).ok());
|
|
}
|
|
|
|
TEST_CASE("seeking: a burst of presses is one seek, 300 ms after the last") {
|
|
player::SeekAccumulator seek;
|
|
for (int i = 0; i < 5; ++i) {
|
|
seek.press(10, 1000 + i * 100, 100, 3600);
|
|
}
|
|
CHECK(seek.pending());
|
|
CHECK(seek.target() == doctest::Approx(150));
|
|
CHECK_FALSE(seek.due(1600).has_value()); // 200 ms after the last press
|
|
const auto target = seek.due(1700);
|
|
REQUIRE(target.has_value());
|
|
CHECK(*target == doctest::Approx(150));
|
|
CHECK_FALSE(seek.pending());
|
|
CHECK_FALSE(seek.due(5000).has_value());
|
|
}
|
|
|
|
TEST_CASE("seeking stays inside the video") {
|
|
player::SeekAccumulator seek;
|
|
seek.press(60, 0, 3580, 3600);
|
|
CHECK(seek.target() == doctest::Approx(3599));
|
|
player::SeekAccumulator back;
|
|
back.press(-10, 0, 4, 3600);
|
|
CHECK(back.target() == doctest::Approx(0));
|
|
CHECK(player::seekStep(500) == doctest::Approx(10));
|
|
CHECK(player::seekStep(2500) == doctest::Approx(30));
|
|
}
|
|
|
|
TEST_CASE("reporting: start once, progress every 10 s and on events, stop once") {
|
|
std::vector<player::ReportKind> sent;
|
|
player::Reporter reporter([&sent](player::ReportKind kind, const jellyfin::PlaybackState&) { sent.push_back(kind); });
|
|
jellyfin::PlaybackState state;
|
|
reporter.tick(0, state); // before the first frame: nothing
|
|
reporter.stop(state); // left before playing: nothing
|
|
CHECK(sent.empty());
|
|
player::Reporter playing([&sent](player::ReportKind kind, const jellyfin::PlaybackState&) { sent.push_back(kind); });
|
|
playing.onFirstFrame(0, state);
|
|
playing.tick(5000, state);
|
|
playing.tick(10000, state);
|
|
playing.tick(15000, state);
|
|
playing.onPause(16000, state);
|
|
playing.tick(25000, state);
|
|
playing.tick(26000, state);
|
|
playing.stop(state);
|
|
playing.stop(state);
|
|
using K = player::ReportKind;
|
|
CHECK(sent == std::vector<K>{K::Start, K::Progress, K::Progress, K::Progress, K::Stopped});
|
|
}
|
|
|
|
TEST_CASE("segment buttons: intro, credits and next episode") {
|
|
std::vector<jellyfin::MediaSegment> segments = {{jellyfin::SegmentType::Intro, 0, 600000000},
|
|
{jellyfin::SegmentType::Outro, 24000000000, 25500000000}};
|
|
using B = player::SegmentButton;
|
|
player::SegmentButton intro = player::buttonAt(30, 2600, segments, false);
|
|
CHECK(intro.kind == B::SkipIntro);
|
|
CHECK(intro.seekTo == doctest::Approx(60));
|
|
CHECK(player::buttonAt(100, 2600, segments, true).kind == B::None);
|
|
player::SegmentButton outro = player::buttonAt(2450, 2600, segments, true);
|
|
CHECK(outro.kind == B::SkipOutro);
|
|
CHECK(outro.seekTo == doctest::Approx(2550));
|
|
CHECK(player::buttonAt(2580, 2600, segments, true).kind == B::NextEpisode);
|
|
CHECK(player::buttonAt(2580, 2600, segments, false).kind == B::None);
|
|
CHECK(player::buttonAt(2580, 2600, {}, true).kind == B::NextEpisode);
|
|
}
|
|
|
|
TEST_CASE("segment buttons leave after 8 s on screen") {
|
|
player::ButtonTimer timer;
|
|
const player::SegmentButton intro{player::SegmentButton::SkipIntro, 60};
|
|
CHECK(timer.visible(intro, 1000).kind == player::SegmentButton::SkipIntro);
|
|
CHECK(timer.visible(intro, 8999).kind == player::SegmentButton::SkipIntro);
|
|
CHECK(timer.visible(intro, 9001).kind == player::SegmentButton::None);
|
|
const player::SegmentButton next{player::SegmentButton::NextEpisode, 0};
|
|
CHECK(timer.visible(next, 9500).kind == player::SegmentButton::NextEpisode); // a new button starts again
|
|
}
|
|
|
|
TEST_CASE("tracks: labels, mpv ids and the current one") {
|
|
const jellyfin::MediaSource source = fixture("playbackinfo_direct.json").sources[0];
|
|
const auto audio = player::audioTracks(source, 2);
|
|
REQUIRE(audio.size() == 2);
|
|
CHECK(audio[0].jellyfinIndex == 1);
|
|
CHECK(audio[0].mpvId == 1);
|
|
CHECK(audio[0].label == "Français - Dolby Digital+ - 5.1 - Par défaut");
|
|
CHECK(audio[1].mpvId == 2);
|
|
CHECK(audio[1].current);
|
|
const auto subtitles = player::subtitleTracks(source, -1, "Aucun");
|
|
REQUIRE(subtitles.size() == 4);
|
|
CHECK(subtitles[0].label == "Aucun");
|
|
CHECK(subtitles[0].current);
|
|
CHECK(subtitles[0].mpvId == 0);
|
|
CHECK(subtitles[1].jellyfinIndex == 3);
|
|
CHECK(subtitles[1].mpvId == 1);
|
|
CHECK(subtitles[2].jellyfinIndex == 4); // external: added after the two embedded ones
|
|
CHECK(subtitles[2].external);
|
|
CHECK(subtitles[2].mpvId == 3);
|
|
CHECK(subtitles[3].jellyfinIndex == 5);
|
|
CHECK(subtitles[3].mpvId == 2);
|
|
}
|
|
|
|
TEST_CASE("a transcode restarts for audio and burned-in subtitles") {
|
|
jellyfin::MediaStream audio;
|
|
audio.type = jellyfin::StreamType::Audio;
|
|
CHECK(player::needsRestart(true, audio));
|
|
CHECK_FALSE(player::needsRestart(false, audio));
|
|
jellyfin::MediaStream burned;
|
|
burned.type = jellyfin::StreamType::Subtitle;
|
|
burned.deliveryMethod = "Encode";
|
|
CHECK(player::needsRestart(false, burned));
|
|
jellyfin::MediaStream text;
|
|
text.type = jellyfin::StreamType::Subtitle;
|
|
text.deliveryMethod = "External";
|
|
CHECK_FALSE(player::needsRestart(true, text));
|
|
}
|
|
|
|
TEST_CASE("streams and subtitles outside the server's own address are refused (the token would follow them)") {
|
|
jellyfin::PlaybackInfo info = fixture("playbackinfo_transcode.json");
|
|
info.sources[0].transcodingUrl = "https://elsewhere.example/videos/x/master.m3u8";
|
|
CHECK_FALSE(player::chooseStream(server(), "m-transcode", info).ok());
|
|
jellyfin::PlaybackInfo direct = fixture("playbackinfo_direct.json");
|
|
direct.sources[0].streams[4].deliveryUrl = "https://elsewhere.example/sub.srt";
|
|
const auto choice = player::chooseStream(server(), "e-1", direct);
|
|
REQUIRE(choice.ok());
|
|
CHECK(choice.value().externalSubtitles.empty());
|
|
jellyfin::PlaybackInfo same = fixture("playbackinfo_transcode.json");
|
|
same.sources[0].transcodingUrl = "https://h:8920/videos/x/master.m3u8?a=1";
|
|
CHECK(player::chooseStream(server(), "m-transcode", same).ok());
|
|
}
|
|
|
|
TEST_CASE("a music track plays directly from the audio endpoint") {
|
|
const auto choice = player::chooseStream(server(), "8ae260f682d60f5d17b5aafe1a7c8dd2",
|
|
fixture("ts/playbackinfo_audio.json"), player::MediaKind::Audio);
|
|
REQUIRE(choice.ok());
|
|
CHECK(choice.value().url.find("/Audio/8ae260f682d60f5d17b5aafe1a7c8dd2/stream?static=true") != std::string::npos);
|
|
CHECK_FALSE(choice.value().transcoding);
|
|
}
|
|
|
|
TEST_CASE("a live channel plays through the server's relay and keeps its live stream id") {
|
|
const auto choice = player::chooseStream(server(), "79cf8fe44f3f38fbe05265ad38d3f2aa",
|
|
fixture("ts/playbackinfo_live.json"));
|
|
REQUIRE(choice.ok());
|
|
CHECK(choice.value().transcoding);
|
|
CHECK(choice.value().url.find("/videos/79cf8fe4-4f3f-38fb-e052-65ad38d3f2aa/master.m3u8") != std::string::npos);
|
|
CHECK(choice.value().url.find("ApiKey") == std::string::npos);
|
|
CHECK(choice.value().liveStreamId.rfind("e2329f4997b378e64ccf8fa396deb76e_", 0) == 0);
|
|
}
|