The manifest named /services/plex/{config,transcode} with owner and mode.
On ace /services/plex is a link to /mnt/plex, plex's 133 GB of state, so
a take would have chmod'ed 0700 the top of the one directory the operator
ruled must never be re-moded or re-owned. The directories are now pathless
(config, data, transcode), placed by the mesh; on an adopted machine they
must be placed where the data is (hq 153) before plex is ever taken.
- The container sees exactly the paths ace's plex sees today: /config,
/data (HAL mounts it; the catalogue did not), /transcode and all eight
libraries, including sport-games, live-shows and formula-1. A library
whose mount disappears is emptied by Plex's automatic trash emptying,
taking its watch state with it.
- Libraries are mounted read-only, as the accesses already said.
- Owner 1000:1000 and mode 0755: plex runs as uid 1000 (the image reads
PLEX_UID, not the PUID HAL passes), pms-docker leaves its dirs 0755, and
ace's /mnt/plex is 1000:1000 0755 - so placing adopted data is a no-op.
- Image pinned to what ace runs, 1.43.4.10903; the old pin was 1.43.3.
- ADVERTISE_IP comes from the route's own public name through an env-file
(${bound:route:name}); it depends on mesh-controller #149, and without
it the declaration is refused, not applied.
- The server is routed (label plex) and declares its four GDM discovery
ports, which LAN players use.
- No token secret: a minted one is not a Plex token and the sidecar
preferred it. The sidecar reads PlexOnlineToken from Preferences.xml
through its read-only config mount, and dials ${port:32400}.
Verified: catalogue tests with MESH_CATALOGUE (parse, mounts); a scratch
resolution with ace's assignment on the #149 controller renders
ADVERTISE_IP=https://plex.zurag.be/, both route names and 32400/tcp +
GDM/udp open to anywhere; on main it is refused naming "name". A
throwaway pms-docker at the pinned digest on empty dirs answered
/identity, wrote customConnections from the env-file and ran as 1000;
client.ts typechecks strict and found the token in a Preferences.xml.
161 lines
6.2 KiB
TypeScript
161 lines
6.2 KiB
TypeScript
// The Plex API client — plex's own code, living in the module (novox/hq ADR 0039). 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";
|
|
|
|
/** Read a secret the mesh mounted at a file path (an own-secret); absent or unreadable yields
|
|
* undefined, so callers can fall back rather than crash. */
|
|
function readSecret(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
try {
|
|
return readFileSync(path, "utf8").trim();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
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";
|
|
// The manifest sets neither MESH_PLEX_TOKEN_FILE nor MESH_PLEX_TOKEN: the server already keeps its
|
|
// token in Preferences.xml, read here through the manifest's read-only mount of the config dir.
|
|
// A token the mesh minted would be one plex.tv never issued, and preferring it would break every
|
|
// call, so the module declares no token secret. The file and env overrides stay for hand runs.
|
|
const token = readSecret(env.MESH_PLEX_TOKEN_FILE) ?? 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);
|
|
}
|
|
|
|
/**
|
|
* A health probe that never throws: report whether the Plex server this client is pointed at
|
|
* answers, and identify it when it does. Every other call assumes the server is up; this is the
|
|
* one that tells the mesh whether it is, so a diagnosis does not start from a stack trace.
|
|
*/
|
|
async reachable(): Promise<{ reachable: boolean; url: string; server?: { name: string; version: string }; error?: string }> {
|
|
try {
|
|
const info = await this.getServerInfo();
|
|
return { reachable: true, url: this.baseUrl, server: { name: info.name, version: info.version } };
|
|
} catch (err) {
|
|
return { reachable: false, url: this.baseUrl, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
}
|