acdc25c797
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>
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
"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.",
|
|
};
|
|
}
|