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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 11:10:04 -05:00
co-authored by Claude Opus 4.6
parent dcb48eaf37
commit bebc4dc9fa
2 changed files with 97 additions and 36 deletions
+25 -1
View File
@@ -2,16 +2,40 @@ import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema"; 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( export async function GET(
_req: NextRequest, _req: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { id } = await params; 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) if (!title)
return NextResponse.json({ error: "Title not found" }, { status: 404 }); 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<{ let titleSeasons: Array<{
id: string; id: string;
seasonNumber: number; seasonNumber: number;
+72 -35
View File
@@ -21,7 +21,38 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
.from(titles) .from(titles)
.where(eq(titles.tmdbId, tmdbId)) .where(eq(titles.tmdbId, tmdbId))
.get(); .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(); const now = new Date();
@@ -139,54 +170,60 @@ export async function refreshTvChildren(
// Rate-limit: 250ms between TMDB calls // Rate-limit: 250ms between TMDB calls
if (sn > 1) await delay(250); if (sn > 1) await delay(250);
const seasonData = await getTvSeasonDetails(tmdbId, sn); try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const seasonRow = db const seasonRow = db
.insert(seasons) .insert(seasons)
.values({ .values({
titleId, titleId,
seasonNumber: seasonData.season_number, 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: {
name: seasonData.name, name: seasonData.name,
overview: seasonData.overview, overview: seasonData.overview,
posterPath: seasonData.poster_path, posterPath: seasonData.poster_path,
airDate: seasonData.air_date, airDate: seasonData.air_date,
lastFetchedAt: now, 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({ .onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber], target: [seasons.titleId, seasons.seasonNumber],
set: { 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, name: ep.name,
overview: ep.overview, overview: ep.overview,
stillPath: ep.still_path, stillPath: ep.still_path,
airDate: ep.air_date, airDate: ep.air_date,
runtimeMinutes: ep.runtime, runtimeMinutes: ep.runtime,
}, })
}) .onConflictDoUpdate({
.run(); 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}`);
} }
} }
} }