From 9ffd0dd96a81a37b75b50d2a7b14b1fd94db0fec Mon Sep 17 00:00:00 2001 From: jochens Date: Thu, 1 Oct 2026 15:54:20 +0200 Subject: [PATCH] radarr, sonarr, lidarr: a search tells everything the app keeps, and where the item is in Plex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/lidarr/Dockerfile | 2 +- modules/lidarr/client.ts | 37 +++++++++++--- modules/lidarr/module.json | 21 +++++--- modules/lidarr/plex.ts | 94 +++++++++++++++++++++++++++++++++++ modules/lidarr/tools/index.ts | 2 +- modules/lidarr/tsconfig.json | 9 +++- modules/radarr/Dockerfile | 2 +- modules/radarr/client.ts | 38 +++++++++++--- modules/radarr/module.json | 21 +++++--- modules/radarr/plex.ts | 94 +++++++++++++++++++++++++++++++++++ modules/radarr/tools/index.ts | 2 +- modules/radarr/tsconfig.json | 9 +++- modules/sonarr/Dockerfile | 2 +- modules/sonarr/client.ts | 40 ++++++++++++--- modules/sonarr/module.json | 21 +++++--- modules/sonarr/plex.ts | 94 +++++++++++++++++++++++++++++++++++ modules/sonarr/tools/index.ts | 2 +- modules/sonarr/tsconfig.json | 9 +++- 18 files changed, 449 insertions(+), 50 deletions(-) create mode 100644 modules/lidarr/plex.ts create mode 100644 modules/radarr/plex.ts create mode 100644 modules/sonarr/plex.ts diff --git a/modules/lidarr/Dockerfile b/modules/lidarr/Dockerfile index fc3c4b8..117966c 100644 --- a/modules/lidarr/Dockerfile +++ b/modules/lidarr/Dockerfile @@ -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} diff --git a/modules/lidarr/client.ts b/modules/lidarr/client.ts index 7693d8d..5f4e86d 100644 --- a/modules/lidarr/client.ts +++ b/modules/lidarr/client.ts @@ -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 & { 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 { 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[] }> { diff --git a/modules/lidarr/module.json b/modules/lidarr/module.json index 7e689ec..6905bbd 100644 --- a/modules/lidarr/module.json +++ b/modules/lidarr/module.json @@ -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": [ diff --git a/modules/lidarr/plex.ts b/modules/lidarr/plex.ts new file mode 100644 index 0000000..6d930a1 --- /dev/null +++ b/modules/lidarr/plex.ts @@ -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 = {}): Promise { + 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 { + 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 { + 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}`, + }; + } +} diff --git a/modules/lidarr/tools/index.ts b/modules/lidarr/tools/index.ts index 4d454e0..8a84f47 100644 --- a/modules/lidarr/tools/index.ts +++ b/modules/lidarr/tools/index.ts @@ -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); diff --git a/modules/lidarr/tsconfig.json b/modules/lidarr/tsconfig.json index 46692d3..58658b4 100644 --- a/modules/lidarr/tsconfig.json +++ b/modules/lidarr/tsconfig.json @@ -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" + ] } diff --git a/modules/radarr/Dockerfile b/modules/radarr/Dockerfile index a03e8d9..2eb74af 100644 --- a/modules/radarr/Dockerfile +++ b/modules/radarr/Dockerfile @@ -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} diff --git a/modules/radarr/client.ts b/modules/radarr/client.ts index d6a60f4..51cda83 100644 --- a/modules/radarr/client.ts +++ b/modules/radarr/client.ts @@ -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 & { 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 { 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[] }> { diff --git a/modules/radarr/module.json b/modules/radarr/module.json index 14e0f51..66927e3 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -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": [ diff --git a/modules/radarr/plex.ts b/modules/radarr/plex.ts new file mode 100644 index 0000000..6bff271 --- /dev/null +++ b/modules/radarr/plex.ts @@ -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 = {}): Promise { + 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 { + 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 { + 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}`, + }; + } +} diff --git a/modules/radarr/tools/index.ts b/modules/radarr/tools/index.ts index 763bdb6..bb5de62 100644 --- a/modules/radarr/tools/index.ts +++ b/modules/radarr/tools/index.ts @@ -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); diff --git a/modules/radarr/tsconfig.json b/modules/radarr/tsconfig.json index 46692d3..58658b4 100644 --- a/modules/radarr/tsconfig.json +++ b/modules/radarr/tsconfig.json @@ -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" + ] } diff --git a/modules/sonarr/Dockerfile b/modules/sonarr/Dockerfile index e740da3..2e28cdb 100644 --- a/modules/sonarr/Dockerfile +++ b/modules/sonarr/Dockerfile @@ -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} diff --git a/modules/sonarr/client.ts b/modules/sonarr/client.ts index 26bff54..b2bc433 100644 --- a/modules/sonarr/client.ts +++ b/modules/sonarr/client.ts @@ -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 & { 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 { 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[] }> { diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index 511ffcf..682792b 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -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": [ diff --git a/modules/sonarr/plex.ts b/modules/sonarr/plex.ts new file mode 100644 index 0000000..c486b0e --- /dev/null +++ b/modules/sonarr/plex.ts @@ -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 = {}): Promise { + 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 { + 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 { + 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}`, + }; + } +} diff --git a/modules/sonarr/tools/index.ts b/modules/sonarr/tools/index.ts index 61e9815..d911181 100644 --- a/modules/sonarr/tools/index.ts +++ b/modules/sonarr/tools/index.ts @@ -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); diff --git a/modules/sonarr/tsconfig.json b/modules/sonarr/tsconfig.json index 46692d3..58658b4 100644 --- a/modules/sonarr/tsconfig.json +++ b/modules/sonarr/tsconfig.json @@ -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" + ] }