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:
2026-09-04 02:33:32 +02:00
parent 259c3721b5
commit fb42fb956b
12 changed files with 588 additions and 0 deletions
+127
View File
@@ -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]}`;
}
+55
View File
@@ -0,0 +1,55 @@
// radarr's events. The tool runtime imports this once the broker is bound. It watches the download
// queue and turns its comings and goings into mesh events.
//
// Emits (novox/hq ADR 0046/0047):
// module.radarr.movie.grabbed — a release entered the queue (Radarr grabbed it)
// module.radarr.download.completed — a release left the queue, imported. This exact routing key
// is what the plex module consumes (module.*.download.completed)
// to rescan, so the new movie becomes a visible item.
// Consumes: none.
//
// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a
// restart mid-download does not re-announce everything already in flight as freshly grabbed.
import { emit } from "@novox/mesh-sdk/events";
import { RadarrClient, type RadarrQueueItem } from "./client.js";
const radarr = RadarrClient.fromEnv();
// Radarr removes an item from the queue once it has been imported; a "warning"/"failed" status is
// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish.
const FAILED_STATUSES = new Set(["failed", "warning"]);
const inQueue = new Map<number, RadarrQueueItem>();
let primed = false;
async function pollQueue(): Promise<void> {
const { items } = await radarr.getQueue();
const now = new Map(items.map((i) => [i.id, i]));
if (primed) {
// Entered the queue since last look — Radarr grabbed a release.
for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("module.radarr.movie.grabbed", { title: item.title, status: item.status });
}
// Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("module.radarr.download.completed", { title: item.title });
}
}
}
inQueue.clear();
for (const [id, item] of now) inQueue.set(id, item);
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[radarr] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollQueue, 30_000);
console.log("[radarr] watching the download queue, emitting grabs and completions");
+8
View File
@@ -4,6 +4,14 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.radarr.movie.grabbed",
"module.radarr.download.completed"
],
"consumes": [],
"own-secrets": {
"broker": "/var/lib/radarr/broker"
},
"listens": [
{
"port": 7878,
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-radarr",
"version": "0.1.0",
"description": "radarr — movie management. Its API client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+78
View File
@@ -0,0 +1,78 @@
// radarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing radarr's own
// client. They return structured data (not pre-formatted text as hal did); the mesh serves them
// through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { RadarrClient } from "../client.js";
export function getRadarrTools(radarr: RadarrClient): ToolDefinition[] {
return [
{
name: "radarr_status",
description: "Radarr status overview: version, movie count, monitored count, queue size.",
input: {},
run: async () => {
const [status, content, queue] = await Promise.all([
radarr.getStatus(),
radarr.getContent(),
radarr.getQueue(),
]);
return {
app: status.appName,
version: status.version,
movies: content.length,
monitored: content.filter((c) => c.monitored).length,
queue: queue.totalRecords,
};
},
},
{
name: "radarr_library",
description: "List movies from the Radarr library.",
input: { limit: { type: "number", description: "max items to return (default 50)" } },
run: async (args) => {
const items = await radarr.getContent(args.limit ? Number(args.limit) : 50);
return { count: items.length, movies: items };
},
},
{
name: "radarr_search",
description: "Search the Radarr library for movies by title (filters existing content, not indexers).",
input: { query: { type: "string", description: "the search term" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await radarr.searchContent(query) };
},
},
{
name: "radarr_queue",
description: "Show the Radarr download queue — what is downloading and how far along.",
input: {},
run: async () => {
const queue = await radarr.getQueue();
return { count: queue.totalRecords, items: queue.items };
},
},
{
name: "radarr_calendar",
description: "Upcoming movie releases from the Radarr calendar.",
input: { days: { type: "number", description: "how many days to look ahead (default 7)" } },
run: async (args) => {
const days = args.days ? Number(args.days) : 7;
const items = await radarr.getCalendar(days);
items.sort((a, b) => a.date.localeCompare(b.date));
return { days, count: items.length, items };
},
},
];
}
// The tools exist only when Radarr is configured; without a URL and key, radarr contributes none
// rather than failing the whole runtime.
registerModuleTools("radarr", (env) => {
try {
return getRadarrTools(RadarrClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}
+127
View File
@@ -0,0 +1,127 @@
// The Sonarr API client — sonarr'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 Sonarr's API rebuilds only sonarr and nothing else. Both this module's tools and its
// events entrypoint import it, and nothing outside sonarr does.
// Sonarr speaks the v3 API; its content is "series".
const API_VERSION = "v3";
const CONTENT_ENDPOINT = "series";
const APP_NAME = "Sonarr";
export interface SonarrQueueItem {
/** 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 SonarrCalendarItem {
title: string;
date: string;
overview?: string;
}
export interface SonarrContentItem {
title: string;
year?: number;
status?: string;
monitored: boolean;
}
export class SonarrClient {
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_SONARR_URL and
* MESH_SONARR_API_KEY; both must be present — an unconfigured Sonarr 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): SonarrClient {
const url = env.MESH_SONARR_URL;
const apiKey = env.MESH_SONARR_API_KEY;
if (!url || !apiKey) {
throw new Error("Sonarr not configured — set MESH_SONARR_URL and MESH_SONARR_API_KEY");
}
return new SonarrClient(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<SonarrContentItem[]> {
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<SonarrContentItem[]> {
const all = await this.getContent();
const lower = term.toLowerCase();
return all.filter((item) => item.title.toLowerCase().includes(lower));
}
async getQueue(): Promise<{ totalRecords: number; items: SonarrQueueItem[] }> {
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.series?.title ?? "Unknown",
status: r.status ?? "unknown",
size: formatBytes(r.size ?? 0),
sizeleft: formatBytes(r.sizeleft ?? 0),
timeleft: r.timeleft,
})),
};
}
async getCalendar(days = 7): Promise<SonarrCalendarItem[]> {
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.series?.title ?? "Unknown",
date: item.airDateUtc ?? "",
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]}`;
}
+55
View File
@@ -0,0 +1,55 @@
// sonarr's events. The tool runtime imports this once the broker is bound. It watches the download
// queue and turns its comings and goings into mesh events.
//
// Emits (novox/hq ADR 0046/0047):
// module.sonarr.episode.grabbed — a release entered the queue (Sonarr grabbed it)
// module.sonarr.download.completed — a release left the queue, imported. This exact routing key
// is what the plex module consumes (module.*.download.completed)
// to rescan, so the new episode becomes a visible item.
// Consumes: none.
//
// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a
// restart mid-download does not re-announce everything already in flight as freshly grabbed.
import { emit } from "@novox/mesh-sdk/events";
import { SonarrClient, type SonarrQueueItem } from "./client.js";
const sonarr = SonarrClient.fromEnv();
// Sonarr removes an item from the queue once it has been imported; a "warning"/"failed" status is
// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish.
const FAILED_STATUSES = new Set(["failed", "warning"]);
const inQueue = new Map<number, SonarrQueueItem>();
let primed = false;
async function pollQueue(): Promise<void> {
const { items } = await sonarr.getQueue();
const now = new Map(items.map((i) => [i.id, i]));
if (primed) {
// Entered the queue since last look — Sonarr grabbed a release.
for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("module.sonarr.episode.grabbed", { title: item.title, status: item.status });
}
// Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("module.sonarr.download.completed", { title: item.title });
}
}
}
inQueue.clear();
for (const [id, item] of now) inQueue.set(id, item);
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[sonarr] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollQueue, 30_000);
console.log("[sonarr] watching the download queue, emitting grabs and completions");
+8
View File
@@ -4,6 +4,14 @@
"capabilities": [
"container-runtime"
],
"emits": [
"module.sonarr.episode.grabbed",
"module.sonarr.download.completed"
],
"consumes": [],
"own-secrets": {
"broker": "/var/lib/sonarr/broker"
},
"listens": [
{
"port": 8989,
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-sonarr",
"version": "0.1.0",
"description": "sonarr — TV series management. Its API client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+78
View File
@@ -0,0 +1,78 @@
// sonarr's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing sonarr's own
// client. They return structured data (not pre-formatted text as hal did); the mesh serves them
// through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { SonarrClient } from "../client.js";
export function getSonarrTools(sonarr: SonarrClient): ToolDefinition[] {
return [
{
name: "sonarr_status",
description: "Sonarr status overview: version, series count, monitored count, queue size.",
input: {},
run: async () => {
const [status, content, queue] = await Promise.all([
sonarr.getStatus(),
sonarr.getContent(),
sonarr.getQueue(),
]);
return {
app: status.appName,
version: status.version,
series: content.length,
monitored: content.filter((c) => c.monitored).length,
queue: queue.totalRecords,
};
},
},
{
name: "sonarr_library",
description: "List series from the Sonarr library.",
input: { limit: { type: "number", description: "max items to return (default 50)" } },
run: async (args) => {
const items = await sonarr.getContent(args.limit ? Number(args.limit) : 50);
return { count: items.length, series: items };
},
},
{
name: "sonarr_search",
description: "Search the Sonarr library for series by title (filters existing content, not indexers).",
input: { query: { type: "string", description: "the search term" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await sonarr.searchContent(query) };
},
},
{
name: "sonarr_queue",
description: "Show the Sonarr download queue — what is downloading and how far along.",
input: {},
run: async () => {
const queue = await sonarr.getQueue();
return { count: queue.totalRecords, items: queue.items };
},
},
{
name: "sonarr_calendar",
description: "Upcoming episode releases from the Sonarr calendar.",
input: { days: { type: "number", description: "how many days to look ahead (default 7)" } },
run: async (args) => {
const days = args.days ? Number(args.days) : 7;
const items = await sonarr.getCalendar(days);
items.sort((a, b) => a.date.localeCompare(b.date));
return { days, count: items.length, items };
},
},
];
}
// The tools exist only when Sonarr is configured; without a URL and key, sonarr contributes none
// rather than failing the whole runtime.
registerModuleTools("sonarr", (env) => {
try {
return getSonarrTools(SonarrClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}