Each gains _lookup (the metadata source, by title or by id), _add (by TMDb, TVDb or MusicBrainz id, with the first quality/metadata profile and root folder unless named, monitored, searching only when asked) and _remove (by the app's id, files kept unless asked).
226 lines
9.8 KiB
TypeScript
226 lines
9.8 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 { 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 interface RadarrContentItem {
|
|
title: string;
|
|
year?: number;
|
|
status?: string;
|
|
monitored: boolean;
|
|
}
|
|
|
|
/** 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,
|
|
) {
|
|
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);
|
|
}
|
|
|
|
/** 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 };
|
|
}
|
|
const pick = <T extends { id: number; name?: string; path?: string }>(list: T[], want: string | undefined, what: string): T => {
|
|
if (!list.length) throw new Error(`Radarr has no ${what} configured`);
|
|
if (!want) return list[0];
|
|
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");
|
|
|
|
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) => ({
|
|
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<RadarrContentItem[]> {
|
|
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<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]}`;
|
|
}
|