"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 }; /** * The board, the profile page and the embed all show Watch Now, so all three * are stale the moment any of them changes. Revalidating only "/" was correct * when the board was the only place slots were visible; it no longer is. */ function revalidateWatchNow() { revalidatePath("/"); revalidatePath("/profile"); revalidatePath("/embed/profile"); } 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 { 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) { revalidateWatchNow(); 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, }); } revalidateWatchNow(); 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 { 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), ), ); } revalidateWatchNow(); 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))); revalidateWatchNow(); } 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)); revalidateWatchNow(); }