// Talking to Plex. // // Plex spreads what looks like one product over four hosts, and which one // answers depends on what is being asked: // // :32400 the library itself, on the LAN // plex.tv accounts, sharing, and the PIN sign-in flow // discover.provider… the owner's watchlist, with external ids attached // community.plex.tv everybody else's watchlist, over GraphQL // // Only the owner's token is ever used. Linking a user proves who they are and // then discards their token; see plexAccounts in the schema for why. import { APP_URL } from "@/lib/app-url"; const PLEX_TOKEN = process.env.PLEX_AUTH_TOKEN ?? ""; const MACHINE_ID = process.env.PLEX_MACHINE_IDENTIFIER ?? ""; // Identifies Ampelos to Plex. Stable on purpose: the PIN flow ties a sign-in to // the client that started it, so a value that changed per request would make // every link fail at the last step. export const CLIENT_IDENTIFIER = "ampelos"; function ownerHeaders(extra: Record = {}) { return { "X-Plex-Token": PLEX_TOKEN, "X-Plex-Client-Identifier": CLIENT_IDENTIFIER, "X-Plex-Product": "Ampelos", Accept: "application/json", ...extra, }; } /** The LAN address of the server, with the scheme the env may omit. */ export function serverUrl() { let base = (process.env.PLEX_URL ?? "").replace(/\/$/, ""); if (!base) return null; if (!/^https?:\/\//.test(base)) base = `http://${base}`; const port = process.env.PLEX_PORT; return /:\d+$/.test(base) || !port ? base : `${base}:${port}`; } export type PlexSection = { /** plex.tv's id, which is what sharing takes -- NOT the local section key. */ id: string; key: string; type: string; title: string; }; /** * Sections as plex.tv sees them. * * Sharing is keyed on plex.tv's section id and the local server calls the same * library something else entirely: Movies is key 1 on the LAN and 140184388 to * plex.tv. Reading them from the server would produce numbers the sharing API * silently ignores. */ export async function listSections(): Promise { const response = await fetch(`https://plex.tv/api/servers/${MACHINE_ID}`, { headers: ownerHeaders({ Accept: "application/xml" }), signal: AbortSignal.timeout(20000), }); if (!response.ok) throw new Error(`Plex returned HTTP ${response.status} listing sections`); const xml = await response.text(); const sections = new Map(); for (const element of xml.match(/]*>/g) ?? []) { const attr = (name: string) => element.match(new RegExp(`${name}="([^"]*)"`))?.[1] ?? ""; const id = attr("id"); // The same section is repeated once per existing share; first wins. if (id && !sections.has(id)) { sections.set(id, { id, key: attr("key"), type: attr("type"), title: attr("title") }); } } return [...sections.values()]; } /** * What a linked user gets: everything except Adult and Pictures. * * Matched on what the section IS rather than on a list of ids, so a library * added later is shared without anyone remembering to update a constant -- * and, more importantly, so a RENAMED adult library cannot quietly become * shareable because its id was never on an exclusion list. */ export function isShareable(section: PlexSection) { if (section.type === "photo") return false; return !/\b(adult|xxx|porn)\b/i.test(section.title); } // --- linking --------------------------------------------------------------- export type PlexPin = { id: number; code: string }; /** * Start a sign-in. The user takes the code to plex.tv; we poll for the result. * * THE ORIGIN HEADER IS LOAD-BEARING AND IS WHY THIS USED TO FAIL. * * Plex records an `origin` against the PIN, taken from the Origin header on * this request, and the sign-in page at app.plex.tv reads it back through * /api/v2/pins/info before it will honour `forwardUrl`. A PIN with a null * origin gets the user signed in and then stranded on plex.tv instead of * returned here. * * Everything else creates its PIN from the BROWSER, where the header is sent * automatically and nobody has to know this. Ampelos creates it in a server * action, where fetch sends no Origin at all -- so it has to be stated. * Measured against the live API: without it origin is null, with it origin is * "ampelos.sticknife.com". */ export async function createPin(): Promise { const response = await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: { "X-Plex-Client-Identifier": CLIENT_IDENTIFIER, "X-Plex-Product": "Ampelos", Origin: new URL(APP_URL).origin, Accept: "application/json", }, signal: AbortSignal.timeout(20000), }); if (!response.ok) throw new Error(`Plex would not issue a sign-in code (HTTP ${response.status})`); const body = await response.json(); return { id: body.id, code: body.code }; } /** Where to send the user. Plex returns them to `returnTo` when they are done. */ export function authUrl(pin: PlexPin, returnTo: string) { const params = new URLSearchParams({ clientID: CLIENT_IDENTIFIER, code: pin.code, "context[device][product]": "Ampelos", forwardUrl: returnTo, }); return `https://app.plex.tv/auth#?${params}`; } export type PlexIdentity = { plexUserId: string; plexUuid: string | null; username: string; email: string | null; }; /** * Has the user finished signing in, and if so who are they? * * Returns null while the PIN is still unclaimed. The token that comes back is * used once, to ask Plex whose it is, and is never stored or returned to the * caller. */ export async function claimPin(pinId: number): Promise { const response = await fetch(`https://plex.tv/api/v2/pins/${pinId}`, { headers: { "X-Plex-Client-Identifier": CLIENT_IDENTIFIER, Accept: "application/json", }, signal: AbortSignal.timeout(20000), }); if (!response.ok) throw new Error(`Plex would not confirm the sign-in (HTTP ${response.status})`); const body = await response.json(); const token: string | null = body.authToken ?? null; if (!token) return null; const who = await fetch("https://plex.tv/api/v2/user", { headers: { "X-Plex-Token": token, "X-Plex-Client-Identifier": CLIENT_IDENTIFIER, Accept: "application/json", }, signal: AbortSignal.timeout(20000), }); if (!who.ok) throw new Error("Plex accepted the sign-in but would not say who it was"); const account = await who.json(); return { plexUserId: String(account.id), plexUuid: account.uuid ?? null, username: account.username ?? account.title ?? "unknown", email: account.email ?? null, }; } /** * Who owns this server, according to the token Ampelos holds. * * Needed because the owner cannot be invited to their own libraries -- Plex * answers a share request naming them with HTTP 400 "You cannot send an * invitation to yourself." That is not a failure to handle, it is a state to * recognise: the owner already has every library, so there is nothing to grant. * * Asked rather than pattern-matched on that error text, because the wording is * Plex's to change and being wrong here would mean telling the owner their * libraries had failed to share forever. */ export async function ownerAccountId(): Promise { const response = await fetch("https://plex.tv/api/v2/user", { headers: ownerHeaders(), signal: AbortSignal.timeout(20000), }); if (!response.ok) return null; const account = await response.json(); return account?.id != null ? String(account.id) : null; } // --- sharing --------------------------------------------------------------- /** * The human-readable half of a Plex error. * * Plex answers failures with XML whose only useful content is the `status` * attribute. Surfacing the raw document instead put ` Number(s.id)), invited_email: invited, }, sharing_settings: {}, }), signal: AbortSignal.timeout(30000), }); // 400 with an "already sharing" body is the ordinary outcome of linking an // account that was already invited by hand, and is not a failure. const text = await response.text(); const alreadyShared = /already/i.test(text) && /shar/i.test(text); if (!response.ok && !alreadyShared) { throw new Error(`Plex refused the share: ${plexErrorText(text)}`); } return { sectionIds: sections.map((s) => s.id), sectionTitles: sections.map((s) => s.title), alreadyShared, }; }