Files
mesh-media-catalog/modules/radarr/client.ts
T
jschoubben 9ffd0dd96a radarr, sonarr, lidarr: a search tells everything the app keeps, and where the item is in Plex
The library search returned title, year, status and monitored. It now
returns every field the app keeps (file on disk, quality, size, path,
counts, ratings, genres, added…), and each module binds plex-api so the
first ten hits carry Plex's view: rating key, library, resolution, added,
watched, and a link that opens the item in Plex.
2026-10-01 15:54:20 +02:00

251 lines
12 KiB
TypeScript

// 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<string, unknown> & { 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 <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();
}
/** 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<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(), {
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:<id>`. */
async lookup(term: string): Promise<Record<string, unknown>[]> {
const found = (await this.get("movie/lookup", { term })) as Record<string, unknown>[];
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<Record<string, unknown>> {
const found = (await this.get("movie/lookup", { term: `tmdb:${id}` })) as Record<string, unknown>[];
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 = <T extends { id: number; name?: string; path?: string }>(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<string, unknown>;
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<RadarrContentItem[]> {
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<RadarrContentItem[]> {
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<RadarrCalendarItem[]> {
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]}`;
}