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:
2026-09-04 02:43:20 +02:00
parent 1e2a849b90
commit eccfc70991
23 changed files with 897 additions and 2 deletions
+98
View File
@@ -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 ?? [] }));
}
}
+48
View File
@@ -0,0 +1,48 @@
// tautulli's events. The tool runtime imports this once the broker is bound. It watches Tautulli's
// history and announces each newly recorded watch.
//
// Emits (novox/hq ADR 0046/0047):
// module.tautulli.watch.recorded — a play appeared in Tautulli's history
//
// Diffed on the history row id and primed silently on the first look, so a restart does not
// re-announce the whole existing history as freshly watched.
import { emit } from "@novox/mesh-sdk/events";
import { TautulliClient, type TautulliWatch } from "./client.js";
// Constructed lazily so an unconfigured node (no API key) loads this entrypoint without crashing
// the events host — it simply watches nothing.
let tautulli: TautulliClient | undefined;
try {
tautulli = TautulliClient.fromEnv();
} catch (err) {
console.log(`[tautulli] not configured, not watching history: ${err}`);
}
const seen = new Set<number>();
let primed = false;
async function pollHistory(client: TautulliClient): Promise<void> {
const history = await client.getHistory(25);
for (const w of history) {
if (w.id === 0 || seen.has(w.id)) continue;
if (primed) await emitWatch(w);
seen.add(w.id);
}
primed = true;
}
async function emitWatch(w: TautulliWatch): Promise<void> {
await emit("module.tautulli.watch.recorded", {
title: w.title, user: w.user, mediaType: w.mediaType,
watchedStatus: w.watchedStatus, percentComplete: w.percentComplete, at: w.date,
});
}
if (tautulli) {
const client = tautulli;
const run = (): void => void pollHistory(client).catch((err) => console.error(`[tautulli] ${err}`));
setInterval(run, 60_000);
run();
console.log("[tautulli] watching watch history");
}
+6
View File
@@ -1,6 +1,12 @@
{
"module": "tautulli",
"version": "1",
"emits": [
"module.tautulli.watch.recorded"
],
"own-secrets": {
"broker": "/var/lib/tautulli/broker"
},
"capabilities": [
"container-runtime"
],
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-tautulli",
"version": "0.1.0",
"description": "tautulli — Plex watch statistics. Its API client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
// tautulli's tools — importing tautulli's own client (novox/hq ADR 0044). They return structured
// data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { TautulliClient } from "../client.js";
export function getTautulliTools(tautulli: TautulliClient): ToolDefinition[] {
return [
{
name: "tautulli_activity",
description: "Current Plex activity as Tautulli sees it — who is streaming what, and progress.",
input: {},
run: async () => tautulli.getActivity(),
},
{
name: "tautulli_history",
description: "Recent Plex watch history — who watched what, and whether they finished.",
input: { length: { type: "number", description: "how many rows (default 25)" } },
run: async (args) => {
const history = await tautulli.getHistory(args.length ? Number(args.length) : 25);
return { count: history.length, history };
},
},
{
name: "tautulli_stats",
description: "Tautulli home statistics — most-watched media, most-active users, and platforms.",
input: {},
run: async () => {
const stats = await tautulli.getHomeStats();
return { count: stats.length, stats };
},
},
];
}
// The tools exist only when an API key can be resolved; without one, tautulli contributes none
// rather than failing the whole tool runtime.
registerModuleTools("tautulli", (env) => {
try {
return getTautulliTools(TautulliClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}