// The Radarr API client — radarr'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 Radarr's API rebuilds only radarr and nothing else. Both this module's tools and its // events entrypoint import it, and nothing outside radarr does. // Radarr speaks the v3 API; its content is "movie". const API_VERSION = "v3"; const CONTENT_ENDPOINT = "movie"; const APP_NAME = "Radarr"; export interface RadarrQueueItem { /** 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 RadarrCalendarItem { title: string; date: string; overview?: string; } export interface RadarrContentItem { title: string; year?: number; status?: string; monitored: boolean; } export class RadarrClient { readonly baseUrl: string; constructor( url: string, private readonly apiKey: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** * Build from the module's resolved environment. URL and key are read from MESH_RADARR_URL and * MESH_RADARR_API_KEY; both must be present — an unconfigured Radarr throws rather than pretend to * be reachable, so the tools/events simply do not load (the harness treats the throw as "exposes * nothing"). */ static fromEnv(env: NodeJS.ProcessEnv = process.env): RadarrClient { const url = env.MESH_RADARR_URL; const apiKey = env.MESH_RADARR_API_KEY; if (!url || !apiKey) { throw new Error("Radarr not configured — set MESH_RADARR_URL and MESH_RADARR_API_KEY"); } return new RadarrClient(url, apiKey); } private async get(endpoint: string, params?: Record): Promise { 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 { 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 { const all = await this.getContent(); const lower = term.toLowerCase(); return all.filter((item) => item.title.toLowerCase().includes(lower)); } async getQueue(): Promise<{ totalRecords: number; items: RadarrQueueItem[] }> { 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.movie?.title ?? "Unknown", status: r.status ?? "unknown", size: formatBytes(r.size ?? 0), sizeleft: formatBytes(r.sizeleft ?? 0), timeleft: r.timeleft, })), }; } async getCalendar(days = 7): Promise { 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.movie?.title ?? "Unknown", date: item.inCinemas ?? item.digitalRelease ?? "", 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]}`; }