"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; 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 { 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 { 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.", }; }