mirror of
https://github.com/LowganDev/PlayfinPS4.git
synced 2026-10-18 08:01:05 +02:00
408 lines
17 KiB
C++
408 lines
17 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <string>
|
|
|
|
#include "core/version.hpp"
|
|
#include "jellyfin/client.hpp"
|
|
#include "net/crypto.hpp"
|
|
#include "support/fake_jellyfin.hpp"
|
|
|
|
using namespace playfin;
|
|
using core::ErrorKind;
|
|
using jellyfin::Client;
|
|
using jellyfin::ImageType;
|
|
using jellyfin::ItemQuery;
|
|
using jellyfin::Session;
|
|
using test::FakeJellyfin;
|
|
using test::TlsMode;
|
|
|
|
namespace {
|
|
|
|
net::HttpClient http() {
|
|
net::HttpConfig config;
|
|
config.caFile = test::fixturePath("tls/ca.crt");
|
|
return net::HttpClient(config);
|
|
}
|
|
|
|
Session session(const std::string& baseUrl) {
|
|
Session s;
|
|
s.baseUrl = baseUrl;
|
|
s.deviceId = "dev-1";
|
|
s.deviceName = "Linux";
|
|
return s;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("authorization header") {
|
|
Session s = session("http://x");
|
|
s.deviceName = "PlayStation 4";
|
|
s.deviceId = "d1";
|
|
const std::string base = "MediaBrowser Client=\"Playfin\", Device=\"PlayStation 4\", DeviceId=\"d1\", Version=\"" +
|
|
std::string(core::version()) + "\"";
|
|
CHECK(jellyfin::authorizationHeader(s) == base);
|
|
s.token = "t\"x,y\n";
|
|
CHECK(jellyfin::authorizationHeader(s) == base + ", Token=\"txy\"");
|
|
}
|
|
|
|
TEST_CASE("connect finds the server and returns the working address") {
|
|
FakeJellyfin server;
|
|
const auto client = http();
|
|
const auto connection = jellyfin::connect(client, server.baseUrl(), "");
|
|
REQUIRE(connection.ok());
|
|
CHECK(connection.value().baseUrl == server.baseUrl());
|
|
CHECK(connection.value().info.name == "Test Server");
|
|
}
|
|
|
|
TEST_CASE("connect without a scheme falls back to http") {
|
|
FakeJellyfin server;
|
|
const auto client = http();
|
|
const std::string hostPort = server.baseUrl().substr(std::string("http://").size());
|
|
const auto connection = jellyfin::connect(client, hostPort, "");
|
|
REQUIRE(connection.ok());
|
|
CHECK(connection.value().baseUrl == server.baseUrl());
|
|
}
|
|
|
|
TEST_CASE("connect refuses servers older than 10.10") {
|
|
FakeJellyfin server(TlsMode::None, "10.9.11");
|
|
const auto client = http();
|
|
const auto connection = jellyfin::connect(client, server.baseUrl(), "");
|
|
REQUIRE_FALSE(connection.ok());
|
|
CHECK(connection.error().kind == ErrorKind::UnsupportedServer);
|
|
CHECK(connection.error().message.find("10.9.11") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("a self-signed server asks for trust, then works once pinned") {
|
|
FakeJellyfin server(TlsMode::SelfSigned);
|
|
const auto client = http();
|
|
const auto first = jellyfin::connect(client, server.baseUrl(), "");
|
|
REQUIRE_FALSE(first.ok());
|
|
REQUIRE(first.error().kind == ErrorKind::CertificateUntrusted);
|
|
const std::string pin = net::curlPin(first.error().certificate.spkiSha256);
|
|
const auto second = jellyfin::connect(client, server.baseUrl(), pin);
|
|
REQUIRE(second.ok());
|
|
CHECK(second.value().info.id == "f1e2d3c4b5a6");
|
|
}
|
|
|
|
TEST_CASE("an untrusted certificate on one address does not hide Jellyfin on the next one") {
|
|
test::FakeServer router(TlsMode::SelfSigned);
|
|
router.start();
|
|
FakeJellyfin server;
|
|
const auto client = http();
|
|
const auto connection = jellyfin::connectCandidates(client, {router.baseUrl(), server.baseUrl()}, "");
|
|
REQUIRE(connection.ok());
|
|
CHECK(connection.value().baseUrl == server.baseUrl());
|
|
const auto onlyRouter = jellyfin::connectCandidates(client, {router.baseUrl(), "http://127.0.0.1:1"}, "");
|
|
REQUIRE_FALSE(onlyRouter.ok());
|
|
CHECK(onlyRouter.error().kind == ErrorKind::CertificateUntrusted);
|
|
}
|
|
|
|
TEST_CASE("bad addresses and silent hosts") {
|
|
const auto client = http();
|
|
const auto invalid = jellyfin::connect(client, "not an address", "");
|
|
REQUIRE_FALSE(invalid.ok());
|
|
CHECK(invalid.error().kind == ErrorKind::InvalidAddress);
|
|
const auto refused = jellyfin::connect(client, "127.0.0.1:1", "");
|
|
REQUIRE_FALSE(refused.ok());
|
|
CHECK(refused.error().kind == ErrorKind::Network);
|
|
}
|
|
|
|
TEST_CASE("a web server that is not Jellyfin is reported as such") {
|
|
test::FakeServer other;
|
|
other.http().Get("/System/Info/Public", [](const httplib::Request&, httplib::Response& res) {
|
|
res.set_content("<html>hello</html>", "text/html");
|
|
});
|
|
other.start();
|
|
const auto client = http();
|
|
const auto connection = jellyfin::connect(client, other.baseUrl(), "");
|
|
REQUIRE_FALSE(connection.ok());
|
|
CHECK(connection.error().kind == ErrorKind::BadResponse);
|
|
}
|
|
|
|
TEST_CASE("sign in with a password") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
const auto wrong = client.signIn("demo", "nope");
|
|
REQUIRE_FALSE(wrong.ok());
|
|
CHECK(wrong.error().kind == ErrorKind::Unauthorized);
|
|
const auto auth = client.signIn("demo", "secret");
|
|
REQUIRE(auth.ok());
|
|
CHECK(auth.value().token == "tok-demo");
|
|
CHECK(auth.value().user.id == "u-demo");
|
|
CHECK(server.last().authorization.find("Token=") == std::string::npos);
|
|
CHECK(server.last().body.find(R"("Pw":"secret")") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Quick Connect flow") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
REQUIRE(client.quickConnectEnabled().ok());
|
|
CHECK(client.quickConnectEnabled().value());
|
|
const auto started = client.quickConnectStart();
|
|
REQUIRE(started.ok());
|
|
CHECK(started.value().code == "123456");
|
|
const auto waiting = client.quickConnectPoll(started.value().secret);
|
|
REQUIRE(waiting.ok());
|
|
CHECK_FALSE(waiting.value().authenticated);
|
|
CHECK_FALSE(client.quickConnectFinish(started.value().secret).ok());
|
|
server.approveQuickConnect();
|
|
REQUIRE(client.quickConnectPoll(started.value().secret).value().authenticated);
|
|
const auto auth = client.quickConnectFinish(started.value().secret);
|
|
REQUIRE(auth.ok());
|
|
CHECK(auth.value().token == "tok-demo");
|
|
}
|
|
|
|
TEST_CASE("authenticated calls send the token in the header, never in the URL") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("tok-demo", "u-demo");
|
|
const auto libraries = client.libraries();
|
|
REQUIRE(libraries.ok());
|
|
CHECK(libraries.value().items.size() == 2);
|
|
const auto seen = server.last();
|
|
CHECK(seen.authorization.find("Token=\"tok-demo\"") != std::string::npos);
|
|
CHECK(seen.target.find("tok-demo") == std::string::npos);
|
|
CHECK(seen.target.find("userId=u-demo") != std::string::npos);
|
|
REQUIRE(client.currentUser().ok());
|
|
CHECK(client.currentUser().value().name == "demo");
|
|
}
|
|
|
|
TEST_CASE("a refused token is Unauthorized") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("bad", "u-demo");
|
|
const auto libraries = client.libraries();
|
|
REQUIRE_FALSE(libraries.ok());
|
|
CHECK(libraries.error().kind == ErrorKind::Unauthorized);
|
|
CHECK(libraries.error().httpStatus == 401);
|
|
}
|
|
|
|
TEST_CASE("browsing endpoints") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("tok-demo", "u-demo");
|
|
const auto latest = client.latest("lib-movies", 16);
|
|
REQUIRE(latest.ok());
|
|
CHECK(latest.value().items.size() == 2);
|
|
CHECK(server.last().target.find("parentId=lib-movies") != std::string::npos);
|
|
CHECK(server.last().target.find("limit=16") != std::string::npos);
|
|
CHECK(client.resume(12).value().items.size() == 1);
|
|
CHECK(server.last().target.find("mediaTypes=Video") != std::string::npos);
|
|
CHECK(client.nextUp(12).value().items.size() == 1);
|
|
const auto movie = client.item("m-1");
|
|
REQUIRE(movie.ok());
|
|
CHECK(movie.value().userData.favorite);
|
|
const auto missing = client.item("missing");
|
|
REQUIRE_FALSE(missing.ok());
|
|
CHECK(missing.error().kind == ErrorKind::NotFound);
|
|
CHECK(client.seasons("s-1").value().items.size() == 2);
|
|
CHECK(client.episodes("s-1", "se-1").value().items.size() == 2);
|
|
CHECK(server.last().target.find("seasonId=se-1") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("library items are paged and searchable") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("tok-demo", "u-demo");
|
|
ItemQuery query;
|
|
query.parentId = "lib-movies";
|
|
query.includeTypes = {"Movie"};
|
|
query.startIndex = 200;
|
|
query.limit = 100;
|
|
const auto page = client.items(query);
|
|
REQUIRE(page.ok());
|
|
CHECK(page.value().items.size() == 50);
|
|
CHECK(page.value().total == FakeJellyfin::kMovieCount);
|
|
CHECK(page.value().start == 200);
|
|
const std::string target = server.last().target;
|
|
CHECK(target.find("includeItemTypes=Movie") != std::string::npos);
|
|
CHECK(target.find("sortBy=SortName") != std::string::npos);
|
|
CHECK(target.find("sortOrder=Ascending") != std::string::npos);
|
|
CHECK(target.find("recursive=true") != std::string::npos);
|
|
ItemQuery search;
|
|
search.searchTerm = "movie 12";
|
|
const auto found = client.items(search);
|
|
REQUIRE(found.ok());
|
|
CHECK(found.value().total == 10); // Movie 120 to 129
|
|
CHECK(server.last().target.find("searchTerm=movie%2012") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("favorite and played use POST to set and DELETE to clear") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("tok-demo", "u-demo");
|
|
REQUIRE(client.setFavorite("m-1", true).ok());
|
|
CHECK(server.last().method == "POST");
|
|
CHECK(server.last().path == "/UserFavoriteItems/m-1");
|
|
REQUIRE(client.setFavorite("m-1", false).ok());
|
|
CHECK(server.last().method == "DELETE");
|
|
REQUIRE(client.setPlayed("m-1", true).ok());
|
|
CHECK(server.last().path == "/UserPlayedItems/m-1");
|
|
REQUIRE(client.signOut().ok());
|
|
CHECK(server.last().path == "/Sessions/Logout");
|
|
}
|
|
|
|
TEST_CASE("image URLs carry no token and fetch sends it only to the same server") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("tok-demo", "u-demo");
|
|
const std::string url = client.imageUrl("m-1", ImageType::Primary, "t-m1", 300, 450);
|
|
CHECK(url == server.baseUrl() + "/Items/m-1/Images/Primary?tag=t-m1&maxWidth=300&maxHeight=450&format=Jpg&quality=90");
|
|
const auto bytes = client.fetch(url);
|
|
REQUIRE(bytes.ok());
|
|
REQUIRE(bytes.value().size() > 2);
|
|
CHECK(static_cast<unsigned char>(bytes.value()[0]) == 0xFF);
|
|
CHECK(static_cast<unsigned char>(bytes.value()[1]) == 0xD8);
|
|
const auto foreign = client.fetch("http://evil.example/steal");
|
|
REQUIRE_FALSE(foreign.ok());
|
|
CHECK(foreign.error().kind == ErrorKind::BadResponse);
|
|
const auto lookalike = client.fetch(server.baseUrl() + ".evil.example/x");
|
|
CHECK_FALSE(lookalike.ok());
|
|
}
|
|
|
|
TEST_CASE("API replies are limited to 8 MiB, fetched files are not") {
|
|
const std::string huge(jellyfin::kMaxApiReplyBytes + 1, ' ');
|
|
test::FakeServer server;
|
|
server.http().Get("/Users/Me", [&](const httplib::Request&, httplib::Response& res) {
|
|
res.set_content(huge + test::readFile(test::fixturePath("jellyfin/user_me.json")), "application/json");
|
|
});
|
|
server.http().Get("/Items/m-1/Images/Primary", [&](const httplib::Request&, httplib::Response& res) {
|
|
res.set_content(huge, "image/jpeg");
|
|
});
|
|
server.start();
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("t", "u");
|
|
const auto me = client.currentUser();
|
|
REQUIRE_FALSE(me.ok());
|
|
CHECK(me.error().kind == ErrorKind::BadResponse);
|
|
CHECK(me.error().message.find("too large") != std::string::npos);
|
|
const auto image = client.fetch(server.baseUrl() + "/Items/m-1/Images/Primary");
|
|
REQUIRE(image.ok());
|
|
CHECK(image.value().size() == huge.size());
|
|
}
|
|
|
|
TEST_CASE("server errors and broken replies") {
|
|
test::FakeServer broken;
|
|
broken.http().Get("/UserViews", [](const httplib::Request&, httplib::Response& res) { res.status = 500; });
|
|
broken.http().Get("/Users/Me", [](const httplib::Request&, httplib::Response& res) {
|
|
res.set_content(R"({"Name":"trunc)", "application/json");
|
|
});
|
|
broken.start();
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(broken.baseUrl()));
|
|
client.setToken("t", "u");
|
|
const auto views = client.libraries();
|
|
REQUIRE_FALSE(views.ok());
|
|
CHECK(views.error().kind == ErrorKind::Server);
|
|
CHECK(views.error().httpStatus == 500);
|
|
const auto me = client.currentUser();
|
|
REQUIRE_FALSE(me.ok());
|
|
CHECK(me.error().kind == ErrorKind::BadResponse);
|
|
}
|
|
|
|
TEST_CASE("user images have their own URL, without the token, and can be fetched") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
const std::string url = client.userImageUrl("u-demo", "img-demo", 180);
|
|
CHECK(url == server.baseUrl() + "/Users/u-demo/Images/Primary?tag=img-demo&maxWidth=180&maxHeight=180&format=Jpg&quality=90");
|
|
const auto bytes = client.fetch(url);
|
|
REQUIRE(bytes.ok());
|
|
CHECK(static_cast<unsigned char>(bytes.value()[0]) == 0xFF);
|
|
}
|
|
|
|
namespace {
|
|
Client signedIn(const net::HttpClient& httpClient, const FakeJellyfin& server) {
|
|
Client client(httpClient, session(server.baseUrl()));
|
|
client.setToken("tok-demo", "u-demo");
|
|
return client;
|
|
}
|
|
} // namespace
|
|
|
|
TEST_CASE("music: album artists, an artist's albums and a playlist's tracks") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
const Client client = signedIn(httpClient, server);
|
|
const auto artists = client.artists("lib-music", 0, 100);
|
|
REQUIRE(artists.ok());
|
|
CHECK(artists.value().items.size() == 3);
|
|
CHECK(server.last().path == "/Artists/AlbumArtists");
|
|
CHECK(server.last().target.find("parentId=lib-music") != std::string::npos);
|
|
ItemQuery query;
|
|
query.albumArtistId = "6233866b316f71faa355d1f0d999f963";
|
|
query.includeTypes = {"MusicAlbum"};
|
|
REQUIRE(client.items(query).ok());
|
|
CHECK(server.last().target.find("albumArtistIds=6233866b316f71faa355d1f0d999f963") != std::string::npos);
|
|
const auto tracks = client.playlistItems("pl-1");
|
|
REQUIRE(tracks.ok());
|
|
CHECK(tracks.value().items.size() == 8);
|
|
CHECK(server.last().path == "/Playlists/pl-1/Items");
|
|
}
|
|
|
|
TEST_CASE("Live TV: channels, programmes, what is on, recordings") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
const Client client = signedIn(httpClient, server);
|
|
const auto channels = client.liveTvChannels();
|
|
REQUIRE(channels.ok());
|
|
CHECK(channels.value().items.size() == 3);
|
|
CHECK(server.last().target.find("addCurrentProgram=true") != std::string::npos);
|
|
const auto programs = client.programs("ch-1", 1792179000, 1792265400);
|
|
REQUIRE(programs.ok());
|
|
CHECK(server.last().path == "/LiveTv/Programs");
|
|
CHECK(server.last().target.find("channelIds=ch-1") != std::string::npos);
|
|
CHECK(server.last().target.find("minEndDate=2026-10-16T19%3A30%3A00Z") != std::string::npos);
|
|
CHECK(server.last().target.find("maxStartDate=2026-10-17T19%3A30%3A00Z") != std::string::npos);
|
|
CHECK(server.last().target.find("sortBy=StartDate") != std::string::npos);
|
|
REQUIRE(client.airingNow(16).ok());
|
|
CHECK(server.last().path == "/LiveTv/Programs/Recommended");
|
|
CHECK(server.last().target.find("isAiring=true") != std::string::npos);
|
|
REQUIRE(client.recordings().ok());
|
|
CHECK(server.last().path == "/LiveTv/Recordings");
|
|
}
|
|
|
|
TEST_CASE("Live TV: a recording is scheduled from the server's defaults and cancelled") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
const Client client = signedIn(httpClient, server);
|
|
REQUIRE(client.createTimer("prog-7").ok());
|
|
const auto requests = server.requests();
|
|
REQUIRE(requests.size() >= 2);
|
|
CHECK(requests[requests.size() - 2].target.find("/LiveTv/Timers/Defaults?programId=prog-7") == 0);
|
|
CHECK(requests.back().method == "POST");
|
|
CHECK(requests.back().path == "/LiveTv/Timers");
|
|
CHECK(server.timerBody().find(R"("ProgramId":"1bbabe69245f97e2485d7053b72ae3a9")") != std::string::npos);
|
|
const auto timers = client.timers();
|
|
REQUIRE(timers.ok());
|
|
CHECK(timers.value().size() == 1);
|
|
REQUIRE(client.cancelTimer("t-1").ok());
|
|
CHECK(server.last().method == "DELETE");
|
|
CHECK(server.last().path == "/LiveTv/Timers/t-1");
|
|
}
|
|
|
|
TEST_CASE("Live TV: a channel's live stream is opened by PlaybackInfo and closed") {
|
|
FakeJellyfin server;
|
|
const auto httpClient = http();
|
|
const Client client = signedIn(httpClient, server);
|
|
jellyfin::PlaybackRequest request;
|
|
request.liveTv = true;
|
|
const auto info = client.playbackInfo("79cf8fe44f3f38fbe05265ad38d3f2aa", request);
|
|
REQUIRE(info.ok());
|
|
CHECK_FALSE(info.value().sources.front().liveStreamId.empty());
|
|
const std::string body = server.playbackInfoBody();
|
|
CHECK(body.find(R"("AutoOpenLiveStream":true)") != std::string::npos);
|
|
CHECK(body.find(R"("EnableDirectPlay":false)") != std::string::npos);
|
|
REQUIRE(client.closeLiveStream("ls-1").ok());
|
|
CHECK(server.last().method == "POST");
|
|
CHECK(server.last().target == "/LiveStreams/Close?liveStreamId=ls-1");
|
|
}
|