grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046)
grafana: status/datasources/dashboards/alerts tools, emits alert.firing. tautulli: activity/history/stats tools, emits watch.recorded. nextcloud: users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits user.created/share.created. nodered: flows/nodes/deploy tools, emits flows.deployed inline from the deploy tool. All typecheck; manifests parse.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// 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.
|
||||
|
||||
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>>;
|
||||
}
|
||||
|
||||
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 url = env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`;
|
||||
const 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 ?? [] }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user