Ampelos dashboard: the web face, and the owner of the schema

Split out of the single Ampelos repository. Next.js app, Drizzle schema and
migrations, brand art, planning notes.

What left: scripts/, which was the agent's job library misfiled under web/ and
imported nothing from src/; and deploy/truenas, whose broadcast posts to the
scan listener on :3427 -- an agent script -- so it belongs beside the thing it
talks to.

This repository keeps the schema. The agent speaks raw SQL against the same
tables and holds no copy of it, so a rename here can break it silently where it
used to be one commit. The README says so, and the agent carries a snapshot to
check against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ryan
2026-08-15 12:12:08 +02:00
commit acdc25c797
138 changed files with 70946 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
// 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.
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. */
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",
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,
};
}
// --- sharing ---------------------------------------------------------------
/**
* 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 (HTTP ${response.status}): ${text.slice(0, 200)}`);
}
return {
sectionIds: sections.map((s) => s.id),
sectionTitles: sections.map((s) => s.title),
alreadyShared,
};
}