// 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=…&, 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>; } 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 = {}): Promise { 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 { 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 { const data = (await this.cmd("get_home_stats")) as any[]; return (data ?? []).map((s) => ({ statId: s.stat_id, rows: s.rows ?? [] })); } }