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.
This commit is contained in:
2026-03-05 13:16:08 -05:00
parent ff3c8a737b
commit f3a425a5a9
10 changed files with 240 additions and 76 deletions
+7 -3
View File
@@ -273,9 +273,13 @@ export function getContinueWatchingFeed(
// Build lookup maps
const watchedEpisodeIds = new Set(allWatches.map((w) => w.episodeId));
const watchDateMap = new Map(
allWatches.map((w) => [w.episodeId, w.watchedAt]),
);
const watchDateMap = new Map<string, Date>();
for (const watch of allWatches) {
const existing = watchDateMap.get(watch.episodeId);
if (!existing || watch.watchedAt > existing) {
watchDateMap.set(watch.episodeId, watch.watchedAt);
}
}
// Group seasons by title
const seasonsByTitle = new Map<string, typeof allSeasons>();
+17 -1
View File
@@ -559,13 +559,29 @@ export async function getTitleWithChildren(id: string): Promise<{
let titleSeasons: Season[] = [];
if (title.type === "tv") {
const seasonRows = db
let seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
.orderBy(seasons.seasonNumber)
.all();
// Retry hydration when a TV title exists but no seasons were stored.
if (seasonRows.length === 0) {
try {
const show = await getTvDetails(title.tmdbId);
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
.orderBy(seasons.seasonNumber)
.all();
} catch (err) {
log.debug(`Failed to backfill missing seasons for title ${id}:`, err);
}
}
// Batch fetch all episodes for all seasons (1 query)
const seasonIds = seasonRows.map((s) => s.id);
const allEps =
+23 -15
View File
@@ -1,4 +1,4 @@
import { and, count, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
episodes,
@@ -183,7 +183,9 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
const epIds = allEps.map((ep) => ep.id);
const [watchCount] = db
.select({ count: count(userEpisodeWatches.id) })
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(
and(
@@ -343,9 +345,11 @@ export function getEpisodeProgressByTmdbIds(
const rows = db
.select({
tmdbId: titles.tmdbId,
totalEpisodes: count(episodes.id),
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
"totalEpisodes",
),
watchedEpisodes:
sql<number>`sum(case when ${userEpisodeWatches.id} is not null then 1 else 0 end)`.as(
sql<number>`count(distinct case when ${userEpisodeWatches.id} is not null then ${episodes.id} end)`.as(
"watchedEpisodes",
),
})
@@ -417,17 +421,21 @@ export function getUserTitleInfo(userId: string, titleId: string) {
// Batch fetch all watches for these episodes
const watchedEpisodeIds =
epIds.length > 0
? db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId)
? Array.from(
new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId),
),
)
: [];
return {
+23 -14
View File
@@ -29,6 +29,17 @@ export interface WebhookEvent {
showTitle?: string;
}
function toOptionalInt(value: unknown): number | undefined {
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value, 10);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
// ─── Payload Parsers ────────────────────────────────────────────────
export function parsePlexPayload(formData: FormData): WebhookEvent | null {
@@ -63,7 +74,7 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
if (Array.isArray(guids)) {
for (const g of guids) {
const id = g.id ?? "";
if (id.startsWith("tmdb://")) tmdbId = Number.parseInt(id.slice(7), 10);
if (id.startsWith("tmdb://")) tmdbId = toOptionalInt(id.slice(7));
else if (id.startsWith("imdb://")) imdbId = id.slice(7);
else if (id.startsWith("tvdb://")) tvdbId = id.slice(7);
}
@@ -73,11 +84,11 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
provider: "plex",
mediaType: isMovie ? "movie" : "episode",
title: (metadata.title ?? metadata.Title ?? "") as string,
tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined,
tmdbId,
imdbId,
tvdbId,
seasonNumber: metadata.parentIndex as number | undefined,
episodeNumber: metadata.index as number | undefined,
seasonNumber: toOptionalInt(metadata.parentIndex),
episodeNumber: toOptionalInt(metadata.index),
showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as
| string
| undefined,
@@ -96,18 +107,17 @@ export function parseJellyfinPayload(
const isEpisode = itemType === "Episode";
if (!isMovie && !isEpisode) return null;
const tmdbRaw = body.Provider_tmdb as string | undefined;
const tmdbId = tmdbRaw ? Number.parseInt(tmdbRaw, 10) : undefined;
const tmdbId = toOptionalInt(body.Provider_tmdb);
return {
provider: "jellyfin",
mediaType: isMovie ? "movie" : "episode",
title: (body.Name ?? "") as string,
tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined,
tmdbId,
imdbId: (body.Provider_imdb as string) || undefined,
tvdbId: (body.Provider_tvdb as string) || undefined,
seasonNumber: body.SeasonNumber as number | undefined,
episodeNumber: body.EpisodeNumber as number | undefined,
seasonNumber: toOptionalInt(body.SeasonNumber),
episodeNumber: toOptionalInt(body.EpisodeNumber),
showTitle: (body.SeriesName ?? body.ShowName) as string | undefined,
};
}
@@ -129,18 +139,17 @@ export function parseEmbyPayload(
if (!isMovie && !isEpisode) return null;
const providerIds = (item.ProviderIds ?? {}) as Record<string, string>;
const tmdbRaw = providerIds.Tmdb;
const tmdbId = tmdbRaw ? Number.parseInt(tmdbRaw, 10) : undefined;
const tmdbId = toOptionalInt(providerIds.Tmdb);
return {
provider: "emby",
mediaType: isMovie ? "movie" : "episode",
title: (item.Name ?? "") as string,
tmdbId: tmdbId && !Number.isNaN(tmdbId) ? tmdbId : undefined,
tmdbId,
imdbId: providerIds.Imdb || undefined,
tvdbId: providerIds.Tvdb || undefined,
seasonNumber: item.ParentIndexNumber as number | undefined,
episodeNumber: item.IndexNumber as number | undefined,
seasonNumber: toOptionalInt(item.ParentIndexNumber),
episodeNumber: toOptionalInt(item.IndexNumber),
showTitle: (item.SeriesName ?? item.ShowName) as string | undefined,
};
}