// The Radarr API client — radarr'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 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. import { existsSync, readFileSync } from "node:fs"; import { PlexLookup, PLEX_MOVIE, type PlexMatch } from "./plex.js"; import { join } from "node:path"; // 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 type RadarrContentItem = Record & { title: string; monitored: boolean; plex?: PlexMatch | null }; /** The vault's copy of the key, where the runtime mounts it (ADR 0158): read fresh, trimmed, or null. */ function keyFile(file?: string): string | null { if (!file) return null; try { const v = readFileSync(file, "utf8").trim(); return v.length ? v : null; } catch { return null; } } export class RadarrClient { readonly baseUrl: string; constructor( url: string, private readonly apiKey: string, private readonly plex: PlexLookup | null = null, ) { 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_RADARR_API_KEY or, failing that, * discovered from the server's own config.xml under MESH_RADARR_CONFIG_DIR — the same file Radarr * 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): RadarrClient { const url = env.MESH_RADARR_URL ?? `http://127.0.0.1:${env.MESH_RADARR_PORT ?? "7878"}`; const configDir = env.MESH_RADARR_CONFIG_DIR ?? "/config"; const apiKey = keyFile(env.MESH_RADARR_API_KEY_FILE) ?? env.MESH_RADARR_API_KEY ?? RadarrClient.detectApiKey(configDir); if (!apiKey) { throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable"); } return new RadarrClient(url, apiKey, PlexLookup.fromEnv(env)); } /** 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(); } /** A request with a body or without one, for the control tools: POST adds, DELETE removes. */ private async send(method: string, endpoint: string, body?: unknown, 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(), { method, headers: { "X-Api-Key": this.apiKey, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (!res.ok) throw new Error(`Radarr API ${method} /${endpoint}: ${res.status} ${await res.text()}`); const text = await res.text(); return text ? JSON.parse(text) : null; } /** Look a movie up outside the library — Radarr's metadata source — by title, or by `tmdb:`. */ async lookup(term: string): Promise[]> { const found = (await this.get("movie/lookup", { term })) as Record[]; return (found ?? []).map((x) => ({ title: x.title, year: x.year, tmdbId: x.tmdbId, overview: typeof x.overview === "string" ? x.overview.slice(0, 200) : undefined, inLibrary: typeof x.id === "number" && x.id > 0, })); } async rootFolders(): Promise<{ id: number; path: string; accessible: boolean }[]> { const list = (await this.get("rootfolder")) as { id: number; path: string; accessible: boolean }[]; return list.map((r) => ({ id: r.id, path: r.path, accessible: r.accessible })); } async qualityProfiles(): Promise<{ id: number; name: string }[]> { const list = (await this.get("qualityprofile")) as { id: number; name: string }[]; return list.map((q) => ({ id: q.id, name: q.name })); } /** * Add a movie by its TMDb id. The metadata source's record is fetched and posted with the * profile and root folder chosen — by name when given, else the first of each. `search` asks * Radarr to look for it on the indexers right away. */ async add(id: string, opts: { qualityProfile?: string; rootFolder?: string; monitored?: boolean; search?: boolean } = {}): Promise> { const found = (await this.get("movie/lookup", { term: `tmdb:${id}` })) as Record[]; const record = found?.[0]; if (!record) throw new Error(`Radarr knows no movie with TMDb id ${id}`); if (typeof record.id === "number" && record.id > 0) { return { added: false, alreadyInLibrary: true, id: record.id, title: record.title }; } // A profile is a preference, so the first stands in when none is named; a root folder decides // where the data lands, so with several it must be named — the refusal lists them. const pick = (list: T[], want: string | undefined, what: string, firstWillDo = true): T => { if (!list.length) throw new Error(`Radarr has no ${what} configured`); if (!want && (firstWillDo || list.length === 1)) return list[0]; if (!want) throw new Error(`Radarr has ${list.length} ${what}s — name one: ${list.map((x) => x.name ?? x.path).join(", ")}`); const hit = list.find((x) => x.name === want || x.path === want); if (!hit) throw new Error(`Radarr has no ${what} ${JSON.stringify(want)}; it has ${list.map((x) => x.name ?? x.path).join(", ")}`); return hit; }; const quality = pick(await this.qualityProfiles(), opts.qualityProfile, "quality profile"); const root = pick(await this.rootFolders(), opts.rootFolder, "root folder", false); const search = opts.search ?? false; const body = { ...record, qualityProfileId: quality.id, rootFolderPath: root.path, monitored: opts.monitored ?? true, addOptions: { searchForMovie: search }, }; const made = (await this.send("POST", "movie", body)) as Record; return { added: true, id: made.id, title: made.title, tmdbId: made.tmdbId, rootFolder: root.path, qualityProfile: quality.name, monitored: body.monitored, searchRequested: search }; } /** Remove a movie from the library by its Radarr id. Files stay on disk unless `deleteFiles`. */ async remove(id: number, deleteFiles = false): Promise<{ removed: boolean; id: number }> { await this.send("DELETE", `movie/${id}`, undefined, { deleteFiles: String(deleteFiles), addImportExclusion: "false" }); return { removed: true, id }; } 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) => ({ id: item.id, title: item.title ?? "Unknown", year: item.year, tmdbId: item.tmdbId, imdbId: item.imdbId, status: item.status, monitored: item.monitored ?? true, hasFile: item.hasFile ?? false, sizeOnDiskBytes: item.sizeOnDisk, quality: item.movieFile?.quality?.quality?.name, path: item.path, runtimeMinutes: item.runtime, genres: item.genres, added: item.added, qualityProfileId: item.qualityProfileId, ratings: item.ratings ? { tmdb: item.ratings.tmdb?.value, imdb: item.ratings.imdb?.value } : undefined, overview: typeof item.overview === "string" ? item.overview.slice(0, 300) : undefined, })); return limit ? mapped.slice(0, limit) : mapped; } /** Library search is a filter over existing content, not an indexer lookup — same as hal's. */ /** Library search is a filter over existing content, not an indexer lookup — same as hal's. * Each of the first ten hits also says where it is in Plex (when this module binds plex-api): * `plex` is the match, null when Plex has no such title, or `{ error }` when Plex could not be asked. */ async searchContent(term: string): Promise { const all = await this.getContent(); const lower = term.toLowerCase(); const hits = all.filter((item) => item.title.toLowerCase().includes(lower)); if (!this.plex) return hits; return Promise.all(hits.map(async (item, i) => { if (i >= 10) return item; try { return { ...item, plex: await this.plex!.find(PLEX_MOVIE, item.title, typeof item.year === "number" ? item.year : undefined) }; } catch (err) { return { ...item, plex: { error: String(err) } as unknown as PlexMatch }; } })); } 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]}`; }