Compare commits
2 Commits
3ccae54259
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1752870cee | |||
| 1dfa2c0a04 |
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "plex_accounts" ADD COLUMN "is_server_owner" boolean DEFAULT false NOT NULL;
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user