radarr, sonarr, lidarr: control tools — look up, add and remove
Each gains _lookup (the metadata source, by title or by id), _add (by TMDb, TVDb or MusicBrainz id, with the first quality/metadata profile and root folder unless named, monitored, searching only when asked) and _remove (by the app's id, files kept unless asked).
This commit is contained in:
@@ -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" };
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user