Files
mesh-catalog/modules/sonarr/client.ts
T
jschoubben 7b55e834e9 sonarr, radarr: tool runtime container + self-detect API key from config.xml (ADR 0052)
Mirrors plex: a broker-bound runtime that serves the module's tools, discovering
the app's API key from its own config.xml under a read-only config-dir mount, URL
defaulting to the server on the node. Proven in the mesh-lab: assigned-sonarr green
(key detected, tools served under the scoped account, no live Sonarr needed).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 22:54:53 +02:00

145 lines
5.5 KiB
TypeScript

// The Sonarr API client — sonarr's own code, living in the module (novox/hq ADR 0044). Ported from
// the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a
// change to Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its
// events entrypoint import it, and nothing outside sonarr does.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Sonarr speaks the v3 API; its content is "series".
const API_VERSION = "v3";
const CONTENT_ENDPOINT = "series";
const APP_NAME = "Sonarr";
export interface SonarrQueueItem {
/** The queue record id — stable while the item is in the queue, so events can diff on it. */
id: number;
title: string;
status: string;
size: string;
sizeleft: string;
timeleft?: string;
}
export interface SonarrCalendarItem {
title: string;
date: string;
overview?: string;
}
export interface SonarrContentItem {
title: string;
year?: number;
status?: string;
monitored: boolean;
}
export class SonarrClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. The URL defaults to the server on this node
* (the runtime shares its network), and the API key is read from MESH_SONARR_API_KEY or, failing
* that, discovered from the server's own config.xml under MESH_SONARR_CONFIG_DIR — the same file
* Sonarr writes it to, so a running server needs nothing configured by hand (as plex does with
* its token). Throws when no key can be found, so the tools/events simply do not load (the harness
* treats the throw as "exposes nothing").
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): SonarrClient {
const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`;
const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable");
}
return new SonarrClient(url, apiKey);
}
/** Discover the API key from the server's config.xml, falling back to null. Every Servarr app
* writes <ApiKey> into config.xml at the root of its config directory. */
static detectApiKey(configDir: string): string | null {
const config = join(configDir, "config.xml");
if (existsSync(config)) {
const match = readFileSync(config, "utf8").match(/<ApiKey>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } });
if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`);
return res.json();
}
async getStatus(): Promise<{ appName: string; version: string }> {
const data = (await this.get("system/status")) as { appName?: string; version?: string };
return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" };
}
async getContent(limit?: number): Promise<SonarrContentItem[]> {
const data = await this.get(CONTENT_ENDPOINT);
const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []);
const mapped = items.map((item) => ({
title: item.title ?? "Unknown",
year: item.year,
status: item.status,
monitored: item.monitored ?? true,
}));
return limit ? mapped.slice(0, limit) : mapped;
}
/** Library search is a filter over existing content, not an indexer lookup — same as hal's. */
async searchContent(term: string): Promise<SonarrContentItem[]> {
const all = await this.getContent();
const lower = term.toLowerCase();
return all.filter((item) => item.title.toLowerCase().includes(lower));
}
async getQueue(): Promise<{ totalRecords: number; items: SonarrQueueItem[] }> {
const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] };
const records = data.records ?? [];
return {
totalRecords: data.totalRecords ?? records.length,
items: records.map((r) => ({
id: r.id,
title: r.title ?? r.series?.title ?? "Unknown",
status: r.status ?? "unknown",
size: formatBytes(r.size ?? 0),
sizeleft: formatBytes(r.sizeleft ?? 0),
timeleft: r.timeleft,
})),
};
}
async getCalendar(days = 7): Promise<SonarrCalendarItem[]> {
const start = new Date().toISOString().split("T")[0];
const end = new Date(Date.now() + days * 86400000).toISOString().split("T")[0];
const data = await this.get("calendar", { start, end });
const items: any[] = Array.isArray(data) ? data : [];
return items.map((item) => ({
title: item.title ?? item.series?.title ?? "Unknown",
date: item.airDateUtc ?? "",
overview: item.overview?.slice(0, 150),
}));
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}