// The Plex API client — plex's own code, living in the module (novox/hq ADR 0044). Moved out of the // shared hal sdk, where a change to Plex's API rebuilt everything; here it rebuilds only plex. Both // this module's tools and its events entrypoint import it, and nothing outside plex does. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; export interface PlexLibrary { key: string; title: string; type: string; count?: number; } export interface PlexSession { key: string; title: string; user: string; player: string; state: string; type: string; } export interface PlexItem { title: string; type: string; year?: number; summary?: string; addedAt?: string; } export class PlexClient { readonly baseUrl: string; constructor( url: string, private readonly token: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** * Build from the module's resolved environment. The token is read from MESH_PLEX_TOKEN, or * discovered from the server's own Preferences.xml under the data directory — the same file Plex * writes it to, so a running server needs nothing configured by hand. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexClient { const url = env.MESH_PLEX_URL ?? `http://127.0.0.1:${env.PLEX_PORT ?? "32400"}`; const dataDir = env.MESH_PLEX_DATA_DIR ?? "/var/lib/plex"; const token = env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir); if (!token) throw new Error("no Plex token — set MESH_PLEX_TOKEN or make the data dir readable"); return new PlexClient(url, token); } /** Discover the token from the server's Preferences.xml, falling back to null. */ static detectToken(dataDir: string): string | null { const prefs = join(dataDir, "config", "Library", "Application Support", "Plex Media Server", "Preferences.xml"); if (existsSync(prefs)) { const match = readFileSync(prefs, "utf8").match(/PlexOnlineToken="([^"]+)"/); if (match) return match[1]; } return null; } private async get(path: string): Promise { const url = `${this.baseUrl}${path}`; const sep = url.includes("?") ? "&" : "?"; const res = await fetch(`${url}${sep}X-Plex-Token=${this.token}`, { headers: { Accept: "application/json" } }); if (!res.ok) throw new Error(`Plex API ${path}: ${res.status} ${await res.text()}`); return res.json(); } async getServerInfo(): Promise<{ name: string; version: string; platform: string }> { const mc = (await this.get("/")).MediaContainer; return { name: mc.friendlyName || mc.machineIdentifier, version: mc.version, platform: mc.platform }; } async getLibraries(): Promise { const dirs = (await this.get("/library/sections")).MediaContainer?.Directory ?? []; return dirs.map((d: any) => ({ key: d.key, title: d.title, type: d.type, count: d.count })); } async getSessions(): Promise { const sessions = (await this.get("/status/sessions")).MediaContainer?.Metadata ?? []; return sessions.map((s: any) => ({ key: s.sessionKey ?? s.ratingKey, title: s.title + (s.grandparentTitle ? ` (${s.grandparentTitle})` : ""), user: s.User?.title ?? "unknown", player: s.Player?.title ?? s.Player?.product ?? "unknown", state: s.Player?.state ?? "unknown", type: s.type, })); } async search(query: string): Promise { const hubs = (await this.get(`/hubs/search?query=${encodeURIComponent(query)}&limit=20`)).MediaContainer?.Hub ?? []; const results: PlexItem[] = []; for (const hub of hubs) { for (const m of hub.Metadata ?? []) { results.push({ title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), type: m.type, year: m.year, summary: m.summary?.slice(0, 200), }); } } return results; } async getRecentlyAdded(limit = 20): Promise { const items = (await this.get(`/library/recentlyAdded?X-Plex-Container-Size=${limit}`)).MediaContainer?.Metadata ?? []; return items.map((m: any) => ({ title: m.title + (m.grandparentTitle ? ` (${m.grandparentTitle})` : ""), type: m.type, year: m.year, summary: m.summary?.slice(0, 200), addedAt: m.addedAt ? new Date(m.addedAt * 1000).toISOString() : undefined, })); } /** Ask Plex to rescan a library section — how a "new media arrived" event becomes a visible item. */ async refreshLibrary(key: string): Promise { await this.get(`/library/sections/${key}/refresh`); } /** Rescan every library, for when what arrived is not known to belong to one. */ async refreshAll(): Promise { for (const library of await this.getLibraries()) await this.refreshLibrary(library.key); } }