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:
@@ -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("/");
|
||||
}
|
||||
Reference in New Issue
Block a user