Add the profile page, and publish it as an embeddable panel

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>
This commit is contained in:
odin
2026-08-17 15:42:17 +02:00
parent 3ccae54259
commit 1dfa2c0a04
30 changed files with 8119 additions and 233 deletions
+15
View File
@@ -153,6 +153,21 @@ function imageUrl(path?: string | null) {
return path ? `${TMDB_IMAGE_BASE_URL}${path}` : null;
}
/**
* A usable poster URL from whatever media_items.poster_path happens to hold.
*
* Two writers disagree about that column. The metadata refresh stores TMDB's
* bare path (`/abc.jpg`, 2,802 rows) and the catalog board stores the absolute
* URL it had already built (1 row). Both forms are in the table right now, so
* anything rendering a stored poster has to cope with both -- and anything
* handing one to ANOTHER SERVICE has to resolve it, because a bare TMDB path
* means nothing on accounts.sticknife.com.
*/
export function posterUrlFromStored(path?: string | null) {
if (!path) return null;
return /^https?:\/\//.test(path) ? path : imageUrl(path);
}
function backdropUrl(path?: string | null) {
return path ? `${TMDB_BACKDROP_BASE_URL}${path}` : null;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The origins allowed to embed Ampelos or read its profile API from a browser.
*
* AMPELOS_EMBED_ANCESTORS is the single source of truth for both, and it is
* read in two places on purpose: next.config.ts needs it at build time to write
* the CSP `frame-ancestors` header, and route handlers need it at request time
* to answer CORS. Keeping one env var and two readers is better than a shared
* module, because next.config.ts cannot use the "@/" path alias.
*
* Origins, not hostnames -- scheme included. Both the CSP parser and the CORS
* Origin header deal in origins, and a bare hostname is silently ignored by the
* first and never matches the second.
*/
const DEFAULT_ORIGINS = "https://accounts.sticknife.com";
export function embedOrigins(): string[] {
return (process.env.AMPELOS_EMBED_ANCESTORS ?? DEFAULT_ORIGINS)
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
}
export function isAllowedOrigin(origin: string | null): origin is string {
return Boolean(origin) && embedOrigins().includes(origin!);
}
+123
View File
@@ -0,0 +1,123 @@
// Finishing a Plex link: the part that touches the database.
//
// Deliberately knows nothing about cookies, redirects or revalidation. It used
// to run inside the render of the profile page, which is why linking never
// completed: a page render may not delete a cookie or call revalidatePath, so
// the moment Plex returned the user, the page threw. The caller is now a route
// handler, which is allowed to do both -- see src/app/api/plex/callback/route.ts.
import { eq } from "drizzle-orm";
import { db } from "@/db/client";
import { plexAccounts } from "@/db/schema";
import { claimPin, ownerAccountId, shareLibraries } from "@/lib/plex";
/**
* Where the in-flight PIN id is parked between starting a link and finishing
* it. Lives here rather than in the route handler because a route.ts may only
* export handlers and route config -- exporting a constant from one trips
* Next's type check on the module.
*/
export const PIN_COOKIE = "ampelos_plex_pin";
/**
* Outcome codes. These travel back to the profile page in the query string, so
* they are short, stable, and carry no detail that would be wrong to put in a
* URL somebody might paste.
*/
export type PlexLinkOutcome =
| "linked"
| "owner"
| "already-shared"
| "share-failed"
| "not-completed"
| "taken"
| "error";
export type PlexLinkResult = {
outcome: PlexLinkOutcome;
username?: string;
/** Only for the failure cases, and only ever a short reason. */
reason?: string;
};
export async function completePlexLink(userId: string, pinId: number): Promise<PlexLinkResult> {
let identity;
try {
identity = await claimPin(pinId);
} catch (error) {
return { outcome: "error", reason: (error as Error).message };
}
// An abandoned sign-in and an expired PIN are both things a person does, not
// errors: the PIN is simply still unclaimed.
if (!identity) return { outcome: "not-completed" };
// 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 !== userId) {
return { outcome: "taken", username: identity.username };
}
// THE OWNER IS NOT INVITED TO THEIR OWN SERVER. Plex answers a share request
// naming them with "You cannot send an invitation to yourself", and reporting
// that as a failed share would tell the one person who definitely has access
// that they have none, on every visit, forever.
//
// Checked before attempting the share rather than by matching that sentence
// afterwards: the wording belongs to Plex and may change, and a share request
// that was never going to succeed is not worth making.
let isOwner = false;
try {
const owner = await ownerAccountId();
isOwner = owner !== null && owner === identity.plexUserId;
} catch {
// Unreachable plex.tv means we cannot tell. Falling through to the share
// attempt is the safe direction: a non-owner still gets their invitation,
// and an owner gets the old confusing message rather than a wrong claim of
// access.
}
let shared: { sectionTitles: string[]; sectionIds: string[]; alreadyShared: boolean } | null = null;
let shareError: string | null = null;
if (!isOwner) {
try {
shared = await shareLibraries(identity);
} catch (error) {
// The link is still worth recording: it is what makes the watchlist and
// the watch history readable, and a share that failed can be retried
// without signing in again.
shareError = (error as Error).message;
}
}
const values = {
plexUserId: identity.plexUserId,
plexUuid: identity.plexUuid,
plexUsername: identity.username,
plexEmail: identity.email,
isServerOwner: isOwner,
// Both stay null for the owner: nothing was shared with them because
// nothing needed to be.
librariesSharedAt: shared ? new Date() : null,
sharedSectionIds: shared?.sectionIds ?? null,
};
await db
.insert(plexAccounts)
.values({ userId, ...values })
.onConflictDoUpdate({
target: plexAccounts.userId,
set: { ...values, updatedAt: new Date() },
});
if (isOwner) return { outcome: "owner", username: identity.username };
if (shareError) return { outcome: "share-failed", username: identity.username, reason: shareError };
if (shared?.alreadyShared) return { outcome: "already-shared", username: identity.username };
return { outcome: "linked", username: identity.username };
}
+56 -2
View File
@@ -11,6 +11,8 @@
// 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 ?? "";
@@ -91,13 +93,30 @@ export function isShareable(section: PlexSection) {
export type PlexPin = { id: number; code: string };
/** Start a sign-in. The user takes the code to plex.tv; we poll for the result. */
/**
* 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),
@@ -163,8 +182,43 @@ export async function claimPin(pinId: number): Promise<PlexIdentity | 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.
*
@@ -197,7 +251,7 @@ export async function shareLibraries(identity: { email: string | null; plexUserI
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)}`);
throw new Error(`Plex refused the share: ${plexErrorText(text)}`);
}
return {
+397
View File
@@ -0,0 +1,397 @@
// Everything Ampelos knows about one person, in one shape.
//
// This exists because the same profile is rendered three ways -- the page at
// /profile, the chrome-less fragment charon iframes at /embed/profile, and the
// JSON at /api/profile -- and three readers of the same tables would drift.
// They have drifted before: the catalog board and the placement classifier each
// decided for themselves what "purged" meant and disagreed about which titles
// were.
//
// PROFILE_SCHEMA_VERSION is part of the contract with accounts.sticknife.com.
// Charon renders a panel it does not own from a service it cannot redeploy in
// step, so it needs to be able to say "I understand version 1" and degrade
// rather than break. Bump it when a field's MEANING changes; adding a field
// does not need a bump, because a consumer ignoring an unknown key is fine.
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { db } from "@/db/client";
import {
episodes,
externalIds,
mediaItems,
plexAccounts,
seasons,
users,
watchHistory,
watchingNowItems,
} from "@/db/schema";
import { posterUrlFromStored } from "@/lib/catalog";
// 2: history.recent is one row per EPISODE rather than per play, and its
// watchedAt is the most recent play of that episode. A consumer written
// against 1 would double-count. Adding playCount, distinctEpisodes and the
// catalog refs alone would not have earned a bump; changing what a row
// MEANS does.
export const PROFILE_SCHEMA_VERSION = 2;
/** How many plays the panel shows. The full history is not a profile panel's job. */
const RECENT_HISTORY_LIMIT = 25;
export type ProfileMediaType = "tv_series" | "movie";
/**
* What the catalog needs to open its detail panel for a title.
*
* The board deals in TMDB ids, not media_items ids, so a profile entry cannot
* be opened without one. 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,
* and following it would open somebody else's film.
*/
export type CatalogRef = {
tmdbId: string;
kind: "television" | "movies";
} | null;
export type WatchNowEntry = {
id: string;
mediaItemId: string;
title: string;
year: number | null;
posterUrl: string | null;
slotNumber: number | null;
addedAt: string;
catalog: CatalogRef;
};
export type WatchNowList = {
quota: number;
used: number;
items: WatchNowEntry[];
};
export type HistoryEntry = {
id: string;
mediaItemId: string;
mediaType: ProfileMediaType;
title: string;
year: number | null;
posterUrl: string | null;
/** Null for a movie. */
seasonNumber: number | null;
episodeNumber: number | null;
/** The episode's own title, when this database has a row for it. */
episodeTitle: string | null;
/** The MOST RECENT time this episode was played; see getWatchHistory. */
watchedAt: string;
/** How many times in total, so collapsing repeats does not hide them. */
playCount: number;
source: "plex" | "manual";
catalog: CatalogRef;
};
export type PlexLink =
| { linked: false }
| {
linked: true;
username: string;
email: string | null;
linkedAt: string;
/** This account owns the Plex server, so it was never invited to anything. */
isServerOwner: boolean;
librariesSharedAt: string | null;
sharedLibraryCount: number;
};
export type Profile = {
service: "ampelos";
schemaVersion: number;
generatedAt: string;
user: {
id: string;
displayName: string;
email: string;
};
plex: PlexLink;
watchNow: {
television: WatchNowList;
movies: WatchNowList;
};
history: {
/** Every play on record, counting repeats. */
total: number;
/** Distinct episodes and films -- what `recent` is a window onto. */
distinctEpisodes: number;
playsLast30Days: number;
lastWatchedAt: string | null;
recent: HistoryEntry[];
};
/** Absolute, so charon can link back into Ampelos without knowing its address. */
links: {
self: string;
embed: string;
api: string;
};
};
function iso(value: Date | string | null | undefined) {
if (!value) return null;
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
async function getPlexLink(userId: string): Promise<PlexLink> {
const [row] = await db
.select()
.from(plexAccounts)
.where(eq(plexAccounts.userId, userId))
.limit(1);
if (!row) return { linked: false };
return {
linked: true,
username: row.plexUsername,
email: row.plexEmail,
linkedAt: iso(row.linkedAt)!,
isServerOwner: row.isServerOwner,
librariesSharedAt: iso(row.librariesSharedAt),
sharedLibraryCount: row.sharedSectionIds?.length ?? 0,
};
}
/**
* The TMDB link to join through, when there is a trustworthy one.
*
* `rejected_at is null` is the load-bearing half: external_ids deliberately
* KEEPS an id a reviewer has rejected, as the evidence of what went wrong. It
* is a known-wrong id, so a link built on it would open the wrong title.
*/
const tmdbJoin = and(
eq(externalIds.source, "tmdb"),
isNull(externalIds.rejectedAt),
);
function catalogRef(tmdbId: string | null, mediaType: ProfileMediaType): CatalogRef {
if (!tmdbId) return null;
return { tmdbId, kind: mediaType === "movie" ? "movies" : "television" };
}
async function getWatchNow(userId: string, mediaType: ProfileMediaType, quota: number): Promise<WatchNowList> {
const rows = await db
.select({
id: watchingNowItems.id,
mediaItemId: mediaItems.id,
title: mediaItems.title,
year: mediaItems.year,
posterPath: mediaItems.posterPath,
slotNumber: watchingNowItems.slotNumber,
addedAt: watchingNowItems.addedAt,
tmdbId: externalIds.externalId,
})
.from(watchingNowItems)
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), tmdbJoin))
.where(
and(
eq(watchingNowItems.userId, userId),
isNull(watchingNowItems.removedAt),
eq(mediaItems.mediaType, mediaType),
),
)
// Slot order, with the unslotted last rather than first: a null sorts high
// in Postgres by default and would put the least-placed item at the top.
.orderBy(sql`${watchingNowItems.slotNumber} asc nulls last`);
return {
quota,
used: rows.length,
items: rows.map((row) => ({
id: row.id,
mediaItemId: row.mediaItemId,
title: row.title,
year: row.year,
posterUrl: posterUrlFromStored(row.posterPath),
slotNumber: row.slotNumber,
addedAt: iso(row.addedAt)!,
catalog: catalogRef(row.tmdbId, mediaType),
})),
};
}
/**
* This person's plays, and only ever this person's.
*
* The userId filter is the whole privacy guarantee for the feature. Plex hands
* the owner's token everybody's history, so the restraint has to live here --
* there is no separate credential that would have failed if this were widened
* by accident. Nothing in this file takes an "all users" mode, deliberately:
* an admin view of who watched what would be a surveillance surface over the
* people the server is shared with, and it is not one anybody asked for.
*/
async function getWatchHistory(userId: string) {
// 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 -- so the raw table shows the same
// SVU episode filling the panel and buries everything else. Collapsing to the
// most recent play is what makes this readable as "what have I been
// watching".
//
// `distinct on` rather than a group-by because it keeps the whole winning row
// (the id, the poster, the exact timestamp) without listing every column
// twice. Its ORDER BY must lead with the distinct expressions, which is why
// the ordering people actually see is applied in the outer query below.
//
// NULLs are treated as equal here, unlike in a unique index -- which is what
// makes a movie (null season, null episode) collapse to one row correctly.
const deduped = db
.selectDistinctOn([watchHistory.mediaItemId, watchHistory.seasonNumber, watchHistory.episodeNumber], {
id: watchHistory.id,
mediaItemId: watchHistory.mediaItemId,
seasonNumber: watchHistory.seasonNumber,
episodeNumber: watchHistory.episodeNumber,
watchedAt: watchHistory.watchedAt,
source: watchHistory.source,
// Counted before the dedup throws the repeats away, so "watched 3 times"
// survives showing only one of them.
playCount: sql<number>`count(*) over (
partition by ${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber}
)::int`.as("play_count"),
})
.from(watchHistory)
.where(eq(watchHistory.userId, userId))
.orderBy(
watchHistory.mediaItemId,
watchHistory.seasonNumber,
watchHistory.episodeNumber,
desc(watchHistory.watchedAt),
)
.as("deduped");
// Episode titles come from a left join through seasons, because a play can
// name a season and episode this database has no row for -- see the schema
// comment on watch_history.season_number.
const recent = await db
.select({
id: deduped.id,
mediaItemId: mediaItems.id,
mediaType: mediaItems.mediaType,
title: mediaItems.title,
year: mediaItems.year,
posterPath: mediaItems.posterPath,
seasonNumber: deduped.seasonNumber,
episodeNumber: deduped.episodeNumber,
episodeTitle: episodes.title,
watchedAt: deduped.watchedAt,
playCount: deduped.playCount,
source: deduped.source,
tmdbId: externalIds.externalId,
})
.from(deduped)
.innerJoin(mediaItems, eq(mediaItems.id, deduped.mediaItemId))
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), tmdbJoin))
.leftJoin(
seasons,
and(
eq(seasons.seriesId, deduped.mediaItemId),
eq(seasons.seasonNumber, deduped.seasonNumber),
),
)
.leftJoin(
episodes,
and(
eq(episodes.seasonId, seasons.id),
eq(episodes.episodeNumber, deduped.episodeNumber),
),
)
.orderBy(desc(deduped.watchedAt))
.limit(RECENT_HISTORY_LIMIT);
// watched_at is `timestamp without time zone` holding UTC, which is the
// house-wide fragility recorded in PLANNING.md. Comparing it against now()
// directly would be wrong by the server's Europe/Stockholm offset, so the
// cutoff is computed in UTC on both sides.
const [totals] = await db
.select({
total: sql<number>`count(*)::int`,
// The number the deduped list is a window onto. Counting plays there
// instead would promise more rows than the list can ever show.
distinctEpisodes: sql<number>`count(distinct (
${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber}
))::int`,
last30: sql<number>`count(*) filter (
where ${watchHistory.watchedAt} >= (now() at time zone 'utc') - interval '30 days'
)::int`,
lastWatchedAt: sql<Date | null>`max(${watchHistory.watchedAt})`,
})
.from(watchHistory)
.where(eq(watchHistory.userId, userId));
return {
total: totals?.total ?? 0,
distinctEpisodes: totals?.distinctEpisodes ?? 0,
playsLast30Days: totals?.last30 ?? 0,
lastWatchedAt: iso(totals?.lastWatchedAt),
recent: recent.map((row) => ({
id: row.id,
mediaItemId: row.mediaItemId,
mediaType: row.mediaType,
title: row.title,
year: row.year,
posterUrl: posterUrlFromStored(row.posterPath),
seasonNumber: row.seasonNumber,
episodeNumber: row.episodeNumber,
episodeTitle: row.episodeTitle,
watchedAt: iso(row.watchedAt)!,
playCount: row.playCount,
source: row.source,
catalog: catalogRef(row.tmdbId, row.mediaType),
})),
};
}
/**
* Assemble the profile.
*
* Every read is scoped to `userId` and there is no parameter that widens that.
* Callers are responsible for proving who the user is; see the route handlers.
*/
export async function getProfile(userId: string, baseUrl: string): Promise<Profile | null> {
const [user] = await db
.select({
id: users.id,
displayName: users.displayName,
email: users.email,
tvSlots: users.watchingNowTvSlots,
movieSlots: users.watchingNowMovieSlots,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) return null;
const [plex, television, movies, history] = await Promise.all([
getPlexLink(user.id),
getWatchNow(user.id, "tv_series", user.tvSlots),
getWatchNow(user.id, "movie", user.movieSlots),
getWatchHistory(user.id),
]);
const base = baseUrl.replace(/\/$/, "");
return {
service: "ampelos",
schemaVersion: PROFILE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
user: { id: user.id, displayName: user.displayName, email: user.email },
plex,
watchNow: { television, movies },
history,
links: {
self: `${base}/profile`,
embed: `${base}/embed/profile`,
api: `${base}/api/profile`,
},
};
}