diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts new file mode 100644 index 0000000..74a4bff --- /dev/null +++ b/modules/radarr/client.ts @@ -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): Promise { + 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 { + 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 { + 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 { + 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]}`; +} diff --git a/modules/radarr/index.ts b/modules/radarr/index.ts new file mode 100644 index 0000000..3b08be7 --- /dev/null +++ b/modules/radarr/index.ts @@ -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(); +let primed = false; + +async function pollQueue(): Promise { + 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, 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"); diff --git a/modules/radarr/module.json b/modules/radarr/module.json index 51a782f..e05b77d 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -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, diff --git a/modules/radarr/package.json b/modules/radarr/package.json new file mode 100644 index 0000000..9393ff4 --- /dev/null +++ b/modules/radarr/package.json @@ -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" + } +} diff --git a/modules/radarr/tools/index.ts b/modules/radarr/tools/index.ts new file mode 100644 index 0000000..d147012 --- /dev/null +++ b/modules/radarr/tools/index.ts @@ -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 []; + } +}); diff --git a/modules/radarr/tsconfig.json b/modules/radarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/radarr/tsconfig.json @@ -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"] +} diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts new file mode 100644 index 0000000..d5f9c89 --- /dev/null +++ b/modules/sonarr/client.ts @@ -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): Promise { + 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 { + 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 { + 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 { + 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]}`; +} diff --git a/modules/sonarr/index.ts b/modules/sonarr/index.ts new file mode 100644 index 0000000..fb03857 --- /dev/null +++ b/modules/sonarr/index.ts @@ -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(); +let primed = false; + +async function pollQueue(): Promise { + 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, 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"); diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index b42fb6c..aa0a983 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -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, diff --git a/modules/sonarr/package.json b/modules/sonarr/package.json new file mode 100644 index 0000000..7fd642d --- /dev/null +++ b/modules/sonarr/package.json @@ -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" + } +} diff --git a/modules/sonarr/tools/index.ts b/modules/sonarr/tools/index.ts new file mode 100644 index 0000000..ea5a586 --- /dev/null +++ b/modules/sonarr/tools/index.ts @@ -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 []; + } +}); diff --git a/modules/sonarr/tsconfig.json b/modules/sonarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/sonarr/tsconfig.json @@ -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"] +}