Fix empty series episodes: never block Stremio meta on TMDB enrich or bad air dates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 04:33:21 +02:00
parent 9c3fd94f03
commit 52e2be7296
2 changed files with 95 additions and 71 deletions

View file

@ -54,6 +54,13 @@ function needsEpisodeEnrichment(ep: {
return false; return false;
} }
function parseAirDate(raw?: string): Date | undefined {
if (!raw || !/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return undefined;
return d;
}
export class MetadataService { export class MetadataService {
private movieCache = new Map<number, TmdbMeta>(); private movieCache = new Map<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>(); private seriesCache = new Map<number, TmdbMeta>();
@ -365,47 +372,58 @@ export class MetadataService {
/** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */ /** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */
async enrichSeriesEpisodes(seriesId: string): Promise<void> { async enrichSeriesEpisodes(seriesId: string): Promise<void> {
if (!this.tmdbApiKey) return; if (!this.tmdbApiKey) return;
const series = await prisma.series.findUnique({ try {
where: { id: seriesId }, const series = await prisma.series.findUnique({
include: { where: { id: seriesId },
episodes: { include: {
select: { episodes: {
id: true, select: {
seasonNumber: true, id: true,
episodeNumber: true, seasonNumber: true,
title: true, episodeNumber: true,
overview: true, title: true,
stillUrl: true, overview: true,
stillUrl: true,
},
}, },
}, },
}, });
}); if (!series?.tmdbId || series.episodes.length === 0) return;
if (!series?.tmdbId || series.episodes.length === 0) return;
const seasons = new Set( const seasons = new Set(
series.episodes.filter((ep) => needsEpisodeEnrichment(ep)).map((ep) => ep.seasonNumber) series.episodes.filter((ep) => needsEpisodeEnrichment(ep)).map((ep) => ep.seasonNumber)
); );
for (const seasonNumber of seasons) { for (const seasonNumber of seasons) {
const key = `${series.id}:${seasonNumber}`; const key = `${series.id}:${seasonNumber}`;
if (this.enrichedSeriesSeasons.has(key)) continue; if (this.enrichedSeriesSeasons.has(key)) continue;
const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber); const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber);
if (!metas.length) continue; if (!metas.length) {
for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) { this.enrichedSeriesSeasons.add(key);
const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber); continue;
if (!meta) continue; }
await prisma.episode.update({ for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) {
where: { id: ep.id }, const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber);
data: { if (!meta) continue;
title: meta.title, try {
overview: meta.overview ?? undefined, await prisma.episode.update({
stillUrl: meta.stillUrl ?? undefined, where: { id: ep.id },
runtime: meta.runtime ?? undefined, data: {
tmdbId: meta.tmdbId ?? undefined, title: meta.title,
airDate: meta.airDate ? new Date(meta.airDate) : undefined, overview: meta.overview ?? undefined,
}, stillUrl: meta.stillUrl ?? undefined,
}); runtime: meta.runtime ?? undefined,
tmdbId: meta.tmdbId ?? undefined,
airDate: parseAirDate(meta.airDate),
},
});
} catch {
// Column/migration mismatch or concurrent update — skip this episode.
}
}
this.enrichedSeriesSeasons.add(key);
} }
this.enrichedSeriesSeasons.add(key); } catch {
// Never fail the caller (Stremio meta) because of enrichment.
} }
} }
@ -414,20 +432,24 @@ export class MetadataService {
episode: EpisodeRow episode: EpisodeRow
): Promise<EpisodeRow> { ): Promise<EpisodeRow> {
if (!needsEpisodeEnrichment(episode)) return episode; if (!needsEpisodeEnrichment(episode)) return episode;
const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber); try {
const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber); const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber);
if (!meta) return episode; const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber);
return prisma.episode.update({ if (!meta) return episode;
where: { id: episode.id }, return await prisma.episode.update({
data: { where: { id: episode.id },
title: meta.title, data: {
overview: meta.overview ?? undefined, title: meta.title,
stillUrl: meta.stillUrl ?? undefined, overview: meta.overview ?? undefined,
runtime: meta.runtime ?? undefined, stillUrl: meta.stillUrl ?? undefined,
tmdbId: meta.tmdbId ?? undefined, runtime: meta.runtime ?? undefined,
airDate: meta.airDate ? new Date(meta.airDate) : undefined, tmdbId: meta.tmdbId ?? undefined,
}, airDate: parseAirDate(meta.airDate),
}); },
});
} catch {
return episode;
}
} }
private async fetchTmdbSeasonEpisodes( private async fetchTmdbSeasonEpisodes(

View file

@ -322,13 +322,6 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
where: { where: {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }],
}, },
});
if (!series) return null;
await metadata.enrichSeriesEpisodes(series.id);
const refreshed = await prisma.series.findFirst({
where: { id: series.id },
include: { include: {
seasons: { seasons: {
include: { include: {
@ -341,37 +334,46 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
}, },
}, },
}); });
if (!refreshed) return null; if (!series) return null;
const sid = seriesId(refreshed.imdbId, refreshed.id); // Enrich in background — never block or break episode listing on TMDB/DB issues.
const videos = refreshed.seasons.flatMap((season) => void metadata.enrichSeriesEpisodes(series.id).catch(() => undefined);
const sid = seriesId(series.imdbId, series.id);
const videos = series.seasons.flatMap((season) =>
season.episodes season.episodes
.filter((ep) => ep.mediaFiles.length > 0) .filter((ep) => ep.mediaFiles.length > 0)
.map((ep) => ({ .map((ep) => ({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
title: ep.title ?? `Episode ${ep.episodeNumber}`, title: ep.title && !/^episode\s+\d+$/i.test(ep.title) ? ep.title : `Episode ${ep.episodeNumber}`,
season: ep.seasonNumber, season: ep.seasonNumber,
episode: ep.episodeNumber, episode: ep.episodeNumber,
overview: ep.overview ?? undefined, overview: ep.overview ?? undefined,
thumbnail: ep.stillUrl ?? undefined, thumbnail: ep.stillUrl || undefined,
released: ep.airDate?.toISOString().slice(0, 10), released: formatMetaDate(ep.airDate),
runtime: ep.runtime ? `${ep.runtime} min` : undefined,
})) }))
); );
return { return {
id: sid, id: sid,
type: "series" as const, type: "series" as const,
name: refreshed.title, name: series.title,
poster: refreshed.posterUrl ?? undefined, poster: series.posterUrl ?? undefined,
background: refreshed.backdropUrl ?? undefined, background: series.backdropUrl ?? undefined,
description: refreshed.overview ?? undefined, description: series.overview ?? undefined,
releaseInfo: refreshed.year ? String(refreshed.year) : undefined, releaseInfo: series.year ? String(series.year) : undefined,
genres: refreshed.genres, genres: series.genres,
videos, videos,
}; };
} }
function formatMetaDate(value: Date | null | undefined): string | undefined {
if (!value) return undefined;
const t = value.getTime();
if (Number.isNaN(t)) return undefined;
return value.toISOString().slice(0, 10);
}
async function getStreams( async function getStreams(
type: string, type: string,
id: string, id: string,