1dfa2c0a04
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
9.8 KiB
TypeScript
328 lines
9.8 KiB
TypeScript
"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<WatchNowResult | undefined> {
|
|
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<WatchNowResult> {
|
|
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();
|
|
}
|