Files
sofa/app/(pages)/titles/[id]/_components/use-title-actions.ts
T
jake f3a425a5a9 Harden API input validation, error handling, and optimistic rollbacks
Add try/catch around `request.json()` in all POST routes to return a
400 instead of crashing on malformed bodies. Validate `type` as a
strict `"movie" | "tv"` enum in search, discover, import, and resolve
routes. Validate `tmdbId` as a positive integer. Add a
`SORT_BY_PATTERN` regex and page-range check (1–500) to the discover
route. Wrap all outbound TMDB calls in try/catch and return 502 on
failure so clients get a structured error rather than an unhandled
rejection.

Fix optimistic-update rollbacks in `use-title-actions`: capture
`prevStatus` and `prevWatches` before each mutation and restore both
atoms in the catch block for catchUp, handleMarkSeason,
handleUnmarkSeason, and single-episode toggle.

Fix a bug in `getContinueWatchingFeed` where the watchDateMap could
hold a stale date for episodes watched more than once; the map now
keeps the most-recent `watchedAt` per episode.
2026-03-05 13:16:08 -05:00

290 lines
8.9 KiB
TypeScript

"use client";
import { useStore } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner";
import {
batchWatchEpisodes,
markAllWatchedAction,
unwatchEpisodeAction,
unwatchSeasonAction,
updateTitleRating,
updateTitleStatus,
watchEpisode,
watchMovie,
watchSeason,
} from "@/lib/actions/titles";
import {
episodeWatchesAtom,
seasonsAtom,
titleIdAtom,
titleNameAtom,
userRatingAtom,
userStatusAtom,
watchingEpAtom,
} from "@/lib/atoms/title";
import type { Season } from "@/lib/types/title";
export function useTitleActions() {
const store = useStore();
const catchUp = useCallback(
async (episodeIds: string[]) => {
const currentWatches = store.get(episodeWatchesAtom);
const prevStatus = store.get(userStatusAtom);
const newWatchSet = new Set(currentWatches);
for (const id of episodeIds) newWatchSet.add(id);
store.set(episodeWatchesAtom, [...newWatchSet]);
const seasons = store.get(seasonsAtom);
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
if (allEpIds.every((id) => newWatchSet.has(id))) {
store.set(userStatusAtom, "completed");
}
try {
await batchWatchEpisodes(episodeIds);
toast.success(
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`,
);
} catch {
store.set(episodeWatchesAtom, currentWatches);
store.set(userStatusAtom, prevStatus);
toast.error("Failed to catch up");
}
},
[store],
);
const handleStatusChange = useCallback(
async (status: string | null) => {
const prev = store.get(userStatusAtom);
const titleId = store.get(titleIdAtom);
store.set(
userStatusAtom,
status === "watchlist" ? "in_progress" : status,
);
try {
await updateTitleStatus(titleId, status ? "in_progress" : null);
toast.success(status ? "Added to watchlist" : "Removed from library");
} catch {
store.set(userStatusAtom, prev);
toast.error("Failed to update status");
}
},
[store],
);
const handleRating = useCallback(
async (ratingStars: number) => {
const prev = store.get(userRatingAtom);
const titleId = store.get(titleIdAtom);
store.set(userRatingAtom, ratingStars);
try {
await updateTitleRating(titleId, ratingStars);
toast.success(
ratingStars > 0
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
: "Rating removed",
);
} catch {
store.set(userRatingAtom, prev);
toast.error("Failed to update rating");
}
},
[store],
);
const handleWatchMovie = useCallback(async () => {
const prev = store.get(userStatusAtom);
const titleId = store.get(titleIdAtom);
const titleName = store.get(titleNameAtom);
store.set(userStatusAtom, "completed");
try {
await watchMovie(titleId);
toast.success(`Marked "${titleName}" as watched`);
} catch {
store.set(userStatusAtom, prev);
toast.error("Failed to mark as watched");
}
}, [store]);
const handleWatchEpisode = useCallback(
async (
episodeId: string,
seasonNum: number,
epNum: number,
isWatched: boolean,
) => {
store.set(watchingEpAtom, episodeId);
if (isWatched) {
const prevStatus = store.get(userStatusAtom);
store.set(
episodeWatchesAtom,
store.get(episodeWatchesAtom).filter((id) => id !== episodeId),
);
if (prevStatus === "completed")
store.set(userStatusAtom, "in_progress");
try {
await unwatchEpisodeAction(episodeId);
toast.success(`Unwatched S${seasonNum} E${epNum}`);
} catch {
const w = store.get(episodeWatchesAtom);
if (!w.includes(episodeId))
store.set(episodeWatchesAtom, [...w, episodeId]);
store.set(userStatusAtom, prevStatus);
toast.error("Failed to unmark episode");
}
} else {
const currentWatches = store.get(episodeWatchesAtom);
const prevStatus = store.get(userStatusAtom);
if (!currentWatches.includes(episodeId)) {
store.set(episodeWatchesAtom, [...currentWatches, episodeId]);
}
if (prevStatus === null || prevStatus === "watchlist") {
store.set(userStatusAtom, "in_progress");
}
try {
await watchEpisode(episodeId);
const seasons = store.get(seasonsAtom);
const episodeWatches = store.get(episodeWatchesAtom);
const previousUnwatched: string[] = [];
for (const s of seasons) {
for (const ep of s.episodes) {
if (
s.seasonNumber < seasonNum ||
(s.seasonNumber === seasonNum && ep.episodeNumber < epNum)
) {
if (!episodeWatches.includes(ep.id) && ep.id !== episodeId) {
previousUnwatched.push(ep.id);
}
}
}
}
if (previousUnwatched.length > 0) {
const count = previousUnwatched.length;
toast.success(`Watched S${seasonNum} E${epNum}`, {
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
action: {
label: "Catch up",
onClick: () => catchUp(previousUnwatched),
},
duration: 8000,
});
} else {
toast.success(`Watched S${seasonNum} E${epNum}`);
}
} catch {
store.set(
episodeWatchesAtom,
store.get(episodeWatchesAtom).filter((id) => id !== episodeId),
);
store.set(userStatusAtom, prevStatus);
toast.error("Failed to mark episode");
}
}
store.set(watchingEpAtom, null);
},
[store, catchUp],
);
const handleMarkSeason = useCallback(
async (season: Season) => {
const prevWatches = store.get(episodeWatchesAtom);
const prevStatus = store.get(userStatusAtom);
const episodeWatches = store.get(episodeWatchesAtom);
const unwatched = season.episodes.filter(
(ep) => !episodeWatches.includes(ep.id),
);
if (unwatched.length === 0) return;
const newWatchSet = new Set(episodeWatches);
for (const ep of unwatched) newWatchSet.add(ep.id);
store.set(episodeWatchesAtom, [...newWatchSet]);
const seasons = store.get(seasonsAtom);
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
if (allEpIds.every((id) => newWatchSet.has(id))) {
store.set(userStatusAtom, "completed");
} else {
const status = store.get(userStatusAtom);
if (status === null || status === "watchlist") {
store.set(userStatusAtom, "in_progress");
}
}
try {
await watchSeason(season.id);
toast.success(
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
} catch {
store.set(episodeWatchesAtom, prevWatches);
store.set(userStatusAtom, prevStatus);
toast.error("Failed to mark some episodes");
}
},
[store],
);
const handleUnmarkSeason = useCallback(
async (season: Season) => {
const prevWatches = store.get(episodeWatchesAtom);
const prevStatus = store.get(userStatusAtom);
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
store.set(
episodeWatchesAtom,
store.get(episodeWatchesAtom).filter((id) => !seasonEpIds.has(id)),
);
const status = store.get(userStatusAtom);
if (status === "completed") store.set(userStatusAtom, "in_progress");
try {
await unwatchSeasonAction(season.id);
toast.success(
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
} catch {
store.set(episodeWatchesAtom, prevWatches);
store.set(userStatusAtom, prevStatus);
toast.error("Failed to unmark some episodes");
}
},
[store],
);
const handleMarkAllWatched = useCallback(async () => {
const titleId = store.get(titleIdAtom);
const prevStatus = store.get(userStatusAtom);
const prevWatches = store.get(episodeWatchesAtom);
const seasons = store.get(seasonsAtom);
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
store.set(episodeWatchesAtom, allEpIds);
store.set(userStatusAtom, "completed");
try {
await markAllWatchedAction(titleId);
toast.success("Marked all episodes as watched");
} catch {
store.set(userStatusAtom, prevStatus);
store.set(episodeWatchesAtom, prevWatches);
toast.error("Failed to mark all episodes as watched");
}
}, [store]);
return {
handleStatusChange,
handleRating,
handleWatchMovie,
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
handleMarkAllWatched,
};
}