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,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.",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user