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 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]}`;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user