diff --git a/.env.example b/.env.example
index 8e48af8..17c5884 100644
--- a/.env.example
+++ b/.env.example
@@ -32,6 +32,26 @@ AMPELOS_AGENT_TIMEOUT_SECONDS=30
# both repositories' environments, in neither's history.
AMPELOS_AGENT_TOKEN=
+# --- the sticknife-wide profile -------------------------------------------
+# accounts.sticknife.com shows an Ampelos section in its own profile page, by
+# framing /embed/profile and/or reading /api/profile. See PROFILE-INTEGRATION.md.
+#
+# Which origins may frame /embed/* and call /api/profile from a browser.
+# Comma-separated ORIGINS, scheme included -- a bare hostname is silently
+# ignored by the CSP parser and never matches a CORS Origin header. Everything
+# outside /embed is frame-ancestors 'none' regardless of what is set here.
+AMPELOS_EMBED_ANCESTORS=https://accounts.sticknife.com
+#
+# Optional, and OFF unless set. Lets charon render the panel SERVER-side, where
+# there is no browser to carry a session cookie: it presents this as a bearer
+# token and names the user with ?email= or ?userId=.
+#
+# That is a full read of anybody's profile, watch history included. It is
+# deliberately NOT AMPELOS_AGENT_TOKEN -- that one is deployed to every
+# satellite host, and a leaked herald script should not also hand over
+# everyone's viewing. Leave it unset if charon only ever iframes the panel.
+AMPELOS_PROFILE_API_TOKEN=
+
# --- external services ----------------------------------------------------
MOVIEDB_API=
PLEX_URL=
diff --git a/PLANNING.md b/PLANNING.md
index a1c1555..ed7916a 100755
--- a/PLANNING.md
+++ b/PLANNING.md
@@ -287,9 +287,25 @@ Expected Plex-facing media:
Archive-only media should not normally be visible in Plex unless restored or promoted.
+Decided 2026-08-17 — **Ampelos copies Plex watch history into its own
+`watch_history` table** rather than reading it live. Plex prunes its own
+history and a rebuilt server starts empty, so a live read would silently lose
+years of viewing and would go blank whenever the media server was down. The
+copy is also the first signal Ampelos has that says what somebody *finished*
+rather than what they intended, which is the missing input to demotion.
+
+- Only accounts that linked themselves at `/profile` are read, even though the
+ owner's token can reach everybody's. Consent, not capability.
+- History is shown only to the person it belongs to. There is no admin view of
+ who watched what.
+- An episode is attributed to its **series** by the series' external ids, never
+ by title, and never by the episode's own TMDB id — those are different ids
+ and matching on the wrong one would be a collision rather than a match.
+- `plex:history` in ampelos-agent, hourly. Active sessions and user libraries
+ are still not read.
+
Open decisions:
-- Whether Ampelos should read Plex watch history, active sessions, or user libraries directly.
- Whether existing Plex watchlists remain an input signal.
- Whether Plex availability should be polled or inferred from filesystem and Ampelos inventory state.
diff --git a/PROFILE-INTEGRATION.md b/PROFILE-INTEGRATION.md
new file mode 100644
index 0000000..c63deda
--- /dev/null
+++ b/PROFILE-INTEGRATION.md
@@ -0,0 +1,294 @@
+# The Ampelos profile, and how accounts.sticknife.com shows it
+
+Ampelos has a profile page at `https://ampelos.sticknife.com/profile`. It shows
+one person their Plex link, their Watch Now slots, and their watch history.
+
+The same panel is published in two other forms so that
+`accounts.sticknife.com` on charon can carry it as one section of the wider
+sticknife profile, next to the other services' sections:
+
+| Surface | Address | For |
+| --- | --- | --- |
+| Page | `/profile` | People coming to Ampelos directly |
+| Embed | `/embed/profile` | Charon framing the panel Ampelos renders |
+| API | `/api/profile` | Charon rendering the section itself |
+
+All three are the same data: the page, the embed and the JSON all come from
+`getProfile()` in `src/lib/profile.ts`. There is no second implementation to
+drift.
+
+---
+
+## The short version
+
+Put this in the Ampelos section of the sticknife profile:
+
+```html
+
+```
+
+That is the whole integration. **No token, no secret, no configuration on
+charon's side.** Why it works is worth understanding, because it constrains
+where these services may live.
+
+---
+
+## Why no token is needed
+
+`accounts.sticknife.com` and `ampelos.sticknife.com` share the registrable
+domain `sticknife.com`. A request from one to the other is therefore
+**same-site**, even though it is cross-origin.
+
+The Ampelos session cookie is `SameSite=Lax`, and Lax cookies *are* sent on
+same-site subresource loads. So the person is simply already signed in inside
+the frame. Nothing is exchanged, nothing is impersonated, and no cookie had to
+be loosened to `SameSite=None`.
+
+> **This holds only while both services are under `sticknife.com`.** Moving
+> either to its own domain makes the pair cross-site, the cookie stops being
+> sent, and the embed will show the signed-out state to everybody. That would
+> need a real token flow — most likely charon forwarding the user's Authentik
+> ID token — and is a deliberate piece of work, not a config tweak.
+
+Both services already sign in against the same Authentik at
+`charon.sticknife.com`, so in practice a person signed in to the accounts page
+is signed in to Ampelos.
+
+### The signed-out case
+
+If the visitor has no Ampelos session, the embed does **not** redirect to
+Authentik. Authentik refuses to be framed, so a redirect would render an
+`X-Frame-Options` error inside charon's panel — a broken box with no
+explanation. The embed instead renders a short "Sign in to Ampelos" link that
+targets `_top` and leaves the frame.
+
+For the same reason, every link inside the embed carries `target="_top"`, and
+the Plex link button becomes a link to the full profile page rather than
+starting a flow that would dead-end in the panel (plex.tv also refuses to be
+framed).
+
+---
+
+## Sizing the frame
+
+A cross-origin iframe cannot size itself and the parent cannot measure across
+the origin boundary. Ampelos therefore posts its height to the parent whenever
+it changes:
+
+```js
+window.addEventListener("message", (event) => {
+ if (event.origin !== "https://ampelos.sticknife.com") return;
+ if (event.data?.type !== "ampelos:profile:height") return;
+ iframe.style.height = `${event.data.height}px`;
+});
+```
+
+The message is posted only to the origins in `AMPELOS_EMBED_ANCESTORS`, never
+to `*`. **Ignoring it is fine** — the panel then scrolls inside whatever height
+you give it. Check `event.origin` if you do listen; that check is charon's job
+and Ampelos cannot do it for you.
+
+---
+
+## The JSON, if charon would rather render it itself
+
+```
+GET https://ampelos.sticknife.com/api/profile
+```
+
+```jsonc
+{
+ "service": "ampelos",
+ "schemaVersion": 2,
+ "generatedAt": "2026-08-17T11:00:41.965Z",
+ "user": { "id": "…", "displayName": "Ryan Potter", "email": "ryan@…" },
+
+ "plex": {
+ "linked": true,
+ "username": "…",
+ "email": "…",
+ "linkedAt": "2026-08-10T…",
+ "isServerOwner": false,
+ "librariesSharedAt": "2026-08-10T…", // null if the share failed
+ "sharedLibraryCount": 3
+ },
+ // or simply: { "linked": false }
+
+ "watchNow": {
+ "television": {
+ "quota": 5,
+ "used": 1,
+ "items": [{
+ "id": "…", "mediaItemId": "…",
+ "title": "Law & Order- Special Victims Unit",
+ "year": 1999,
+ "posterUrl": "https://image.tmdb.org/t/p/w342/….jpg",
+ "slotNumber": 1,
+ "addedAt": "2026-08-09T…",
+ "catalog": { "tmdbId": "2734", "kind": "television" } // or null
+ }]
+ },
+ "movies": { "quota": 10, "used": 6, "items": [ … ] }
+ },
+
+ "history": {
+ "total": 1668, // plays, counting repeats
+ "distinctEpisodes": 993, // what `recent` is a window onto
+ "playsLast30Days": 276,
+ "lastWatchedAt": "2026-08-17T01:45:25.000Z",
+ "recent": [{ // 25 most recent, ONE PER EPISODE
+ "id": "…", "mediaItemId": "…",
+ "mediaType": "tv_series", // or "movie"
+ "title": "Gen V",
+ "year": 2023,
+ "posterUrl": "https://image.tmdb.org/t/p/w342/….jpg",
+ "seasonNumber": 2, // null for a movie
+ "episodeNumber": 8, // null for a movie
+ "episodeTitle": "Trojan", // null if not in the database yet
+ "watchedAt": "2026-08-17T01:45:25.000Z", // the MOST RECENT play
+ "playCount": 3, // times this episode was played
+ "source": "plex",
+ "catalog": { "tmdbId": "205715", "kind": "television" }
+ }]
+ },
+
+ "links": {
+ "self": "https://ampelos.sticknife.com/profile",
+ "embed": "https://ampelos.sticknife.com/embed/profile",
+ "api": "https://ampelos.sticknife.com/api/profile"
+ }
+}
+```
+
+`posterUrl` is always absolute or null. Ampelos stores TMDB's bare path in some
+rows and a full URL in others, and resolves both before handing them over — a
+bare `/abc.jpg` would mean nothing on charon.
+
+`isServerOwner` matters for how you word the libraries line. Plex will not
+invite a server's owner to their own libraries ("You cannot send an invitation
+to yourself"), so an owner legitimately has `librariesSharedAt: null` while
+having access to everything. Do not render that as a failed share.
+
+### Deduplication, and `catalog`
+
+`history.recent` is **one row per episode, not per play.** A rewatch, a resume
+after stopping, and a client that reports the same episode twice all land as
+separate plays, and left raw they bury everything else — this library's real
+numbers are 1,668 plays across 993 distinct episodes. Each row carries the most
+recent `watchedAt` and a `playCount` so collapsing repeats does not hide them.
+Count against `distinctEpisodes`, not `total`.
+
+`catalog` is what lets a row be clicked. Ampelos's own panel links it to
+`/?tab=&open=`, which opens the catalog detail panel on that
+title; charon can do the same, or use the TMDB id directly. It is **null** when
+the title has no TMDB link or has one a reviewer has *rejected* — a rejected id
+is a known-wrong id kept as evidence, so following it would open the wrong
+film. Render those rows as plain text.
+
+### `schemaVersion`
+
+Part of the contract. Charon renders a panel it does not own from a service it
+cannot redeploy in step, so it should check this and degrade rather than break.
+
+- **Adding** a field does not bump it. Ignore keys you do not know.
+- **Changing what a field means** bumps it.
+
+**Version 2** (2026-08-17): `history.recent` became one row per episode rather
+than per play, and its `watchedAt` became the most recent play of that episode.
+A consumer written against version 1 would double-count. `playCount`,
+`distinctEpisodes` and `catalog` arrived at the same time but would not, alone,
+have earned the bump.
+
+### Authenticating
+
+**From the browser** — same-site, so the session cookie carries it:
+
+```js
+fetch("https://ampelos.sticknife.com/api/profile", { credentials: "include" })
+```
+
+CORS allows exactly the origins in `AMPELOS_EMBED_ANCESTORS`, with
+`Access-Control-Allow-Credentials: true`. Any other origin gets no
+allow-origin header and the browser blocks the read. This mode answers for
+the signed-in user and **ignores the query string entirely** — a signed-in
+person cannot widen their own request by adding `?email=`.
+
+**From charon's server** — no browser, so no cookie:
+
+```
+GET /api/profile?email=someone@example.com
+Authorization: Bearer $AMPELOS_PROFILE_API_TOKEN
+```
+
+`?userId=` (the Ampelos uuid) works too. `email` is the useful one: it is
+unique in `users` and is the claim Authentik issues to every sticknife service,
+so charon already has it.
+
+> This mode is a **full read of anybody's profile, watch history included.** It
+> is off unless `AMPELOS_PROFILE_API_TOKEN` is set, and it deliberately does
+> **not** reuse `AMPELOS_AGENT_TOKEN` — that token is deployed to every
+> satellite host, and a leaked herald script should not also hand over
+> everyone's viewing.
+>
+> If charon only frames the panel, leave it unset.
+
+Responses are `Cache-Control: private, no-store`.
+
+---
+
+## What Ampelos needs configured
+
+In `.env.local` (see `.env.example`):
+
+```sh
+# Who may frame /embed/* and call /api/profile from a browser.
+# ORIGINS, scheme included -- a bare hostname is ignored by the CSP parser.
+AMPELOS_EMBED_ANCESTORS=https://accounts.sticknife.com
+
+# Only if charon renders server-side. Off when unset.
+AMPELOS_PROFILE_API_TOKEN=
+```
+
+Everything outside `/embed/*` is served `frame-ancestors 'none'` and
+`X-Frame-Options: DENY`. Framing the catalog or the admin views has no
+legitimate use and is how a clickjacked "Remove" button gets pressed.
+
+---
+
+## Privacy
+
+Watch history is per-person and is shown **only to the person it belongs to.**
+
+This matters more than it looks. Ampelos reads history from Plex using the
+server owner's token, and that token can read *every* user's viewing. Being
+able to is not permission to:
+
+- History is recorded only for accounts that linked themselves at `/profile`.
+ Everyone else on the Plex server is skipped and reported as unlinked.
+- Every read is scoped to one user id. `src/lib/profile.ts` has no "all users"
+ mode, deliberately.
+- **There is no admin view of who watched what.** An administrator sees their
+ own history and nobody else's.
+
+The one way to read another person's profile is the server-side token above,
+which is why it is a separate secret, off by default, and documented as being
+exactly as sensitive as it is.
+
+---
+
+## Where the data comes from
+
+| Section | Source |
+| --- | --- |
+| Plex link | `plex_accounts`, written by the PIN flow in `src/app/profile/actions.ts`. No Plex token is ever stored. |
+| Watch Now | `watching_now_items` + the per-user quotas on `users` |
+| Watch history | `watch_history`, filled by `plex:history` in ampelos-agent |
+
+History is **copied** out of Plex hourly rather than read live, because Plex
+prunes its own history and a rebuilt server starts empty — both of which have
+happened here. See `scripts/sync-plex-history.mjs` in ampelos-agent.
diff --git a/next.config.ts b/next.config.ts
index cf5b68c..b8483c2 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,5 +1,20 @@
import type { NextConfig } from "next";
+/**
+ * Who may put Ampelos in an iframe.
+ *
+ * Only /embed/* is framable, and only by the sticknife accounts page. The rest
+ * of the app is denied outright below -- framing the catalog or the admin views
+ * has no legitimate use and is how a clickjacked "Remove" button gets pressed.
+ *
+ * Configurable because the accounts host is charon's to name, not ours: a
+ * staging origin or a rename should not need a code change. Comma-separated,
+ * and note these are ORIGINS (scheme and host), which is what frame-ancestors
+ * takes -- a bare hostname is silently ignored by the CSP parser.
+ */
+const EMBED_ANCESTORS =
+ process.env.AMPELOS_EMBED_ANCESTORS ?? "https://accounts.sticknife.com";
+
const nextConfig: NextConfig = {
allowedDevOrigins: ["10.24.88.95", "ampelos.sticknife.com"],
images: {
@@ -11,6 +26,34 @@ const nextConfig: NextConfig = {
},
],
},
+ async headers() {
+ return [
+ {
+ // Everything EXCEPT the embed, framed by nobody.
+ //
+ // The exclusion is in the matcher rather than left to header override,
+ // because X-Frame-Options has no "allow these origins" form -- the
+ // multi-origin ALLOW-FROM was never implemented by any browser -- so a
+ // blanket rule could set CSP correctly for /embed and still stamp a
+ // DENY that some agent honours in preference. Not matching at all is
+ // the only version with no precedence question in it.
+ source: "/:path((?!embed/).*)",
+ headers: [
+ { key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
+ { key: "X-Frame-Options", value: "DENY" },
+ ],
+ },
+ {
+ source: "/embed/:path*",
+ headers: [
+ {
+ key: "Content-Security-Policy",
+ value: `frame-ancestors 'self' ${EMBED_ANCESTORS.split(",").map((o) => o.trim()).filter(Boolean).join(" ")}`,
+ },
+ ],
+ },
+ ];
+ },
};
export default nextConfig;
diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts
deleted file mode 100644
index 96535e4..0000000
--- a/src/app/account/actions.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-"use server";
-
-// Linking a Plex account.
-//
-// Two steps, because Plex's sign-in is a round trip through their site: we ask
-// for a PIN, send the person to plex.tv to claim it, and pick the result up
-// when they come back. The PIN id is parked in an httpOnly cookie for the
-// duration -- it is a one-use handle that is worthless without the sign-in that
-// claims it, and it means the flow survives the user taking a minute over it.
-//
-// Linking is what grants library access AND what makes a watchlist readable.
-// Both follow from the same consent, which is why they happen together here
-// rather than being two things an admin has to remember to do.
-
-import { cookies } from "next/headers";
-import { redirect } from "next/navigation";
-import { revalidatePath } from "next/cache";
-import { eq } from "drizzle-orm";
-
-import { auth } from "@/auth";
-import { db } from "@/db/client";
-import { plexAccounts } from "@/db/schema";
-import { appUrl } from "@/lib/app-url";
-import { createPin, authUrl, claimPin, shareLibraries } from "@/lib/plex";
-
-const PIN_COOKIE = "ampelos_plex_pin";
-
-async function requireUser() {
- const session = await auth();
- if (!session?.user?.id) throw new Error("Sign in first");
- return session.user;
-}
-
-export async function startPlexLinkAction() {
- await requireUser();
-
- const pin = await createPin();
- const jar = await cookies();
- jar.set(PIN_COOKIE, String(pin.id), {
- httpOnly: true,
- sameSite: "lax",
- secure: true,
- path: "/",
- maxAge: 15 * 60,
- });
-
- // redirect() throws, so it must be the last thing here.
- redirect(authUrl(pin, appUrl("/account?linking=1")));
-}
-
-/**
- * Finish the link, if the user has been to plex.tv and back.
- *
- * Returns a message rather than throwing on the ordinary failures -- an
- * abandoned sign-in and an expired PIN are both things a person does, not
- * errors.
- */
-export async function completePlexLink(): Promise {
- const user = await requireUser();
- const jar = await cookies();
- const pinId = jar.get(PIN_COOKIE)?.value;
- if (!pinId) return null;
-
- let identity;
- try {
- identity = await claimPin(Number(pinId));
- } catch (error) {
- jar.delete(PIN_COOKIE);
- return `Plex could not confirm the sign-in: ${(error as Error).message}`;
- }
- if (!identity) return "That sign-in was not completed. Try linking again.";
-
- jar.delete(PIN_COOKIE);
-
- // One Plex account per person, in both directions. Without this a second
- // person could link an account already in use and inherit its watchlist.
- const existing = await db
- .select()
- .from(plexAccounts)
- .where(eq(plexAccounts.plexUserId, identity.plexUserId))
- .limit(1);
- if (existing.length && existing[0].userId !== user.id) {
- return "That Plex account is already linked to another Ampelos user.";
- }
-
- let shared: { sectionTitles: string[]; sectionIds: string[]; alreadyShared: boolean } | null = null;
- let shareError: string | null = null;
- try {
- shared = await shareLibraries(identity);
- } catch (error) {
- // The link is still worth recording: it is what makes the watchlist
- // readable, and a share that failed can be retried without signing in
- // again.
- shareError = (error as Error).message;
- }
-
- await db
- .insert(plexAccounts)
- .values({
- userId: user.id,
- plexUserId: identity.plexUserId,
- plexUuid: identity.plexUuid,
- plexUsername: identity.username,
- plexEmail: identity.email,
- librariesSharedAt: shared ? new Date() : null,
- sharedSectionIds: shared?.sectionIds ?? null,
- })
- .onConflictDoUpdate({
- target: plexAccounts.userId,
- set: {
- plexUserId: identity.plexUserId,
- plexUuid: identity.plexUuid,
- plexUsername: identity.username,
- plexEmail: identity.email,
- librariesSharedAt: shared ? new Date() : null,
- sharedSectionIds: shared?.sectionIds ?? null,
- updatedAt: new Date(),
- },
- });
-
- revalidatePath("/account");
-
- if (shareError) {
- return `Linked as ${identity.username}, but the libraries could not be shared: ${shareError}`;
- }
- if (shared?.alreadyShared) {
- return `Linked as ${identity.username}. You already had access to the libraries.`;
- }
- return `Linked as ${identity.username}. Shared: ${shared?.sectionTitles.join(", ")}.`;
-}
-
-export async function unlinkPlexAction() {
- const user = await requireUser();
- // Only the link is removed. Library access is granted on Plex's side and is
- // not ours to quietly revoke from a button labelled "unlink"; whoever owns
- // the server can take it back there.
- await db.delete(plexAccounts).where(eq(plexAccounts.userId, user.id));
- revalidatePath("/account");
-}
diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx
index 7fe9e41..d1f2071 100644
--- a/src/app/account/page.tsx
+++ b/src/app/account/page.tsx
@@ -1,10 +1,4 @@
-import { eq } from "drizzle-orm";
-
-import { auth } from "@/auth";
-import { db } from "@/db/client";
-import { plexAccounts } from "@/db/schema";
-
-import { startPlexLinkAction, unlinkPlexAction, completePlexLink } from "./actions";
+import { permanentRedirect } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -12,83 +6,28 @@ type PageProps = {
searchParams?: Promise>;
};
+/**
+ * /account was the Plex linking page before the profile existed.
+ *
+ * Kept as a redirect rather than deleted because Plex used to be told to return
+ * the user HERE after signing in, so a bookmark or a PIN round trip started
+ * before the move should land on the profile rather than a 404.
+ *
+ * The query string is carried across so nothing is silently dropped on the way.
+ * Finishing a link no longer depends on it -- that happens in
+ * /api/plex/callback now -- but a redirect that eats its parameters is a trap
+ * for whatever gets added to this page later.
+ */
export default async function AccountPage({ searchParams }: PageProps) {
- const session = await auth();
- if (!session?.user?.id) {
- return (
-
-
Sign in to manage your account.
-
- );
+ const resolved = (await searchParams) ?? {};
+ const params = new URLSearchParams();
+
+ for (const [key, value] of Object.entries(resolved)) {
+ for (const single of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
+ params.append(key, single);
+ }
}
- // Plex sends the user back here when they are done. Completing on GET rather
- // than asking them to press another button: they have already consented, and
- // a second click to finish something they thought was finished is just a way
- // to lose people half way through.
- const resolved = (await searchParams) ?? {};
- const returning = resolved.linking === "1";
- const message = returning ? await completePlexLink() : null;
-
- const [link] = await db
- .select()
- .from(plexAccounts)
- .where(eq(plexAccounts.userId, session.user.id))
- .limit(1);
-
- return (
-
-
-
Your account
-
{session.user.email}
-
-
- {message && (
-
{message}
- )}
-
-
-
Plex
-
- {link ? (
- <>
-
- Linked as {link.plexUsername}.
-
-
- {link.librariesSharedAt
- ? "The Movies, TV Shows and Music libraries are shared with you. Check your email for the invitation if you have not accepted it yet."
- : "The libraries have not been shared yet — link again to retry."}
-
-
- Anything you add to your Plex watchlist is treated as a request: if we already
- have it you will find it in the library, and if we do not, we will go and get it.
-
-
- >
- ) : (
- <>
-
- Link your Plex account to get access to the Movies, TV Shows and Music libraries.
- Once linked, your Plex watchlist becomes your request list — add something there
- and it will be found for you.
-
-
- You sign in at plex.tv, not here. Ampelos never sees your Plex password, and the
- sign-in token is discarded as soon as Plex confirms who you are.
-
-
- >
- )}
-
-
- );
+ const query = params.toString();
+ permanentRedirect(query ? `/profile?${query}` : "/profile");
}
diff --git a/src/app/api/plex/callback/route.ts b/src/app/api/plex/callback/route.ts
new file mode 100644
index 0000000..68cfbbf
--- /dev/null
+++ b/src/app/api/plex/callback/route.ts
@@ -0,0 +1,63 @@
+// Where Plex returns the user after they sign in.
+//
+// A ROUTE HANDLER, NOT A PAGE, AND THAT IS THE POINT. Finishing the link means
+// deleting the PIN cookie and revalidating the profile, and Next.js permits
+// neither during a page render. Doing this on /profile is why linking never
+// completed: Plex returned the user, the page tried to clear the cookie mid
+// render, and threw.
+//
+// The forward URL has NO QUERY STRING either, which matters more than it looks.
+// Plex passes it inside the fragment of app.plex.tv/auth#?...&forwardUrl=...,
+// where it is parsed by their client-side code rather than by a server. A bare
+// path cannot be mangled by that; one carrying its own `?a=b` depends entirely
+// on how carefully somebody else's parser splits a string.
+
+import { cookies } from "next/headers";
+import { revalidatePath } from "next/cache";
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { appUrl } from "@/lib/app-url";
+import { completePlexLink, PIN_COOKIE } from "@/lib/plex-link";
+
+export const dynamic = "force-dynamic";
+
+function backToProfile(params: Record) {
+ const url = new URL(appUrl("/profile"));
+ for (const [key, value] of Object.entries(params)) {
+ if (value) url.searchParams.set(key, value);
+ }
+ return NextResponse.redirect(url);
+}
+
+export async function GET() {
+ const session = await auth();
+ // The session cookie is SameSite=Lax and this is a top-level GET navigation,
+ // so it is sent even though Plex is a different site. No session here means
+ // the person genuinely signed out mid-flow.
+ if (!session?.user?.id) return NextResponse.redirect(new URL(appUrl("/login")));
+
+ const jar = await cookies();
+ const pinId = jar.get(PIN_COOKIE)?.value;
+
+ // No PIN means somebody reached this URL without starting a link. Nothing to
+ // finish, and nothing worth an error page.
+ if (!pinId) return backToProfile({});
+
+ const result = await completePlexLink(session.user.id, Number(pinId));
+
+ const response = backToProfile({
+ plex: result.outcome,
+ who: result.username,
+ // Truncated: this ends up in a URL, and the full text of a Plex error is
+ // for the log rather than the address bar.
+ reason: result.reason?.slice(0, 120),
+ });
+
+ // Allowed here, unlike in a render. Both are why this route exists.
+ response.cookies.delete(PIN_COOKIE);
+ revalidatePath("/profile");
+ revalidatePath("/embed/profile");
+
+ return response;
+}
diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts
new file mode 100644
index 0000000..c844bca
--- /dev/null
+++ b/src/app/api/profile/route.ts
@@ -0,0 +1,127 @@
+// The Ampelos profile, as data.
+//
+// The companion to /embed/profile: charon can either frame the panel Ampelos
+// renders, or take this and render the section itself in its own design system.
+// Both come from getProfile(), so they cannot disagree about what somebody's
+// Watch Now list contains.
+//
+// TWO WAYS TO AUTHENTICATE, and the difference matters:
+//
+// 1. The session cookie. accounts.sticknife.com is same-site with this host,
+// so a browser fetch with `credentials: "include"` carries the signed-in
+// user's session and this endpoint answers for THAT person only. Nothing
+// is trusted from the caller.
+//
+// 2. AMPELOS_PROFILE_API_TOKEN plus ?email= or ?userId=. For charon rendering
+// the panel server-side, where there is no browser to carry a cookie. The
+// caller names the user, so this is a full read of anybody's profile --
+// watch history included -- and it is exactly as sensitive as that sounds.
+//
+// Mode 2 has its OWN token rather than reusing AMPELOS_AGENT_TOKEN. That token
+// is deployed to every satellite host, including ones nobody has physical
+// control over; a herald script leaking it should not also hand over everyone's
+// viewing history. Mode 2 stays off entirely until the variable is set.
+
+import { timingSafeEqual } from "node:crypto";
+
+import { eq } from "drizzle-orm";
+
+import { auth } from "@/auth";
+import { db } from "@/db/client";
+import { users } from "@/db/schema";
+import { APP_URL } from "@/lib/app-url";
+import { isAllowedOrigin } from "@/lib/embed-origins";
+import { getProfile } from "@/lib/profile";
+
+export const dynamic = "force-dynamic";
+
+function corsHeaders(request: Request) {
+ const origin = request.headers.get("origin");
+ const headers = new Headers();
+
+ // Vary regardless of the outcome: the response differs by Origin, and a cache
+ // that missed that would hand one site's allowance to another.
+ headers.set("Vary", "Origin");
+
+ if (isAllowedOrigin(origin)) {
+ headers.set("Access-Control-Allow-Origin", origin);
+ // Required for the cookie to be sent at all, and the reason the allowed
+ // origin is an explicit list rather than "*" -- the two cannot be combined.
+ headers.set("Access-Control-Allow-Credentials", "true");
+ }
+
+ return headers;
+}
+
+function tokenMatches(request: Request, expected: string) {
+ const header = request.headers.get("authorization") ?? "";
+ const provided = header.startsWith("Bearer ") ? header.slice(7) : "";
+ const a = Buffer.from(provided);
+ const b = Buffer.from(expected);
+ return Boolean(provided) && a.length === b.length && timingSafeEqual(a, b);
+}
+
+/**
+ * Whose profile is being asked for, and may the caller have it?
+ *
+ * Returns a user id or a refusal. The session path never reads the query
+ * string, so a signed-in user cannot widen their own request by adding
+ * ?email= to it.
+ */
+async function resolveUserId(request: Request): Promise<
+ { ok: true; userId: string } | { ok: false; status: number; error: string }
+> {
+ const session = await auth();
+ if (session?.user?.id) return { ok: true, userId: session.user.id };
+
+ const expected = process.env.AMPELOS_PROFILE_API_TOKEN;
+ if (!expected) return { ok: false, status: 401, error: "not signed in" };
+ if (!tokenMatches(request, expected)) return { ok: false, status: 401, error: "not signed in" };
+
+ const params = new URL(request.url).searchParams;
+ const userId = params.get("userId");
+ const email = params.get("email");
+
+ if (userId) return { ok: true, userId };
+
+ if (email) {
+ const [row] = await db
+ .select({ id: users.id })
+ .from(users)
+ // users.email is unique and is the claim Authentik issues to every
+ // sticknife service, which makes it the one identifier charon already
+ // holds without Ampelos telling it anything.
+ .where(eq(users.email, email))
+ .limit(1);
+ if (!row) return { ok: false, status: 404, error: "no such user" };
+ return { ok: true, userId: row.id };
+ }
+
+ return { ok: false, status: 400, error: "name a user with ?userId= or ?email=" };
+}
+
+export async function GET(request: Request) {
+ const headers = corsHeaders(request);
+ const resolved = await resolveUserId(request);
+
+ if (!resolved.ok) {
+ return Response.json({ error: resolved.error }, { status: resolved.status, headers });
+ }
+
+ const profile = await getProfile(resolved.userId, APP_URL);
+ if (!profile) {
+ return Response.json({ error: "no such user" }, { status: 404, headers });
+ }
+
+ // Somebody's viewing history is not something to leave in a shared cache.
+ headers.set("Cache-Control", "private, no-store");
+ return Response.json(profile, { headers });
+}
+
+export async function OPTIONS(request: Request) {
+ const headers = corsHeaders(request);
+ headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
+ headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
+ headers.set("Access-Control-Max-Age", "86400");
+ return new Response(null, { status: 204, headers });
+}
diff --git a/src/app/catalog-board.tsx b/src/app/catalog-board.tsx
index f68bd92..e377bca 100644
--- a/src/app/catalog-board.tsx
+++ b/src/app/catalog-board.tsx
@@ -41,6 +41,16 @@ type CatalogBoardProps = {
watchNowItems: WatchNowItem[];
slotCount: number;
isAdmin: boolean;
+ /**
+ * A TMDB id to open the detail panel on as soon as the board mounts.
+ *
+ * How /profile links into the catalog: a Watch Now slot or a history entry
+ * points at `/?tab=&open=`, and this is what makes that URL
+ * land on the open panel rather than merely the right tab. It is also the
+ * only route that works from charon's iframe, where a modal would open
+ * inside a 400px box.
+ */
+ openExternalId?: string | null;
};
function mediaLabel(kind: CatalogItem["kind"]) {
@@ -644,7 +654,7 @@ function WatchNowTray({ kind, items, slotCount, onRejectClassic }: { kind: Catal
);
}
-export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExternalIds, purgedExternalIds, watchNowItems, slotCount, isAdmin }: CatalogBoardProps) {
+export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExternalIds, purgedExternalIds, watchNowItems, slotCount, isAdmin, openExternalId }: CatalogBoardProps) {
const collectionSet = useMemo(() => new Set(collectionIds), [collectionIds]);
const watchNowSet = useMemo(() => new Set(watchNowExternalIds), [watchNowExternalIds]);
const purgedSet = useMemo(() => new Set(purgedExternalIds), [purgedExternalIds]);
@@ -666,6 +676,48 @@ export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExtern
);
}
+ /**
+ * Open a title the board may not be holding.
+ *
+ * The list is whatever is popular or matched the search, and a title somebody
+ * watched two years ago is usually in neither -- so there is often no
+ * CatalogItem to open. CatalogDetails extends CatalogItem, which means the
+ * detail fetch returns everything the panel needs to stand one up from just
+ * an id.
+ */
+ async function openById(externalId: string, itemKind: Exclude) {
+ const known = visibleItems.find((item) => item.id === externalId);
+ if (known) {
+ await openDetails(known);
+ return;
+ }
+
+ const placeholder: CatalogItem = {
+ id: externalId,
+ source: "tmdb",
+ kind: itemKind,
+ title: "",
+ year: null,
+ overview: null,
+ posterUrl: null,
+ backdropUrl: null,
+ rating: null,
+ popularity: null,
+ releaseDate: null,
+ };
+ await openDetails(placeholder);
+ }
+
+ // Deep link from /profile. Runs once per id: re-running on every render would
+ // reopen the panel the moment somebody closed it.
+ useEffect(() => {
+ if (!openExternalId || kind === "music") return;
+ void openById(openExternalId, kind);
+ // openById closes over visibleItems, but re-opening because the list
+ // changed underneath is exactly what this must not do.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [openExternalId, kind]);
+
async function openDetails(item: CatalogItem) {
const flags: PanelFlags = {
timeless: Boolean(item.isClassic),
diff --git a/src/app/embed/frame-height.tsx b/src/app/embed/frame-height.tsx
new file mode 100644
index 0000000..e1000d7
--- /dev/null
+++ b/src/app/embed/frame-height.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+// Tell the framing page how tall this panel is.
+//
+// A cross-origin iframe cannot size itself to its content and the parent cannot
+// measure across the origin boundary, so without this charon has to guess a
+// height -- and a guess is either a panel that scrolls inside its own box or one
+// with a band of dead space under it. This is the only way to get it right.
+//
+// PURELY ADDITIVE. A parent that ignores the message loses nothing but the
+// exact fit, so the panel still works if charon never implements the listener.
+// That is why the rest of the embed stays free of client JavaScript: this is the
+// one thing that genuinely cannot be done on the server.
+//
+// The message is targeted at the configured ancestors rather than "*", so the
+// height is not broadcast to any page that happens to frame us.
+
+import { useEffect } from "react";
+
+const MESSAGE_TYPE = "ampelos:profile:height";
+
+export function FrameHeight({ targetOrigins }: { targetOrigins: string[] }) {
+ useEffect(() => {
+ if (window.parent === window) return;
+
+ const post = (height: number) => {
+ for (const origin of targetOrigins) {
+ // A wrong origin here throws rather than leaking, which is the failure
+ // direction we want; other targets still get theirs.
+ try {
+ window.parent.postMessage({ type: MESSAGE_TYPE, height }, origin);
+ } catch {
+ // The parent is not this origin. Nothing to do.
+ }
+ }
+ };
+
+ // ResizeObserver rather than a one-shot measure on mount: posters load late
+ // and change the height after first paint, and a single measurement would
+ // leave the frame sized for a panel with no images in it.
+ const observer = new ResizeObserver((entries) => {
+ const height = entries[0]?.target.scrollHeight ?? document.body.scrollHeight;
+ if (height > 0) post(Math.ceil(height));
+ });
+
+ observer.observe(document.documentElement);
+ post(Math.ceil(document.documentElement.scrollHeight));
+
+ return () => observer.disconnect();
+ }, [targetOrigins]);
+
+ return null;
+}
diff --git a/src/app/embed/layout.tsx b/src/app/embed/layout.tsx
new file mode 100644
index 0000000..6707abc
--- /dev/null
+++ b/src/app/embed/layout.tsx
@@ -0,0 +1,23 @@
+// Chrome-less shell for anything charon frames.
+//
+// No topbar, no page background, no fixed viewport height: the panel is a
+// fragment of somebody else's page and has to size to whatever box that page
+// gives it. The `.ampelos-app` background in particular must NOT be here --
+// it paints a full-viewport gradient that would show as a hard-edged rectangle
+// inside accounts.sticknife.com's own theme.
+//
+// The root layout still wraps this (fonts and globals.css come from there),
+// which is what keeps the embed looking like Ampelos rather than unstyled HTML.
+
+import { embedOrigins } from "@/lib/embed-origins";
+
+import { FrameHeight } from "./frame-height";
+
+export default function EmbedLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/app/embed/profile/page.tsx b/src/app/embed/profile/page.tsx
new file mode 100644
index 0000000..fc53dd2
--- /dev/null
+++ b/src/app/embed/profile/page.tsx
@@ -0,0 +1,82 @@
+// The Ampelos section of the sticknife-wide profile.
+//
+// accounts.sticknife.com iframes this into its own collapsible panel:
+//
+//
+//
+// WHY THE COOKIE REACHES US. accounts.sticknife.com and ampelos.sticknife.com
+// share the registrable domain sticknife.com, so a request from one to the
+// other is SAME-SITE even though it is cross-origin. The NextAuth session
+// cookie is SameSite=Lax, which is sent on same-site subresource loads -- so
+// the person is simply signed in inside the frame, with no token exchange, no
+// shared secret, and no cookie loosened to SameSite=None. This holds only while
+// both services live under sticknife.com; moving either to its own domain
+// breaks it and would need a real token flow instead.
+//
+// WHY IT DOES NOT REDIRECT TO /login. /embed is exempted from the proxy's login
+// redirect on purpose. Authentik refuses to be framed, so redirecting would
+// render an X-Frame-Options error inside charon's panel -- a broken-looking box
+// with no explanation. A signed-out visitor gets a sign-in link that escapes
+// the frame instead.
+
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { APP_URL } from "@/lib/app-url";
+import { getProfile } from "@/lib/profile";
+
+import { ProfilePanel } from "../../profile/profile-panel";
+
+export const dynamic = "force-dynamic";
+
+function Shell({ children }: { children: React.ReactNode }) {
+ return
+
+ );
+ }
+
+ const profile = await getProfile(session.user.id, APP_URL);
+
+ // Signed in to Authentik but with no Ampelos user row. Rare, and worth saying
+ // plainly rather than showing an empty panel that reads as "you have nothing".
+ if (!profile) {
+ return (
+
+
+ This account has no Ampelos profile yet. Visit{" "}
+
+ ampelos.sticknife.com
+ {" "}
+ once to create one.
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 4955f40..a3e5dbb 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -174,6 +174,8 @@ export default async function Home({ searchParams }: PageProps) {
const kind = activeKind(resolvedSearchParams.tab);
const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
const collectionOnly = singleParam(resolvedSearchParams.collection) === "1";
+ // Set by the links on /profile, which point at a title rather than a tab.
+ const openExternalId = singleParam(resolvedSearchParams.open)?.trim() || null;
// The same flag the admin layout and the server actions check, rather than
// re-deriving it from the group list: a button that appears under one rule and
// an action that refuses under another is the worst version of this.
@@ -211,6 +213,9 @@ export default async function Home({ searchParams }: PageProps) {
+
+ Profile
+
{isAdmin && (
Admin
@@ -274,6 +279,7 @@ export default async function Home({ searchParams }: PageProps) {
watchNowItems={watchNowItems}
slotCount={slotCount}
isAdmin={isAdmin}
+ openExternalId={openExternalId}
/>
diff --git a/src/app/profile/actions.ts b/src/app/profile/actions.ts
new file mode 100644
index 0000000..2360b22
--- /dev/null
+++ b/src/app/profile/actions.ts
@@ -0,0 +1,75 @@
+"use server";
+
+// Starting and undoing a Plex link.
+//
+// Linking is a round trip through plex.tv: we ask for a PIN, send the person to
+// plex.tv to claim it, and Plex returns them to /api/plex/callback, which is
+// where the link is actually finished. The PIN id is parked in an httpOnly
+// cookie for the duration -- it is a one-use handle, worthless without the
+// sign-in that claims it, and it means the flow survives the user taking a
+// minute over it.
+//
+// The FINISHING half deliberately lives in a route handler rather than here or
+// in the page: it has to delete that cookie and revalidate, and a page render
+// may do neither. See src/app/api/plex/callback/route.ts.
+//
+// Linking is what grants library access AND what makes a watchlist and watch
+// history readable. All of it follows from the same consent, which is why it
+// happens together rather than being things an admin has to remember to do.
+
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { eq } from "drizzle-orm";
+
+import { auth } from "@/auth";
+import { db } from "@/db/client";
+import { plexAccounts } from "@/db/schema";
+import { appUrl } from "@/lib/app-url";
+import { createPin, authUrl } from "@/lib/plex";
+import { PIN_COOKIE } from "@/lib/plex-link";
+
+/**
+ * Both surfaces, always.
+ *
+ * The embed is a separate route rendering the same data, so refreshing only
+ * /profile would leave charon's panel showing a Plex link that has already been
+ * removed until something else happened to invalidate it.
+ */
+function revalidateProfile() {
+ revalidatePath("/profile");
+ revalidatePath("/embed/profile");
+}
+
+async function requireUser() {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Sign in first");
+ return session.user;
+}
+
+export async function startPlexLinkAction() {
+ await requireUser();
+
+ const pin = await createPin();
+ const jar = await cookies();
+ jar.set(PIN_COOKIE, String(pin.id), {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: true,
+ path: "/",
+ maxAge: 15 * 60,
+ });
+
+ // A bare path, no query string -- Plex carries this inside a URL fragment
+ // that its own client-side code parses. redirect() throws, so it goes last.
+ redirect(authUrl(pin, appUrl("/api/plex/callback")));
+}
+
+export async function unlinkPlexAction() {
+ const user = await requireUser();
+ // Only the link is removed. Library access is granted on Plex's side and is
+ // not ours to quietly revoke from a button labelled "unlink"; whoever owns
+ // the server can take it back there.
+ await db.delete(plexAccounts).where(eq(plexAccounts.userId, user.id));
+ revalidateProfile();
+}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
new file mode 100644
index 0000000..23d0432
--- /dev/null
+++ b/src/app/profile/page.tsx
@@ -0,0 +1,121 @@
+import Image from "next/image";
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { auth, signOut } from "@/auth";
+import { APP_URL } from "@/lib/app-url";
+import { getProfile } from "@/lib/profile";
+
+import { ProfilePanel } from "./profile-panel";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams?: Promise>;
+};
+
+function single(value: string | string[] | undefined) {
+ return Array.isArray(value) ? value[0] : value;
+}
+
+/**
+ * What to say about a link attempt that just came back.
+ *
+ * The linking itself happens in /api/plex/callback, which redirects here with
+ * an outcome code; this page only renders it. Reading a code rather than a
+ * ready-made sentence keeps somebody else's error text out of a URL that a
+ * person might paste to ask for help.
+ */
+function linkMessage(params: Record) {
+ const outcome = single(params.plex);
+ if (!outcome) return null;
+
+ const who = single(params.who);
+ const reason = single(params.reason);
+ const named = who ? `Linked as ${who}` : "Linked";
+
+ switch (outcome) {
+ case "linked":
+ return `${named}. The Plex libraries have been shared with you — check your email for the invitation.`;
+ case "owner":
+ return `${named}. You own this Plex server, so you already have every library — nothing needed sharing.`;
+ case "already-shared":
+ return `${named}. You already had access to the libraries.`;
+ case "share-failed":
+ return `${named}, but the libraries could not be shared${reason ? `: ${reason}` : "."} You can try linking again.`;
+ case "not-completed":
+ return "That sign-in was not completed. Try linking again.";
+ case "taken":
+ return "That Plex account is already linked to another Ampelos user.";
+ case "error":
+ return `Plex could not confirm the sign-in${reason ? `: ${reason}` : "."}`;
+ default:
+ return null;
+ }
+}
+
+export default async function ProfilePage({ searchParams }: PageProps) {
+ const session = await auth();
+ if (!session?.user?.id) redirect("/login");
+
+ const resolved = (await searchParams) ?? {};
+ const message = linkMessage(resolved);
+
+ const profile = await getProfile(session.user.id, APP_URL);
+ if (!profile) redirect("/login");
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/app/profile/profile-panel.tsx b/src/app/profile/profile-panel.tsx
new file mode 100644
index 0000000..a29a859
--- /dev/null
+++ b/src/app/profile/profile-panel.tsx
@@ -0,0 +1,372 @@
+// The Ampelos profile panel.
+//
+// ONE component behind three surfaces: the page at /profile, the fragment
+// charon iframes at /embed/profile, and -- through getProfile -- the JSON at
+// /api/profile. The `embedded` prop changes only chrome and navigation, never
+// what is shown, so the panel accounts.sticknife.com displays is the panel here
+// and there is no second implementation to keep in step.
+//
+// Everything is a server component and every control is a plain form. That is
+// worth keeping: the embed is a cross-origin iframe, and a panel that needs no
+// client JavaScript is one that cannot be broken by charon's CSP.
+
+import Image from "next/image";
+import Link from "next/link";
+
+import type { CatalogRef, HistoryEntry, Profile, WatchNowList } from "@/lib/profile";
+
+import { startPlexLinkAction, unlinkPlexAction } from "./actions";
+import { removeFromWatchNow } from "../watch-now-actions";
+
+type PanelProps = {
+ profile: Profile;
+ /** Rendered inside charon's iframe: no in-app navigation, links escape the frame. */
+ embedded?: boolean;
+};
+
+/**
+ * Anchors inside the embed must break out of the frame.
+ *
+ * Without target="_top" a click loads Ampelos's full page inside charon's
+ * collapsible panel, which looks like the site has broken. Rendering the whole
+ * app in a 400px box is not a state anybody meant to reach.
+ */
+function frameTarget(embedded?: boolean) {
+ return embedded ? { target: "_top" as const } : {};
+}
+
+function whenText(iso: string) {
+ const then = new Date(iso);
+ const days = Math.floor((Date.now() - then.getTime()) / 86_400_000);
+
+ if (days <= 0) return "today";
+ if (days === 1) return "yesterday";
+ if (days < 30) return `${days} days ago`;
+
+ return then.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
+}
+
+function episodeLabel(entry: HistoryEntry) {
+ if (entry.seasonNumber === null || entry.episodeNumber === null) return null;
+
+ const code = `S${String(entry.seasonNumber).padStart(2, "0")}E${String(entry.episodeNumber).padStart(2, "0")}`;
+ return entry.episodeTitle ? `${code} · ${entry.episodeTitle}` : code;
+}
+
+/**
+ * Wrap a row so clicking it opens the catalog's detail panel for that title.
+ *
+ * The panel lives in the catalog board and is driven by TMDB ids, so this is a
+ * link to the board with `?open=` rather than a second copy of the panel here.
+ * That also happens to be the only version that works inside charon's iframe,
+ * where a modal would open in a 400px box.
+ *
+ * A title with no usable TMDB id -- never linked, or linked to an id somebody
+ * rejected -- renders as plain text rather than a link that would open the
+ * wrong thing or nothing at all.
+ */
+function CatalogLink({
+ catalog,
+ title,
+ embedded,
+ className,
+ children,
+}: {
+ catalog: CatalogRef;
+ title: string;
+ embedded?: boolean;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ if (!catalog) return
+ {/*
+ The owner is never invited to their own server -- Plex refuses with
+ "You cannot send an invitation to yourself" -- so an unshared link
+ means something completely different for them than for everybody
+ else, and must not read as a failure.
+ */}
+
+ {plex.isServerOwner
+ ? "You own this Plex server, so you already have every library. Nothing needed sharing."
+ : plex.librariesSharedAt
+ ? `${plex.sharedLibraryCount} libraries shared with you. Check your email for the invitation if you have not accepted it yet.`
+ : "The libraries have not been shared yet — link again to retry."}
+
+
+ Anything you add to your Plex watchlist is treated as a request: if we already have
+ it you will find it in the library, and if we do not, we will go and get it.
+
+ {/* Unlinking stays in place -- it navigates nowhere, so it is safe in the frame. */}
+
+ >
+ ) : (
+ <>
+
+ Link your Plex account to get access to the Movies, TV Shows and Music libraries.
+ Once linked, your Plex watchlist becomes your request list — add something there and
+ it will be found for you.
+
+
+ You sign in at plex.tv, not here. Ampelos never sees your Plex password, and the
+ sign-in token is discarded as soon as Plex confirms who you are.
+
+ {/*
+ Linking is a round trip through plex.tv, and plex.tv refuses to be
+ framed. Inside the embed the button therefore hands the person over
+ to the real profile page at top level rather than starting a flow
+ that would dead-end in charon's panel.
+ */}
+ {embedded ? (
+
+ Link my Plex account on Ampelos
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function WatchNowGroup({
+ label,
+ list,
+ embedded,
+}: {
+ label: string;
+ list: WatchNowList;
+ embedded?: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {list.used} of {list.quota} slots
+
+
+
+ {list.items.length === 0 ? (
+
+ Nothing here yet. Add something from the{" "}
+
+ catalog
+ {" "}
+ to keep it in high quality on live storage.
+
+ Watch Now is what keeps a title on live storage in full quality. Everything else falls
+ back to the 720p archive over time.
+
+
+
+
+ );
+}
+
+function WatchHistorySection({ profile, embedded }: PanelProps) {
+ const { history } = profile;
+
+ return (
+
+ 0
+ ? `${history.playsLast30Days} plays in the last 30 days · ${history.distinctEpisodes} titles`
+ : undefined
+ }
+ />
+
+ {history.recent.length === 0 ? (
+
+ {profile.plex.linked
+ ? "Nothing recorded yet. History is copied from Plex about once an hour, so anything you watch will show up here shortly."
+ : "Link your Plex account above and what you watch will be recorded here."}
+
+
+
+
+
+ {entry.title}
+
+
+ {episode ? `${episode} · ` : ""}
+ {whenText(entry.watchedAt)}
+ {/*
+ Repeats are collapsed to the most recent play, so the
+ count is what stops that from looking like data loss.
+ */}
+ {entry.playCount > 1 ? ` · watched ${entry.playCount} times` : ""}
+
+
+
+
+ );
+ })}
+
+ {/*
+ Counted against distinctEpisodes rather than total plays: the list
+ is one row per episode, so promising it is a window onto 1,668
+ plays when it can only ever show 993 things is just wrong.
+ */}
+ {history.distinctEpisodes > history.recent.length && (
+
+ Showing the {history.recent.length} most recently watched of{" "}
+ {history.distinctEpisodes}. Repeats are collapsed to the latest play.
+
+ )}
+ >
+ )}
+
+
+ Only you can see this. Ampelos records history for linked accounts and never shows one
+ person's viewing to anybody else, administrators included.
+