From bebc4dc9fa7164372eaf17c23708a5d0cbecb0b7 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 1 Mar 2026 11:10:04 -0500 Subject: [PATCH] Fix intermittent missing episodes for TV shows TV episodes were missing when: (1) a title existed as a recommendation "shell" with no episode data, (2) a prior refreshTvChildren() call failed partway through, or (3) the title detail page was loaded before episodes finished importing. importTitle() now re-fetches episodes for existing TV titles with zero seasons, the GET endpoint hydrates shell titles on access, and refreshTvChildren() handles per-season errors gracefully instead of aborting entirely. Co-Authored-By: Claude Opus 4.6 --- app/api/titles/[id]/route.ts | 26 ++++++++- lib/services/metadata.ts | 107 +++++++++++++++++++++++------------ 2 files changed, 97 insertions(+), 36 deletions(-) diff --git a/app/api/titles/[id]/route.ts b/app/api/titles/[id]/route.ts index b9178cd..a85343b 100644 --- a/app/api/titles/[id]/route.ts +++ b/app/api/titles/[id]/route.ts @@ -2,16 +2,40 @@ import { eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db/client"; import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema"; +import { refreshTvChildren } from "@/lib/services/metadata"; +import { getTvDetails } from "@/lib/tmdb/client"; export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - const title = db.select().from(titles).where(eq(titles.id, id)).get(); + let title = db.select().from(titles).where(eq(titles.id, id)).get(); if (!title) return NextResponse.json({ error: "Title not found" }, { status: 404 }); + // If this is a shell TV title (created by recommendations with no episode data), + // fetch the full details and episodes now. + if (title.type === "tv" && !title.lastFetchedAt) { + try { + const show = await getTvDetails(title.tmdbId); + db.update(titles) + .set({ + overview: show.overview, + posterPath: show.poster_path, + backdropPath: show.backdrop_path, + status: show.status, + lastFetchedAt: new Date(), + }) + .where(eq(titles.id, id)) + .run(); + await refreshTvChildren(id, title.tmdbId, show.number_of_seasons); + title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; + } catch { + // Continue with whatever data we have + } + } + let titleSeasons: Array<{ id: string; seasonNumber: number; diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 334b656..fa37291 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -21,7 +21,38 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") { .from(titles) .where(eq(titles.tmdbId, tmdbId)) .get(); - if (existing) return existing; + if (existing) { + // For TV shows, check if seasons/episodes were actually loaded. + // They may be missing if a prior fetch failed or the title was created + // as a shell by the recommendations system (lastFetchedAt: null). + if (existing.type === "tv") { + const seasonCount = db + .select() + .from(seasons) + .where(eq(seasons.titleId, existing.id)) + .all().length; + if (seasonCount === 0) { + const show = await getTvDetails(tmdbId); + if (!existing.lastFetchedAt) { + db.update(titles) + .set({ + overview: show.overview, + posterPath: show.poster_path, + backdropPath: show.backdrop_path, + status: show.status, + lastFetchedAt: new Date(), + }) + .where(eq(titles.id, existing.id)) + .run(); + } + await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); + refreshAvailability(existing.id).catch(() => {}); + refreshRecommendations(existing.id).catch(() => {}); + return db.select().from(titles).where(eq(titles.id, existing.id)).get(); + } + } + return existing; + } const now = new Date(); @@ -139,54 +170,60 @@ export async function refreshTvChildren( // Rate-limit: 250ms between TMDB calls if (sn > 1) await delay(250); - const seasonData = await getTvSeasonDetails(tmdbId, sn); + try { + const seasonData = await getTvSeasonDetails(tmdbId, sn); - const seasonRow = db - .insert(seasons) - .values({ - titleId, - seasonNumber: seasonData.season_number, - name: seasonData.name, - overview: seasonData.overview, - posterPath: seasonData.poster_path, - airDate: seasonData.air_date, - lastFetchedAt: now, - }) - .onConflictDoUpdate({ - target: [seasons.titleId, seasons.seasonNumber], - set: { + const seasonRow = db + .insert(seasons) + .values({ + titleId, + seasonNumber: seasonData.season_number, name: seasonData.name, overview: seasonData.overview, posterPath: seasonData.poster_path, airDate: seasonData.air_date, lastFetchedAt: now, - }, - }) - .returning() - .get(); - - for (const ep of seasonData.episodes) { - db.insert(episodes) - .values({ - seasonId: seasonRow.id, - episodeNumber: ep.episode_number, - name: ep.name, - overview: ep.overview, - stillPath: ep.still_path, - airDate: ep.air_date, - runtimeMinutes: ep.runtime, }) .onConflictDoUpdate({ - target: [episodes.seasonId, episodes.episodeNumber], + target: [seasons.titleId, seasons.seasonNumber], set: { + name: seasonData.name, + overview: seasonData.overview, + posterPath: seasonData.poster_path, + airDate: seasonData.air_date, + lastFetchedAt: now, + }, + }) + .returning() + .get(); + + for (const ep of seasonData.episodes) { + db.insert(episodes) + .values({ + seasonId: seasonRow.id, + episodeNumber: ep.episode_number, name: ep.name, overview: ep.overview, stillPath: ep.still_path, airDate: ep.air_date, runtimeMinutes: ep.runtime, - }, - }) - .run(); + }) + .onConflictDoUpdate({ + target: [episodes.seasonId, episodes.episodeNumber], + set: { + name: ep.name, + overview: ep.overview, + stillPath: ep.still_path, + airDate: ep.air_date, + runtimeMinutes: ep.runtime, + }, + }) + .run(); + } + } catch { + // Skip this season and continue with the rest — partial data is + // better than aborting entirely. The next refresh cycle will retry. + console.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}`); } } }