Files
mesh-media-catalog/modules/lidarr/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

257 lines
12 KiB
TypeScript

// The Lidarr API client — lidarr'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 Lidarr's API rebuilds only lidarr and nothing else. Both this module's tools and its
// events entrypoint import it, and nothing outside lidarr does.
import { existsSync, readFileSync } from "node:fs";
import { PlexLookup, PLEX_ARTIST, type PlexMatch } from "./plex.js";
import { join } from "node:path";
// Lidarr speaks the v1 API (Radarr/Sonarr are v3); its content is the "artist".
const API_VERSION = "v1";
const CONTENT_ENDPOINT = "artist";
const APP_NAME = "Lidarr";
export interface LidarrQueueItem {
/** 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 LidarrCalendarItem {
title: string;
date: string;
overview?: string;
}
export type LidarrContentItem = 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 LidarrClient {
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_LIDARR_API_KEY or, failing that,
* discovered from the server's own config.xml under MESH_LIDARR_CONFIG_DIR — the same file Lidarr
* writes it to, so a running server needs nothing configured by hand. 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): LidarrClient {
const url = env.MESH_LIDARR_URL ?? `http://127.0.0.1:${env.MESH_LIDARR_PORT ?? "8686"}`;
const configDir = env.MESH_LIDARR_CONFIG_DIR ?? "/config";
const apiKey = keyFile(env.MESH_LIDARR_API_KEY_FILE) ?? env.MESH_LIDARR_API_KEY ?? LidarrClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Lidarr not configured — set MESH_LIDARR_API_KEY or make the config dir readable");
}
return new LidarrClient(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(`Lidarr API ${method} /${endpoint}: ${res.status} ${await res.text()}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
/** Look a artist up outside the library — Lidarr's metadata source — by title, or by `lidarr:<id>`. */
async lookup(term: string): Promise<Record<string, unknown>[]> {
const found = (await this.get("artist/lookup", { term })) as Record<string, unknown>[];
return (found ?? []).map((x) => ({
title: x.artistName ?? x.title, year: x.year, foreignArtistId: x.foreignArtistId,
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 }));
}
async metadataProfiles(): Promise<{ id: number; name: string }[]> {
const list = (await this.get("metadataprofile")) as { id: number; name: string }[];
return list.map((q) => ({ id: q.id, name: q.name }));
}
/**
* Add a artist by its MusicBrainz artist 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
* Lidarr to look for it on the indexers right away.
*/
async add(id: string, opts: { qualityProfile?: string; metadataProfile?: string; rootFolder?: string; monitored?: boolean; search?: boolean } = {}): Promise<Record<string, unknown>> {
const found = (await this.get("artist/lookup", { term: `lidarr:${id}` })) as Record<string, unknown>[];
const record = found?.[0];
if (!record) throw new Error(`Lidarr knows no artist with MusicBrainz artist 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(`Lidarr has no ${what} configured`);
if (!want && (firstWillDo || list.length === 1)) return list[0];
if (!want) throw new Error(`Lidarr 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(`Lidarr 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 metadata = pick(await this.metadataProfiles(), opts.metadataProfile, "metadata profile");
const search = opts.search ?? false;
const body = {
...record,
qualityProfileId: quality.id,
metadataProfileId: metadata.id,
rootFolderPath: root.path,
monitored: opts.monitored ?? true,
addOptions: { monitor: "all", searchForMissingAlbums: search },
};
const made = (await this.send("POST", "artist", body)) as Record<string, unknown>;
return { added: true, id: made.id, title: made.artistName ?? made.title, foreignArtistId: made.foreignArtistId, rootFolder: root.path, qualityProfile: quality.name, metadataProfile: metadata.name, monitored: body.monitored, searchRequested: search };
}
/** Remove a artist from the library by its Lidarr id. Files stay on disk unless `deleteFiles`. */
async remove(id: number, deleteFiles = false): Promise<{ removed: boolean; id: number }> {
await this.send("DELETE", `artist/${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<LidarrContentItem[]> {
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,
// Lidarr's content is an artist; its display name is artistName, not title.
title: item.artistName ?? item.title ?? "Unknown",
foreignArtistId: item.foreignArtistId,
status: item.status,
monitored: item.monitored ?? true,
albums: item.statistics?.albumCount,
tracksOnDisk: item.statistics?.trackFileCount,
tracksTotal: item.statistics?.trackCount,
percentOnDisk: item.statistics?.percentOfTracks,
sizeOnDiskBytes: item.statistics?.sizeOnDisk,
path: item.path,
genres: item.genres,
added: item.added,
qualityProfileId: item.qualityProfileId,
metadataProfileId: item.metadataProfileId,
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<LidarrContentItem[]> {
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_ARTIST, 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: LidarrQueueItem[] }> {
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.artist?.artistName ?? r.album?.title ?? "Unknown",
status: r.status ?? "unknown",
size: formatBytes(r.size ?? 0),
sizeleft: formatBytes(r.sizeleft ?? 0),
timeleft: r.timeleft,
})),
};
}
async getCalendar(days = 7): Promise<LidarrCalendarItem[]> {
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) => ({
// A Lidarr calendar entry is an album release.
title: item.title ?? item.artist?.artistName ?? "Unknown",
date: item.releaseDate ?? "",
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]}`;
}