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.
136 lines
5.6 KiB
TypeScript
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 ?? [] }));
|
|
}
|
|
}
|