Ampelos dashboard: the web face, and the owner of the schema

Split out of the single Ampelos repository. Next.js app, Drizzle schema and
migrations, brand art, planning notes.

What left: scripts/, which was the agent's job library misfiled under web/ and
imported nothing from src/; and deploy/truenas, whose broadcast posts to the
scan listener on :3427 -- an agent script -- so it belongs beside the thing it
talks to.

This repository keeps the schema. The agent speaks raw SQL against the same
tables and holds no copy of it, so a rename here can break it silently where it
used to be one commit. The README says so, and the agent carries a snapshot to
check against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ryan
2026-08-15 12:12:08 +02:00
commit acdc25c797
138 changed files with 70946 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/db/client";
import { classics, externalIds, mediaItems, users, watchingNowItems } from "@/db/schema";
import { ensureMediaItem, findMediaItemByTmdbId } from "@/lib/media-item";
import { and, eq, isNull, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
type MediaType = "tv_series" | "movie";
type WatchKind = "television" | "movies";
type WatchNowResult = { ok: true } | { ok: false; message: string };
function validDate(value: FormDataEntryValue | null) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
return value;
}
function optionalString(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function mediaTypeFromKind(kind: string | null): MediaType | null {
if (kind === "movies") {
return "movie";
}
if (kind === "television") {
return "tv_series";
}
return null;
}
function kindFromMediaType(mediaType: MediaType): WatchKind {
return mediaType === "movie" ? "movies" : "television";
}
function parseSlot(value: FormDataEntryValue | null) {
const slot = typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
return Number.isInteger(slot) && slot > 0 ? slot : null;
}
async function matchingClassic(mediaType: MediaType, tmdbId: string, title: string, year: number | null) {
const rows = await db
.select({
mediaItemId: mediaItems.id,
title: mediaItems.title,
year: mediaItems.year,
externalId: externalIds.externalId,
})
.from(classics)
.innerJoin(mediaItems, eq(mediaItems.id, classics.mediaItemId))
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), eq(externalIds.source, "tmdb")))
.where(eq(mediaItems.mediaType, mediaType));
const normalizedTitle = title.toLocaleLowerCase("en-US");
return rows.find((row) =>
row.externalId === tmdbId ||
(row.title.toLocaleLowerCase("en-US") === normalizedTitle && (row.year ?? null) === year),
);
}
async function mediaItemIsClassic(mediaItemId: string) {
const [row] = await db
.select({ id: classics.id })
.from(classics)
.where(eq(classics.mediaItemId, mediaItemId))
.limit(1);
return Boolean(row);
}
async function getQuota(userId: string, kind: WatchKind) {
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 activeSlots(userId: string, mediaType: MediaType) {
return db
.select({ id: watchingNowItems.id, slotNumber: watchingNowItems.slotNumber })
.from(watchingNowItems)
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
.where(
and(
eq(watchingNowItems.userId, userId),
isNull(watchingNowItems.removedAt),
eq(mediaItems.mediaType, mediaType),
),
);
}
async function firstOpenSlot(userId: string, mediaType: MediaType, quota: number) {
const occupied = new Set(
(await activeSlots(userId, mediaType))
.map((row) => row.slotNumber)
.filter((slot): slot is number => typeof slot === "number"),
);
for (let slot = 1; slot <= quota; slot += 1) {
if (!occupied.has(slot)) {
return slot;
}
}
return null;
}
async function clearSlot(userId: string, mediaType: MediaType, slotNumber: number, keepId?: string) {
const rows = await db
.select({ id: watchingNowItems.id })
.from(watchingNowItems)
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
.where(
and(
eq(watchingNowItems.userId, userId),
isNull(watchingNowItems.removedAt),
eq(mediaItems.mediaType, mediaType),
eq(watchingNowItems.slotNumber, slotNumber),
keepId ? ne(watchingNowItems.id, keepId) : undefined,
),
);
await Promise.all(
rows.map((row) =>
db.update(watchingNowItems).set({ removedAt: new Date() }).where(eq(watchingNowItems.id, row.id)),
),
);
}
export async function addToWatchNow(formData: FormData): Promise<WatchNowResult | undefined> {
const session = await auth();
if (!session) redirect("/login");
const kind = optionalString(formData.get("kind"));
const mediaType = mediaTypeFromKind(kind);
const tmdbId = optionalString(formData.get("tmdbId"));
const title = optionalString(formData.get("title"));
if (!mediaType || !tmdbId || !title) {
return { ok: false, message: "This title could not be added to Watch Now." };
}
const yearValue = optionalString(formData.get("year"));
const parsedYear = yearValue ? Number.parseInt(yearValue, 10) : Number.NaN;
const year = Number.isFinite(parsedYear) ? parsedYear : null;
const classic = await matchingClassic(mediaType, tmdbId, title, year);
if (classic) {
return { ok: false, message: title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot." };
}
const watchKind = kindFromMediaType(mediaType);
const quota = await getQuota(session.user.id, watchKind);
const requestedSlot = parseSlot(formData.get("slotNumber"));
const targetSlot = requestedSlot && requestedSlot <= quota
? requestedSlot
: await firstOpenSlot(session.user.id, mediaType, quota);
if (!targetSlot) {
revalidatePath("/");
return { ok: false, message: "No Watch Now slots are open." };
}
const overview = optionalString(formData.get("overview"));
const posterPath = optionalString(formData.get("posterPath"));
const releaseDate = validDate(formData.get("releaseDate"));
const existingId = await findMediaItemByTmdbId(mediaType, tmdbId);
if (existingId && await mediaItemIsClassic(existingId)) {
return { ok: false, message: title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot." };
}
const mediaItemId = existingId ?? await ensureMediaItem({
mediaType,
tmdbId,
title,
year: Number.isFinite(year) ? year : null,
overview,
posterPath,
releaseDate,
});
const [active] = await db
.select({ id: watchingNowItems.id })
.from(watchingNowItems)
.where(
and(
eq(watchingNowItems.userId, session.user.id),
eq(watchingNowItems.mediaItemId, mediaItemId),
isNull(watchingNowItems.removedAt),
),
)
.limit(1);
await clearSlot(session.user.id, mediaType, targetSlot, active?.id);
if (active) {
await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, active.id));
} else {
await db.insert(watchingNowItems).values({
userId: session.user.id,
mediaItemId,
scope: "show",
slotNumber: targetSlot,
});
}
revalidatePath("/");
return { ok: true };
}
/**
* Remove a title from Watch Now knowing only what the board knows.
*
* The catalog deals in TMDB ids -- most of what it shows has no row here at
* all -- so the detail panel cannot name the watching_now row it wants gone.
* Looking it up from the id is what lets one button both add and remove.
*/
export async function removeFromWatchNowByTmdbId(formData: FormData): Promise<WatchNowResult> {
const session = await auth();
if (!session) redirect("/login");
const mediaType = mediaTypeFromKind(optionalString(formData.get("kind")));
const tmdbId = optionalString(formData.get("tmdbId"));
if (!mediaType || !tmdbId) {
return { ok: false, message: "This title could not be identified." };
}
const mediaItemId = await findMediaItemByTmdbId(mediaType, tmdbId);
// Nothing here means nothing to remove, which is the state the caller wanted.
if (mediaItemId) {
await db
.update(watchingNowItems)
.set({ removedAt: new Date() })
.where(
and(
eq(watchingNowItems.userId, session.user.id),
eq(watchingNowItems.mediaItemId, mediaItemId),
isNull(watchingNowItems.removedAt),
),
);
}
revalidatePath("/");
return { ok: true };
}
export async function removeFromWatchNow(formData: FormData) {
const session = await auth();
if (!session) redirect("/login");
const itemId = optionalString(formData.get("watchNowItemId"));
if (!itemId) {
return;
}
await db
.update(watchingNowItems)
.set({ removedAt: new Date() })
.where(and(eq(watchingNowItems.id, itemId), eq(watchingNowItems.userId, session.user.id)));
revalidatePath("/");
}
export async function moveWatchNowItem(formData: FormData) {
const session = await auth();
if (!session) redirect("/login");
const itemId = optionalString(formData.get("watchNowItemId"));
const targetSlot = parseSlot(formData.get("slotNumber"));
if (!itemId || !targetSlot) {
return;
}
const [item] = await db
.select({ mediaType: mediaItems.mediaType })
.from(watchingNowItems)
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
.where(
and(
eq(watchingNowItems.id, itemId),
eq(watchingNowItems.userId, session.user.id),
isNull(watchingNowItems.removedAt),
),
)
.limit(1);
if (!item) {
return;
}
const mediaType = item.mediaType;
const quota = await getQuota(session.user.id, kindFromMediaType(mediaType));
if (targetSlot > quota) {
return;
}
await clearSlot(session.user.id, mediaType, targetSlot, itemId);
await db.update(watchingNowItems).set({ slotNumber: targetSlot }).where(eq(watchingNowItems.id, itemId));
revalidatePath("/");
}