// The Lidarr API client — lidarr's own code, living in the module (novox/hq ADR 0039). Ported from // the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own copy, so a // change to Lidarr's API rebuilds only lidarr and nothing else. Both this module's tools and its // events entrypoint import it, and nothing outside lidarr does. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; // Lidarr speaks the v1 API (Radarr/Sonarr are v3); its content is the "artist". const API_VERSION = "v1"; const CONTENT_ENDPOINT = "artist"; const APP_NAME = "Lidarr"; export interface LidarrQueueItem { /** 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 LidarrCalendarItem { title: string; date: string; overview?: string; } export interface LidarrContentItem { title: string; status?: string; monitored: boolean; } export class LidarrClient { 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_LIDARR_API_KEY or, failing that, * discovered from the server's own config.xml under MESH_LIDARR_CONFIG_DIR — the same file Lidarr * writes it to, so a running server needs nothing configured by hand. 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): LidarrClient { const url = env.MESH_LIDARR_URL ?? `http://127.0.0.1:${env.MESH_LIDARR_PORT ?? "8686"}`; const configDir = env.MESH_LIDARR_CONFIG_DIR ?? "/config"; const apiKey = env.MESH_LIDARR_API_KEY ?? LidarrClient.detectApiKey(configDir); if (!apiKey) { throw new Error("Lidarr not configured — set MESH_LIDARR_API_KEY or make the config dir readable"); } return new LidarrClient(url, apiKey); } /** Discover the API key from the server's config.xml, falling back to null. Every Servarr app * writes 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>/); if (match) return match[1]; } return null; } 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) => ({ // Lidarr's content is an artist; its display name is artistName, not title. title: item.artistName ?? item.title ?? "Unknown", 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: LidarrQueueItem[] }> { 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.artist?.artistName ?? r.album?.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) => ({ // A Lidarr calendar entry is an album release. title: item.title ?? item.artist?.artistName ?? "Unknown", date: item.releaseDate ?? "", 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]}`; }