mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -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>();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user