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" + ] }