sonarr, radarr: full nox modules — clients, tools and events (ADR 0044/0046)
The Servarr apps (TV, movies), each self-contained from hal's shared arr client. Tools: status, library, search, queue, calendar. Events by polling the download queue: a new item emits module.<app>.<thing>.grabbed, an item that leaves as completed emits module.<app>.download.completed — the exact key plex consumes to rescan. A grab that left as failed/warning is not reported as a completion. Both typecheck; manifests parse.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// 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<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();
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user