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.
This commit is contained in:
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/lidarr
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts plex.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// 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".
|
||||
@@ -27,11 +28,7 @@ export interface LidarrCalendarItem {
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
export interface LidarrContentItem {
|
||||
title: string;
|
||||
status?: string;
|
||||
monitored: boolean;
|
||||
}
|
||||
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 {
|
||||
@@ -45,6 +42,7 @@ export class LidarrClient {
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly plex: PlexLookup | null = null,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
@@ -64,7 +62,7 @@ export class LidarrClient {
|
||||
if (!apiKey) {
|
||||
throw new Error("Lidarr not configured — set MESH_LIDARR_API_KEY or make the config dir readable");
|
||||
}
|
||||
return new LidarrClient(url, apiKey);
|
||||
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
|
||||
@@ -180,19 +178,44 @@ export class LidarrClient {
|
||||
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();
|
||||
return all.filter((item) => item.title.toLowerCase().includes(lower));
|
||||
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[] }> {
|
||||
|
||||
@@ -132,17 +132,23 @@
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:config}:/var/lib/lidarr/config:ro",
|
||||
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
|
||||
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro",
|
||||
"${dir:state}/plex-api.json:/run/plex/plex-api.json:ro",
|
||||
"${dir:state}/plex-api.secret:/run/plex/plex-api.secret:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
|
||||
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config",
|
||||
"MESH_LIDARR_API_KEY_FILE": "/run/secrets/api-key"
|
||||
"MESH_LIDARR_API_KEY_FILE": "/run/secrets/api-key",
|
||||
"MESH_PLEX_BIND_FILE": "/run/plex/plex-api.json",
|
||||
"MESH_PLEX_TOKEN_FILE": "/run/plex/plex-api.secret"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"needs-api-key"
|
||||
"needs-api-key",
|
||||
"bound-plex-api",
|
||||
"secret-plex-api"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -204,7 +210,8 @@
|
||||
"jackett-api",
|
||||
"nzbget-api",
|
||||
"qbittorrent-api",
|
||||
"route"
|
||||
"route",
|
||||
"plex-api"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
@@ -216,12 +223,14 @@
|
||||
"route": "${dir:state}/route.json",
|
||||
"nzbget-api": "${dir:state}/nzbget-api.json",
|
||||
"qbittorrent-api": "${dir:state}/qbittorrent-api.json",
|
||||
"jackett-api": "${dir:state}/jackett-api.json"
|
||||
"jackett-api": "${dir:state}/jackett-api.json",
|
||||
"plex-api": "${dir:state}/plex-api.json"
|
||||
},
|
||||
"secrets": {
|
||||
"nzbget-api": "${dir:state}/nzbget-api.secret",
|
||||
"qbittorrent-api": "${dir:state}/qbittorrent-api.secret",
|
||||
"jackett-api": "${dir:state}/jackett-api.secret"
|
||||
"jackett-api": "${dir:state}/jackett-api.secret",
|
||||
"plex-api": "${dir:state}/plex-api.secret"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Where a library item lives in Plex — Lidarr's own view of its neighbour (novox/hq ADR 0039).
|
||||
// This module binds `plex-api`; the binding carries where the server is, the pair secret its
|
||||
// token. A search result that says "it is in Plex, in this quality, added then, watched n times,
|
||||
// here is the link" answers the question an operator actually has.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface PlexMatch {
|
||||
ratingKey: string;
|
||||
librarySection?: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
addedAt?: string;
|
||||
lastViewedAt?: string;
|
||||
viewCount?: number;
|
||||
resolution?: string;
|
||||
videoCodec?: string;
|
||||
sizeBytes?: number;
|
||||
durationMinutes?: number;
|
||||
/** Shows and artists: how much of it is there and seen. */
|
||||
leafCount?: number;
|
||||
viewedLeafCount?: number;
|
||||
/** Opens the item in Plex, for whoever is signed in to this server. */
|
||||
link: string;
|
||||
}
|
||||
|
||||
/** Plex's library item types, as its /library/all?type= wants them. */
|
||||
export const PLEX_MOVIE = 1, PLEX_SHOW = 2, PLEX_ARTIST = 8;
|
||||
|
||||
export class PlexLookup {
|
||||
private machineId: string | null = null;
|
||||
|
||||
constructor(private readonly baseUrl: string, private readonly token: string) {}
|
||||
|
||||
/** From the binding and the pair secret the mesh delivered; null when this module has no Plex. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexLookup | null {
|
||||
try {
|
||||
const bind = JSON.parse(readFileSync(env.MESH_PLEX_BIND_FILE ?? "", "utf8")) as {
|
||||
at?: string; serves?: { scheme?: string; port?: number };
|
||||
};
|
||||
const token = readFileSync(env.MESH_PLEX_TOKEN_FILE ?? "", "utf8").trim();
|
||||
const scheme = bind.serves?.scheme ?? "http", port = bind.serves?.port ?? 32400;
|
||||
if (!bind.at || !token) return null;
|
||||
return new PlexLookup(`${scheme}://${bind.at}:${port}`, token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(this.baseUrl + path);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url.toString(), { headers: { "X-Plex-Token": this.token, Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Plex ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async identity(): Promise<string> {
|
||||
if (this.machineId === null) {
|
||||
const data = await this.get("/identity");
|
||||
this.machineId = String(data?.MediaContainer?.machineIdentifier ?? "");
|
||||
}
|
||||
return this.machineId;
|
||||
}
|
||||
|
||||
/** The library item of this type with this title (and year, when given), or null. */
|
||||
async find(type: number, title: string, year?: number): Promise<PlexMatch | null> {
|
||||
const data = await this.get("/library/all", { type: String(type), title });
|
||||
const items: any[] = data?.MediaContainer?.Metadata ?? [];
|
||||
const wanted = title.trim().toLowerCase();
|
||||
const hit = items.find((x) => String(x.title ?? "").trim().toLowerCase() === wanted && (year === undefined || x.year === undefined || x.year === year))
|
||||
?? items.find((x) => String(x.title ?? "").trim().toLowerCase() === wanted);
|
||||
if (!hit) return null;
|
||||
const machine = await this.identity();
|
||||
const media = hit.Media?.[0];
|
||||
const when = (t: unknown) => (typeof t === "number" ? new Date(t * 1000).toISOString() : undefined);
|
||||
return {
|
||||
ratingKey: String(hit.ratingKey),
|
||||
librarySection: hit.librarySectionTitle,
|
||||
title: hit.title,
|
||||
year: hit.year,
|
||||
addedAt: when(hit.addedAt),
|
||||
lastViewedAt: when(hit.lastViewedAt),
|
||||
viewCount: hit.viewCount,
|
||||
resolution: media?.videoResolution ? `${media.videoResolution}p`.replace("pp", "p").replace("4kp", "4k") : undefined,
|
||||
videoCodec: media?.videoCodec,
|
||||
sizeBytes: media?.Part?.[0]?.size,
|
||||
durationMinutes: typeof hit.duration === "number" ? Math.round(hit.duration / 60000) : undefined,
|
||||
leafCount: hit.leafCount,
|
||||
viewedLeafCount: hit.viewedLeafCount,
|
||||
link: `https://app.plex.tv/desktop/#!/server/${machine}/details?key=%2Flibrary%2Fmetadata%2F${hit.ratingKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function getLidarrTools(lidarr: LidarrClient): ToolDefinition[] {
|
||||
},
|
||||
{
|
||||
name: "lidarr_search",
|
||||
description: "Search the Lidarr library for artists by name (filters existing content, not indexers).",
|
||||
description: "Search the Lidarr library (every field Lidarr keeps, plus where each of the first ten hits is in Plex: quality, added, watched, link) for artists by name (filters existing content, not indexers).",
|
||||
input: { query: { type: "string", description: "the search term" } },
|
||||
run: async (args) => {
|
||||
const query = String(args.query);
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"]
|
||||
"include": [
|
||||
"client.ts",
|
||||
"plex.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts",
|
||||
"downloads/settings.ts",
|
||||
"downloads/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/radarr
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts plex.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// events entrypoint import it, and nothing outside radarr does.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { PlexLookup, PLEX_MOVIE, type PlexMatch } from "./plex.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Radarr speaks the v3 API; its content is "movie".
|
||||
@@ -27,12 +28,7 @@ export interface RadarrCalendarItem {
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
export interface RadarrContentItem {
|
||||
title: string;
|
||||
year?: number;
|
||||
status?: string;
|
||||
monitored: boolean;
|
||||
}
|
||||
export type RadarrContentItem = 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 {
|
||||
@@ -46,6 +42,7 @@ export class RadarrClient {
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly plex: PlexLookup | null = null,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
@@ -65,7 +62,7 @@ export class RadarrClient {
|
||||
if (!apiKey) {
|
||||
throw new Error("Radarr not configured — set MESH_RADARR_API_KEY or make the config dir readable");
|
||||
}
|
||||
return new RadarrClient(url, apiKey);
|
||||
return new RadarrClient(url, apiKey, PlexLookup.fromEnv(env));
|
||||
}
|
||||
|
||||
/** Discover the API key from the server's config.xml, falling back to null. Every Servarr app
|
||||
@@ -176,19 +173,44 @@ export class RadarrClient {
|
||||
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,
|
||||
tmdbId: item.tmdbId,
|
||||
imdbId: item.imdbId,
|
||||
status: item.status,
|
||||
monitored: item.monitored ?? true,
|
||||
hasFile: item.hasFile ?? false,
|
||||
sizeOnDiskBytes: item.sizeOnDisk,
|
||||
quality: item.movieFile?.quality?.quality?.name,
|
||||
path: item.path,
|
||||
runtimeMinutes: item.runtime,
|
||||
genres: item.genres,
|
||||
added: item.added,
|
||||
qualityProfileId: item.qualityProfileId,
|
||||
ratings: item.ratings ? { tmdb: item.ratings.tmdb?.value, imdb: item.ratings.imdb?.value } : undefined,
|
||||
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<RadarrContentItem[]> {
|
||||
const all = await this.getContent();
|
||||
const lower = term.toLowerCase();
|
||||
return all.filter((item) => item.title.toLowerCase().includes(lower));
|
||||
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_MOVIE, 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: RadarrQueueItem[] }> {
|
||||
|
||||
@@ -126,17 +126,23 @@
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:config}:/var/lib/radarr/config:ro",
|
||||
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
|
||||
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro",
|
||||
"${dir:state}/plex-api.json:/run/plex/plex-api.json:ro",
|
||||
"${dir:state}/plex-api.secret:/run/plex/plex-api.secret:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
|
||||
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config",
|
||||
"MESH_RADARR_API_KEY_FILE": "/run/secrets/api-key"
|
||||
"MESH_RADARR_API_KEY_FILE": "/run/secrets/api-key",
|
||||
"MESH_PLEX_BIND_FILE": "/run/plex/plex-api.json",
|
||||
"MESH_PLEX_TOKEN_FILE": "/run/plex/plex-api.secret"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"needs-api-key"
|
||||
"needs-api-key",
|
||||
"bound-plex-api",
|
||||
"secret-plex-api"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -198,7 +204,8 @@
|
||||
"jackett-api",
|
||||
"nzbget-api",
|
||||
"qbittorrent-api",
|
||||
"route"
|
||||
"route",
|
||||
"plex-api"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
@@ -210,12 +217,14 @@
|
||||
"route": "${dir:state}/route.json",
|
||||
"nzbget-api": "${dir:state}/nzbget-api.json",
|
||||
"qbittorrent-api": "${dir:state}/qbittorrent-api.json",
|
||||
"jackett-api": "${dir:state}/jackett-api.json"
|
||||
"jackett-api": "${dir:state}/jackett-api.json",
|
||||
"plex-api": "${dir:state}/plex-api.json"
|
||||
},
|
||||
"secrets": {
|
||||
"nzbget-api": "${dir:state}/nzbget-api.secret",
|
||||
"qbittorrent-api": "${dir:state}/qbittorrent-api.secret",
|
||||
"jackett-api": "${dir:state}/jackett-api.secret"
|
||||
"jackett-api": "${dir:state}/jackett-api.secret",
|
||||
"plex-api": "${dir:state}/plex-api.secret"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Where a library item lives in Plex — Radarr's own view of its neighbour (novox/hq ADR 0039).
|
||||
// This module binds `plex-api`; the binding carries where the server is, the pair secret its
|
||||
// token. A search result that says "it is in Plex, in this quality, added then, watched n times,
|
||||
// here is the link" answers the question an operator actually has.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface PlexMatch {
|
||||
ratingKey: string;
|
||||
librarySection?: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
addedAt?: string;
|
||||
lastViewedAt?: string;
|
||||
viewCount?: number;
|
||||
resolution?: string;
|
||||
videoCodec?: string;
|
||||
sizeBytes?: number;
|
||||
durationMinutes?: number;
|
||||
/** Shows and artists: how much of it is there and seen. */
|
||||
leafCount?: number;
|
||||
viewedLeafCount?: number;
|
||||
/** Opens the item in Plex, for whoever is signed in to this server. */
|
||||
link: string;
|
||||
}
|
||||
|
||||
/** Plex's library item types, as its /library/all?type= wants them. */
|
||||
export const PLEX_MOVIE = 1, PLEX_SHOW = 2, PLEX_ARTIST = 8;
|
||||
|
||||
export class PlexLookup {
|
||||
private machineId: string | null = null;
|
||||
|
||||
constructor(private readonly baseUrl: string, private readonly token: string) {}
|
||||
|
||||
/** From the binding and the pair secret the mesh delivered; null when this module has no Plex. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexLookup | null {
|
||||
try {
|
||||
const bind = JSON.parse(readFileSync(env.MESH_PLEX_BIND_FILE ?? "", "utf8")) as {
|
||||
at?: string; serves?: { scheme?: string; port?: number };
|
||||
};
|
||||
const token = readFileSync(env.MESH_PLEX_TOKEN_FILE ?? "", "utf8").trim();
|
||||
const scheme = bind.serves?.scheme ?? "http", port = bind.serves?.port ?? 32400;
|
||||
if (!bind.at || !token) return null;
|
||||
return new PlexLookup(`${scheme}://${bind.at}:${port}`, token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(this.baseUrl + path);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url.toString(), { headers: { "X-Plex-Token": this.token, Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Plex ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async identity(): Promise<string> {
|
||||
if (this.machineId === null) {
|
||||
const data = await this.get("/identity");
|
||||
this.machineId = String(data?.MediaContainer?.machineIdentifier ?? "");
|
||||
}
|
||||
return this.machineId;
|
||||
}
|
||||
|
||||
/** The library item of this type with this title (and year, when given), or null. */
|
||||
async find(type: number, title: string, year?: number): Promise<PlexMatch | null> {
|
||||
const data = await this.get("/library/all", { type: String(type), title });
|
||||
const items: any[] = data?.MediaContainer?.Metadata ?? [];
|
||||
const wanted = title.trim().toLowerCase();
|
||||
const hit = items.find((x) => String(x.title ?? "").trim().toLowerCase() === wanted && (year === undefined || x.year === undefined || x.year === year))
|
||||
?? items.find((x) => String(x.title ?? "").trim().toLowerCase() === wanted);
|
||||
if (!hit) return null;
|
||||
const machine = await this.identity();
|
||||
const media = hit.Media?.[0];
|
||||
const when = (t: unknown) => (typeof t === "number" ? new Date(t * 1000).toISOString() : undefined);
|
||||
return {
|
||||
ratingKey: String(hit.ratingKey),
|
||||
librarySection: hit.librarySectionTitle,
|
||||
title: hit.title,
|
||||
year: hit.year,
|
||||
addedAt: when(hit.addedAt),
|
||||
lastViewedAt: when(hit.lastViewedAt),
|
||||
viewCount: hit.viewCount,
|
||||
resolution: media?.videoResolution ? `${media.videoResolution}p`.replace("pp", "p").replace("4kp", "4k") : undefined,
|
||||
videoCodec: media?.videoCodec,
|
||||
sizeBytes: media?.Part?.[0]?.size,
|
||||
durationMinutes: typeof hit.duration === "number" ? Math.round(hit.duration / 60000) : undefined,
|
||||
leafCount: hit.leafCount,
|
||||
viewedLeafCount: hit.viewedLeafCount,
|
||||
link: `https://app.plex.tv/desktop/#!/server/${machine}/details?key=%2Flibrary%2Fmetadata%2F${hit.ratingKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function getRadarrTools(radarr: RadarrClient): ToolDefinition[] {
|
||||
},
|
||||
{
|
||||
name: "radarr_search",
|
||||
description: "Search the Radarr library for movies by title (filters existing content, not indexers).",
|
||||
description: "Search the Radarr library (every field Radarr keeps, plus where each of the first ten hits is in Plex: quality, added, watched, link) 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);
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"]
|
||||
"include": [
|
||||
"client.ts",
|
||||
"plex.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts",
|
||||
"downloads/settings.ts",
|
||||
"downloads/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
|
||||
FROM ${BUILD_BASE} AS build
|
||||
WORKDIR /app/modules/sonarr
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts plex.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// 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".
|
||||
@@ -27,12 +28,7 @@ export interface SonarrCalendarItem {
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
export interface SonarrContentItem {
|
||||
title: string;
|
||||
year?: number;
|
||||
status?: string;
|
||||
monitored: boolean;
|
||||
}
|
||||
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 {
|
||||
@@ -46,6 +42,7 @@ export class SonarrClient {
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly plex: PlexLookup | null = null,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
@@ -65,7 +62,7 @@ export class SonarrClient {
|
||||
if (!apiKey) {
|
||||
throw new Error("Sonarr not configured — set MESH_SONARR_API_KEY or make the config dir readable");
|
||||
}
|
||||
return new SonarrClient(url, apiKey);
|
||||
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
|
||||
@@ -176,19 +173,46 @@ export class SonarrClient {
|
||||
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();
|
||||
return all.filter((item) => item.title.toLowerCase().includes(lower));
|
||||
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[] }> {
|
||||
|
||||
@@ -132,17 +132,23 @@
|
||||
"volumes": [
|
||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
||||
"${dir:config}:/var/lib/sonarr/config:ro",
|
||||
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro"
|
||||
"${dir:mesh-state}/api-key:/run/secrets/api-key:ro",
|
||||
"${dir:state}/plex-api.json:/run/plex/plex-api.json:ro",
|
||||
"${dir:state}/plex-api.secret:/run/plex/plex-api.secret:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
|
||||
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config",
|
||||
"MESH_SONARR_API_KEY_FILE": "/run/secrets/api-key"
|
||||
"MESH_SONARR_API_KEY_FILE": "/run/secrets/api-key",
|
||||
"MESH_PLEX_BIND_FILE": "/run/plex/plex-api.json",
|
||||
"MESH_PLEX_TOKEN_FILE": "/run/plex/plex-api.secret"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
"needs-api-key"
|
||||
"needs-api-key",
|
||||
"bound-plex-api",
|
||||
"secret-plex-api"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -204,7 +210,8 @@
|
||||
"jackett-api",
|
||||
"nzbget-api",
|
||||
"qbittorrent-api",
|
||||
"route"
|
||||
"route",
|
||||
"plex-api"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
@@ -216,12 +223,14 @@
|
||||
"route": "${dir:state}/route.json",
|
||||
"nzbget-api": "${dir:state}/nzbget-api.json",
|
||||
"qbittorrent-api": "${dir:state}/qbittorrent-api.json",
|
||||
"jackett-api": "${dir:state}/jackett-api.json"
|
||||
"jackett-api": "${dir:state}/jackett-api.json",
|
||||
"plex-api": "${dir:state}/plex-api.json"
|
||||
},
|
||||
"secrets": {
|
||||
"nzbget-api": "${dir:state}/nzbget-api.secret",
|
||||
"qbittorrent-api": "${dir:state}/qbittorrent-api.secret",
|
||||
"jackett-api": "${dir:state}/jackett-api.secret"
|
||||
"jackett-api": "${dir:state}/jackett-api.secret",
|
||||
"plex-api": "${dir:state}/plex-api.secret"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Where a library item lives in Plex — Sonarr's own view of its neighbour (novox/hq ADR 0039).
|
||||
// This module binds `plex-api`; the binding carries where the server is, the pair secret its
|
||||
// token. A search result that says "it is in Plex, in this quality, added then, watched n times,
|
||||
// here is the link" answers the question an operator actually has.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface PlexMatch {
|
||||
ratingKey: string;
|
||||
librarySection?: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
addedAt?: string;
|
||||
lastViewedAt?: string;
|
||||
viewCount?: number;
|
||||
resolution?: string;
|
||||
videoCodec?: string;
|
||||
sizeBytes?: number;
|
||||
durationMinutes?: number;
|
||||
/** Shows and artists: how much of it is there and seen. */
|
||||
leafCount?: number;
|
||||
viewedLeafCount?: number;
|
||||
/** Opens the item in Plex, for whoever is signed in to this server. */
|
||||
link: string;
|
||||
}
|
||||
|
||||
/** Plex's library item types, as its /library/all?type= wants them. */
|
||||
export const PLEX_MOVIE = 1, PLEX_SHOW = 2, PLEX_ARTIST = 8;
|
||||
|
||||
export class PlexLookup {
|
||||
private machineId: string | null = null;
|
||||
|
||||
constructor(private readonly baseUrl: string, private readonly token: string) {}
|
||||
|
||||
/** From the binding and the pair secret the mesh delivered; null when this module has no Plex. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexLookup | null {
|
||||
try {
|
||||
const bind = JSON.parse(readFileSync(env.MESH_PLEX_BIND_FILE ?? "", "utf8")) as {
|
||||
at?: string; serves?: { scheme?: string; port?: number };
|
||||
};
|
||||
const token = readFileSync(env.MESH_PLEX_TOKEN_FILE ?? "", "utf8").trim();
|
||||
const scheme = bind.serves?.scheme ?? "http", port = bind.serves?.port ?? 32400;
|
||||
if (!bind.at || !token) return null;
|
||||
return new PlexLookup(`${scheme}://${bind.at}:${port}`, token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(this.baseUrl + path);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url.toString(), { headers: { "X-Plex-Token": this.token, Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Plex ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async identity(): Promise<string> {
|
||||
if (this.machineId === null) {
|
||||
const data = await this.get("/identity");
|
||||
this.machineId = String(data?.MediaContainer?.machineIdentifier ?? "");
|
||||
}
|
||||
return this.machineId;
|
||||
}
|
||||
|
||||
/** The library item of this type with this title (and year, when given), or null. */
|
||||
async find(type: number, title: string, year?: number): Promise<PlexMatch | null> {
|
||||
const data = await this.get("/library/all", { type: String(type), title });
|
||||
const items: any[] = data?.MediaContainer?.Metadata ?? [];
|
||||
const wanted = title.trim().toLowerCase();
|
||||
const hit = items.find((x) => String(x.title ?? "").trim().toLowerCase() === wanted && (year === undefined || x.year === undefined || x.year === year))
|
||||
?? items.find((x) => String(x.title ?? "").trim().toLowerCase() === wanted);
|
||||
if (!hit) return null;
|
||||
const machine = await this.identity();
|
||||
const media = hit.Media?.[0];
|
||||
const when = (t: unknown) => (typeof t === "number" ? new Date(t * 1000).toISOString() : undefined);
|
||||
return {
|
||||
ratingKey: String(hit.ratingKey),
|
||||
librarySection: hit.librarySectionTitle,
|
||||
title: hit.title,
|
||||
year: hit.year,
|
||||
addedAt: when(hit.addedAt),
|
||||
lastViewedAt: when(hit.lastViewedAt),
|
||||
viewCount: hit.viewCount,
|
||||
resolution: media?.videoResolution ? `${media.videoResolution}p`.replace("pp", "p").replace("4kp", "4k") : undefined,
|
||||
videoCodec: media?.videoCodec,
|
||||
sizeBytes: media?.Part?.[0]?.size,
|
||||
durationMinutes: typeof hit.duration === "number" ? Math.round(hit.duration / 60000) : undefined,
|
||||
leafCount: hit.leafCount,
|
||||
viewedLeafCount: hit.viewedLeafCount,
|
||||
link: `https://app.plex.tv/desktop/#!/server/${machine}/details?key=%2Flibrary%2Fmetadata%2F${hit.ratingKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function getSonarrTools(sonarr: SonarrClient): ToolDefinition[] {
|
||||
},
|
||||
{
|
||||
name: "sonarr_search",
|
||||
description: "Search the Sonarr library for series by title (filters existing content, not indexers).",
|
||||
description: "Search the Sonarr library (every field Sonarr keeps, plus where each of the first ten hits is in Plex: quality, added, watched, link) 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);
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"]
|
||||
"include": [
|
||||
"client.ts",
|
||||
"plex.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts",
|
||||
"downloads/settings.ts",
|
||||
"downloads/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user