Files
mesh-catalog/modules/tautulli/client.ts
T
jschoubben 991e33f749 tautulli: reach plex through the mesh, written before Tautulli starts
Tautulli reached plex at 172.18.0.1, the gateway of a HAL network that goes
away with HAL, and the plan was to retype it by hand in the window. Tautulli
now requires plex-api, and where plex is comes from the binding.

Tautulli keeps the connection only in config.ini, reads it at start and
writes its whole config back on every shutdown; its API cannot set it and
its settings form needs an admin login. So a step after start would be
overwritten the moment the container is recreated. The write is made where
nothing can overwrite it: the linuxserver image's custom-init runs
plex/mesh-plex.py as root before Tautulli starts, and the server restarts on
its binding and credential, so a moved plex or an accepted token lands.

It writes only [PMS] keys, only when they differ, every other line byte for
byte: pms_ip, pms_port, pms_ssl and pms_url from the binding; pms_identifier
from plex's /identity; pms_token only when plex takes it. A minted value -
before the operator accepts the server's X-Plex-Token for this pair - is
never written, while the address still is, so Tautulli's own working token
keeps working at plex's new address.

A failure in custom-init is a log line nobody reads, so a run-once `plex`
step, declared last so it gates nothing (ADR 0136), checks what the mesh can
report: plex takes the credential (else it names the secret accept), Tautulli
holds the bound URL, and Tautulli says it is connected. It writes nothing.

The script is kept as plex/mesh-plex.py and plex/50-mesh-plex; module.json
carries copies, and a test fails when they differ. Tests run the script with
python3 against a fake plex (skipped where there is none) and the step
against fakes; `npm test` builds first.
2026-09-30 13:09:43 +02:00

136 lines
5.6 KiB
TypeScript

// Tautulli's API client — tautulli's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its events entrypoint import it, and nothing outside tautulli does.
//
// Tautulli speaks one endpoint: GET /api/v2?apikey=…&cmd=…&<params>, answering
// { response: { result: "success" | "error", message, data } }. This client unwraps that envelope
// and hands back only the data.
import { readFileSync } from "node:fs";
export interface TautulliSession {
user: string;
title: string;
mediaType: string;
state: string;
progressPercent: number;
player: string;
}
export interface TautulliWatch {
/** Tautulli's history row id — the stable identity a recorded watch is diffed on. */
id: number;
user: string;
title: string;
mediaType: string;
/** "watched" | "watching" | ... — Tautulli's own watched_status label. */
watchedStatus: string;
percentComplete: number;
/** Unix seconds the play started, as Tautulli reports it. */
date?: number;
}
export interface TautulliHomeStat {
statId: string;
rows: Array<Record<string, unknown>>;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
/**
* The API key Tautulli minted for itself, read from its own config.ini (mounted read-only).
*
* Tautulli owns this key: it writes it on first run and every client of its API — this runtime
* included — must present the same one. So the mesh does not mint or hold it; the one place it
* lives is the file Tautulli keeps, and a key regenerated in Tautulli's settings is simply read
* again on the next start. Undefined when there is no file or no key yet (a fresh install whose
* setup wizard has not run).
*/
export function keyOfTautulli(dir?: string): string | undefined {
if (!dir) return undefined;
let ini: string;
try { ini = readFileSync(`${dir.replace(/\/$/, "")}/config.ini`, "utf8"); }
catch { return undefined; }
let section = "";
for (const raw of ini.split(/\r?\n/)) {
const line = raw.trim();
const header = /^\[(.+)\]$/.exec(line);
if (header) { section = header[1]; continue; }
if (section !== "General") continue;
const kv = /^api_key\s*=\s*"?([^"]*)"?$/.exec(line);
if (kv && kv[1]) return kv[1];
}
return undefined;
}
export class TautulliClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. The API key is the one Tautulli minted for
* itself (Settings → Web Interface), read from its config.ini under MESH_TAUTULLI_CONFIG_DIR;
* MESH_TAUTULLI_APIKEY still wins where it is set. The base URL defaults to the local container.
* Throws when no key is configured — the module then contributes nothing rather than failing.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient {
const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE);
const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`);
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY ?? keyOfTautulli(env.MESH_TAUTULLI_CONFIG_DIR);
if (!apiKey) throw new Error("no Tautulli API key — Tautulli's config.ini has none yet (finish its setup and enable the API)");
return new TautulliClient(url, apiKey);
}
/** Call one Tautulli command and return its unwrapped data, throwing on a non-success result. */
private async cmd(command: string, params: Record<string, string> = {}): Promise<any> {
const q = new URLSearchParams({ apikey: this.apiKey, cmd: command, ...params });
const res = await fetch(`${this.baseUrl}/api/v2?${q.toString()}`);
if (!res.ok) throw new Error(`Tautulli ${command}: ${res.status} ${await res.text()}`);
const body = (await res.json()).response ?? {};
if (body.result !== "success") throw new Error(`Tautulli ${command}: ${body.message ?? "error"}`);
return body.data;
}
async getActivity(): Promise<{ streamCount: number; sessions: TautulliSession[] }> {
const data = await this.cmd("get_activity");
const sessions = ((data?.sessions ?? []) as any[]).map((s) => ({
user: s.friendly_name ?? s.user ?? "unknown",
title: s.full_title ?? s.title ?? "unknown",
mediaType: s.media_type ?? "unknown",
state: s.state ?? "unknown",
progressPercent: Number(s.progress_percent ?? 0),
player: s.player ?? "unknown",
}));
return { streamCount: Number(data?.stream_count ?? sessions.length), sessions };
}
async getHistory(length = 25): Promise<TautulliWatch[]> {
const data = await this.cmd("get_history", { length: String(length), order_column: "date", order_dir: "desc" });
return ((data?.data ?? []) as any[]).map((r) => ({
id: Number(r.row_id ?? r.id ?? r.reference_id ?? 0),
user: r.friendly_name ?? r.user ?? "unknown",
title: r.full_title ?? r.title ?? "unknown",
mediaType: r.media_type ?? "unknown",
watchedStatus: String(r.watched_status ?? ""),
percentComplete: Number(r.percent_complete ?? 0),
date: r.date != null ? Number(r.date) : undefined,
}));
}
/** The home-page statistics blocks — most-watched shows, most-active users, and so on. */
async getHomeStats(): Promise<TautulliHomeStat[]> {
const data = (await this.cmd("get_home_stats")) as any[];
return (data ?? []).map((s) => ({ statId: s.stat_id, rows: s.rows ?? [] }));
}
}