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 ?? [] }));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"module": "tautulli",
|
||||
"version": "1",
|
||||
"emits": [
|
||||
"module.tautulli.watch.recorded"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/tautulli/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user