diff --git a/.env.example b/.env.example index 8e48af8..17c5884 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,26 @@ AMPELOS_AGENT_TIMEOUT_SECONDS=30 # both repositories' environments, in neither's history. AMPELOS_AGENT_TOKEN= +# --- the sticknife-wide profile ------------------------------------------- +# accounts.sticknife.com shows an Ampelos section in its own profile page, by +# framing /embed/profile and/or reading /api/profile. See PROFILE-INTEGRATION.md. +# +# Which origins may frame /embed/* and call /api/profile from a browser. +# Comma-separated ORIGINS, scheme included -- a bare hostname is silently +# ignored by the CSP parser and never matches a CORS Origin header. Everything +# outside /embed is frame-ancestors 'none' regardless of what is set here. +AMPELOS_EMBED_ANCESTORS=https://accounts.sticknife.com +# +# Optional, and OFF unless set. Lets charon render the panel SERVER-side, where +# there is no browser to carry a session cookie: it presents this as a bearer +# token and names the user with ?email= or ?userId=. +# +# That is a full read of anybody's profile, watch history included. It is +# deliberately NOT AMPELOS_AGENT_TOKEN -- that one is deployed to every +# satellite host, and a leaked herald script should not also hand over +# everyone's viewing. Leave it unset if charon only ever iframes the panel. +AMPELOS_PROFILE_API_TOKEN= + # --- external services ---------------------------------------------------- MOVIEDB_API= PLEX_URL= diff --git a/PLANNING.md b/PLANNING.md index a1c1555..ed7916a 100755 --- a/PLANNING.md +++ b/PLANNING.md @@ -287,9 +287,25 @@ Expected Plex-facing media: Archive-only media should not normally be visible in Plex unless restored or promoted. +Decided 2026-08-17 — **Ampelos copies Plex watch history into its own +`watch_history` table** rather than reading it live. Plex prunes its own +history and a rebuilt server starts empty, so a live read would silently lose +years of viewing and would go blank whenever the media server was down. The +copy is also the first signal Ampelos has that says what somebody *finished* +rather than what they intended, which is the missing input to demotion. + +- Only accounts that linked themselves at `/profile` are read, even though the + owner's token can reach everybody's. Consent, not capability. +- History is shown only to the person it belongs to. There is no admin view of + who watched what. +- An episode is attributed to its **series** by the series' external ids, never + by title, and never by the episode's own TMDB id — those are different ids + and matching on the wrong one would be a collision rather than a match. +- `plex:history` in ampelos-agent, hourly. Active sessions and user libraries + are still not read. + Open decisions: -- Whether Ampelos should read Plex watch history, active sessions, or user libraries directly. - Whether existing Plex watchlists remain an input signal. - Whether Plex availability should be polled or inferred from filesystem and Ampelos inventory state. diff --git a/PROFILE-INTEGRATION.md b/PROFILE-INTEGRATION.md new file mode 100644 index 0000000..c63deda --- /dev/null +++ b/PROFILE-INTEGRATION.md @@ -0,0 +1,294 @@ +# The Ampelos profile, and how accounts.sticknife.com shows it + +Ampelos has a profile page at `https://ampelos.sticknife.com/profile`. It shows +one person their Plex link, their Watch Now slots, and their watch history. + +The same panel is published in two other forms so that +`accounts.sticknife.com` on charon can carry it as one section of the wider +sticknife profile, next to the other services' sections: + +| Surface | Address | For | +| --- | --- | --- | +| Page | `/profile` | People coming to Ampelos directly | +| Embed | `/embed/profile` | Charon framing the panel Ampelos renders | +| API | `/api/profile` | Charon rendering the section itself | + +All three are the same data: the page, the embed and the JSON all come from +`getProfile()` in `src/lib/profile.ts`. There is no second implementation to +drift. + +--- + +## The short version + +Put this in the Ampelos section of the sticknife profile: + +```html + +``` + +That is the whole integration. **No token, no secret, no configuration on +charon's side.** Why it works is worth understanding, because it constrains +where these services may live. + +--- + +## Why no token is needed + +`accounts.sticknife.com` and `ampelos.sticknife.com` share the registrable +domain `sticknife.com`. A request from one to the other is therefore +**same-site**, even though it is cross-origin. + +The Ampelos session cookie is `SameSite=Lax`, and Lax cookies *are* sent on +same-site subresource loads. So the person is simply already signed in inside +the frame. Nothing is exchanged, nothing is impersonated, and no cookie had to +be loosened to `SameSite=None`. + +> **This holds only while both services are under `sticknife.com`.** Moving +> either to its own domain makes the pair cross-site, the cookie stops being +> sent, and the embed will show the signed-out state to everybody. That would +> need a real token flow — most likely charon forwarding the user's Authentik +> ID token — and is a deliberate piece of work, not a config tweak. + +Both services already sign in against the same Authentik at +`charon.sticknife.com`, so in practice a person signed in to the accounts page +is signed in to Ampelos. + +### The signed-out case + +If the visitor has no Ampelos session, the embed does **not** redirect to +Authentik. Authentik refuses to be framed, so a redirect would render an +`X-Frame-Options` error inside charon's panel — a broken box with no +explanation. The embed instead renders a short "Sign in to Ampelos" link that +targets `_top` and leaves the frame. + +For the same reason, every link inside the embed carries `target="_top"`, and +the Plex link button becomes a link to the full profile page rather than +starting a flow that would dead-end in the panel (plex.tv also refuses to be +framed). + +--- + +## Sizing the frame + +A cross-origin iframe cannot size itself and the parent cannot measure across +the origin boundary. Ampelos therefore posts its height to the parent whenever +it changes: + +```js +window.addEventListener("message", (event) => { + if (event.origin !== "https://ampelos.sticknife.com") return; + if (event.data?.type !== "ampelos:profile:height") return; + iframe.style.height = `${event.data.height}px`; +}); +``` + +The message is posted only to the origins in `AMPELOS_EMBED_ANCESTORS`, never +to `*`. **Ignoring it is fine** — the panel then scrolls inside whatever height +you give it. Check `event.origin` if you do listen; that check is charon's job +and Ampelos cannot do it for you. + +--- + +## The JSON, if charon would rather render it itself + +``` +GET https://ampelos.sticknife.com/api/profile +``` + +```jsonc +{ + "service": "ampelos", + "schemaVersion": 2, + "generatedAt": "2026-08-17T11:00:41.965Z", + "user": { "id": "…", "displayName": "Ryan Potter", "email": "ryan@…" }, + + "plex": { + "linked": true, + "username": "…", + "email": "…", + "linkedAt": "2026-08-10T…", + "isServerOwner": false, + "librariesSharedAt": "2026-08-10T…", // null if the share failed + "sharedLibraryCount": 3 + }, + // or simply: { "linked": false } + + "watchNow": { + "television": { + "quota": 5, + "used": 1, + "items": [{ + "id": "…", "mediaItemId": "…", + "title": "Law & Order- Special Victims Unit", + "year": 1999, + "posterUrl": "https://image.tmdb.org/t/p/w342/….jpg", + "slotNumber": 1, + "addedAt": "2026-08-09T…", + "catalog": { "tmdbId": "2734", "kind": "television" } // or null + }] + }, + "movies": { "quota": 10, "used": 6, "items": [ … ] } + }, + + "history": { + "total": 1668, // plays, counting repeats + "distinctEpisodes": 993, // what `recent` is a window onto + "playsLast30Days": 276, + "lastWatchedAt": "2026-08-17T01:45:25.000Z", + "recent": [{ // 25 most recent, ONE PER EPISODE + "id": "…", "mediaItemId": "…", + "mediaType": "tv_series", // or "movie" + "title": "Gen V", + "year": 2023, + "posterUrl": "https://image.tmdb.org/t/p/w342/….jpg", + "seasonNumber": 2, // null for a movie + "episodeNumber": 8, // null for a movie + "episodeTitle": "Trojan", // null if not in the database yet + "watchedAt": "2026-08-17T01:45:25.000Z", // the MOST RECENT play + "playCount": 3, // times this episode was played + "source": "plex", + "catalog": { "tmdbId": "205715", "kind": "television" } + }] + }, + + "links": { + "self": "https://ampelos.sticknife.com/profile", + "embed": "https://ampelos.sticknife.com/embed/profile", + "api": "https://ampelos.sticknife.com/api/profile" + } +} +``` + +`posterUrl` is always absolute or null. Ampelos stores TMDB's bare path in some +rows and a full URL in others, and resolves both before handing them over — a +bare `/abc.jpg` would mean nothing on charon. + +`isServerOwner` matters for how you word the libraries line. Plex will not +invite a server's owner to their own libraries ("You cannot send an invitation +to yourself"), so an owner legitimately has `librariesSharedAt: null` while +having access to everything. Do not render that as a failed share. + +### Deduplication, and `catalog` + +`history.recent` is **one row per episode, not per play.** A rewatch, a resume +after stopping, and a client that reports the same episode twice all land as +separate plays, and left raw they bury everything else — this library's real +numbers are 1,668 plays across 993 distinct episodes. Each row carries the most +recent `watchedAt` and a `playCount` so collapsing repeats does not hide them. +Count against `distinctEpisodes`, not `total`. + +`catalog` is what lets a row be clicked. Ampelos's own panel links it to +`/?tab=&open=`, which opens the catalog detail panel on that +title; charon can do the same, or use the TMDB id directly. It is **null** when +the title has no TMDB link or has one a reviewer has *rejected* — a rejected id +is a known-wrong id kept as evidence, so following it would open the wrong +film. Render those rows as plain text. + +### `schemaVersion` + +Part of the contract. Charon renders a panel it does not own from a service it +cannot redeploy in step, so it should check this and degrade rather than break. + +- **Adding** a field does not bump it. Ignore keys you do not know. +- **Changing what a field means** bumps it. + +**Version 2** (2026-08-17): `history.recent` became one row per episode rather +than per play, and its `watchedAt` became the most recent play of that episode. +A consumer written against version 1 would double-count. `playCount`, +`distinctEpisodes` and `catalog` arrived at the same time but would not, alone, +have earned the bump. + +### Authenticating + +**From the browser** — same-site, so the session cookie carries it: + +```js +fetch("https://ampelos.sticknife.com/api/profile", { credentials: "include" }) +``` + +CORS allows exactly the origins in `AMPELOS_EMBED_ANCESTORS`, with +`Access-Control-Allow-Credentials: true`. Any other origin gets no +allow-origin header and the browser blocks the read. This mode answers for +the signed-in user and **ignores the query string entirely** — a signed-in +person cannot widen their own request by adding `?email=`. + +**From charon's server** — no browser, so no cookie: + +``` +GET /api/profile?email=someone@example.com +Authorization: Bearer $AMPELOS_PROFILE_API_TOKEN +``` + +`?userId=` (the Ampelos uuid) works too. `email` is the useful one: it is +unique in `users` and is the claim Authentik issues to every sticknife service, +so charon already has it. + +> This mode is a **full read of anybody's profile, watch history included.** It +> is off unless `AMPELOS_PROFILE_API_TOKEN` is set, and it deliberately does +> **not** reuse `AMPELOS_AGENT_TOKEN` — that token is deployed to every +> satellite host, and a leaked herald script should not also hand over +> everyone's viewing. +> +> If charon only frames the panel, leave it unset. + +Responses are `Cache-Control: private, no-store`. + +--- + +## What Ampelos needs configured + +In `.env.local` (see `.env.example`): + +```sh +# Who may frame /embed/* and call /api/profile from a browser. +# ORIGINS, scheme included -- a bare hostname is ignored by the CSP parser. +AMPELOS_EMBED_ANCESTORS=https://accounts.sticknife.com + +# Only if charon renders server-side. Off when unset. +AMPELOS_PROFILE_API_TOKEN= +``` + +Everything outside `/embed/*` is served `frame-ancestors 'none'` and +`X-Frame-Options: DENY`. Framing the catalog or the admin views has no +legitimate use and is how a clickjacked "Remove" button gets pressed. + +--- + +## Privacy + +Watch history is per-person and is shown **only to the person it belongs to.** + +This matters more than it looks. Ampelos reads history from Plex using the +server owner's token, and that token can read *every* user's viewing. Being +able to is not permission to: + +- History is recorded only for accounts that linked themselves at `/profile`. + Everyone else on the Plex server is skipped and reported as unlinked. +- Every read is scoped to one user id. `src/lib/profile.ts` has no "all users" + mode, deliberately. +- **There is no admin view of who watched what.** An administrator sees their + own history and nobody else's. + +The one way to read another person's profile is the server-side token above, +which is why it is a separate secret, off by default, and documented as being +exactly as sensitive as it is. + +--- + +## Where the data comes from + +| Section | Source | +| --- | --- | +| Plex link | `plex_accounts`, written by the PIN flow in `src/app/profile/actions.ts`. No Plex token is ever stored. | +| Watch Now | `watching_now_items` + the per-user quotas on `users` | +| Watch history | `watch_history`, filled by `plex:history` in ampelos-agent | + +History is **copied** out of Plex hourly rather than read live, because Plex +prunes its own history and a rebuilt server starts empty — both of which have +happened here. See `scripts/sync-plex-history.mjs` in ampelos-agent. diff --git a/next.config.ts b/next.config.ts index cf5b68c..b8483c2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,20 @@ import type { NextConfig } from "next"; +/** + * Who may put Ampelos in an iframe. + * + * Only /embed/* is framable, and only by the sticknife accounts page. The rest + * of the app is denied outright below -- framing the catalog or the admin views + * has no legitimate use and is how a clickjacked "Remove" button gets pressed. + * + * Configurable because the accounts host is charon's to name, not ours: a + * staging origin or a rename should not need a code change. Comma-separated, + * and note these are ORIGINS (scheme and host), which is what frame-ancestors + * takes -- a bare hostname is silently ignored by the CSP parser. + */ +const EMBED_ANCESTORS = + process.env.AMPELOS_EMBED_ANCESTORS ?? "https://accounts.sticknife.com"; + const nextConfig: NextConfig = { allowedDevOrigins: ["10.24.88.95", "ampelos.sticknife.com"], images: { @@ -11,6 +26,34 @@ const nextConfig: NextConfig = { }, ], }, + async headers() { + return [ + { + // Everything EXCEPT the embed, framed by nobody. + // + // The exclusion is in the matcher rather than left to header override, + // because X-Frame-Options has no "allow these origins" form -- the + // multi-origin ALLOW-FROM was never implemented by any browser -- so a + // blanket rule could set CSP correctly for /embed and still stamp a + // DENY that some agent honours in preference. Not matching at all is + // the only version with no precedence question in it. + source: "/:path((?!embed/).*)", + headers: [ + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + { key: "X-Frame-Options", value: "DENY" }, + ], + }, + { + source: "/embed/:path*", + headers: [ + { + key: "Content-Security-Policy", + value: `frame-ancestors 'self' ${EMBED_ANCESTORS.split(",").map((o) => o.trim()).filter(Boolean).join(" ")}`, + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts deleted file mode 100644 index 96535e4..0000000 --- a/src/app/account/actions.ts +++ /dev/null @@ -1,139 +0,0 @@ -"use server"; - -// Linking a Plex account. -// -// Two steps, because Plex's sign-in is a round trip through their site: we ask -// for a PIN, send the person to plex.tv to claim it, and pick the result up -// when they come back. The PIN id is parked in an httpOnly cookie for the -// duration -- it is a one-use handle that is worthless without the sign-in that -// claims it, and it means the flow survives the user taking a minute over it. -// -// Linking is what grants library access AND what makes a watchlist readable. -// Both follow from the same consent, which is why they happen together here -// rather than being two things an admin has to remember to do. - -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { revalidatePath } from "next/cache"; -import { eq } from "drizzle-orm"; - -import { auth } from "@/auth"; -import { db } from "@/db/client"; -import { plexAccounts } from "@/db/schema"; -import { appUrl } from "@/lib/app-url"; -import { createPin, authUrl, claimPin, shareLibraries } from "@/lib/plex"; - -const PIN_COOKIE = "ampelos_plex_pin"; - -async function requireUser() { - const session = await auth(); - if (!session?.user?.id) throw new Error("Sign in first"); - return session.user; -} - -export async function startPlexLinkAction() { - await requireUser(); - - const pin = await createPin(); - const jar = await cookies(); - jar.set(PIN_COOKIE, String(pin.id), { - httpOnly: true, - sameSite: "lax", - secure: true, - path: "/", - maxAge: 15 * 60, - }); - - // redirect() throws, so it must be the last thing here. - redirect(authUrl(pin, appUrl("/account?linking=1"))); -} - -/** - * Finish the link, if the user has been to plex.tv and back. - * - * Returns a message rather than throwing on the ordinary failures -- an - * abandoned sign-in and an expired PIN are both things a person does, not - * errors. - */ -export async function completePlexLink(): Promise { - const user = await requireUser(); - const jar = await cookies(); - const pinId = jar.get(PIN_COOKIE)?.value; - if (!pinId) return null; - - let identity; - try { - identity = await claimPin(Number(pinId)); - } catch (error) { - jar.delete(PIN_COOKIE); - return `Plex could not confirm the sign-in: ${(error as Error).message}`; - } - if (!identity) return "That sign-in was not completed. Try linking again."; - - jar.delete(PIN_COOKIE); - - // One Plex account per person, in both directions. Without this a second - // person could link an account already in use and inherit its watchlist. - const existing = await db - .select() - .from(plexAccounts) - .where(eq(plexAccounts.plexUserId, identity.plexUserId)) - .limit(1); - if (existing.length && existing[0].userId !== user.id) { - return "That Plex account is already linked to another Ampelos user."; - } - - let shared: { sectionTitles: string[]; sectionIds: string[]; alreadyShared: boolean } | null = null; - let shareError: string | null = null; - try { - shared = await shareLibraries(identity); - } catch (error) { - // The link is still worth recording: it is what makes the watchlist - // readable, and a share that failed can be retried without signing in - // again. - shareError = (error as Error).message; - } - - await db - .insert(plexAccounts) - .values({ - userId: user.id, - plexUserId: identity.plexUserId, - plexUuid: identity.plexUuid, - plexUsername: identity.username, - plexEmail: identity.email, - librariesSharedAt: shared ? new Date() : null, - sharedSectionIds: shared?.sectionIds ?? null, - }) - .onConflictDoUpdate({ - target: plexAccounts.userId, - set: { - plexUserId: identity.plexUserId, - plexUuid: identity.plexUuid, - plexUsername: identity.username, - plexEmail: identity.email, - librariesSharedAt: shared ? new Date() : null, - sharedSectionIds: shared?.sectionIds ?? null, - updatedAt: new Date(), - }, - }); - - revalidatePath("/account"); - - if (shareError) { - return `Linked as ${identity.username}, but the libraries could not be shared: ${shareError}`; - } - if (shared?.alreadyShared) { - return `Linked as ${identity.username}. You already had access to the libraries.`; - } - return `Linked as ${identity.username}. Shared: ${shared?.sectionTitles.join(", ")}.`; -} - -export async function unlinkPlexAction() { - const user = await requireUser(); - // Only the link is removed. Library access is granted on Plex's side and is - // not ours to quietly revoke from a button labelled "unlink"; whoever owns - // the server can take it back there. - await db.delete(plexAccounts).where(eq(plexAccounts.userId, user.id)); - revalidatePath("/account"); -} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 7fe9e41..d1f2071 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,10 +1,4 @@ -import { eq } from "drizzle-orm"; - -import { auth } from "@/auth"; -import { db } from "@/db/client"; -import { plexAccounts } from "@/db/schema"; - -import { startPlexLinkAction, unlinkPlexAction, completePlexLink } from "./actions"; +import { permanentRedirect } from "next/navigation"; export const dynamic = "force-dynamic"; @@ -12,83 +6,28 @@ type PageProps = { searchParams?: Promise>; }; +/** + * /account was the Plex linking page before the profile existed. + * + * Kept as a redirect rather than deleted because Plex used to be told to return + * the user HERE after signing in, so a bookmark or a PIN round trip started + * before the move should land on the profile rather than a 404. + * + * The query string is carried across so nothing is silently dropped on the way. + * Finishing a link no longer depends on it -- that happens in + * /api/plex/callback now -- but a redirect that eats its parameters is a trap + * for whatever gets added to this page later. + */ export default async function AccountPage({ searchParams }: PageProps) { - const session = await auth(); - if (!session?.user?.id) { - return ( -
-

Sign in to manage your account.

-
- ); + const resolved = (await searchParams) ?? {}; + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(resolved)) { + for (const single of Array.isArray(value) ? value : value === undefined ? [] : [value]) { + params.append(key, single); + } } - // Plex sends the user back here when they are done. Completing on GET rather - // than asking them to press another button: they have already consented, and - // a second click to finish something they thought was finished is just a way - // to lose people half way through. - const resolved = (await searchParams) ?? {}; - const returning = resolved.linking === "1"; - const message = returning ? await completePlexLink() : null; - - const [link] = await db - .select() - .from(plexAccounts) - .where(eq(plexAccounts.userId, session.user.id)) - .limit(1); - - return ( -
-
-

Your account

-

{session.user.email}

-
- - {message && ( -

{message}

- )} - -
-

Plex

- - {link ? ( - <> -

- Linked as {link.plexUsername}. -

-

- {link.librariesSharedAt - ? "The Movies, TV Shows and Music libraries are shared with you. Check your email for the invitation if you have not accepted it yet." - : "The libraries have not been shared yet — link again to retry."} -

-

- Anything you add to your Plex watchlist is treated as a request: if we already - have it you will find it in the library, and if we do not, we will go and get it. -

-
- -
- - ) : ( - <> -

- Link your Plex account to get access to the Movies, TV Shows and Music libraries. - Once linked, your Plex watchlist becomes your request list — add something there - and it will be found for you. -

-

- You sign in at plex.tv, not here. Ampelos never sees your Plex password, and the - sign-in token is discarded as soon as Plex confirms who you are. -

-
- -
- - )} -
-
- ); + const query = params.toString(); + permanentRedirect(query ? `/profile?${query}` : "/profile"); } diff --git a/src/app/api/plex/callback/route.ts b/src/app/api/plex/callback/route.ts new file mode 100644 index 0000000..68cfbbf --- /dev/null +++ b/src/app/api/plex/callback/route.ts @@ -0,0 +1,63 @@ +// Where Plex returns the user after they sign in. +// +// A ROUTE HANDLER, NOT A PAGE, AND THAT IS THE POINT. Finishing the link means +// deleting the PIN cookie and revalidating the profile, and Next.js permits +// neither during a page render. Doing this on /profile is why linking never +// completed: Plex returned the user, the page tried to clear the cookie mid +// render, and threw. +// +// The forward URL has NO QUERY STRING either, which matters more than it looks. +// Plex passes it inside the fragment of app.plex.tv/auth#?...&forwardUrl=..., +// where it is parsed by their client-side code rather than by a server. A bare +// path cannot be mangled by that; one carrying its own `?a=b` depends entirely +// on how carefully somebody else's parser splits a string. + +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { appUrl } from "@/lib/app-url"; +import { completePlexLink, PIN_COOKIE } from "@/lib/plex-link"; + +export const dynamic = "force-dynamic"; + +function backToProfile(params: Record) { + const url = new URL(appUrl("/profile")); + for (const [key, value] of Object.entries(params)) { + if (value) url.searchParams.set(key, value); + } + return NextResponse.redirect(url); +} + +export async function GET() { + const session = await auth(); + // The session cookie is SameSite=Lax and this is a top-level GET navigation, + // so it is sent even though Plex is a different site. No session here means + // the person genuinely signed out mid-flow. + if (!session?.user?.id) return NextResponse.redirect(new URL(appUrl("/login"))); + + const jar = await cookies(); + const pinId = jar.get(PIN_COOKIE)?.value; + + // No PIN means somebody reached this URL without starting a link. Nothing to + // finish, and nothing worth an error page. + if (!pinId) return backToProfile({}); + + const result = await completePlexLink(session.user.id, Number(pinId)); + + const response = backToProfile({ + plex: result.outcome, + who: result.username, + // Truncated: this ends up in a URL, and the full text of a Plex error is + // for the log rather than the address bar. + reason: result.reason?.slice(0, 120), + }); + + // Allowed here, unlike in a render. Both are why this route exists. + response.cookies.delete(PIN_COOKIE); + revalidatePath("/profile"); + revalidatePath("/embed/profile"); + + return response; +} diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts new file mode 100644 index 0000000..c844bca --- /dev/null +++ b/src/app/api/profile/route.ts @@ -0,0 +1,127 @@ +// The Ampelos profile, as data. +// +// The companion to /embed/profile: charon can either frame the panel Ampelos +// renders, or take this and render the section itself in its own design system. +// Both come from getProfile(), so they cannot disagree about what somebody's +// Watch Now list contains. +// +// TWO WAYS TO AUTHENTICATE, and the difference matters: +// +// 1. The session cookie. accounts.sticknife.com is same-site with this host, +// so a browser fetch with `credentials: "include"` carries the signed-in +// user's session and this endpoint answers for THAT person only. Nothing +// is trusted from the caller. +// +// 2. AMPELOS_PROFILE_API_TOKEN plus ?email= or ?userId=. For charon rendering +// the panel server-side, where there is no browser to carry a cookie. The +// caller names the user, so this is a full read of anybody's profile -- +// watch history included -- and it is exactly as sensitive as that sounds. +// +// Mode 2 has its OWN token rather than reusing AMPELOS_AGENT_TOKEN. That token +// is deployed to every satellite host, including ones nobody has physical +// control over; a herald script leaking it should not also hand over everyone's +// viewing history. Mode 2 stays off entirely until the variable is set. + +import { timingSafeEqual } from "node:crypto"; + +import { eq } from "drizzle-orm"; + +import { auth } from "@/auth"; +import { db } from "@/db/client"; +import { users } from "@/db/schema"; +import { APP_URL } from "@/lib/app-url"; +import { isAllowedOrigin } from "@/lib/embed-origins"; +import { getProfile } from "@/lib/profile"; + +export const dynamic = "force-dynamic"; + +function corsHeaders(request: Request) { + const origin = request.headers.get("origin"); + const headers = new Headers(); + + // Vary regardless of the outcome: the response differs by Origin, and a cache + // that missed that would hand one site's allowance to another. + headers.set("Vary", "Origin"); + + if (isAllowedOrigin(origin)) { + headers.set("Access-Control-Allow-Origin", origin); + // Required for the cookie to be sent at all, and the reason the allowed + // origin is an explicit list rather than "*" -- the two cannot be combined. + headers.set("Access-Control-Allow-Credentials", "true"); + } + + return headers; +} + +function tokenMatches(request: Request, expected: string) { + const header = request.headers.get("authorization") ?? ""; + const provided = header.startsWith("Bearer ") ? header.slice(7) : ""; + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return Boolean(provided) && a.length === b.length && timingSafeEqual(a, b); +} + +/** + * Whose profile is being asked for, and may the caller have it? + * + * Returns a user id or a refusal. The session path never reads the query + * string, so a signed-in user cannot widen their own request by adding + * ?email= to it. + */ +async function resolveUserId(request: Request): Promise< + { ok: true; userId: string } | { ok: false; status: number; error: string } +> { + const session = await auth(); + if (session?.user?.id) return { ok: true, userId: session.user.id }; + + const expected = process.env.AMPELOS_PROFILE_API_TOKEN; + if (!expected) return { ok: false, status: 401, error: "not signed in" }; + if (!tokenMatches(request, expected)) return { ok: false, status: 401, error: "not signed in" }; + + const params = new URL(request.url).searchParams; + const userId = params.get("userId"); + const email = params.get("email"); + + if (userId) return { ok: true, userId }; + + if (email) { + const [row] = await db + .select({ id: users.id }) + .from(users) + // users.email is unique and is the claim Authentik issues to every + // sticknife service, which makes it the one identifier charon already + // holds without Ampelos telling it anything. + .where(eq(users.email, email)) + .limit(1); + if (!row) return { ok: false, status: 404, error: "no such user" }; + return { ok: true, userId: row.id }; + } + + return { ok: false, status: 400, error: "name a user with ?userId= or ?email=" }; +} + +export async function GET(request: Request) { + const headers = corsHeaders(request); + const resolved = await resolveUserId(request); + + if (!resolved.ok) { + return Response.json({ error: resolved.error }, { status: resolved.status, headers }); + } + + const profile = await getProfile(resolved.userId, APP_URL); + if (!profile) { + return Response.json({ error: "no such user" }, { status: 404, headers }); + } + + // Somebody's viewing history is not something to leave in a shared cache. + headers.set("Cache-Control", "private, no-store"); + return Response.json(profile, { headers }); +} + +export async function OPTIONS(request: Request) { + const headers = corsHeaders(request); + headers.set("Access-Control-Allow-Methods", "GET, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type"); + headers.set("Access-Control-Max-Age", "86400"); + return new Response(null, { status: 204, headers }); +} diff --git a/src/app/catalog-board.tsx b/src/app/catalog-board.tsx index f68bd92..e377bca 100644 --- a/src/app/catalog-board.tsx +++ b/src/app/catalog-board.tsx @@ -41,6 +41,16 @@ type CatalogBoardProps = { watchNowItems: WatchNowItem[]; slotCount: number; isAdmin: boolean; + /** + * A TMDB id to open the detail panel on as soon as the board mounts. + * + * How /profile links into the catalog: a Watch Now slot or a history entry + * points at `/?tab=&open=`, and this is what makes that URL + * land on the open panel rather than merely the right tab. It is also the + * only route that works from charon's iframe, where a modal would open + * inside a 400px box. + */ + openExternalId?: string | null; }; function mediaLabel(kind: CatalogItem["kind"]) { @@ -644,7 +654,7 @@ function WatchNowTray({ kind, items, slotCount, onRejectClassic }: { kind: Catal ); } -export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExternalIds, purgedExternalIds, watchNowItems, slotCount, isAdmin }: CatalogBoardProps) { +export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExternalIds, purgedExternalIds, watchNowItems, slotCount, isAdmin, openExternalId }: CatalogBoardProps) { const collectionSet = useMemo(() => new Set(collectionIds), [collectionIds]); const watchNowSet = useMemo(() => new Set(watchNowExternalIds), [watchNowExternalIds]); const purgedSet = useMemo(() => new Set(purgedExternalIds), [purgedExternalIds]); @@ -666,6 +676,48 @@ export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExtern ); } + /** + * Open a title the board may not be holding. + * + * The list is whatever is popular or matched the search, and a title somebody + * watched two years ago is usually in neither -- so there is often no + * CatalogItem to open. CatalogDetails extends CatalogItem, which means the + * detail fetch returns everything the panel needs to stand one up from just + * an id. + */ + async function openById(externalId: string, itemKind: Exclude) { + const known = visibleItems.find((item) => item.id === externalId); + if (known) { + await openDetails(known); + return; + } + + const placeholder: CatalogItem = { + id: externalId, + source: "tmdb", + kind: itemKind, + title: "", + year: null, + overview: null, + posterUrl: null, + backdropUrl: null, + rating: null, + popularity: null, + releaseDate: null, + }; + await openDetails(placeholder); + } + + // Deep link from /profile. Runs once per id: re-running on every render would + // reopen the panel the moment somebody closed it. + useEffect(() => { + if (!openExternalId || kind === "music") return; + void openById(openExternalId, kind); + // openById closes over visibleItems, but re-opening because the list + // changed underneath is exactly what this must not do. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openExternalId, kind]); + async function openDetails(item: CatalogItem) { const flags: PanelFlags = { timeless: Boolean(item.isClassic), diff --git a/src/app/embed/frame-height.tsx b/src/app/embed/frame-height.tsx new file mode 100644 index 0000000..e1000d7 --- /dev/null +++ b/src/app/embed/frame-height.tsx @@ -0,0 +1,53 @@ +"use client"; + +// Tell the framing page how tall this panel is. +// +// A cross-origin iframe cannot size itself to its content and the parent cannot +// measure across the origin boundary, so without this charon has to guess a +// height -- and a guess is either a panel that scrolls inside its own box or one +// with a band of dead space under it. This is the only way to get it right. +// +// PURELY ADDITIVE. A parent that ignores the message loses nothing but the +// exact fit, so the panel still works if charon never implements the listener. +// That is why the rest of the embed stays free of client JavaScript: this is the +// one thing that genuinely cannot be done on the server. +// +// The message is targeted at the configured ancestors rather than "*", so the +// height is not broadcast to any page that happens to frame us. + +import { useEffect } from "react"; + +const MESSAGE_TYPE = "ampelos:profile:height"; + +export function FrameHeight({ targetOrigins }: { targetOrigins: string[] }) { + useEffect(() => { + if (window.parent === window) return; + + const post = (height: number) => { + for (const origin of targetOrigins) { + // A wrong origin here throws rather than leaking, which is the failure + // direction we want; other targets still get theirs. + try { + window.parent.postMessage({ type: MESSAGE_TYPE, height }, origin); + } catch { + // The parent is not this origin. Nothing to do. + } + } + }; + + // ResizeObserver rather than a one-shot measure on mount: posters load late + // and change the height after first paint, and a single measurement would + // leave the frame sized for a panel with no images in it. + const observer = new ResizeObserver((entries) => { + const height = entries[0]?.target.scrollHeight ?? document.body.scrollHeight; + if (height > 0) post(Math.ceil(height)); + }); + + observer.observe(document.documentElement); + post(Math.ceil(document.documentElement.scrollHeight)); + + return () => observer.disconnect(); + }, [targetOrigins]); + + return null; +} diff --git a/src/app/embed/layout.tsx b/src/app/embed/layout.tsx new file mode 100644 index 0000000..6707abc --- /dev/null +++ b/src/app/embed/layout.tsx @@ -0,0 +1,23 @@ +// Chrome-less shell for anything charon frames. +// +// No topbar, no page background, no fixed viewport height: the panel is a +// fragment of somebody else's page and has to size to whatever box that page +// gives it. The `.ampelos-app` background in particular must NOT be here -- +// it paints a full-viewport gradient that would show as a hard-edged rectangle +// inside accounts.sticknife.com's own theme. +// +// The root layout still wraps this (fonts and globals.css come from there), +// which is what keeps the embed looking like Ampelos rather than unstyled HTML. + +import { embedOrigins } from "@/lib/embed-origins"; + +import { FrameHeight } from "./frame-height"; + +export default function EmbedLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/embed/profile/page.tsx b/src/app/embed/profile/page.tsx new file mode 100644 index 0000000..fc53dd2 --- /dev/null +++ b/src/app/embed/profile/page.tsx @@ -0,0 +1,82 @@ +// The Ampelos section of the sticknife-wide profile. +// +// accounts.sticknife.com iframes this into its own collapsible panel: +// +// +// +// WHY THE COOKIE REACHES US. accounts.sticknife.com and ampelos.sticknife.com +// share the registrable domain sticknife.com, so a request from one to the +// other is SAME-SITE even though it is cross-origin. The NextAuth session +// cookie is SameSite=Lax, which is sent on same-site subresource loads -- so +// the person is simply signed in inside the frame, with no token exchange, no +// shared secret, and no cookie loosened to SameSite=None. This holds only while +// both services live under sticknife.com; moving either to its own domain +// breaks it and would need a real token flow instead. +// +// WHY IT DOES NOT REDIRECT TO /login. /embed is exempted from the proxy's login +// redirect on purpose. Authentik refuses to be framed, so redirecting would +// render an X-Frame-Options error inside charon's panel -- a broken-looking box +// with no explanation. A signed-out visitor gets a sign-in link that escapes +// the frame instead. + +import Link from "next/link"; + +import { auth } from "@/auth"; +import { APP_URL } from "@/lib/app-url"; +import { getProfile } from "@/lib/profile"; + +import { ProfilePanel } from "../../profile/profile-panel"; + +export const dynamic = "force-dynamic"; + +function Shell({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export default async function EmbeddedProfilePage() { + const session = await auth(); + + if (!session?.user?.id) { + return ( + +
+

+ You are not signed in to Ampelos. +

+ + Sign in to Ampelos + +
+
+ ); + } + + const profile = await getProfile(session.user.id, APP_URL); + + // Signed in to Authentik but with no Ampelos user row. Rare, and worth saying + // plainly rather than showing an empty panel that reads as "you have nothing". + if (!profile) { + return ( + +

+ This account has no Ampelos profile yet. Visit{" "} + + ampelos.sticknife.com + {" "} + once to create one. +

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 4955f40..a3e5dbb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -174,6 +174,8 @@ export default async function Home({ searchParams }: PageProps) { const kind = activeKind(resolvedSearchParams.tab); const query = singleParam(resolvedSearchParams.q)?.trim() ?? ""; const collectionOnly = singleParam(resolvedSearchParams.collection) === "1"; + // Set by the links on /profile, which point at a title rather than a tab. + const openExternalId = singleParam(resolvedSearchParams.open)?.trim() || null; // The same flag the admin layout and the server actions check, rather than // re-deriving it from the group list: a button that appears under one rule and // an action that refuses under another is the worst version of this. @@ -211,6 +213,9 @@ export default async function Home({ searchParams }: PageProps) {
+ + Profile + {isAdmin && ( Admin @@ -274,6 +279,7 @@ export default async function Home({ searchParams }: PageProps) { watchNowItems={watchNowItems} slotCount={slotCount} isAdmin={isAdmin} + openExternalId={openExternalId} /> diff --git a/src/app/profile/actions.ts b/src/app/profile/actions.ts new file mode 100644 index 0000000..2360b22 --- /dev/null +++ b/src/app/profile/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +// Starting and undoing a Plex link. +// +// Linking is a round trip through plex.tv: we ask for a PIN, send the person to +// plex.tv to claim it, and Plex returns them to /api/plex/callback, which is +// where the link is actually finished. The PIN id is parked in an httpOnly +// cookie for the duration -- it is a one-use handle, worthless without the +// sign-in that claims it, and it means the flow survives the user taking a +// minute over it. +// +// The FINISHING half deliberately lives in a route handler rather than here or +// in the page: it has to delete that cookie and revalidate, and a page render +// may do neither. See src/app/api/plex/callback/route.ts. +// +// Linking is what grants library access AND what makes a watchlist and watch +// history readable. All of it follows from the same consent, which is why it +// happens together rather than being things an admin has to remember to do. + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; + +import { auth } from "@/auth"; +import { db } from "@/db/client"; +import { plexAccounts } from "@/db/schema"; +import { appUrl } from "@/lib/app-url"; +import { createPin, authUrl } from "@/lib/plex"; +import { PIN_COOKIE } from "@/lib/plex-link"; + +/** + * Both surfaces, always. + * + * The embed is a separate route rendering the same data, so refreshing only + * /profile would leave charon's panel showing a Plex link that has already been + * removed until something else happened to invalidate it. + */ +function revalidateProfile() { + revalidatePath("/profile"); + revalidatePath("/embed/profile"); +} + +async function requireUser() { + const session = await auth(); + if (!session?.user?.id) throw new Error("Sign in first"); + return session.user; +} + +export async function startPlexLinkAction() { + await requireUser(); + + const pin = await createPin(); + const jar = await cookies(); + jar.set(PIN_COOKIE, String(pin.id), { + httpOnly: true, + sameSite: "lax", + secure: true, + path: "/", + maxAge: 15 * 60, + }); + + // A bare path, no query string -- Plex carries this inside a URL fragment + // that its own client-side code parses. redirect() throws, so it goes last. + redirect(authUrl(pin, appUrl("/api/plex/callback"))); +} + +export async function unlinkPlexAction() { + const user = await requireUser(); + // Only the link is removed. Library access is granted on Plex's side and is + // not ours to quietly revoke from a button labelled "unlink"; whoever owns + // the server can take it back there. + await db.delete(plexAccounts).where(eq(plexAccounts.userId, user.id)); + revalidateProfile(); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..23d0432 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,121 @@ +import Image from "next/image"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { auth, signOut } from "@/auth"; +import { APP_URL } from "@/lib/app-url"; +import { getProfile } from "@/lib/profile"; + +import { ProfilePanel } from "./profile-panel"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams?: Promise>; +}; + +function single(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +/** + * What to say about a link attempt that just came back. + * + * The linking itself happens in /api/plex/callback, which redirects here with + * an outcome code; this page only renders it. Reading a code rather than a + * ready-made sentence keeps somebody else's error text out of a URL that a + * person might paste to ask for help. + */ +function linkMessage(params: Record) { + const outcome = single(params.plex); + if (!outcome) return null; + + const who = single(params.who); + const reason = single(params.reason); + const named = who ? `Linked as ${who}` : "Linked"; + + switch (outcome) { + case "linked": + return `${named}. The Plex libraries have been shared with you — check your email for the invitation.`; + case "owner": + return `${named}. You own this Plex server, so you already have every library — nothing needed sharing.`; + case "already-shared": + return `${named}. You already had access to the libraries.`; + case "share-failed": + return `${named}, but the libraries could not be shared${reason ? `: ${reason}` : "."} You can try linking again.`; + case "not-completed": + return "That sign-in was not completed. Try linking again."; + case "taken": + return "That Plex account is already linked to another Ampelos user."; + case "error": + return `Plex could not confirm the sign-in${reason ? `: ${reason}` : "."}`; + default: + return null; + } +} + +export default async function ProfilePage({ searchParams }: PageProps) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const resolved = (await searchParams) ?? {}; + const message = linkMessage(resolved); + + const profile = await getProfile(session.user.id, APP_URL); + if (!profile) redirect("/login"); + + return ( +
+
+
+
+ Ampelos +
+

+ Profile +

+

+ {profile.user.displayName} +

+

{profile.user.email}

+
+
+
+ + Catalog + + {session.user.isAdmin && ( + + Admin + + )} +
{ "use server"; await signOut({ redirectTo: "/login" }); }}> + +
+
+
+
+ +
+ {message && ( +

+ {message} +

+ )} + +
+ +
+
+
+ ); +} diff --git a/src/app/profile/profile-panel.tsx b/src/app/profile/profile-panel.tsx new file mode 100644 index 0000000..a29a859 --- /dev/null +++ b/src/app/profile/profile-panel.tsx @@ -0,0 +1,372 @@ +// The Ampelos profile panel. +// +// ONE component behind three surfaces: the page at /profile, the fragment +// charon iframes at /embed/profile, and -- through getProfile -- the JSON at +// /api/profile. The `embedded` prop changes only chrome and navigation, never +// what is shown, so the panel accounts.sticknife.com displays is the panel here +// and there is no second implementation to keep in step. +// +// Everything is a server component and every control is a plain form. That is +// worth keeping: the embed is a cross-origin iframe, and a panel that needs no +// client JavaScript is one that cannot be broken by charon's CSP. + +import Image from "next/image"; +import Link from "next/link"; + +import type { CatalogRef, HistoryEntry, Profile, WatchNowList } from "@/lib/profile"; + +import { startPlexLinkAction, unlinkPlexAction } from "./actions"; +import { removeFromWatchNow } from "../watch-now-actions"; + +type PanelProps = { + profile: Profile; + /** Rendered inside charon's iframe: no in-app navigation, links escape the frame. */ + embedded?: boolean; +}; + +/** + * Anchors inside the embed must break out of the frame. + * + * Without target="_top" a click loads Ampelos's full page inside charon's + * collapsible panel, which looks like the site has broken. Rendering the whole + * app in a 400px box is not a state anybody meant to reach. + */ +function frameTarget(embedded?: boolean) { + return embedded ? { target: "_top" as const } : {}; +} + +function whenText(iso: string) { + const then = new Date(iso); + const days = Math.floor((Date.now() - then.getTime()) / 86_400_000); + + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days} days ago`; + + return then.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +function episodeLabel(entry: HistoryEntry) { + if (entry.seasonNumber === null || entry.episodeNumber === null) return null; + + const code = `S${String(entry.seasonNumber).padStart(2, "0")}E${String(entry.episodeNumber).padStart(2, "0")}`; + return entry.episodeTitle ? `${code} · ${entry.episodeTitle}` : code; +} + +/** + * Wrap a row so clicking it opens the catalog's detail panel for that title. + * + * The panel lives in the catalog board and is driven by TMDB ids, so this is a + * link to the board with `?open=` rather than a second copy of the panel here. + * That also happens to be the only version that works inside charon's iframe, + * where a modal would open in a 400px box. + * + * A title with no usable TMDB id -- never linked, or linked to an id somebody + * rejected -- renders as plain text rather than a link that would open the + * wrong thing or nothing at all. + */ +function CatalogLink({ + catalog, + title, + embedded, + className, + children, +}: { + catalog: CatalogRef; + title: string; + embedded?: boolean; + className?: string; + children: React.ReactNode; +}) { + if (!catalog) return
{children}
; + + return ( + + {children} + + ); +} + +function Poster({ url, alt, size = 40 }: { url: string | null; alt: string; size?: number }) { + return ( +
+ {url && {alt}} +
+ ); +} + +function SectionHeading({ title, aside }: { title: string; aside?: React.ReactNode }) { + return ( +
+

{title}

+ {aside && {aside}} +
+ ); +} + +function PlexSection({ profile, embedded }: PanelProps) { + const { plex } = profile; + + return ( +
+ + + {plex.linked ? ( + <> +

+ Linked as {plex.username} + {plex.email ? · {plex.email} : null} + {plex.isServerOwner ? ( + · server owner + ) : null} +

+ {/* + The owner is never invited to their own server -- Plex refuses with + "You cannot send an invitation to yourself" -- so an unshared link + means something completely different for them than for everybody + else, and must not read as a failure. + */} +

+ {plex.isServerOwner + ? "You own this Plex server, so you already have every library. Nothing needed sharing." + : plex.librariesSharedAt + ? `${plex.sharedLibraryCount} libraries shared with you. Check your email for the invitation if you have not accepted it yet.` + : "The libraries have not been shared yet — link again to retry."} +

+

+ Anything you add to your Plex watchlist is treated as a request: if we already have + it you will find it in the library, and if we do not, we will go and get it. +

+ {/* Unlinking stays in place -- it navigates nowhere, so it is safe in the frame. */} +
+ +
+ + ) : ( + <> +

+ Link your Plex account to get access to the Movies, TV Shows and Music libraries. + Once linked, your Plex watchlist becomes your request list — add something there and + it will be found for you. +

+

+ You sign in at plex.tv, not here. Ampelos never sees your Plex password, and the + sign-in token is discarded as soon as Plex confirms who you are. +

+ {/* + Linking is a round trip through plex.tv, and plex.tv refuses to be + framed. Inside the embed the button therefore hands the person over + to the real profile page at top level rather than starting a flow + that would dead-end in charon's panel. + */} + {embedded ? ( + + Link my Plex account on Ampelos + + ) : ( +
+ +
+ )} + + )} +
+ ); +} + +function WatchNowGroup({ + label, + list, + embedded, +}: { + label: string; + list: WatchNowList; + embedded?: boolean; +}) { + return ( +
+
+

+ {label} +

+ + {list.used} of {list.quota} slots + +
+ + {list.items.length === 0 ? ( +

+ Nothing here yet. Add something from the{" "} + + catalog + {" "} + to keep it in high quality on live storage. +

+ ) : ( +
    + {list.items.map((item) => ( +
  • + + + + + {item.title} + {item.year ? ({item.year}) : null} + + + {item.slotNumber ? `Slot ${item.slotNumber}` : "Unslotted"} · added{" "} + {whenText(item.addedAt)} + + + +
    + + +
    +
  • + ))} +
+ )} +
+ ); +} + +function WatchNowSection({ profile, embedded }: PanelProps) { + const { television, movies } = profile.watchNow; + + return ( +
+ +

+ Watch Now is what keeps a title on live storage in full quality. Everything else falls + back to the 720p archive over time. +

+ + +
+ ); +} + +function WatchHistorySection({ profile, embedded }: PanelProps) { + const { history } = profile; + + return ( +
+ 0 + ? `${history.playsLast30Days} plays in the last 30 days · ${history.distinctEpisodes} titles` + : undefined + } + /> + + {history.recent.length === 0 ? ( +

+ {profile.plex.linked + ? "Nothing recorded yet. History is copied from Plex about once an hour, so anything you watch will show up here shortly." + : "Link your Plex account above and what you watch will be recorded here."} +

+ ) : ( + <> +
    + {history.recent.map((entry) => { + const episode = episodeLabel(entry); + return ( +
  • + + + + + {entry.title} + + + {episode ? `${episode} · ` : ""} + {whenText(entry.watchedAt)} + {/* + Repeats are collapsed to the most recent play, so the + count is what stops that from looking like data loss. + */} + {entry.playCount > 1 ? ` · watched ${entry.playCount} times` : ""} + + + +
  • + ); + })} +
+ {/* + Counted against distinctEpisodes rather than total plays: the list + is one row per episode, so promising it is a window onto 1,668 + plays when it can only ever show 993 things is just wrong. + */} + {history.distinctEpisodes > history.recent.length && ( +

+ Showing the {history.recent.length} most recently watched of{" "} + {history.distinctEpisodes}. Repeats are collapsed to the latest play. +

+ )} + + )} + +

+ Only you can see this. Ampelos records history for linked accounts and never shows one + person's viewing to anybody else, administrators included. +

+
+ ); +} + +export function ProfilePanel({ profile, embedded }: PanelProps) { + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/src/app/watch-now-actions.ts b/src/app/watch-now-actions.ts index aa7ec29..33aeac8 100644 --- a/src/app/watch-now-actions.ts +++ b/src/app/watch-now-actions.ts @@ -14,6 +14,17 @@ type WatchKind = "television" | "movies"; 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) { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { return null; @@ -169,7 +180,7 @@ export async function addToWatchNow(formData: FormData): Promise 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); \ No newline at end of file diff --git a/src/db/migrations/0025_fine_venom.sql b/src/db/migrations/0025_fine_venom.sql new file mode 100644 index 0000000..2edcbc1 --- /dev/null +++ b/src/db/migrations/0025_fine_venom.sql @@ -0,0 +1 @@ +ALTER TABLE "plex_accounts" ADD COLUMN "is_server_owner" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0024_snapshot.json b/src/db/migrations/meta/0024_snapshot.json new file mode 100644 index 0000000..1bcdbc5 --- /dev/null +++ b/src/db/migrations/meta/0024_snapshot.json @@ -0,0 +1,2999 @@ +{ + "id": "d5e8a99b-d086-4518-adcd-d63b51fdaeca", + "prevId": "753b9dd5-709f-4749-a931-09a52fb141d8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.plex_accounts": { + "name": "plex_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plex_user_id": { + "name": "plex_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plex_uuid": { + "name": "plex_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_username": { + "name": "plex_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plex_email": { + "name": "plex_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "libraries_shared_at": { + "name": "libraries_shared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "shared_section_ids": { + "name": "shared_section_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plex_accounts_user_id_users_id_fk": { + "name": "plex_accounts_user_id_users_id_fk", + "tableFrom": "plex_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plex_accounts_user_id_unique": { + "name": "plex_accounts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "plex_accounts_plex_user_id_unique": { + "name": "plex_accounts_plex_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "plex_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "watching_now_tv_slots": { + "name": "watching_now_tv_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "watching_now_movie_slots": { + "name": "watching_now_movie_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_heartbeats": { + "name": "agent_heartbeats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_heartbeats_agent_unique": { + "name": "agent_heartbeats_agent_unique", + "nullsNotDistinct": false, + "columns": [ + "agent" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.corrupt_files": { + "name": "corrupt_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_path": { + "name": "full_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "corrupt_file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_detected_at": { + "name": "first_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_detected_at": { + "name": "last_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delete_error": { + "name": "delete_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "corrupt_files_reviewed_by_users_id_fk": { + "name": "corrupt_files_reviewed_by_users_id_fk", + "tableFrom": "corrupt_files", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "corrupt_files_full_path_unique": { + "name": "corrupt_files_full_path_unique", + "nullsNotDistinct": false, + "columns": [ + "full_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_availability": { + "name": "storage_availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "online": { + "name": "online", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "storage_availability_tier_id_storage_tiers_id_fk": { + "name": "storage_availability_tier_id_storage_tiers_id_fk", + "tableFrom": "storage_availability", + "tableTo": "storage_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_files": { + "name": "storage_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_path": { + "name": "full_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "missing_at": { + "name": "missing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "inferred_title": { + "name": "inferred_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inferred_year": { + "name": "inferred_year", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codec": { + "name": "codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "edition": { + "name": "edition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "video_codec": { + "name": "video_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_channels": { + "name": "audio_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bitrate": { + "name": "bitrate", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "probed_at": { + "name": "probed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "probe_error": { + "name": "probe_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replace_requested_at": { + "name": "replace_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "replace_requested_by": { + "name": "replace_requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "storage_files_tier_relative_path_idx": { + "name": "storage_files_tier_relative_path_idx", + "columns": [ + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_files_media_item_idx": { + "name": "storage_files_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_files_tier_id_storage_tiers_id_fk": { + "name": "storage_files_tier_id_storage_tiers_id_fk", + "tableFrom": "storage_files", + "tableTo": "storage_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_files_media_item_id_media_items_id_fk": { + "name": "storage_files_media_item_id_media_items_id_fk", + "tableFrom": "storage_files", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "storage_files_episode_id_episodes_id_fk": { + "name": "storage_files_episode_id_episodes_id_fk", + "tableFrom": "storage_files", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_tiers": { + "name": "storage_tiers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier": { + "name": "tier", + "type": "storage_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_path": { + "name": "base_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "always_online": { + "name": "always_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "storage_tiers_tier_unique": { + "name": "storage_tiers_tier_unique", + "nullsNotDistinct": false, + "columns": [ + "tier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "episodes_season_id_episode_number_idx": { + "name": "episodes_season_id_episode_number_idx", + "columns": [ + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_season_id_seasons_id_fk": { + "name": "episodes_season_id_seasons_id_fk", + "tableFrom": "episodes", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_ids": { + "name": "external_ids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "external_id_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "remote_title": { + "name": "remote_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_checked_at": { + "name": "remote_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "external_ids_source_external_id_idx": { + "name": "external_ids_source_external_id_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_ids_media_item_idx": { + "name": "external_ids_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_ids_media_item_id_media_items_id_fk": { + "name": "external_ids_media_item_id_media_items_id_fk", + "tableFrom": "external_ids", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_items": { + "name": "media_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_type": { + "name": "media_type", + "type": "media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_title": { + "name": "sort_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "poster_path": { + "name": "poster_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.movies": { + "name": "movies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime_minutes": { + "name": "runtime_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_refreshed_at": { + "name": "metadata_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "movies_id_media_items_id_fk": { + "name": "movies_id_media_items_id_fk", + "tableFrom": "movies", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "episode_count": { + "name": "episode_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "season_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + } + }, + "indexes": { + "seasons_series_id_season_number_idx": { + "name": "seasons_series_id_season_number_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_series_id_series_id_fk": { + "name": "seasons_series_id_series_id_fk", + "tableFrom": "seasons", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.series": { + "name": "series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "series_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_air_date": { + "name": "first_air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "last_air_date": { + "name": "last_air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "episode_runtime_minutes": { + "name": "episode_runtime_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_currently_relevant": { + "name": "is_currently_relevant", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "metadata_refreshed_at": { + "name": "metadata_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "series_id_media_items_id_fk": { + "name": "series_id_media_items_id_fk", + "tableFrom": "series", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.classics": { + "name": "classics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "classics_media_item_id_media_items_id_fk": { + "name": "classics_media_item_id_media_items_id_fk", + "tableFrom": "classics", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "classics_added_by_users_id_fk": { + "name": "classics_added_by_users_id_fk", + "tableFrom": "classics", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "classics_media_item_id_unique": { + "name": "classics_media_item_id_unique", + "nullsNotDistinct": false, + "columns": [ + "media_item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watch_history": { + "name": "watch_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "watched_at": { + "name": "watched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "watch_history_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'plex'" + }, + "plex_history_key": { + "name": "plex_history_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_rating_key": { + "name": "plex_rating_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_history_user_plex_key_idx": { + "name": "watch_history_user_plex_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plex_history_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "plex_history_key is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watch_history_user_watched_idx": { + "name": "watch_history_user_watched_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "watched_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watch_history_user_id_users_id_fk": { + "name": "watch_history_user_id_users_id_fk", + "tableFrom": "watch_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watch_history_media_item_id_media_items_id_fk": { + "name": "watch_history_media_item_id_media_items_id_fk", + "tableFrom": "watch_history", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watching_now_items": { + "name": "watching_now_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "watching_now_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'show'" + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slot_number": { + "name": "slot_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "watching_now_items_user_id_users_id_fk": { + "name": "watching_now_items_user_id_users_id_fk", + "tableFrom": "watching_now_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watching_now_items_media_item_id_media_items_id_fk": { + "name": "watching_now_items_media_item_id_media_items_id_fk", + "tableFrom": "watching_now_items", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_items": { + "name": "watchlist_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "watchlist_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'plex'" + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_items_user_id_users_id_fk": { + "name": "watchlist_items_user_id_users_id_fk", + "tableFrom": "watchlist_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_items_media_item_id_media_items_id_fk": { + "name": "watchlist_items_media_item_id_media_items_id_fk", + "tableFrom": "watchlist_items", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_overrides": { + "name": "admin_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_type": { + "name": "override_type", + "type": "override_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_overrides_media_item_id_media_items_id_fk": { + "name": "admin_overrides_media_item_id_media_items_id_fk", + "tableFrom": "admin_overrides", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admin_overrides_created_by_users_id_fk": { + "name": "admin_overrides_created_by_users_id_fk", + "tableFrom": "admin_overrides", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desired_states": { + "name": "desired_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_run_id": { + "name": "policy_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wanted": { + "name": "wanted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_tier": { + "name": "storage_tier", + "type": "storage_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "min_quality": { + "name": "min_quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "preferred_quality": { + "name": "preferred_quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "monitored": { + "name": "monitored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reason_codes": { + "name": "reason_codes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "desired_states_policy_run_id_policy_runs_id_fk": { + "name": "desired_states_policy_run_id_policy_runs_id_fk", + "tableFrom": "desired_states", + "tableTo": "policy_runs", + "columnsFrom": [ + "policy_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "desired_states_media_item_id_media_items_id_fk": { + "name": "desired_states_media_item_id_media_items_id_fk", + "tableFrom": "desired_states", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_runs": { + "name": "policy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "policy_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "items_evaluated": { + "name": "items_evaluated", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "items_changed": { + "name": "items_changed", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_size_rules": { + "name": "release_size_rules", + "schema": "", + "columns": { + "quality": { + "name": "quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "min_mb_per_minute": { + "name": "min_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": true + }, + "max_mb_per_minute": { + "name": "max_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": true + }, + "atmos_max_mb_per_minute": { + "name": "atmos_max_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": false + }, + "timeless_max_mb_per_minute": { + "name": "timeless_max_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.maintenance_jobs": { + "name": "maintenance_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job": { + "name": "job", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "maintenance_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "status": { + "name": "status", + "type": "maintenance_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "maintenance_jobs_job_started_idx": { + "name": "maintenance_jobs_job_started_idx", + "columns": [ + { + "expression": "job", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grab_files": { + "name": "grab_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grab_id": { + "name": "grab_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_file_id": { + "name": "storage_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "imported_path": { + "name": "imported_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grab_files_grab_path_idx": { + "name": "grab_files_grab_path_idx", + "columns": [ + { + "expression": "grab_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "imported_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grab_files_storage_file_idx": { + "name": "grab_files_storage_file_idx", + "columns": [ + { + "expression": "storage_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grab_files_grab_id_grabs_id_fk": { + "name": "grab_files_grab_id_grabs_id_fk", + "tableFrom": "grab_files", + "tableTo": "grabs", + "columnsFrom": [ + "grab_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grab_files_storage_file_id_storage_files_id_fk": { + "name": "grab_files_storage_file_id_storage_files_id_fk", + "tableFrom": "grab_files", + "tableTo": "storage_files", + "columnsFrom": [ + "storage_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grabs": { + "name": "grabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "info_hash": { + "name": "info_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "magnet_url": { + "name": "magnet_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexer_name": { + "name": "indexer_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seeders": { + "name": "seeders", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "score_reasons": { + "name": "score_reasons", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "source": { + "name": "source", + "type": "grab_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "grab_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "status_detail": { + "name": "status_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "progress_changed_at": { + "name": "progress_changed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "content_path": { + "name": "content_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imported_path": { + "name": "imported_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imported_at": { + "name": "imported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seed_path": { + "name": "seed_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seed_released_at": { + "name": "seed_released_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seed_release_reason": { + "name": "seed_release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grabs_info_hash_idx": { + "name": "grabs_info_hash_idx", + "columns": [ + { + "expression": "info_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grabs_status_idx": { + "name": "grabs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grabs_media_item_idx": { + "name": "grabs_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grabs_media_item_id_media_items_id_fk": { + "name": "grabs_media_item_id_media_items_id_fk", + "tableFrom": "grabs", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grabs_requested_by_users_id_fk": { + "name": "grabs_requested_by_users_id_fk", + "tableFrom": "grabs", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_blocklist": { + "name": "release_blocklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "info_hash": { + "name": "info_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_group": { + "name": "release_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permanent": { + "name": "permanent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "release_blocklist_info_hash_idx": { + "name": "release_blocklist_info_hash_idx", + "columns": [ + { + "expression": "info_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_blocklist_media_item_id_media_items_id_fk": { + "name": "release_blocklist_media_item_id_media_items_id_fk", + "tableFrom": "release_blocklist", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_blocklist_created_by_users_id_fk": { + "name": "release_blocklist_created_by_users_id_fk", + "tableFrom": "release_blocklist", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_runs": { + "name": "search_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "grab_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexers_queried": { + "name": "indexers_queried", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "results_found": { + "name": "results_found", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "results_accepted": { + "name": "results_accepted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "grab_id": { + "name": "grab_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_runs_media_item_idx": { + "name": "search_runs_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "search_runs_media_item_id_media_items_id_fk": { + "name": "search_runs_media_item_id_media_items_id_fk", + "tableFrom": "search_runs", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "search_runs_grab_id_grabs_id_fk": { + "name": "search_runs_grab_id_grabs_id_fk", + "tableFrom": "search_runs", + "tableTo": "grabs", + "columnsFrom": [ + "grab_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.corrupt_file_status": { + "name": "corrupt_file_status", + "schema": "public", + "values": [ + "pending", + "approved", + "deleted", + "dismissed", + "failed" + ] + }, + "public.storage_tier": { + "name": "storage_tier", + "schema": "public", + "values": [ + "live", + "backup", + "archive" + ] + }, + "public.external_id_source": { + "name": "external_id_source", + "schema": "public", + "values": [ + "tvdb", + "tmdb", + "imdb", + "plex" + ] + }, + "public.media_type": { + "name": "media_type", + "schema": "public", + "values": [ + "tv_series", + "movie" + ] + }, + "public.season_status": { + "name": "season_status", + "schema": "public", + "values": [ + "upcoming", + "airing", + "complete" + ] + }, + "public.series_status": { + "name": "series_status", + "schema": "public", + "values": [ + "upcoming", + "continuing", + "ended" + ] + }, + "public.watch_history_source": { + "name": "watch_history_source", + "schema": "public", + "values": [ + "plex", + "manual" + ] + }, + "public.watching_now_scope": { + "name": "watching_now_scope", + "schema": "public", + "values": [ + "show", + "season" + ] + }, + "public.watchlist_source": { + "name": "watchlist_source", + "schema": "public", + "values": [ + "plex", + "manual" + ] + }, + "public.override_type": { + "name": "override_type", + "schema": "public", + "values": [ + "force_live", + "force_archive", + "force_quality", + "force_monitor", + "force_ignore", + "temporary_promotion", + "purge" + ] + }, + "public.policy_trigger": { + "name": "policy_trigger", + "schema": "public", + "values": [ + "scheduled", + "manual", + "demand_change", + "metadata_refresh" + ] + }, + "public.quality": { + "name": "quality", + "schema": "public", + "values": [ + "sd", + "720p", + "1080p", + "4k" + ] + }, + "public.maintenance_job_status": { + "name": "maintenance_job_status", + "schema": "public", + "values": [ + "running", + "succeeded", + "failed", + "skipped" + ] + }, + "public.maintenance_trigger": { + "name": "maintenance_trigger", + "schema": "public", + "values": [ + "scheduled", + "manual" + ] + }, + "public.grab_source": { + "name": "grab_source", + "schema": "public", + "values": [ + "auto", + "manual" + ] + }, + "public.grab_status": { + "name": "grab_status", + "schema": "public", + "values": [ + "queued", + "downloading", + "completed", + "importing", + "imported", + "failed", + "orphaned" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/0025_snapshot.json b/src/db/migrations/meta/0025_snapshot.json new file mode 100644 index 0000000..70aa9bd --- /dev/null +++ b/src/db/migrations/meta/0025_snapshot.json @@ -0,0 +1,3006 @@ +{ + "id": "b4f7bb85-ea5e-4703-82a4-710e053f094b", + "prevId": "d5e8a99b-d086-4518-adcd-d63b51fdaeca", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.plex_accounts": { + "name": "plex_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plex_user_id": { + "name": "plex_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plex_uuid": { + "name": "plex_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_username": { + "name": "plex_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plex_email": { + "name": "plex_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_owner": { + "name": "is_server_owner", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "libraries_shared_at": { + "name": "libraries_shared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "shared_section_ids": { + "name": "shared_section_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plex_accounts_user_id_users_id_fk": { + "name": "plex_accounts_user_id_users_id_fk", + "tableFrom": "plex_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plex_accounts_user_id_unique": { + "name": "plex_accounts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "plex_accounts_plex_user_id_unique": { + "name": "plex_accounts_plex_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "plex_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "watching_now_tv_slots": { + "name": "watching_now_tv_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "watching_now_movie_slots": { + "name": "watching_now_movie_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_heartbeats": { + "name": "agent_heartbeats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_heartbeats_agent_unique": { + "name": "agent_heartbeats_agent_unique", + "nullsNotDistinct": false, + "columns": [ + "agent" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.corrupt_files": { + "name": "corrupt_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_path": { + "name": "full_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "corrupt_file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_detected_at": { + "name": "first_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_detected_at": { + "name": "last_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delete_error": { + "name": "delete_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "corrupt_files_reviewed_by_users_id_fk": { + "name": "corrupt_files_reviewed_by_users_id_fk", + "tableFrom": "corrupt_files", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "corrupt_files_full_path_unique": { + "name": "corrupt_files_full_path_unique", + "nullsNotDistinct": false, + "columns": [ + "full_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_availability": { + "name": "storage_availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "online": { + "name": "online", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "storage_availability_tier_id_storage_tiers_id_fk": { + "name": "storage_availability_tier_id_storage_tiers_id_fk", + "tableFrom": "storage_availability", + "tableTo": "storage_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_files": { + "name": "storage_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_path": { + "name": "full_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "missing_at": { + "name": "missing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "inferred_title": { + "name": "inferred_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inferred_year": { + "name": "inferred_year", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codec": { + "name": "codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "edition": { + "name": "edition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "video_codec": { + "name": "video_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_channels": { + "name": "audio_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bitrate": { + "name": "bitrate", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "probed_at": { + "name": "probed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "probe_error": { + "name": "probe_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replace_requested_at": { + "name": "replace_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "replace_requested_by": { + "name": "replace_requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "storage_files_tier_relative_path_idx": { + "name": "storage_files_tier_relative_path_idx", + "columns": [ + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_files_media_item_idx": { + "name": "storage_files_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_files_tier_id_storage_tiers_id_fk": { + "name": "storage_files_tier_id_storage_tiers_id_fk", + "tableFrom": "storage_files", + "tableTo": "storage_tiers", + "columnsFrom": [ + "tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_files_media_item_id_media_items_id_fk": { + "name": "storage_files_media_item_id_media_items_id_fk", + "tableFrom": "storage_files", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "storage_files_episode_id_episodes_id_fk": { + "name": "storage_files_episode_id_episodes_id_fk", + "tableFrom": "storage_files", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_tiers": { + "name": "storage_tiers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier": { + "name": "tier", + "type": "storage_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_path": { + "name": "base_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "always_online": { + "name": "always_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "storage_tiers_tier_unique": { + "name": "storage_tiers_tier_unique", + "nullsNotDistinct": false, + "columns": [ + "tier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "episodes_season_id_episode_number_idx": { + "name": "episodes_season_id_episode_number_idx", + "columns": [ + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_season_id_seasons_id_fk": { + "name": "episodes_season_id_seasons_id_fk", + "tableFrom": "episodes", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_ids": { + "name": "external_ids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "external_id_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "remote_title": { + "name": "remote_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_checked_at": { + "name": "remote_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "external_ids_source_external_id_idx": { + "name": "external_ids_source_external_id_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_ids_media_item_idx": { + "name": "external_ids_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_ids_media_item_id_media_items_id_fk": { + "name": "external_ids_media_item_id_media_items_id_fk", + "tableFrom": "external_ids", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_items": { + "name": "media_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_type": { + "name": "media_type", + "type": "media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_title": { + "name": "sort_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "poster_path": { + "name": "poster_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.movies": { + "name": "movies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime_minutes": { + "name": "runtime_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_refreshed_at": { + "name": "metadata_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "movies_id_media_items_id_fk": { + "name": "movies_id_media_items_id_fk", + "tableFrom": "movies", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "episode_count": { + "name": "episode_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "season_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + } + }, + "indexes": { + "seasons_series_id_season_number_idx": { + "name": "seasons_series_id_season_number_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_series_id_series_id_fk": { + "name": "seasons_series_id_series_id_fk", + "tableFrom": "seasons", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.series": { + "name": "series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "series_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_air_date": { + "name": "first_air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "last_air_date": { + "name": "last_air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "episode_runtime_minutes": { + "name": "episode_runtime_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_currently_relevant": { + "name": "is_currently_relevant", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "metadata_refreshed_at": { + "name": "metadata_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "series_id_media_items_id_fk": { + "name": "series_id_media_items_id_fk", + "tableFrom": "series", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.classics": { + "name": "classics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "classics_media_item_id_media_items_id_fk": { + "name": "classics_media_item_id_media_items_id_fk", + "tableFrom": "classics", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "classics_added_by_users_id_fk": { + "name": "classics_added_by_users_id_fk", + "tableFrom": "classics", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "classics_media_item_id_unique": { + "name": "classics_media_item_id_unique", + "nullsNotDistinct": false, + "columns": [ + "media_item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watch_history": { + "name": "watch_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "watched_at": { + "name": "watched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "watch_history_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'plex'" + }, + "plex_history_key": { + "name": "plex_history_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_rating_key": { + "name": "plex_rating_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watch_history_user_plex_key_idx": { + "name": "watch_history_user_plex_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plex_history_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "plex_history_key is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "watch_history_user_watched_idx": { + "name": "watch_history_user_watched_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "watched_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watch_history_user_id_users_id_fk": { + "name": "watch_history_user_id_users_id_fk", + "tableFrom": "watch_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watch_history_media_item_id_media_items_id_fk": { + "name": "watch_history_media_item_id_media_items_id_fk", + "tableFrom": "watch_history", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watching_now_items": { + "name": "watching_now_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "watching_now_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'show'" + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slot_number": { + "name": "slot_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "watching_now_items_user_id_users_id_fk": { + "name": "watching_now_items_user_id_users_id_fk", + "tableFrom": "watching_now_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watching_now_items_media_item_id_media_items_id_fk": { + "name": "watching_now_items_media_item_id_media_items_id_fk", + "tableFrom": "watching_now_items", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_items": { + "name": "watchlist_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "watchlist_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'plex'" + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_items_user_id_users_id_fk": { + "name": "watchlist_items_user_id_users_id_fk", + "tableFrom": "watchlist_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_items_media_item_id_media_items_id_fk": { + "name": "watchlist_items_media_item_id_media_items_id_fk", + "tableFrom": "watchlist_items", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_overrides": { + "name": "admin_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_type": { + "name": "override_type", + "type": "override_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_overrides_media_item_id_media_items_id_fk": { + "name": "admin_overrides_media_item_id_media_items_id_fk", + "tableFrom": "admin_overrides", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admin_overrides_created_by_users_id_fk": { + "name": "admin_overrides_created_by_users_id_fk", + "tableFrom": "admin_overrides", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desired_states": { + "name": "desired_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_run_id": { + "name": "policy_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wanted": { + "name": "wanted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_tier": { + "name": "storage_tier", + "type": "storage_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "min_quality": { + "name": "min_quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "preferred_quality": { + "name": "preferred_quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "monitored": { + "name": "monitored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reason_codes": { + "name": "reason_codes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "desired_states_policy_run_id_policy_runs_id_fk": { + "name": "desired_states_policy_run_id_policy_runs_id_fk", + "tableFrom": "desired_states", + "tableTo": "policy_runs", + "columnsFrom": [ + "policy_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "desired_states_media_item_id_media_items_id_fk": { + "name": "desired_states_media_item_id_media_items_id_fk", + "tableFrom": "desired_states", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_runs": { + "name": "policy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger": { + "name": "trigger", + "type": "policy_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "items_evaluated": { + "name": "items_evaluated", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "items_changed": { + "name": "items_changed", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_size_rules": { + "name": "release_size_rules", + "schema": "", + "columns": { + "quality": { + "name": "quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "min_mb_per_minute": { + "name": "min_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": true + }, + "max_mb_per_minute": { + "name": "max_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": true + }, + "atmos_max_mb_per_minute": { + "name": "atmos_max_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": false + }, + "timeless_max_mb_per_minute": { + "name": "timeless_max_mb_per_minute", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.maintenance_jobs": { + "name": "maintenance_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job": { + "name": "job", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "maintenance_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "status": { + "name": "status", + "type": "maintenance_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "maintenance_jobs_job_started_idx": { + "name": "maintenance_jobs_job_started_idx", + "columns": [ + { + "expression": "job", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grab_files": { + "name": "grab_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grab_id": { + "name": "grab_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_file_id": { + "name": "storage_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "imported_path": { + "name": "imported_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grab_files_grab_path_idx": { + "name": "grab_files_grab_path_idx", + "columns": [ + { + "expression": "grab_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "imported_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grab_files_storage_file_idx": { + "name": "grab_files_storage_file_idx", + "columns": [ + { + "expression": "storage_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grab_files_grab_id_grabs_id_fk": { + "name": "grab_files_grab_id_grabs_id_fk", + "tableFrom": "grab_files", + "tableTo": "grabs", + "columnsFrom": [ + "grab_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grab_files_storage_file_id_storage_files_id_fk": { + "name": "grab_files_storage_file_id_storage_files_id_fk", + "tableFrom": "grab_files", + "tableTo": "storage_files", + "columnsFrom": [ + "storage_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grabs": { + "name": "grabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "info_hash": { + "name": "info_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "magnet_url": { + "name": "magnet_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexer_name": { + "name": "indexer_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seeders": { + "name": "seeders", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "quality", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "score_reasons": { + "name": "score_reasons", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "source": { + "name": "source", + "type": "grab_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "grab_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "status_detail": { + "name": "status_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "progress_changed_at": { + "name": "progress_changed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "content_path": { + "name": "content_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imported_path": { + "name": "imported_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imported_at": { + "name": "imported_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seed_path": { + "name": "seed_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seed_released_at": { + "name": "seed_released_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seed_release_reason": { + "name": "seed_release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grabs_info_hash_idx": { + "name": "grabs_info_hash_idx", + "columns": [ + { + "expression": "info_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grabs_status_idx": { + "name": "grabs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grabs_media_item_idx": { + "name": "grabs_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grabs_media_item_id_media_items_id_fk": { + "name": "grabs_media_item_id_media_items_id_fk", + "tableFrom": "grabs", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grabs_requested_by_users_id_fk": { + "name": "grabs_requested_by_users_id_fk", + "tableFrom": "grabs", + "tableTo": "users", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_blocklist": { + "name": "release_blocklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "info_hash": { + "name": "info_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_group": { + "name": "release_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permanent": { + "name": "permanent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "release_blocklist_info_hash_idx": { + "name": "release_blocklist_info_hash_idx", + "columns": [ + { + "expression": "info_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_blocklist_media_item_id_media_items_id_fk": { + "name": "release_blocklist_media_item_id_media_items_id_fk", + "tableFrom": "release_blocklist", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_blocklist_created_by_users_id_fk": { + "name": "release_blocklist_created_by_users_id_fk", + "tableFrom": "release_blocklist", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_runs": { + "name": "search_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_item_id": { + "name": "media_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "grab_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexers_queried": { + "name": "indexers_queried", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "results_found": { + "name": "results_found", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "results_accepted": { + "name": "results_accepted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "grab_id": { + "name": "grab_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_runs_media_item_idx": { + "name": "search_runs_media_item_idx", + "columns": [ + { + "expression": "media_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "search_runs_media_item_id_media_items_id_fk": { + "name": "search_runs_media_item_id_media_items_id_fk", + "tableFrom": "search_runs", + "tableTo": "media_items", + "columnsFrom": [ + "media_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "search_runs_grab_id_grabs_id_fk": { + "name": "search_runs_grab_id_grabs_id_fk", + "tableFrom": "search_runs", + "tableTo": "grabs", + "columnsFrom": [ + "grab_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.corrupt_file_status": { + "name": "corrupt_file_status", + "schema": "public", + "values": [ + "pending", + "approved", + "deleted", + "dismissed", + "failed" + ] + }, + "public.storage_tier": { + "name": "storage_tier", + "schema": "public", + "values": [ + "live", + "backup", + "archive" + ] + }, + "public.external_id_source": { + "name": "external_id_source", + "schema": "public", + "values": [ + "tvdb", + "tmdb", + "imdb", + "plex" + ] + }, + "public.media_type": { + "name": "media_type", + "schema": "public", + "values": [ + "tv_series", + "movie" + ] + }, + "public.season_status": { + "name": "season_status", + "schema": "public", + "values": [ + "upcoming", + "airing", + "complete" + ] + }, + "public.series_status": { + "name": "series_status", + "schema": "public", + "values": [ + "upcoming", + "continuing", + "ended" + ] + }, + "public.watch_history_source": { + "name": "watch_history_source", + "schema": "public", + "values": [ + "plex", + "manual" + ] + }, + "public.watching_now_scope": { + "name": "watching_now_scope", + "schema": "public", + "values": [ + "show", + "season" + ] + }, + "public.watchlist_source": { + "name": "watchlist_source", + "schema": "public", + "values": [ + "plex", + "manual" + ] + }, + "public.override_type": { + "name": "override_type", + "schema": "public", + "values": [ + "force_live", + "force_archive", + "force_quality", + "force_monitor", + "force_ignore", + "temporary_promotion", + "purge" + ] + }, + "public.policy_trigger": { + "name": "policy_trigger", + "schema": "public", + "values": [ + "scheduled", + "manual", + "demand_change", + "metadata_refresh" + ] + }, + "public.quality": { + "name": "quality", + "schema": "public", + "values": [ + "sd", + "720p", + "1080p", + "4k" + ] + }, + "public.maintenance_job_status": { + "name": "maintenance_job_status", + "schema": "public", + "values": [ + "running", + "succeeded", + "failed", + "skipped" + ] + }, + "public.maintenance_trigger": { + "name": "maintenance_trigger", + "schema": "public", + "values": [ + "scheduled", + "manual" + ] + }, + "public.grab_source": { + "name": "grab_source", + "schema": "public", + "values": [ + "auto", + "manual" + ] + }, + "public.grab_status": { + "name": "grab_status", + "schema": "public", + "values": [ + "queued", + "downloading", + "completed", + "importing", + "imported", + "failed", + "orphaned" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 9c25c85..b9bdd96 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -169,6 +169,20 @@ "when": 1786655149441, "tag": "0023_replacement_requests", "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 } ] -} \ No newline at end of file +} diff --git a/src/db/schema/demand.ts b/src/db/schema/demand.ts index 5de58ba..62947c9 100644 --- a/src/db/schema/demand.ts +++ b/src/db/schema/demand.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { pgTable, pgEnum, @@ -5,12 +6,15 @@ import { text, integer, timestamp, + index, + uniqueIndex, } from "drizzle-orm/pg-core"; import { users } from "./users"; import { mediaItems } from "./media"; export const watchlistSourceEnum = pgEnum("watchlist_source", ["plex", "manual"]); 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. // Default behavior: archive-tier 720p. @@ -37,6 +41,57 @@ export const watchingNowItems = pgTable("watching_now_items", { 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. export const classics = pgTable("classics", { id: uuid("id").primaryKey().defaultRandom(), diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index 4a9e174..ad7b075 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -38,8 +38,17 @@ export const plexAccounts = pgTable("plex_accounts", { plexUuid: text("plex_uuid"), plexUsername: text("plex_username").notNull(), 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 // 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"), sharedSectionIds: text("shared_section_ids").array(), linkedAt: timestamp("linked_at").notNull().defaultNow(), diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts index 78acca4..396df8c 100644 --- a/src/lib/catalog.ts +++ b/src/lib/catalog.ts @@ -153,6 +153,21 @@ function imageUrl(path?: string | null) { return path ? `${TMDB_IMAGE_BASE_URL}${path}` : null; } +/** + * A usable poster URL from whatever media_items.poster_path happens to hold. + * + * Two writers disagree about that column. The metadata refresh stores TMDB's + * bare path (`/abc.jpg`, 2,802 rows) and the catalog board stores the absolute + * URL it had already built (1 row). Both forms are in the table right now, so + * anything rendering a stored poster has to cope with both -- and anything + * handing one to ANOTHER SERVICE has to resolve it, because a bare TMDB path + * means nothing on accounts.sticknife.com. + */ +export function posterUrlFromStored(path?: string | null) { + if (!path) return null; + return /^https?:\/\//.test(path) ? path : imageUrl(path); +} + function backdropUrl(path?: string | null) { return path ? `${TMDB_BACKDROP_BASE_URL}${path}` : null; } diff --git a/src/lib/embed-origins.ts b/src/lib/embed-origins.ts new file mode 100644 index 0000000..a13796d --- /dev/null +++ b/src/lib/embed-origins.ts @@ -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!); +} diff --git a/src/lib/plex-link.ts b/src/lib/plex-link.ts new file mode 100644 index 0000000..96127bb --- /dev/null +++ b/src/lib/plex-link.ts @@ -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 { + 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 }; +} diff --git a/src/lib/plex.ts b/src/lib/plex.ts index 095d5f4..4c2b1f1 100644 --- a/src/lib/plex.ts +++ b/src/lib/plex.ts @@ -11,6 +11,8 @@ // Only the owner's token is ever used. Linking a user proves who they are and // then discards their token; see plexAccounts in the schema for why. +import { APP_URL } from "@/lib/app-url"; + const PLEX_TOKEN = process.env.PLEX_AUTH_TOKEN ?? ""; const MACHINE_ID = process.env.PLEX_MACHINE_IDENTIFIER ?? ""; @@ -91,13 +93,30 @@ export function isShareable(section: PlexSection) { export type PlexPin = { id: number; code: string }; -/** Start a sign-in. The user takes the code to plex.tv; we poll for the result. */ +/** + * Start a sign-in. The user takes the code to plex.tv; we poll for the result. + * + * THE ORIGIN HEADER IS LOAD-BEARING AND IS WHY THIS USED TO FAIL. + * + * Plex records an `origin` against the PIN, taken from the Origin header on + * this request, and the sign-in page at app.plex.tv reads it back through + * /api/v2/pins/info before it will honour `forwardUrl`. A PIN with a null + * origin gets the user signed in and then stranded on plex.tv instead of + * returned here. + * + * Everything else creates its PIN from the BROWSER, where the header is sent + * automatically and nobody has to know this. Ampelos creates it in a server + * action, where fetch sends no Origin at all -- so it has to be stated. + * Measured against the live API: without it origin is null, with it origin is + * "ampelos.sticknife.com". + */ export async function createPin(): Promise { const response = await fetch("https://plex.tv/api/v2/pins?strong=true", { method: "POST", headers: { "X-Plex-Client-Identifier": CLIENT_IDENTIFIER, "X-Plex-Product": "Ampelos", + Origin: new URL(APP_URL).origin, Accept: "application/json", }, signal: AbortSignal.timeout(20000), @@ -163,8 +182,43 @@ export async function claimPin(pinId: number): Promise { }; } +/** + * 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 { + const response = await fetch("https://plex.tv/api/v2/user", { + headers: ownerHeaders(), + signal: AbortSignal.timeout(20000), + }); + if (!response.ok) return null; + const account = await response.json(); + return account?.id != null ? String(account.id) : null; +} + // --- sharing --------------------------------------------------------------- +/** + * The human-readable half of a Plex error. + * + * Plex answers failures with XML whose only useful content is the `status` + * attribute. Surfacing the raw document instead put ` { + 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 { + 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`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`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`count(distinct ( + ${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber} + ))::int`, + last30: sql`count(*) filter ( + where ${watchHistory.watchedAt} >= (now() at time zone 'utc') - interval '30 days' + )::int`, + lastWatchedAt: sql`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 { + 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`, + }, + }; +} diff --git a/src/proxy.ts b/src/proxy.ts index 2f0fc04..912832c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,7 +4,19 @@ import { NextResponse } from "next/server"; // /api/agents is machine-to-machine and carries its own bearer token, so it // 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; export default auth((req) => {