Files
mesh-catalog/modules/tautulli/client.ts
T
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00

109 lines
4.4 KiB
TypeScript

// Tautulli's API client — tautulli's own code, living in the module (novox/hq ADR 0044). 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 0051): { 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 {}; }
}
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 read from MESH_TAUTULLI_APIKEY
* (Tautulli mints it in Settings → Web Interface); 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;
if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY");
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 ?? [] }));
}
}