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