Compare commits

...

2 Commits

Author SHA1 Message Date
odin 1752870cee Show the calendar in the viewer's own timezone
An episode's air_date is a DATE in the broadcaster's country, so the calendar
could only ever file things under the network's day. Last Week Tonight airs
23:00 Sunday in New York, which is 05:00 MONDAY in Stockholm -- the calendar
put it on Sunday for everyone, and nothing anywhere showed a time.

series.airs_time and series.airs_timezone (0026) carry the network's local
airtime and its zone; the agent repository fills them from TVDB, which is the
only source that has them. Postgres does the conversion:

    ((e.air_date + se.airs_time) at time zone se.airs_timezone) at time zone $viewer

against the episode's own date, so it stays right across a DST boundary in a
way a stored offset would not.

THE BROWSER REPORTS ITS ZONE IN A COOKIE rather than the page converting after
hydration. Converting client-side would leave the server unable to group, sort
or decide what "today" is, and would make it send HTML it then disagrees with.
The cookie is validated against Intl before it reaches SQL -- it is passed as a
bound parameter either way, but a client-chosen string that ends up inside
`at time zone` is worth closing off at the door too.

The window is widened a day at each end, because an episode airing late on the
last day belongs to the next day for a viewer far enough east, and one on the
first day can move back. Anything landing outside the displayed days is simply
never looked up.

Series with no airtime -- 28 of 517, plus every film -- fall back to the bare
date and no time, which is exactly what this page did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:42:31 +02:00
odin 1dfa2c0a04 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>
2026-08-17 15:42:17 +02:00
35 changed files with 11309 additions and 236 deletions
+20
View File
@@ -32,6 +32,26 @@ AMPELOS_AGENT_TIMEOUT_SECONDS=30
# both repositories' environments, in neither's history. # both repositories' environments, in neither's history.
AMPELOS_AGENT_TOKEN= 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 ---------------------------------------------------- # --- external services ----------------------------------------------------
MOVIEDB_API= MOVIEDB_API=
PLEX_URL= PLEX_URL=
+17 -1
View File
@@ -287,9 +287,25 @@ Expected Plex-facing media:
Archive-only media should not normally be visible in Plex unless restored or promoted. 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: Open decisions:
- Whether Ampelos should read Plex watch history, active sessions, or user libraries directly.
- Whether existing Plex watchlists remain an input signal. - Whether existing Plex watchlists remain an input signal.
- Whether Plex availability should be polled or inferred from filesystem and Ampelos inventory state. - Whether Plex availability should be polled or inferred from filesystem and Ampelos inventory state.
+294
View File
@@ -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
<iframe
src="https://ampelos.sticknife.com/embed/profile"
title="Ampelos"
loading="lazy"
style="width:100%;border:0;height:640px"
></iframe>
```
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=<kind>&open=<tmdbId>`, 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.
+43
View File
@@ -1,5 +1,20 @@
import type { NextConfig } from "next"; 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 = { const nextConfig: NextConfig = {
allowedDevOrigins: ["10.24.88.95", "ampelos.sticknife.com"], allowedDevOrigins: ["10.24.88.95", "ampelos.sticknife.com"],
images: { 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; export default nextConfig;
+93 -4
View File
@@ -1,7 +1,9 @@
import { db } from "@/db/client"; import { db } from "@/db/client";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { cookies } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { ManualSearch } from "../manual-search"; import { ManualSearch } from "../manual-search";
import { ViewerTimezone } from "./viewer-timezone";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,6 +19,8 @@ type CalendarRow = {
episode_number: number | null; episode_number: number | null;
episode_title: string | null; episode_title: string | null;
date: string; date: string;
/** "HH:MM" in the viewer's zone, or null when the airtime is unknown. */
local_time: string | null;
has_file: boolean; has_file: boolean;
grab_status: string | null; grab_status: string | null;
wanted: boolean; wanted: boolean;
@@ -40,6 +44,32 @@ function singleParam(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
// The zone used before the browser has told us its own, and the one used if it
// tells us something that is not a real zone.
const DEFAULT_TIMEZONE = "UTC";
/**
* A timezone name we are willing to hand to Postgres.
*
* This value arrives in a cookie, which is to say from the client, and it ends
* up inside `at time zone`. It is passed as a bound parameter rather than
* interpolated, so this is not the only thing standing between a cookie and the
* database -- but "the client can choose an arbitrary string that reaches SQL"
* is worth closing off at the door as well.
*
* Intl is the authority rather than a list of our own: it is the same table
* Postgres is being asked to look the name up in, and a list here would drift.
*/
function safeTimezone(value: string | undefined): string {
if (!value) return DEFAULT_TIMEZONE;
try {
new Intl.DateTimeFormat("en-GB", { timeZone: value });
return value;
} catch {
return DEFAULT_TIMEZONE;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Dates // Dates
// //
@@ -106,7 +136,7 @@ function monthGridRange(key: string) {
* series-wide rule plus an episode override) and more than one grab, and any of * series-wide rule plus an episode override) and more than one grab, and any of
* those as a join would silently duplicate calendar entries. * those as a join would silently duplicate calendar entries.
*/ */
function calendarQuery(from: string, to: string) { function calendarQuery(from: string, to: string, zone: string) {
return sql` return sql`
with in_window as ( with in_window as (
select select
@@ -120,7 +150,28 @@ function calendarQuery(from: string, to: string) {
-- for a date column, and its string form ("Wed Jul 30 2026 ...") sorts -- for a date column, and its string form ("Wed Jul 30 2026 ...") sorts
-- and compares as nonsense against an ISO day -- which quietly labelled -- and compares as nonsense against an ISO day -- which quietly labelled
-- every past episode as still upcoming. -- every past episode as still upcoming.
to_char(e.air_date, 'YYYY-MM-DD') as date, --
-- THE DAY IS THE VIEWER'S DAY, not the network's. Last Week Tonight
-- airs 23:00 Sunday in New York, which is 05:00 MONDAY in Stockholm --
-- so filing it under Sunday would put it on the wrong square for
-- everyone east of the Atlantic. The conversion is done here rather
-- than in the browser so the grouping, the "today" comparison and the
-- ordering all agree, and so the server renders the same HTML it
-- hydrates.
--
-- Series with no airtime fall back to the bare date, which is exactly
-- what this showed before any of this existed.
to_char(
case
when se.airs_time is not null and se.airs_timezone is not null
then ((e.air_date + se.airs_time) at time zone se.airs_timezone) at time zone ${zone}
else e.air_date::timestamp
end, 'YYYY-MM-DD') as date,
case
when se.airs_time is not null and se.airs_timezone is not null
then to_char(((e.air_date + se.airs_time) at time zone se.airs_timezone)
at time zone ${zone}, 'HH24:MI')
end as local_time,
exists ( exists (
select 1 from storage_files sf select 1 from storage_files sf
join storage_tiers t on t.id = sf.tier_id join storage_tiers t on t.id = sf.tier_id
@@ -166,6 +217,8 @@ function calendarQuery(from: string, to: string) {
null::int, null::int,
null::text, null::text,
to_char(m.release_date, 'YYYY-MM-DD'), to_char(m.release_date, 'YYYY-MM-DD'),
-- A film has a release date and no airtime; there is no hour to show.
null::text,
exists ( exists (
select 1 from storage_files sf select 1 from storage_files sf
join storage_tiers t on t.id = sf.tier_id join storage_tiers t on t.id = sf.tier_id
@@ -286,7 +339,20 @@ export default async function AdminCalendarPage({ searchParams }: PageProps) {
to: toYmd(addDays(fromYmd(today), DAYS_AHEAD)), to: toYmd(addDays(fromYmd(today), DAYS_AHEAD)),
}; };
const { rows } = await db.execute<CalendarRow>(calendarQuery(range.from, range.to)); // The viewer's own clock, if the browser has had a chance to say. Until then
// UTC, which is the honest answer rather than the server's incidental zone.
const cookieZone = (await cookies()).get("ampelos_tz")?.value;
const zone = safeTimezone(cookieZone);
// Widened by a day at each end. An episode airing 23:00 on the last day of
// the window belongs to the NEXT day for a viewer far enough east, and one on
// the first day can move back; querying the exact range would drop both.
// Anything that lands outside the displayed days is simply never looked up.
const { rows } = await db.execute<CalendarRow>(calendarQuery(
toYmd(addDays(fromYmd(range.from), -1)),
toYmd(addDays(fromYmd(range.to), 1)),
zone,
));
// "Monitored" keeps anything we want, already hold, or are fetching. A title // "Monitored" keeps anything we want, already hold, or are fetching. A title
// that is present but no longer monitored still belongs on the calendar -- // that is present but no longer monitored still belongs on the calendar --
@@ -309,6 +375,9 @@ export default async function AdminCalendarPage({ searchParams }: PageProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Renders nothing; reports the browser's zone so the grouping above can
use it on the next render. */}
<ViewerTimezone current={cookieZone ?? null} />
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Schedule</p> <p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Schedule</p>
@@ -474,6 +543,7 @@ function MonthGrid({
key={row.kind + row.media_item_id + entryCode(row)} key={row.kind + row.media_item_id + entryCode(row)}
href={entryHref(row)} href={entryHref(row)}
title={row.title + " " + entryCode(row) + " — " + STATE_LABEL[state] + title={row.title + " " + entryCode(row) + " — " + STATE_LABEL[state] +
(row.local_time ? " at " + row.local_time : "") +
(row.episode_title ? "\n" + row.episode_title : "")} (row.episode_title ? "\n" + row.episode_title : "")}
className={ className={
"block rounded-sm bg-admin-subpanel px-1.5 py-1 leading-tight hover:bg-[#20363a] " + "block rounded-sm bg-admin-subpanel px-1.5 py-1 leading-tight hover:bg-[#20363a] " +
@@ -481,7 +551,15 @@ function MonthGrid({
} }
> >
<span className="block truncate text-[11px] text-admin-text">{row.title}</span> <span className="block truncate text-[11px] text-admin-text">{row.title}</span>
<span className="block truncate text-[10px] text-admin-muted">{entryCode(row)}</span> <span className="block truncate text-[10px] text-admin-muted">
{/* A grid cell has room for one line; the time earns
its place there because it is what decides
whether you can watch it tonight. */}
{row.local_time ? (
<span className="tabular-nums">{row.local_time}</span>
) : null}
{row.local_time ? " " : ""}{entryCode(row)}
</span>
</Link> </Link>
); );
})} })}
@@ -547,6 +625,17 @@ function Agenda({ byDay, today }: { byDay: Map<string, CalendarRow[]>; today: st
> >
<span className="w-14 shrink-0 font-mono text-xs text-admin-muted">{code}</span> <span className="w-14 shrink-0 font-mono text-xs text-admin-muted">{code}</span>
{/* Already the viewer's own clock -- the conversion happened
in SQL, against the zone the browser reported. Absent for
films and for series TVDB has no airtime for. */}
{row.local_time ? (
<span className="w-11 shrink-0 font-mono text-xs tabular-nums text-admin-muted">
{row.local_time}
</span>
) : (
<span className="w-11 shrink-0" aria-hidden="true" />
)}
<Link href={entryHref(row)} className="font-medium text-admin-text hover:text-admin-accent"> <Link href={entryHref(row)} className="font-medium text-admin-text hover:text-admin-accent">
{row.title} {row.title}
</Link> </Link>
@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
* Tells the server which clock the person reading this is on.
*
* The calendar groups episodes by the VIEWER's day, and only the browser knows
* what that is. The obvious alternative -- convert in the browser at render
* time -- means the server cannot group, sort or decide what "today" is, and
* means the HTML it sends does not match what React then renders. Handing the
* zone over once and letting Postgres do the arithmetic keeps all of that on
* one side of the wire.
*
* A cookie rather than a query parameter so it survives navigation and does not
* end up in shared links, where it would export one person's timezone to
* whoever they sent the URL to.
*
* It refreshes exactly once, when the stored zone is wrong or missing. Without
* that guard this is an infinite loop: refresh re-renders the page, which
* re-runs this effect, which refreshes.
*/
export function ViewerTimezone({ current }: { current: string | null }) {
const router = useRouter();
useEffect(() => {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!zone || zone === current) return;
// Lax rather than Strict: the calendar is reached by ordinary navigation
// from elsewhere in the app, and Strict would withhold the cookie on the
// first such request and cause one avoidable refresh every time.
document.cookie = `ampelos_tz=${encodeURIComponent(zone)}; path=/; max-age=31536000; samesite=lax`;
router.refresh();
}, [current, router]);
return null;
}
-139
View File
@@ -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<string | null> {
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");
}
+22 -83
View File
@@ -1,10 +1,4 @@
import { eq } from "drizzle-orm"; import { permanentRedirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/db/client";
import { plexAccounts } from "@/db/schema";
import { startPlexLinkAction, unlinkPlexAction, completePlexLink } from "./actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,83 +6,28 @@ type PageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>; searchParams?: Promise<Record<string, string | string[] | undefined>>;
}; };
/**
* /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) { export default async function AccountPage({ searchParams }: PageProps) {
const session = await auth();
if (!session?.user?.id) {
return (
<main className="mx-auto max-w-2xl px-6 py-12">
<p className="text-sm">Sign in to manage your account.</p>
</main>
);
}
// 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 resolved = (await searchParams) ?? {};
const returning = resolved.linking === "1"; const params = new URLSearchParams();
const message = returning ? await completePlexLink() : null;
const [link] = await db for (const [key, value] of Object.entries(resolved)) {
.select() for (const single of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
.from(plexAccounts) params.append(key, single);
.where(eq(plexAccounts.userId, session.user.id)) }
.limit(1); }
return ( const query = params.toString();
<main className="mx-auto max-w-2xl space-y-8 px-6 py-12"> permanentRedirect(query ? `/profile?${query}` : "/profile");
<header>
<h1 className="font-serif text-3xl font-semibold">Your account</h1>
<p className="mt-2 text-sm text-admin-muted">{session.user.email}</p>
</header>
{message && (
<p className="rounded border border-admin-accent px-4 py-3 text-sm">{message}</p>
)}
<section className="space-y-3">
<h2 className="font-serif text-xl font-semibold">Plex</h2>
{link ? (
<>
<p className="text-sm">
Linked as <strong>{link.plexUsername}</strong>.
</p>
<p className="text-sm text-admin-muted">
{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."}
</p>
<p className="text-sm text-admin-muted">
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.
</p>
<form action={unlinkPlexAction}>
<button type="submit" className="admin-nav-button px-3 py-2 text-sm font-medium">
Unlink
</button>
</form>
</>
) : (
<>
<p className="text-sm text-admin-muted">
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.
</p>
<p className="text-xs text-admin-muted">
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.
</p>
<form action={startPlexLinkAction}>
<button type="submit" className="admin-nav-button px-4 py-2 text-sm font-medium">
Link my Plex account
</button>
</form>
</>
)}
</section>
</main>
);
} }
+63
View File
@@ -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<string, string | undefined>) {
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;
}
+127
View File
@@ -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 });
}
+53 -1
View File
@@ -41,6 +41,16 @@ type CatalogBoardProps = {
watchNowItems: WatchNowItem[]; watchNowItems: WatchNowItem[];
slotCount: number; slotCount: number;
isAdmin: boolean; 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=<kind>&open=<tmdbId>`, 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"]) { 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 collectionSet = useMemo(() => new Set(collectionIds), [collectionIds]);
const watchNowSet = useMemo(() => new Set(watchNowExternalIds), [watchNowExternalIds]); const watchNowSet = useMemo(() => new Set(watchNowExternalIds), [watchNowExternalIds]);
const purgedSet = useMemo(() => new Set(purgedExternalIds), [purgedExternalIds]); 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<CatalogKind, "music">) {
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) { async function openDetails(item: CatalogItem) {
const flags: PanelFlags = { const flags: PanelFlags = {
timeless: Boolean(item.isClassic), timeless: Boolean(item.isClassic),
+53
View File
@@ -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;
}
+23
View File
@@ -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 (
<div className="ampelos-embed text-ampelos-parchment">
<FrameHeight targetOrigins={embedOrigins()} />
{children}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// The Ampelos section of the sticknife-wide profile.
//
// accounts.sticknife.com iframes this into its own collapsible panel:
//
// <iframe src="https://ampelos.sticknife.com/embed/profile"
// title="Ampelos" loading="lazy"></iframe>
//
// 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 <div className="p-4">{children}</div>;
}
export default async function EmbeddedProfilePage() {
const session = await auth();
if (!session?.user?.id) {
return (
<Shell>
<div className="space-y-3">
<p className="text-sm text-ampelos-muted">
You are not signed in to Ampelos.
</p>
<Link
href={`${APP_URL}/profile`}
target="_top"
className="ampelos-stone-button inline-block px-4 py-2 text-sm font-medium"
>
Sign in to Ampelos
</Link>
</div>
</Shell>
);
}
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 (
<Shell>
<p className="text-sm text-ampelos-muted">
This account has no Ampelos profile yet. Visit{" "}
<Link href={`${APP_URL}/`} target="_top" className="text-ampelos-gold hover:underline">
ampelos.sticknife.com
</Link>{" "}
once to create one.
</p>
</Shell>
);
}
return (
<Shell>
<ProfilePanel profile={profile} embedded />
</Shell>
);
}
+6
View File
@@ -174,6 +174,8 @@ export default async function Home({ searchParams }: PageProps) {
const kind = activeKind(resolvedSearchParams.tab); const kind = activeKind(resolvedSearchParams.tab);
const query = singleParam(resolvedSearchParams.q)?.trim() ?? ""; const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
const collectionOnly = singleParam(resolvedSearchParams.collection) === "1"; 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 // 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 // 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. // an action that refuses under another is the worst version of this.
@@ -211,6 +213,9 @@ export default async function Home({ searchParams }: PageProps) {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Link href="/profile" className="ampelos-ghost-button px-3 py-2 font-medium">
Profile
</Link>
{isAdmin && ( {isAdmin && (
<Link href="/admin" className="ampelos-ghost-button px-3 py-2 font-medium"> <Link href="/admin" className="ampelos-ghost-button px-3 py-2 font-medium">
Admin Admin
@@ -274,6 +279,7 @@ export default async function Home({ searchParams }: PageProps) {
watchNowItems={watchNowItems} watchNowItems={watchNowItems}
slotCount={slotCount} slotCount={slotCount}
isAdmin={isAdmin} isAdmin={isAdmin}
openExternalId={openExternalId}
/> />
</section> </section>
</main> </main>
+75
View File
@@ -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();
}
+121
View File
@@ -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<Record<string, string | string[] | undefined>>;
};
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<string, string | string[] | undefined>) {
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 (
<main className="ampelos-app pb-24 text-ampelos-parchment">
<header className="ampelos-topbar">
<div className="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-5 md:flex-row md:items-center md:justify-between md:px-6">
<div className="flex items-center gap-4">
<Image
src="/ampelos.png"
alt="Ampelos"
width={64}
height={64}
priority
className="h-14 w-14 shrink-0 rounded-full border border-ampelos-gold/35 bg-ampelos-ink/45 object-cover p-0.5 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
/>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-ampelos-gold">
Profile
</p>
<h1 className="font-serif text-3xl font-semibold text-ampelos-parchment">
{profile.user.displayName}
</h1>
<p className="text-sm text-ampelos-muted">{profile.user.email}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<Link href="/" className="ampelos-ghost-button px-3 py-2 font-medium">
Catalog
</Link>
{session.user.isAdmin && (
<Link href="/admin" className="ampelos-ghost-button px-3 py-2 font-medium">
Admin
</Link>
)}
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }}>
<button type="submit" className="ampelos-ghost-button px-3 py-2 font-medium">
Sign out
</button>
</form>
</div>
</div>
</header>
<section className="mx-auto max-w-3xl px-4 py-8 md:px-6">
{message && (
<p className="mb-6 rounded border border-ampelos-gold/50 bg-ampelos-ink/40 px-4 py-3 text-sm">
{message}
</p>
)}
<div className="ampelos-panel rounded-lg p-6">
<ProfilePanel profile={profile} />
</div>
</section>
</main>
);
}
+372
View File
@@ -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 <div className={className}>{children}</div>;
return (
<Link
href={`/?tab=${catalog.kind}&open=${encodeURIComponent(catalog.tmdbId)}`}
{...frameTarget(embedded)}
className={`${className ?? ""} rounded transition hover:bg-ampelos-parchment/5 focus:outline-none focus:ring-2 focus:ring-ampelos-gold`}
aria-label={`Open details for ${title}`}
>
{children}
</Link>
);
}
function Poster({ url, alt, size = 40 }: { url: string | null; alt: string; size?: number }) {
return (
<div
className="relative shrink-0 overflow-hidden rounded border border-ampelos-clay/60 bg-ampelos-ink/60"
style={{ width: size, height: Math.round(size * 1.5) }}
>
{url && <Image src={url} alt={alt} fill sizes={`${size}px`} className="object-cover" />}
</div>
);
}
function SectionHeading({ title, aside }: { title: string; aside?: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<h3 className="font-serif text-lg font-semibold text-ampelos-parchment">{title}</h3>
{aside && <span className="text-xs text-ampelos-muted">{aside}</span>}
</div>
);
}
function PlexSection({ profile, embedded }: PanelProps) {
const { plex } = profile;
return (
<section className="space-y-3">
<SectionHeading
title="Plex"
aside={plex.linked ? `linked ${whenText(plex.linkedAt)}` : "not linked"}
/>
{plex.linked ? (
<>
<p className="text-sm text-ampelos-parchment">
Linked as <strong className="text-ampelos-gold">{plex.username}</strong>
{plex.email ? <span className="text-ampelos-muted"> · {plex.email}</span> : null}
{plex.isServerOwner ? (
<span className="text-ampelos-muted"> · server owner</span>
) : null}
</p>
{/*
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.
*/}
<p className="text-sm text-ampelos-muted">
{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."}
</p>
<p className="text-sm text-ampelos-muted">
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.
</p>
{/* Unlinking stays in place -- it navigates nowhere, so it is safe in the frame. */}
<form action={unlinkPlexAction}>
<button type="submit" className="ampelos-ghost-button px-3 py-2 text-sm font-medium">
Unlink
</button>
</form>
</>
) : (
<>
<p className="text-sm text-ampelos-muted">
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.
</p>
<p className="text-xs text-ampelos-muted">
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.
</p>
{/*
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
href={profile.links.self}
target="_top"
className="ampelos-stone-button inline-block px-4 py-2 text-sm font-medium"
>
Link my Plex account on Ampelos
</Link>
) : (
<form action={startPlexLinkAction}>
<button type="submit" className="ampelos-stone-button px-4 py-2 text-sm font-medium">
Link my Plex account
</button>
</form>
)}
</>
)}
</section>
);
}
function WatchNowGroup({
label,
list,
embedded,
}: {
label: string;
list: WatchNowList;
embedded?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-ampelos-gold">
{label}
</h4>
<span className="text-xs text-ampelos-muted">
{list.used} of {list.quota} slots
</span>
</div>
{list.items.length === 0 ? (
<p className="text-sm text-ampelos-muted">
Nothing here yet. Add something from the{" "}
<Link
href="/"
{...frameTarget(embedded)}
className="text-ampelos-gold underline-offset-2 hover:underline"
>
catalog
</Link>{" "}
to keep it in high quality on live storage.
</p>
) : (
<ul className="space-y-2">
{list.items.map((item) => (
<li key={item.id} className="flex items-center gap-3">
<CatalogLink
catalog={item.catalog}
title={item.title}
embedded={embedded}
className="flex min-w-0 flex-1 items-center gap-3 p-1"
>
<Poster url={item.posterUrl} alt="" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ampelos-parchment">
{item.title}
{item.year ? <span className="text-ampelos-muted"> ({item.year})</span> : null}
</span>
<span className="block text-xs text-ampelos-muted">
{item.slotNumber ? `Slot ${item.slotNumber}` : "Unslotted"} · added{" "}
{whenText(item.addedAt)}
</span>
</span>
</CatalogLink>
<form action={removeFromWatchNow}>
<input type="hidden" name="watchNowItemId" value={item.id} />
<button
type="submit"
className="ampelos-ghost-button px-2 py-1 text-xs font-medium"
aria-label={`Remove ${item.title} from Watch Now`}
>
Remove
</button>
</form>
</li>
))}
</ul>
)}
</div>
);
}
function WatchNowSection({ profile, embedded }: PanelProps) {
const { television, movies } = profile.watchNow;
return (
<section className="space-y-4">
<SectionHeading
title="Watch Now"
aside={`${television.used + movies.used} of ${television.quota + movies.quota} slots used`}
/>
<p className="text-sm text-ampelos-muted">
Watch Now is what keeps a title on live storage in full quality. Everything else falls
back to the 720p archive over time.
</p>
<WatchNowGroup label="Television" list={television} embedded={embedded} />
<WatchNowGroup label="Movies" list={movies} embedded={embedded} />
</section>
);
}
function WatchHistorySection({ profile, embedded }: PanelProps) {
const { history } = profile;
return (
<section className="space-y-3">
<SectionHeading
title="Watch history"
aside={
history.total > 0
? `${history.playsLast30Days} plays in the last 30 days · ${history.distinctEpisodes} titles`
: undefined
}
/>
{history.recent.length === 0 ? (
<p className="text-sm text-ampelos-muted">
{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."}
</p>
) : (
<>
<ul className="space-y-2">
{history.recent.map((entry) => {
const episode = episodeLabel(entry);
return (
<li key={entry.id}>
<CatalogLink
catalog={entry.catalog}
title={entry.title}
embedded={embedded}
className="flex items-center gap-3 p-1"
>
<Poster url={entry.posterUrl} alt="" size={32} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ampelos-parchment">
{entry.title}
</span>
<span className="block truncate text-xs text-ampelos-muted">
{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` : ""}
</span>
</span>
</CatalogLink>
</li>
);
})}
</ul>
{/*
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 && (
<p className="text-xs text-ampelos-muted">
Showing the {history.recent.length} most recently watched of{" "}
{history.distinctEpisodes}. Repeats are collapsed to the latest play.
</p>
)}
</>
)}
<p className="text-xs text-ampelos-muted">
Only you can see this. Ampelos records history for linked accounts and never shows one
person&apos;s viewing to anybody else, administrators included.
</p>
</section>
);
}
export function ProfilePanel({ profile, embedded }: PanelProps) {
return (
<div className="space-y-8">
<PlexSection profile={profile} embedded={embedded} />
<hr className="border-ampelos-clay/50" />
<WatchNowSection profile={profile} embedded={embedded} />
<hr className="border-ampelos-clay/50" />
<WatchHistorySection profile={profile} embedded={embedded} />
</div>
);
}
+16 -5
View File
@@ -14,6 +14,17 @@ type WatchKind = "television" | "movies";
type WatchNowResult = { ok: true } | { ok: false; message: string }; type WatchNowResult = { ok: true } | { ok: false; message: string };
/**
* The board, the profile page and the embed all show Watch Now, so all three
* are stale the moment any of them changes. Revalidating only "/" was correct
* when the board was the only place slots were visible; it no longer is.
*/
function revalidateWatchNow() {
revalidatePath("/");
revalidatePath("/profile");
revalidatePath("/embed/profile");
}
function validDate(value: FormDataEntryValue | null) { function validDate(value: FormDataEntryValue | null) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null; return null;
@@ -169,7 +180,7 @@ export async function addToWatchNow(formData: FormData): Promise<WatchNowResult
: await firstOpenSlot(session.user.id, mediaType, quota); : await firstOpenSlot(session.user.id, mediaType, quota);
if (!targetSlot) { if (!targetSlot) {
revalidatePath("/"); revalidateWatchNow();
return { ok: false, message: "No Watch Now slots are open." }; return { ok: false, message: "No Watch Now slots are open." };
} }
const overview = optionalString(formData.get("overview")); const overview = optionalString(formData.get("overview"));
@@ -217,7 +228,7 @@ export async function addToWatchNow(formData: FormData): Promise<WatchNowResult
}); });
} }
revalidatePath("/"); revalidateWatchNow();
return { ok: true }; return { ok: true };
} }
@@ -253,7 +264,7 @@ export async function removeFromWatchNowByTmdbId(formData: FormData): Promise<Wa
); );
} }
revalidatePath("/"); revalidateWatchNow();
return { ok: true }; return { ok: true };
} }
@@ -271,7 +282,7 @@ export async function removeFromWatchNow(formData: FormData) {
.set({ removedAt: new Date() }) .set({ removedAt: new Date() })
.where(and(eq(watchingNowItems.id, itemId), eq(watchingNowItems.userId, session.user.id))); .where(and(eq(watchingNowItems.id, itemId), eq(watchingNowItems.userId, session.user.id)));
revalidatePath("/"); revalidateWatchNow();
} }
export async function moveWatchNowItem(formData: FormData) { export async function moveWatchNowItem(formData: FormData) {
@@ -312,5 +323,5 @@ export async function moveWatchNowItem(formData: FormData) {
await clearSlot(session.user.id, mediaType, targetSlot, itemId); await clearSlot(session.user.id, mediaType, targetSlot, itemId);
await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, itemId)); await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, itemId));
revalidatePath("/"); revalidateWatchNow();
} }
@@ -0,0 +1,18 @@
CREATE TYPE "public"."watch_history_source" AS ENUM('plex', 'manual');--> statement-breakpoint
CREATE TABLE "watch_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"media_item_id" uuid NOT NULL,
"season_number" integer,
"episode_number" integer,
"watched_at" timestamp NOT NULL,
"source" "watch_history_source" DEFAULT 'plex' NOT NULL,
"plex_history_key" text,
"plex_rating_key" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "watch_history" ADD CONSTRAINT "watch_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "watch_history" ADD CONSTRAINT "watch_history_media_item_id_media_items_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "watch_history_user_plex_key_idx" ON "watch_history" USING btree ("user_id","plex_history_key") WHERE plex_history_key is not null;--> statement-breakpoint
CREATE INDEX "watch_history_user_watched_idx" ON "watch_history" USING btree ("user_id","watched_at" DESC NULLS LAST);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "plex_accounts" ADD COLUMN "is_server_owner" boolean DEFAULT false NOT NULL;
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "series" ADD COLUMN "airs_time" time;--> statement-breakpoint
ALTER TABLE "series" ADD COLUMN "airs_timezone" text;--> statement-breakpoint
ALTER TABLE "series" ADD COLUMN "airs_country" text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -169,6 +169,27 @@
"when": 1786655149441, "when": 1786655149441,
"tag": "0023_replacement_requests", "tag": "0023_replacement_requests",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1786963596953,
"tag": "0024_futuristic_mantis",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1786967391633,
"tag": "0025_fine_venom",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1786967937046,
"tag": "0026_polite_wong",
"breakpoints": true
} }
] ]
} }
+55
View File
@@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import { import {
pgTable, pgTable,
pgEnum, pgEnum,
@@ -5,12 +6,15 @@ import {
text, text,
integer, integer,
timestamp, timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { users } from "./users"; import { users } from "./users";
import { mediaItems } from "./media"; import { mediaItems } from "./media";
export const watchlistSourceEnum = pgEnum("watchlist_source", ["plex", "manual"]); export const watchlistSourceEnum = pgEnum("watchlist_source", ["plex", "manual"]);
export const watchingNowScopeEnum = pgEnum("watching_now_scope", ["show", "season"]); export const watchingNowScopeEnum = pgEnum("watching_now_scope", ["show", "season"]);
export const watchHistorySourceEnum = pgEnum("watch_history_source", ["plex", "manual"]);
// Items synced from Plex watchlists or added manually. // Items synced from Plex watchlists or added manually.
// Default behavior: archive-tier 720p. // Default behavior: archive-tier 720p.
@@ -37,6 +41,57 @@ export const watchingNowItems = pgTable("watching_now_items", {
removedAt: timestamp("removed_at"), removedAt: timestamp("removed_at"),
}); });
// What somebody has actually watched.
//
// COPIED OUT OF PLEX RATHER THAN READ FROM IT. Plex prunes its own history, and
// a rebuilt server starts empty -- both of which have happened here -- so a
// profile that queried Plex live would quietly lose years of viewing and a page
// load would depend on the media server being up. This is the durable record.
//
// It is also the signal the placement classifier has never had. "Watched, and
// the show has ended" is the strongest possible argument for demotion, and it
// cannot be made from watchlists and Watching Now alone: those say what someone
// intends, and only this says what they finished.
//
// PRIVACY: the owner's Plex token can read EVERY user's history, which is not
// permission to show it. Rows are written only for accounts that linked
// themselves at /profile, and are only ever read back scoped to the user who
// owns them -- there is no admin view of who watched what. See the guard in
// getWatchHistory().
export const watchHistory = pgTable(
"watch_history",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
mediaItemId: uuid("media_item_id").notNull().references(() => mediaItems.id, { onDelete: "cascade" }),
// Null for a movie. Set for an episode, and stored as numbers rather than a
// reference to episodes.id because Plex reports plays for episodes this
// database has no row for -- a season TMDB has not published yet, or a file
// imported before the metadata refresh caught up. A foreign key there would
// mean dropping the play.
seasonNumber: integer("season_number"),
episodeNumber: integer("episode_number"),
watchedAt: timestamp("watched_at").notNull(),
source: watchHistorySourceEnum("source").notNull().default("plex"),
// Plex's own id for the play (its `historyKey`). This is what makes the
// sync idempotent: it re-reads an overlapping window every run, and without
// a stable id per play every run would duplicate the overlap.
plexHistoryKey: text("plex_history_key"),
plexRatingKey: text("plex_rating_key"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
// Partial, because a manually added row has no Plex key and Postgres would
// otherwise treat every null as distinct anyway -- stating it makes the
// intent readable instead of relying on that.
uniqueIndex("watch_history_user_plex_key_idx")
.on(t.userId, t.plexHistoryKey)
.where(sql`plex_history_key is not null`),
// The one query the profile makes: this person's plays, newest first.
index("watch_history_user_watched_idx").on(t.userId, t.watchedAt.desc()),
],
);
// Admin-managed list of movies that remain live permanently regardless of age. // Admin-managed list of movies that remain live permanently regardless of age.
export const classics = pgTable("classics", { export const classics = pgTable("classics", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
+25
View File
@@ -6,6 +6,7 @@ import {
integer, integer,
boolean, boolean,
date, date,
time,
timestamp, timestamp,
index, index,
uniqueIndex, uniqueIndex,
@@ -45,6 +46,30 @@ export const series = pgTable("series", {
// Recomputed on metadata refresh. See PLANNING.md "Currently Airing". // Recomputed on metadata refresh. See PLANNING.md "Currently Airing".
isCurrentlyRelevant: boolean("is_currently_relevant"), isCurrentlyRelevant: boolean("is_currently_relevant"),
metadataRefreshedAt: timestamp("metadata_refreshed_at"), metadataRefreshedAt: timestamp("metadata_refreshed_at"),
// WHEN, not just what day. `episodes.air_date` is a DATE and carries no time
// at all, which is why the calendar could only ever show a day and why the
// fetch queue began hunting an episode up to 29 hours before it existed.
//
// These come from TVDB, not TMDB: TMDB has no airtime field of any kind.
// TVDB's `airsTimeUTC` is documented but empty for every one of the 18 series
// currently in the calendar window, so it is ignored -- the usable pair is
// `airsTime` (network-LOCAL, "23:00") plus the network's country.
//
// Stored as local-time-plus-zone rather than as a UTC offset on purpose. An
// offset is only correct until the next DST change; a zone stays correct
// because the conversion is done against the episode's own date:
//
// (e.air_date + s.airs_time) at time zone s.airs_timezone
//
// Null means TVDB had nothing, and the calendar falls back to showing the
// date alone -- exactly what it does today.
airsTime: time("airs_time"),
airsTimezone: text("airs_timezone"),
// The raw country TVDB reported ("usa", "gbr", "fra"). Kept so a wrong or
// missing zone can be re-derived by fixing the mapping, without refetching
// every series from the API.
airsCountry: text("airs_country"),
}); });
export const seasons = pgTable( export const seasons = pgTable(
+9
View File
@@ -38,8 +38,17 @@ export const plexAccounts = pgTable("plex_accounts", {
plexUuid: text("plex_uuid"), plexUuid: text("plex_uuid"),
plexUsername: text("plex_username").notNull(), plexUsername: text("plex_username").notNull(),
plexEmail: text("plex_email"), plexEmail: text("plex_email"),
// Does this account own the Plex server?
//
// The owner cannot be invited to their own libraries -- Plex refuses with
// "You cannot send an invitation to yourself." Without this column that
// refusal is indistinguishable from a share that genuinely failed, and the
// owner is told forever that their libraries did not share and to try again.
// They already have every library; there is nothing to grant.
isServerOwner: boolean("is_server_owner").notNull().default(false),
// When the libraries were shared, and which ones. Recorded so a failed or // When the libraries were shared, and which ones. Recorded so a failed or
// partial share is visible rather than being assumed to have worked. // partial share is visible rather than being assumed to have worked.
// Both stay null for the owner, who was never invited to anything.
librariesSharedAt: timestamp("libraries_shared_at"), librariesSharedAt: timestamp("libraries_shared_at"),
sharedSectionIds: text("shared_section_ids").array(), sharedSectionIds: text("shared_section_ids").array(),
linkedAt: timestamp("linked_at").notNull().defaultNow(), linkedAt: timestamp("linked_at").notNull().defaultNow(),
+15
View File
@@ -153,6 +153,21 @@ function imageUrl(path?: string | null) {
return path ? `${TMDB_IMAGE_BASE_URL}${path}` : 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) { function backdropUrl(path?: string | null) {
return path ? `${TMDB_BACKDROP_BASE_URL}${path}` : 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 // 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. // 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 PLEX_TOKEN = process.env.PLEX_AUTH_TOKEN ?? "";
const MACHINE_ID = process.env.PLEX_MACHINE_IDENTIFIER ?? ""; const MACHINE_ID = process.env.PLEX_MACHINE_IDENTIFIER ?? "";
@@ -91,13 +93,30 @@ export function isShareable(section: PlexSection) {
export type PlexPin = { id: number; code: string }; 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> { export async function createPin(): Promise<PlexPin> {
const response = await fetch("https://plex.tv/api/v2/pins?strong=true", { const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
method: "POST", method: "POST",
headers: { headers: {
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER, "X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
"X-Plex-Product": "Ampelos", "X-Plex-Product": "Ampelos",
Origin: new URL(APP_URL).origin,
Accept: "application/json", Accept: "application/json",
}, },
signal: AbortSignal.timeout(20000), 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 --------------------------------------------------------------- // --- 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. * 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 text = await response.text();
const alreadyShared = /already/i.test(text) && /shar/i.test(text); const alreadyShared = /already/i.test(text) && /shar/i.test(text);
if (!response.ok && !alreadyShared) { 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 { 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`,
},
};
}
+13 -1
View File
@@ -4,7 +4,19 @@ import { NextResponse } from "next/server";
// /api/agents is machine-to-machine and carries its own bearer token, so it // /api/agents is machine-to-machine and carries its own bearer token, so it
// must bypass the interactive login redirect. // must bypass the interactive login redirect.
const PUBLIC_PATHS = ["/login", "/api/auth", "/api/agents"]; //
// /embed is exempt for a different reason: it is framed by
// accounts.sticknife.com, and Authentik refuses to be framed. Redirecting a
// signed-out visitor would render an X-Frame-Options error inside charon's
// panel -- a broken box with no explanation -- so the embed checks the session
// itself and offers a sign-in link that escapes the frame. It still shows
// nothing to an unauthenticated caller; see src/app/embed/profile/page.tsx.
//
// /api/profile is exempt for the same reason as /api/agents -- it accepts a
// bearer token as well as a session, and a 302 to a login page is a useless
// answer to a server-to-server GET. It authenticates every request itself and
// refuses with 401 rather than redirecting.
const PUBLIC_PATHS = ["/login", "/api/auth", "/api/agents", "/api/profile", "/embed"];
const PUBLIC_FILE = /\.(?:avif|gif|ico|jpg|jpeg|png|svg|webp)$/i; const PUBLIC_FILE = /\.(?:avif|gif|ico|jpg|jpeg|png|svg|webp)$/i;
export default auth((req) => { export default auth((req) => {