Files

240 lines
8.6 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 steredbits and Pelagia contributors
// pelagia-cli: API validation tool (and a way to find the identifiers
// for pelagia-play --item).
// Authenticates against the Jellyfin server and lists the libraries, movies
// and series, each with its identifier; --episodes <series-id> lists the
// episodes of a series (only the identifier of a movie or an episode is
// playable). Configuration through environment variables:
// JELLYFIN_URL http://ip:8096
// JELLYFIN_USER nom d'utilisateur
// JELLYFIN_PASS password
// The listing goes to stdout (it is the program's result),
// diagnostics go through util/log.
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "api/http_curl.h"
#include "api/jellyfin_client.h"
#include "api/jellyfin_urls.h"
#include "util/log.h"
namespace {
const char* env_or_null(const char* name) {
const char* value = std::getenv(name);
return (value && value[0] != '\0') ? value : nullptr;
}
// API error on a single line; the HTTP code is only shown if there is one
// (0 = transport error, the message is enough).
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_runtime(long long ticks) {
// 1 Jellyfin tick = 100 ns -> 600,000,000 ticks per minute.
long long minutes = ticks / 600000000LL;
if (minutes > 0) {
std::printf(" — %lldh%02lld", minutes / 60, minutes % 60);
}
}
// One line per item, identifier at the end of the line: this is the value to give to
// pelagia-play --item. The duration is only shown for a playable media
// (a series' duration makes no sense).
void print_item(const api::MediaItem& item, bool with_runtime) {
std::printf(" - %s", item.name.c_str());
if (item.production_year > 0) {
std::printf(" (%d)", item.production_year);
}
if (with_runtime) {
print_runtime(item.runtime_ticks);
}
std::printf(" [%s]\n", item.id.c_str());
}
void print_usage(std::FILE* out) {
std::fputs(
"Usage: pelagia-cli [--verbose] [--episodes <series-id>]\n"
"\n"
"Lists the libraries, movies and series of the server, each with its\n"
"identifier (in brackets) to give to pelagia-play --item <id>.\n"
" --episodes <id> lists the episodes of series <id> (with their\n"
" identifiers: a series identifier is not playable)\n"
" -v, --verbose detailed logs\n"
" -h, --help shows this help\n"
"\n"
"Variables d'environnement : JELLYFIN_URL, JELLYFIN_USER, JELLYFIN_PASS.\n"
"Exit codes: 0 success, 1 server error, 2 arguments or configuration\n"
"incorrects.\n",
out);
}
// Episodes of a series: "S01E03 Title [id] - 0h42".
int list_episodes(api::JellyfinClient* client, const char* series_id) {
api::ItemList episodes;
const api::ApiResult result = client->fetch_episodes(series_id, "", &episodes);
if (!result.ok()) {
log_api_error("Failed to list the episodes", result);
return 1;
}
std::printf("Episodes (%zu):\n", episodes.items.size());
for (const api::MediaItem& ep : episodes.items) {
if (ep.parent_index_number >= 0 && ep.index_number >= 0) {
std::printf(" S%02dE%02d %s", ep.parent_index_number, ep.index_number,
ep.name.c_str());
} else {
std::printf(" %s", ep.name.c_str());
}
print_runtime(ep.runtime_ticks);
std::printf(" [%s]\n", ep.id.c_str());
}
if (!episodes.items.empty()) {
std::printf("\nTo play an episode: pelagia-play --item <id>\n");
}
return 0;
}
} // namespace
int main(int argc, char** argv) {
// Help takes precedence over any other argument error.
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) {
print_usage(stdout);
return 0;
}
}
const char* episodes_of = nullptr;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--verbose") == 0 || std::strcmp(argv[i], "-v") == 0) {
util::log_set_level(util::LogLevel::Debug);
} else if (std::strcmp(argv[i], "--episodes") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
std::fprintf(stderr, "--episodes expects the identifier of a series\n");
return 2;
}
episodes_of = argv[++i];
} else {
std::fprintf(stderr, "Unknown option: %s\nTry: pelagia-cli --help\n",
argv[i]);
return 2;
}
}
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,
"Variables d'environnement requises : JELLYFIN_URL, "
"JELLYFIN_USER, JELLYFIN_PASS\n"
"Exemple :\n"
" JELLYFIN_URL=http://192.168.1.10:8096 JELLYFIN_USER=moi \\\n"
" JELLYFIN_PASS=secret ./build-linux/pelagia-cli\n");
return 2;
}
api::HttpCurlTransport transport;
api::JellyfinClient client(transport, url);
api::ApiResult result = client.authenticate(user, pass);
if (!result.ok()) {
log_api_error("Authentication failed", result);
return 1;
}
std::printf("Connected to %s as %s\n\n", client.server_url().c_str(),
client.session().user_name.c_str());
if (episodes_of) {
return list_episodes(&client, episodes_of);
}
std::vector<api::Library> libraries;
result = client.fetch_libraries(&libraries);
if (!result.ok()) {
log_api_error("Failed to list the libraries", result);
return 1;
}
std::printf("Libraries (%zu):\n", libraries.size());
for (const api::Library& lib : libraries) {
std::printf(" - %s [%s]\n", lib.name.c_str(),
lib.collection_type.empty() ? "?" : lib.collection_type.c_str());
}
api::ItemsQuery query;
query.include_item_types = "Movie";
query.recursive = true;
// Without this parameter, the server replaces the movies of a collection with
// their BoxSet (no MediaSource, so not playable) and hides others.
query.collapse_box_set_items = false;
api::ItemList movies;
result = client.fetch_items(query, &movies);
if (!result.ok()) {
log_api_error("Failed to list the movies", result);
return 1;
}
// Safeguard: only count and display real movies.
int movie_count = 0;
for (const api::MediaItem& item : movies.items) {
if (item.type == "Movie") {
++movie_count;
}
}
std::printf("\nFilms (%d) :\n", movie_count);
const api::MediaItem* first_movie = nullptr;
for (const api::MediaItem& movie : movies.items) {
if (movie.type != "Movie") {
continue;
}
if (!first_movie) {
first_movie = &movie;
}
print_item(movie, true);
}
api::ItemsQuery series_query;
series_query.include_item_types = "Series";
series_query.recursive = true;
api::ItemList series;
result = client.fetch_items(series_query, &series);
if (!result.ok()) {
log_api_error("Failed to list the series", result);
return 1;
}
int series_count = 0;
for (const api::MediaItem& item : series.items) {
if (item.type == "Series") {
++series_count;
}
}
std::printf("\nSeries (%d):\n", series_count);
for (const api::MediaItem& item : series.items) {
if (item.type == "Series") {
print_item(item, false);
}
}
if (first_movie) {
// The token goes through the Authorization header (pelagia-play default):
// the URL does not contain it. mask_api_key() stays there as a precaution.
std::printf("\nExample stream URL (H.264 1080p + AAC transcoding; the token"
"goes through the Authorization header, not the URL):\n%s\n",
api::mask_api_key(client.stream_url(first_movie->id, 0, "", false)).c_str());
}
if (first_movie || series_count > 0) {
std::printf("\nPour lire un film : pelagia-play --item <id>\n"
"To list the episodes of a series: pelagia-cli --episodes <id>\n");
}
return 0;
}