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>; }; 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) { 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(); } 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 { 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(); } 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(); } 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()), 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 (
Ampelos

Ampelos

Tend the vine. Set Dionysus's table.

Profile {isAdmin && ( Admin )}
{ "use server"; await signOut({ redirectTo: "/login" }); }}>

{query ? "Results for “" + query + "”" : kind === "movies" ? "Popular movies" : kind === "television" ? "Popular television" : "Music"}

{collectionOnly ? "Showing titles Ampelos already recognizes as part of the local collection." : "Popular picks are sourced from TMDB while search is open-ended."}

{query && ( Clear search )}
); }