diff --git a/app/(pages)/titles/[id]/_components/use-title-actions.ts b/app/(pages)/titles/[id]/_components/use-title-actions.ts index 35b9883..e6a3d06 100644 --- a/app/(pages)/titles/[id]/_components/use-title-actions.ts +++ b/app/(pages)/titles/[id]/_components/use-title-actions.ts @@ -31,6 +31,7 @@ export function useTitleActions() { 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]); @@ -47,6 +48,8 @@ export function useTitleActions() { `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"); } }, @@ -116,12 +119,13 @@ export function useTitleActions() { store.set(watchingEpAtom, episodeId); if (isWatched) { + const prevStatus = store.get(userStatusAtom); store.set( episodeWatchesAtom, store.get(episodeWatchesAtom).filter((id) => id !== episodeId), ); - const status = store.get(userStatusAtom); - if (status === "completed") store.set(userStatusAtom, "in_progress"); + if (prevStatus === "completed") + store.set(userStatusAtom, "in_progress"); try { await unwatchEpisodeAction(episodeId); @@ -130,15 +134,16 @@ export function useTitleActions() { 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]); } - const status = store.get(userStatusAtom); - if (status === null || status === "watchlist") { + if (prevStatus === null || prevStatus === "watchlist") { store.set(userStatusAtom, "in_progress"); } @@ -179,6 +184,7 @@ export function useTitleActions() { episodeWatchesAtom, store.get(episodeWatchesAtom).filter((id) => id !== episodeId), ); + store.set(userStatusAtom, prevStatus); toast.error("Failed to mark episode"); } } @@ -190,6 +196,8 @@ export function useTitleActions() { 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), @@ -217,6 +225,8 @@ export function useTitleActions() { `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`, ); } catch { + store.set(episodeWatchesAtom, prevWatches); + store.set(userStatusAtom, prevStatus); toast.error("Failed to mark some episodes"); } }, @@ -225,6 +235,8 @@ export function useTitleActions() { 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, @@ -239,6 +251,8 @@ export function useTitleActions() { `Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`, ); } catch { + store.set(episodeWatchesAtom, prevWatches); + store.set(userStatusAtom, prevStatus); toast.error("Failed to unmark some episodes"); } }, diff --git a/app/api/admin/jobs/trigger/route.ts b/app/api/admin/jobs/trigger/route.ts index 69eceff..267b630 100644 --- a/app/api/admin/jobs/trigger/route.ts +++ b/app/api/admin/jobs/trigger/route.ts @@ -12,8 +12,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const body = await request.json(); - const jobName = body?.jobName; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const jobName = (body as { jobName?: unknown })?.jobName; if (!jobName || typeof jobName !== "string") { return NextResponse.json( diff --git a/app/api/explore/discover/route.ts b/app/api/explore/discover/route.ts index da0cdd2..f80fff3 100644 --- a/app/api/explore/discover/route.ts +++ b/app/api/explore/discover/route.ts @@ -5,6 +5,9 @@ import { isTmdbConfigured } from "@/lib/config"; import { discover } from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; +const SORT_BY_PATTERN = /^[a-z_]+\.(asc|desc)$/; +const MAX_PAGE = 500; + export async function GET(req: NextRequest) { const session = await auth.api.getSession({ headers: await headers(), @@ -24,10 +27,36 @@ export async function GET(req: NextRequest) { } const { searchParams } = req.nextUrl; - const type = searchParams.get("type") === "tv" ? "tv" : "movie"; + const rawType = searchParams.get("type"); + if (rawType && rawType !== "movie" && rawType !== "tv") { + return NextResponse.json( + { error: "type must be movie or tv" }, + { status: 400 }, + ); + } + const type = rawType === "tv" ? "tv" : "movie"; const genre = searchParams.get("genre"); const sortBy = searchParams.get("sort_by") || "popularity.desc"; - const page = searchParams.get("page") || "1"; + const pageRaw = searchParams.get("page") || "1"; + const page = Number.parseInt(pageRaw, 10); + + if (!Number.isInteger(page) || page < 1 || page > MAX_PAGE) { + return NextResponse.json( + { error: `page must be an integer between 1 and ${MAX_PAGE}` }, + { status: 400 }, + ); + } + + if (!SORT_BY_PATTERN.test(sortBy)) { + return NextResponse.json( + { error: "Invalid sort_by value" }, + { status: 400 }, + ); + } + + if (genre && !/^\d+(,\d+)*$/.test(genre)) { + return NextResponse.json({ error: "Invalid genre value" }, { status: 400 }); + } const params: Record = { sort_by: sortBy, @@ -37,7 +66,15 @@ export async function GET(req: NextRequest) { params.with_genres = genre; } - const results = await discover(type, params, Number(page)); + let results: Awaited>; + try { + results = await discover(type, params, page); + } catch { + return NextResponse.json( + { error: "Failed to fetch discover results" }, + { status: 502 }, + ); + } const filtered = results.results.filter((r) => r.poster_path); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index ef5d2d2..fef6b42 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -24,8 +24,17 @@ export async function GET(req: NextRequest) { ); } - const query = req.nextUrl.searchParams.get("query"); - const type = req.nextUrl.searchParams.get("type"); + const query = req.nextUrl.searchParams.get("query")?.trim(); + const rawType = req.nextUrl.searchParams.get("type"); + const type: "movie" | "tv" | null = + rawType === "movie" || rawType === "tv" ? rawType : null; + + if (rawType && !type) { + return NextResponse.json( + { error: "type must be movie or tv" }, + { status: 400 }, + ); + } if (!query) return NextResponse.json( @@ -34,29 +43,47 @@ export async function GET(req: NextRequest) { ); let results: TmdbSearchResponse; - if (type === "movie") { - results = await searchMovies(query); - } else if (type === "tv") { - results = await searchTv(query); - } else { - results = await searchMulti(query); + try { + if (type === "movie") { + results = await searchMovies(query); + } else if (type === "tv") { + results = await searchTv(query); + } else { + results = await searchMulti(query); + } + } catch { + return NextResponse.json( + { error: "Failed to fetch search results" }, + { status: 502 }, + ); } - // Filter out person results from multi search - const filtered = results.results.filter( - (r) => r.media_type !== "person" || type, - ); + // Filter out person results for multi search + const filtered = + type === "movie" || type === "tv" + ? results.results + : results.results.filter((r) => r.media_type !== "person"); return NextResponse.json({ - results: filtered.map((r) => ({ - tmdbId: r.id, - type: r.media_type ?? type, - title: r.title ?? r.name, - overview: r.overview, - releaseDate: r.release_date ?? r.first_air_date, - posterPath: tmdbImageUrl(r.poster_path, "w500"), - popularity: r.popularity, - voteAverage: r.vote_average, - })), + results: filtered + .map((r) => { + const mediaType = + r.media_type === "movie" || r.media_type === "tv" + ? r.media_type + : type; + if (!mediaType) return null; + + return { + tmdbId: r.id, + type: mediaType, + title: r.title ?? r.name, + overview: r.overview, + releaseDate: r.release_date ?? r.first_air_date, + posterPath: tmdbImageUrl(r.poster_path, "w500"), + popularity: r.popularity, + voteAverage: r.vote_average, + }; + }) + .filter((r): r is NonNullable => r !== null), }); } diff --git a/app/api/titles/import/route.ts b/app/api/titles/import/route.ts index eb8b1fc..b04e197 100644 --- a/app/api/titles/import/route.ts +++ b/app/api/titles/import/route.ts @@ -12,16 +12,38 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = await req.json(); - const { tmdbId, type } = body; + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } - if (!tmdbId || !type || !["movie", "tv"].includes(type)) { + const parsed = body as { tmdbId?: unknown; type?: unknown }; + const type = parsed.type; + const tmdbId = + typeof parsed.tmdbId === "number" + ? parsed.tmdbId + : Number.parseInt(String(parsed.tmdbId), 10); + + if ( + !Number.isInteger(tmdbId) || + tmdbId < 1 || + (type !== "movie" && type !== "tv") + ) { return NextResponse.json( - { error: "tmdbId and type (movie|tv) are required" }, + { error: "tmdbId (positive integer) and type (movie|tv) are required" }, { status: 400 }, ); } - const title = await importTitle(tmdbId, type); - return NextResponse.json(title); + try { + const title = await importTitle(tmdbId, type); + return NextResponse.json(title); + } catch { + return NextResponse.json( + { error: "Failed to import title" }, + { status: 502 }, + ); + } } diff --git a/app/api/titles/resolve/route.ts b/app/api/titles/resolve/route.ts index 2c7c567..e74a193 100644 --- a/app/api/titles/resolve/route.ts +++ b/app/api/titles/resolve/route.ts @@ -12,16 +12,38 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = await req.json(); - const { tmdbId, type } = body; + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } - if (!tmdbId || !type || !["movie", "tv"].includes(type)) { + const parsed = body as { tmdbId?: unknown; type?: unknown }; + const type = parsed.type; + const tmdbId = + typeof parsed.tmdbId === "number" + ? parsed.tmdbId + : Number.parseInt(String(parsed.tmdbId), 10); + + if ( + !Number.isInteger(tmdbId) || + tmdbId < 1 || + (type !== "movie" && type !== "tv") + ) { return NextResponse.json( - { error: "tmdbId and type (movie|tv) are required" }, + { error: "tmdbId (positive integer) and type (movie|tv) are required" }, { status: 400 }, ); } - const title = await importTitle(tmdbId, type, { awaitEnrichment: true }); - return NextResponse.json({ id: title?.id }); + try { + const title = await importTitle(tmdbId, type, { awaitEnrichment: true }); + return NextResponse.json({ id: title?.id }); + } catch { + return NextResponse.json( + { error: "Failed to resolve title" }, + { status: 502 }, + ); + } } diff --git a/lib/services/discovery.ts b/lib/services/discovery.ts index 4d5e6dd..2a9988e 100644 --- a/lib/services/discovery.ts +++ b/lib/services/discovery.ts @@ -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(); + 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(); diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 0ad9d1d..2d27923 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -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 = diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts index 5764158..88fd3af 100644 --- a/lib/services/tracking.ts +++ b/lib/services/tracking.ts @@ -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`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`count(distinct ${episodes.id})`.as( + "totalEpisodes", + ), watchedEpisodes: - sql`sum(case when ${userEpisodeWatches.id} is not null then 1 else 0 end)`.as( + sql`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 { diff --git a/lib/services/webhooks.ts b/lib/services/webhooks.ts index 3298346..5d722d1 100644 --- a/lib/services/webhooks.ts +++ b/lib/services/webhooks.ts @@ -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; - 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, }; }