Files
mesh-media-catalog/modules/sonarr/client.ts
T
jschoubben 9ffd0dd96a radarr, sonarr, lidarr: a search tells everything the app keeps, and where the item is in Plex
The library search returned title, year, status and monitored. It now
returns every field the app keeps (file on disk, quality, size, path,
counts, ratings, genres, added…), and each module binds plex-api so the
first ten hits carry Plex's view: rating key, library, resolution, added,
watched, and a link that opens the item in Plex.
2026-10-01 15:54:20 +02:00

253 lines
12 KiB
TypeScript

// The Sonarr API client — sonarr'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 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.
import { existsSync, readFileSync } from "node:fs";
import { PlexLookup, PLEX_SHOW, type PlexMatch } from "./plex.js";
import { join } from "node:path";
// 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 type SonarrContentItem = Record<string, unknown> & { title: string; monitored: boolean; plex?: PlexMatch | null };
/** 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 SonarrClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
private readonly plex: PlexLookup | null = null,
) {
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_SONARR_API_KEY or, failing
* that, discovered from the server's own config.xml under MESH_SONARR_CONFIG_DIR — the same file
* Sonarr writes it to, so a running server needs nothing configured by hand (as plex does with
* its token). 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): SonarrClient {
const url = env.MESH_SONARR_URL ?? `http://127.0.0.1:${env.MESH_SONARR_PORT ?? "8989"}`;
const configDir = env.MESH_SONARR_CONFIG_DIR ?? "/config";
const apiKey = keyFile(env.MESH_SONARR_API_KEY_FILE) ?? env.MESH_SONARR_API_KEY ?? SonarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable");
}
return new SonarrClient(url, apiKey, PlexLookup.fromEnv(env));
}
/** 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(`Sonarr API ${method} /${endpoint}: ${res.status} ${await res.text()}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
/** Look a series up outside the library — Sonarr's metadata source — by title, or by `tvdb:<id>`. */
async lookup(term: string): Promise<Record<string, unknown>[]> {
const found = (await this.get("series/lookup", { term })) as Record<string, unknown>[];
return (found ?? []).map((x) => ({
title: x.title, year: x.year, tvdbId: x.tvdbId,
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 series by its TVDb 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
* Sonarr 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("series/lookup", { term: `tvdb:${id}` })) as Record<string, unknown>[];
const record = found?.[0];
if (!record) throw new Error(`Sonarr knows no series with TVDb id ${id}`);
if (typeof record.id === "number" && record.id > 0) {
return { added: false, alreadyInLibrary: true, id: record.id, title: record.title };
}
// A profile is a preference, so the first stands in when none is named; a root folder decides
// where the data lands, so with several it must be named — the refusal lists them.
const pick = <T extends { id: number; name?: string; path?: string }>(list: T[], want: string | undefined, what: string, firstWillDo = true): T => {
if (!list.length) throw new Error(`Sonarr has no ${what} configured`);
if (!want && (firstWillDo || list.length === 1)) return list[0];
if (!want) throw new Error(`Sonarr has ${list.length} ${what}s — name one: ${list.map((x) => x.name ?? x.path).join(", ")}`);
const hit = list.find((x) => x.name === want || x.path === want);
if (!hit) throw new Error(`Sonarr 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", false);
const search = opts.search ?? false;
const body = {
...record,
qualityProfileId: quality.id,
rootFolderPath: root.path,
monitored: opts.monitored ?? true,
addOptions: { monitor: "all", searchForMissingEpisodes: search },
};
const made = (await this.send("POST", "series", body)) as Record<string, unknown>;
return { added: true, id: made.id, title: made.title, tvdbId: made.tvdbId, rootFolder: root.path, qualityProfile: quality.name, monitored: body.monitored, searchRequested: search };
}
/** Remove a series from the library by its Sonarr id. Files stay on disk unless `deleteFiles`. */
async remove(id: number, deleteFiles = false): Promise<{ removed: boolean; id: number }> {
await this.send("DELETE", `series/${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<SonarrContentItem[]> {
const data = await this.get(CONTENT_ENDPOINT);
const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []);
const mapped = items.map((item) => ({
id: item.id,
title: item.title ?? "Unknown",
year: item.year,
tvdbId: item.tvdbId,
imdbId: item.imdbId,
status: item.status,
monitored: item.monitored ?? true,
network: item.network,
seasons: item.statistics?.seasonCount,
episodesOnDisk: item.statistics?.episodeFileCount,
episodesAired: item.statistics?.episodeCount,
episodesTotal: item.statistics?.totalEpisodeCount,
percentOnDisk: item.statistics?.percentOfEpisodes,
sizeOnDiskBytes: item.statistics?.sizeOnDisk,
path: item.path,
genres: item.genres,
added: item.added,
qualityProfileId: item.qualityProfileId,
overview: typeof item.overview === "string" ? item.overview.slice(0, 300) : undefined,
}));
return limit ? mapped.slice(0, limit) : mapped;
}
/** Library search is a filter over existing content, not an indexer lookup — same as hal's. */
/** Library search is a filter over existing content, not an indexer lookup — same as hal's.
* Each of the first ten hits also says where it is in Plex (when this module binds plex-api):
* `plex` is the match, null when Plex has no such title, or `{ error }` when Plex could not be asked. */
async searchContent(term: string): Promise<SonarrContentItem[]> {
const all = await this.getContent();
const lower = term.toLowerCase();
const hits = all.filter((item) => item.title.toLowerCase().includes(lower));
if (!this.plex) return hits;
return Promise.all(hits.map(async (item, i) => {
if (i >= 10) return item;
try {
return { ...item, plex: await this.plex!.find(PLEX_SHOW, item.title, typeof item.year === "number" ? item.year : undefined) };
} catch (err) {
return { ...item, plex: { error: String(err) } as unknown as PlexMatch };
}
}));
}
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]}`;
}