Merge pull request 'radarr, sonarr, lidarr: control tools — look up, add and remove' (#7) from feat/arr-control-tools into main

This commit was merged in pull request #7.
This commit is contained in:
2026-10-01 12:26:27 +00:00
6 changed files with 341 additions and 0 deletions
+80
View File
@@ -88,6 +88,86 @@ export class LidarrClient {
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.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 };
}
const pick = <T extends { id: number; name?: string; path?: string }>(list: T[], want: string | undefined, what: string): T => {
if (!list.length) throw new Error(`Lidarr has no ${what} configured`);
if (!want) return list[0];
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");
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.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" };
+37
View File
@@ -64,6 +64,43 @@ export function getLidarrTools(lidarr: LidarrClient): ToolDefinition[] {
return { days, count: items.length, items };
},
},
{
name: "lidarr_lookup",
description: "Look a artist up outside the library, on Lidarr's metadata source: title, year, MusicBrainz artist id and whether it is already in the library.",
input: { query: { type: "string", description: "a title, or `lidarr:<id>`" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await lidarr.lookup(query) };
},
},
{
name: "lidarr_add",
description: "Add a artist to Lidarr by its MusicBrainz artist id (from lidarr_lookup). Uses the first quality and metadata profile and root folder unless named. Does not search the indexers unless `search` is true.",
input: {
id: { type: "string", description: "the MusicBrainz artist id" },
qualityProfile: { type: "string", description: "a quality profile's name (default: the first)" },
metadataProfile: { type: "string", description: "a metadata profile's name (default: the first)" },
rootFolder: { type: "string", description: "a root folder path (default: the first)" },
monitored: { type: "boolean", description: "monitor it (default true)" },
search: { type: "boolean", description: "search the indexers right away (default false)" },
},
run: async (args) => lidarr.add(String(args.id), {
qualityProfile: args.qualityProfile ? String(args.qualityProfile) : undefined,
metadataProfile: args.metadataProfile ? String(args.metadataProfile) : undefined,
rootFolder: args.rootFolder ? String(args.rootFolder) : undefined,
monitored: args.monitored === undefined ? undefined : Boolean(args.monitored),
search: Boolean(args.search),
}),
},
{
name: "lidarr_remove",
description: "Remove a artist from Lidarr's library by its Lidarr id. Files stay on disk unless `deleteFiles` is true.",
input: {
id: { type: "number", description: "the Lidarr id of the artist" },
deleteFiles: { type: "boolean", description: "also delete its files (default false)" },
},
run: async (args) => lidarr.remove(Number(args.id), Boolean(args.deleteFiles)),
},
];
}
+75
View File
@@ -89,6 +89,81 @@ export class RadarrClient {
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(`Radarr API ${method} /${endpoint}: ${res.status} ${await res.text()}`);
const text = await res.text();
return text ? JSON.parse(text) : null;
}
/** Look a movie up outside the library — Radarr's metadata source — by title, or by `tmdb:<id>`. */
async lookup(term: string): Promise<Record<string, unknown>[]> {
const found = (await this.get("movie/lookup", { term })) as Record<string, unknown>[];
return (found ?? []).map((x) => ({
title: x.title, year: x.year, tmdbId: x.tmdbId,
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 movie by its TMDb 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
* Radarr 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("movie/lookup", { term: `tmdb:${id}` })) as Record<string, unknown>[];
const record = found?.[0];
if (!record) throw new Error(`Radarr knows no movie with TMDb id ${id}`);
if (typeof record.id === "number" && record.id > 0) {
return { added: false, alreadyInLibrary: true, id: record.id, title: record.title };
}
const pick = <T extends { id: number; name?: string; path?: string }>(list: T[], want: string | undefined, what: string): T => {
if (!list.length) throw new Error(`Radarr has no ${what} configured`);
if (!want) return list[0];
const hit = list.find((x) => x.name === want || x.path === want);
if (!hit) throw new Error(`Radarr 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");
const search = opts.search ?? false;
const body = {
...record,
qualityProfileId: quality.id,
rootFolderPath: root.path,
monitored: opts.monitored ?? true,
addOptions: { searchForMovie: search },
};
const made = (await this.send("POST", "movie", body)) as Record<string, unknown>;
return { added: true, id: made.id, title: made.title, tmdbId: made.tmdbId, rootFolder: root.path, qualityProfile: quality.name, monitored: body.monitored, searchRequested: search };
}
/** Remove a movie from the library by its Radarr id. Files stay on disk unless `deleteFiles`. */
async remove(id: number, deleteFiles = false): Promise<{ removed: boolean; id: number }> {
await this.send("DELETE", `movie/${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" };
+37
View File
@@ -64,6 +64,43 @@ export function getRadarrTools(radarr: RadarrClient): ToolDefinition[] {
return { days, count: items.length, items };
},
},
{
name: "radarr_lookup",
description: "Look a movie up outside the library, on Radarr's metadata source: title, year, TMDb id and whether it is already in the library.",
input: { query: { type: "string", description: "a title, or `tmdb:<id>`" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await radarr.lookup(query) };
},
},
{
name: "radarr_add",
description: "Add a movie to Radarr by its TMDb id (from radarr_lookup). Uses the first quality profile and root folder unless named. Does not search the indexers unless `search` is true.",
input: {
id: { type: "string", description: "the TMDb id" },
qualityProfile: { type: "string", description: "a quality profile's name (default: the first)" },
rootFolder: { type: "string", description: "a root folder path (default: the first)" },
monitored: { type: "boolean", description: "monitor it (default true)" },
search: { type: "boolean", description: "search the indexers right away (default false)" },
},
run: async (args) => radarr.add(String(args.id), {
qualityProfile: args.qualityProfile ? String(args.qualityProfile) : undefined,
rootFolder: args.rootFolder ? String(args.rootFolder) : undefined,
monitored: args.monitored === undefined ? undefined : Boolean(args.monitored),
search: Boolean(args.search),
}),
},
{
name: "radarr_remove",
description: "Remove a movie from Radarr's library by its Radarr id. Files stay on disk unless `deleteFiles` is true.",
input: {
id: { type: "number", description: "the Radarr id of the movie" },
deleteFiles: { type: "boolean", description: "also delete its files (default false)" },
},
run: async (args) => radarr.remove(Number(args.id), Boolean(args.deleteFiles)),
},
];
}
+75
View File
@@ -89,6 +89,81 @@ export class SonarrClient {
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 };
}
const pick = <T extends { id: number; name?: string; path?: string }>(list: T[], want: string | undefined, what: string): T => {
if (!list.length) throw new Error(`Sonarr has no ${what} configured`);
if (!want) return list[0];
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");
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" };
+37
View File
@@ -64,6 +64,43 @@ export function getSonarrTools(sonarr: SonarrClient): ToolDefinition[] {
return { days, count: items.length, items };
},
},
{
name: "sonarr_lookup",
description: "Look a series up outside the library, on Sonarr's metadata source: title, year, TVDb id and whether it is already in the library.",
input: { query: { type: "string", description: "a title, or `tvdb:<id>`" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await sonarr.lookup(query) };
},
},
{
name: "sonarr_add",
description: "Add a series to Sonarr by its TVDb id (from sonarr_lookup). Uses the first quality profile and root folder unless named. Does not search the indexers unless `search` is true.",
input: {
id: { type: "string", description: "the TVDb id" },
qualityProfile: { type: "string", description: "a quality profile's name (default: the first)" },
rootFolder: { type: "string", description: "a root folder path (default: the first)" },
monitored: { type: "boolean", description: "monitor it (default true)" },
search: { type: "boolean", description: "search the indexers right away (default false)" },
},
run: async (args) => sonarr.add(String(args.id), {
qualityProfile: args.qualityProfile ? String(args.qualityProfile) : undefined,
rootFolder: args.rootFolder ? String(args.rootFolder) : undefined,
monitored: args.monitored === undefined ? undefined : Boolean(args.monitored),
search: Boolean(args.search),
}),
},
{
name: "sonarr_remove",
description: "Remove a series from Sonarr's library by its Sonarr id. Files stay on disk unless `deleteFiles` is true.",
input: {
id: { type: "number", description: "the Sonarr id of the series" },
deleteFiles: { type: "boolean", description: "also delete its files (default false)" },
},
run: async (args) => sonarr.remove(Number(args.id), Boolean(args.deleteFiles)),
},
];
}