Files
mesh-media-catalog/modules/radarr/plex.ts
T
jschoubben 9ffd0dd96a 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.
2026-10-01 15:54:20 +02:00

95 lines
4.0 KiB
TypeScript

// 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}`,
};
}
}