The module stated /services/tautulli/config and /var/lib/mesh/tautulli/route.json,
novox's layout, which no definition may carry (ADR 0112). Tautulli's config dir is
now a placed directory (${dir:config}) and the route binds into the placed state
(${dir:state}), as searxng and mosquitto do.
The image was pinned to v2.18.1-ls242; ace runs ls244 (2026-09-11), and Tautulli
migrates its own database schema, so the pin moves to the digest ace runs.
The runtime called http://127.0.0.1:8181, which is the software's port, not the
machine port the mesh assigns; it now asks with ${port:8181}, as gitea does.
The runtime mounted Tautulli's config dir read-only but never read it: its API key
could only come from MESH_TAUTULLI_APIKEY or the settings-merged config.json, i.e.
a secret in settings. Tautulli mints and owns that key in its config.ini, so the
runtime now reads it from there. Nothing for the mesh to mint or accept.
Verified: catalogue tests with MESH_CATALOGUE pointing here (not skipped); the
pinned image started in a throwaway container on a dir owned 1001:2000 with
PUID/PGID 1000 answers /status 200 and re-owns /config to 1000:1000 on start;
client.ts, run under node, read the key from that instance's config.ini and got
success from get_activity and get_history; without a config.ini it throws, which
the tools and events entrypoints already treat as "not configured".
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).
|
|
*/
|
|
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 ?? [] }));
|
|
}
|
|
}
|