1dfa2c0a04
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
9.8 KiB
TypeScript
288 lines
9.8 KiB
TypeScript
import { auth, signOut } from "@/auth";
|
|
import { db } from "@/db/client";
|
|
import { adminOverrides, externalIds, mediaItems, users, watchingNowItems } from "@/db/schema";
|
|
import { getCatalogItems, type CatalogKind } from "@/lib/catalog";
|
|
import { and, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { CatalogBoard } from "./catalog-board";
|
|
import { CatalogControls } from "./catalog-controls";
|
|
|
|
type PageProps = {
|
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
};
|
|
|
|
type WatchNowItem = {
|
|
id: string;
|
|
title: string;
|
|
mediaType: "tv_series" | "movie";
|
|
year: number | null;
|
|
posterPath: string | null;
|
|
slotNumber: number | null;
|
|
};
|
|
|
|
const tabs: Array<{ key: CatalogKind; label: string }> = [
|
|
{ key: "television", label: "Television" },
|
|
{ key: "movies", label: "Movies" },
|
|
{ key: "music", label: "Music" },
|
|
];
|
|
|
|
function singleParam(value: string | string[] | undefined) {
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
function activeKind(value: string | string[] | undefined): CatalogKind {
|
|
const tab = singleParam(value);
|
|
|
|
if (tab === "movies" || tab === "music") {
|
|
return tab;
|
|
}
|
|
|
|
return "television";
|
|
}
|
|
|
|
function mediaTypeForKind(kind: CatalogKind) {
|
|
return kind === "movies" ? "movie" : "tv_series";
|
|
}
|
|
|
|
function hrefFor(next: Record<string, string | null>) {
|
|
const params = new URLSearchParams();
|
|
|
|
for (const [key, value] of Object.entries(next)) {
|
|
if (value) {
|
|
params.set(key, value);
|
|
}
|
|
}
|
|
|
|
const query = params.toString();
|
|
return query ? "/?" + query : "/";
|
|
}
|
|
|
|
async function getCollectionIds(kind: CatalogKind, ids: string[]) {
|
|
if (kind === "music" || ids.length === 0) {
|
|
return new Set<string>();
|
|
}
|
|
|
|
const rows = await db
|
|
.select({ externalId: externalIds.externalId })
|
|
.from(externalIds)
|
|
.innerJoin(mediaItems, eq(mediaItems.id, externalIds.mediaItemId))
|
|
.where(
|
|
and(
|
|
eq(externalIds.source, "tmdb"),
|
|
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
|
inArray(externalIds.externalId, ids),
|
|
),
|
|
);
|
|
|
|
return new Set(rows.map((row) => row.externalId));
|
|
}
|
|
|
|
async function getSlotCount(userId: string, kind: CatalogKind) {
|
|
if (kind === "music") {
|
|
return 0;
|
|
}
|
|
|
|
const [row] = await db
|
|
.select({ tv: users.watchingNowTvSlots, movies: users.watchingNowMovieSlots })
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
return kind === "movies" ? row?.movies ?? 10 : row?.tv ?? 5;
|
|
}
|
|
|
|
async function getWatchNow(userId: string, kind: CatalogKind): Promise<WatchNowItem[]> {
|
|
if (kind === "music") {
|
|
return [];
|
|
}
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: watchingNowItems.id,
|
|
title: mediaItems.title,
|
|
mediaType: mediaItems.mediaType,
|
|
year: mediaItems.year,
|
|
posterPath: mediaItems.posterPath,
|
|
slotNumber: watchingNowItems.slotNumber,
|
|
})
|
|
.from(watchingNowItems)
|
|
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
|
.where(
|
|
and(
|
|
eq(watchingNowItems.userId, userId),
|
|
isNull(watchingNowItems.removedAt),
|
|
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
|
),
|
|
);
|
|
|
|
return rows;
|
|
}
|
|
|
|
// Purged titles, for the admin buttons. An expired override is not in force, so
|
|
// it must not show as purged -- the classifier applies the same test.
|
|
async function getPurgedExternalIds(kind: CatalogKind) {
|
|
if (kind === "music") {
|
|
return new Set<string>();
|
|
}
|
|
|
|
const rows = await db
|
|
.select({ externalId: externalIds.externalId })
|
|
.from(adminOverrides)
|
|
.innerJoin(mediaItems, eq(mediaItems.id, adminOverrides.mediaItemId))
|
|
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
|
.where(
|
|
and(
|
|
eq(adminOverrides.overrideType, "purge"),
|
|
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
|
eq(externalIds.source, "tmdb"),
|
|
or(isNull(adminOverrides.expiresAt), gt(adminOverrides.expiresAt, new Date())),
|
|
),
|
|
);
|
|
|
|
return new Set(rows.map((row) => row.externalId));
|
|
}
|
|
|
|
async function getWatchNowExternalIds(userId: string, kind: CatalogKind) {
|
|
if (kind === "music") {
|
|
return new Set<string>();
|
|
}
|
|
|
|
const rows = await db
|
|
.select({ externalId: externalIds.externalId })
|
|
.from(watchingNowItems)
|
|
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
|
.innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
|
|
.where(
|
|
and(
|
|
eq(watchingNowItems.userId, userId),
|
|
isNull(watchingNowItems.removedAt),
|
|
eq(mediaItems.mediaType, mediaTypeForKind(kind)),
|
|
eq(externalIds.source, "tmdb"),
|
|
),
|
|
);
|
|
|
|
return new Set(rows.map((row) => row.externalId));
|
|
}
|
|
|
|
export default async function Home({ searchParams }: PageProps) {
|
|
const session = await auth();
|
|
if (!session) redirect("/login");
|
|
|
|
const resolvedSearchParams = (await searchParams) ?? {};
|
|
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.
|
|
const isAdmin = Boolean(session.user.isAdmin);
|
|
|
|
const [catalogItems, watchNowItems, watchNowExternalIds, purgedExternalIds, slotCount] = await Promise.all([
|
|
getCatalogItems(kind, query),
|
|
getWatchNow(session.user.id, kind),
|
|
getWatchNowExternalIds(session.user.id, kind),
|
|
isAdmin ? getPurgedExternalIds(kind) : Promise.resolve(new Set<string>()),
|
|
getSlotCount(session.user.id, kind),
|
|
]);
|
|
|
|
const collectionIds = await getCollectionIds(kind, catalogItems.map((item) => item.id));
|
|
const visibleItems = collectionOnly
|
|
? catalogItems.filter((item) => collectionIds.has(item.id))
|
|
: catalogItems;
|
|
|
|
return (
|
|
<main className="ampelos-app pb-44 text-ampelos-parchment">
|
|
<header className="ampelos-topbar">
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-5 md:flex-row md:items-center md:justify-between md:px-6">
|
|
<div className="flex items-center gap-4">
|
|
<Image
|
|
src="/ampelos.png"
|
|
alt="Ampelos"
|
|
width={64}
|
|
height={64}
|
|
priority
|
|
className="h-14 w-14 shrink-0 rounded-full border border-ampelos-gold/35 bg-ampelos-ink/45 object-cover p-0.5 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
|
/>
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.32em] text-ampelos-gold">Ampelos</p>
|
|
<h1 className="font-serif text-3xl font-semibold tracking-normal text-ampelos-parchment">Tend the vine. Set Dionysus's table.</h1>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
<Link href="/profile" className="ampelos-ghost-button px-3 py-2 font-medium">
|
|
Profile
|
|
</Link>
|
|
{isAdmin && (
|
|
<Link href="/admin" className="ampelos-ghost-button px-3 py-2 font-medium">
|
|
Admin
|
|
</Link>
|
|
)}
|
|
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }}>
|
|
<button type="submit" className="ampelos-ghost-button px-3 py-2 font-medium">
|
|
Sign out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="mx-auto max-w-7xl px-4 py-6 md:px-6">
|
|
<nav className="flex gap-2 overflow-x-auto" aria-label="Media type">
|
|
{tabs.map((tab) => {
|
|
const active = tab.key === kind;
|
|
return (
|
|
<Link
|
|
key={tab.key}
|
|
href={hrefFor({ tab: tab.key, q: query || null, collection: collectionOnly ? "1" : null })}
|
|
className={
|
|
active
|
|
? "ampelos-stone-button px-4 py-2 text-sm"
|
|
: "ampelos-ghost-button px-4 py-2 text-sm font-medium"
|
|
}
|
|
>
|
|
{tab.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<CatalogControls key={kind} kind={kind} query={query} collectionOnly={collectionOnly} />
|
|
|
|
<div className="mt-6 flex items-end justify-between gap-4">
|
|
<div>
|
|
<h2 className="font-serif text-2xl font-semibold text-ampelos-parchment">
|
|
{query ? "Results for “" + query + "”" : kind === "movies" ? "Popular movies" : kind === "television" ? "Popular television" : "Music"}
|
|
</h2>
|
|
<p className="mt-1 text-sm text-ampelos-muted">
|
|
{collectionOnly
|
|
? "Showing titles Ampelos already recognizes as part of the local collection."
|
|
: "Popular picks are sourced from TMDB while search is open-ended."}
|
|
</p>
|
|
</div>
|
|
{query && (
|
|
<Link href={hrefFor({ tab: kind, q: null, collection: collectionOnly ? "1" : null })} className="text-sm font-medium text-ampelos-gold hover:text-ampelos-parchment">
|
|
Clear search
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<CatalogBoard
|
|
kind={kind}
|
|
visibleItems={visibleItems}
|
|
collectionIds={[...collectionIds]}
|
|
watchNowExternalIds={[...watchNowExternalIds]}
|
|
purgedExternalIds={[...purgedExternalIds]}
|
|
watchNowItems={watchNowItems}
|
|
slotCount={slotCount}
|
|
isAdmin={isAdmin}
|
|
openExternalId={openExternalId}
|
|
/>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|