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
+151
View File
@@ -0,0 +1,151 @@
"use server";
// The buttons on a title's detail panel.
//
// The two admin verbs. Both are statements about the LIBRARY, which is what
// separates them from Watch Now -- that one is a statement about the person
// making it, lives in watch-now-actions, and is the button anybody sees.
//
// timeless "This one is kept, at whatever it costs" -- and it is the only
// flag that admits a remux.
// purge "Stop wanting this." Deliberately loses to Watching Now, so
// someone can still pull a purged title back by watching it, and
// it returns to purged afterwards.
//
// Nothing here deletes a file. Purge withdraws intent, which is what makes it
// safe to put behind a button: the worst it can do is stop a download.
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { db } from "@/db/client";
import { adminOverrides, classics } from "@/db/schema";
import { ensureMediaItem, findMediaItemByTmdbId, type CatalogRef, type MediaType } from "@/lib/media-item";
export type TitleFlags = {
timeless: boolean;
purged: boolean;
};
export type TitleActionResult =
| { ok: true; flags: Partial<TitleFlags>; message?: string }
| { ok: false; message: string };
function mediaTypeFromKind(kind: string | null): MediaType | null {
if (kind === "movies") return "movie";
if (kind === "television") return "tv_series";
return null;
}
function text(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function isOn(value: FormDataEntryValue | null) {
return value === "1" || value === "true";
}
/** The catalog entry a form is talking about, or null if it is malformed. */
function catalogRef(formData: FormData): CatalogRef | null {
const mediaType = mediaTypeFromKind(text(formData.get("kind")));
const tmdbId = text(formData.get("tmdbId"));
const title = text(formData.get("title"));
if (!mediaType || !tmdbId || !title) return null;
const parsedYear = Number.parseInt(text(formData.get("year")) ?? "", 10);
const releaseDate = text(formData.get("releaseDate"));
return {
mediaType,
tmdbId,
title,
year: Number.isFinite(parsedYear) ? parsedYear : null,
overview: text(formData.get("overview")),
posterPath: text(formData.get("posterPath")),
releaseDate: releaseDate && /^\d{4}-\d{2}-\d{2}$/.test(releaseDate) ? releaseDate : null,
};
}
async function requireUser() {
const session = await auth();
if (!session?.user?.id) return null;
return session.user;
}
/** Mark a title as one to keep forever, or stop. Admins only. */
export async function setTimeless(formData: FormData): Promise<TitleActionResult> {
const user = await requireUser();
if (!user) return { ok: false, message: "Sign in first." };
if (!user.isAdmin) return { ok: false, message: "Only admins can change this." };
const ref = catalogRef(formData);
if (!ref) return { ok: false, message: "This title could not be identified." };
const on = isOn(formData.get("on"));
if (!on) {
const mediaItemId = await findMediaItemByTmdbId(ref.mediaType, ref.tmdbId);
if (mediaItemId) {
await db.delete(classics).where(eq(classics.mediaItemId, mediaItemId));
}
revalidatePath("/");
return { ok: true, flags: { timeless: false } };
}
const mediaItemId = await ensureMediaItem(ref);
await db
.insert(classics)
.values({ mediaItemId, addedBy: user.id })
.onConflictDoNothing({ target: classics.mediaItemId });
revalidatePath("/");
return {
ok: true,
flags: { timeless: true },
message: ref.title + " is now timeless: kept live, and the only tier allowed a remux.",
};
}
/** Stop wanting a title, or let it be wanted again. Admins only. */
export async function setPurged(formData: FormData): Promise<TitleActionResult> {
const user = await requireUser();
if (!user) return { ok: false, message: "Sign in first." };
if (!user.isAdmin) return { ok: false, message: "Only admins can change this." };
const ref = catalogRef(formData);
if (!ref) return { ok: false, message: "This title could not be identified." };
const on = isOn(formData.get("on"));
// Purge withdraws intent, so there has to be intent to withdraw. Creating a
// media item in order to mark it unwanted would leave a row that exists for
// no reason but to say nobody wants it.
const mediaItemId = await findMediaItemByTmdbId(ref.mediaType, ref.tmdbId);
if (!mediaItemId) {
return { ok: false, message: ref.title + " is not in the collection, so there is nothing to purge." };
}
// Delete first either way: the table allows several overrides per item, and a
// second purge row would be a duplicate nobody could tell apart from the
// first when it came time to lift it.
await db
.delete(adminOverrides)
.where(and(eq(adminOverrides.mediaItemId, mediaItemId), eq(adminOverrides.overrideType, "purge")));
if (on) {
await db.insert(adminOverrides).values({
mediaItemId,
overrideType: "purge",
reason: "purged from the title panel",
createdBy: user.id,
});
}
revalidatePath("/");
return {
ok: true,
flags: { purged: on },
message: on
? ref.title + " is purged: no longer wanted, though watching it still brings it back."
: ref.title + " is no longer purged.",
};
}