1dfa2c0a04
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
9.7 KiB
TypeScript
263 lines
9.7 KiB
TypeScript
// Talking to Plex.
|
|
//
|
|
// Plex spreads what looks like one product over four hosts, and which one
|
|
// answers depends on what is being asked:
|
|
//
|
|
// <server>: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<string, string> = {}) {
|
|
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<PlexSection[]> {
|
|
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<string, PlexSection>();
|
|
for (const element of xml.match(/<Section\b[^>]*>/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<PlexPin> {
|
|
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<PlexIdentity | null> {
|
|
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<string | null> {
|
|
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 `<?xml version="1.0"...`
|
|
* in front of the sentence and pushed the sentence itself past the point where
|
|
* the message got truncated -- which is how "You cannot send an invitation to
|
|
* yourself." reached a person as "You cannot send a".
|
|
*/
|
|
function plexErrorText(body: string) {
|
|
return body.match(/status="([^"]*)"/)?.[1] ?? body.trim().slice(0, 200);
|
|
}
|
|
|
|
/**
|
|
* Invite an account to the shareable libraries.
|
|
*
|
|
* The v2 endpoint only accepts POST and rejects a read, so the shape below is
|
|
* the v1 one that Plex's own clients use. Sharing is idempotent from our side:
|
|
* re-inviting somebody who already has access is answered with a conflict,
|
|
* which is reported as success because the desired state is what matters.
|
|
*/
|
|
export async function shareLibraries(identity: { email: string | null; plexUserId: string }) {
|
|
const sections = (await listSections()).filter(isShareable);
|
|
if (!sections.length) throw new Error("no shareable Plex libraries found");
|
|
|
|
const invited = identity.email ?? identity.plexUserId;
|
|
const response = await fetch(`https://plex.tv/api/servers/${MACHINE_ID}/shared_servers`, {
|
|
method: "POST",
|
|
headers: ownerHeaders({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify({
|
|
server_id: MACHINE_ID,
|
|
shared_server: {
|
|
library_section_ids: sections.map((s) => 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,
|
|
};
|
|
}
|