plex: full nox module — client, tools and events (ADR 0044/0046)
Moves plex's API client and tools out of the shared hal sdk into the module, so a Plex API change rebuilds only plex. Tools: status, search, sessions, recently-added, refresh. And a real event design: it emits playback started/stopped and item.added by watching the server, and consumes module.*.download.completed to rescan so a downloader's fetch becomes a visible item. Typechecks against the sdk; manifest parses with its emits/ consumes and broker own-secret. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// 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<any> {
|
||||
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<PlexLibrary[]> {
|
||||
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<PlexSession[]> {
|
||||
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<PlexItem[]> {
|
||||
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<PlexItem[]> {
|
||||
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<void> {
|
||||
await this.get(`/library/sections/${key}/refresh`);
|
||||
}
|
||||
|
||||
/** Rescan every library, for when what arrived is not known to belong to one. */
|
||||
async refreshAll(): Promise<void> {
|
||||
for (const library of await this.getLibraries()) await this.refreshLibrary(library.key);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user