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;
}
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 {
private movieCache = new Map<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>();
@ -365,6 +372,7 @@ export class MetadataService {
/** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */
async enrichSeriesEpisodes(seriesId: string): Promise<void> {
if (!this.tmdbApiKey) return;
try {
const series = await prisma.series.findUnique({
where: { id: seriesId },
include: {
@ -389,10 +397,14 @@ export class MetadataService {
const key = `${series.id}:${seasonNumber}`;
if (this.enrichedSeriesSeasons.has(key)) continue;
const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber);
if (!metas.length) continue;
if (!metas.length) {
this.enrichedSeriesSeasons.add(key);
continue;
}
for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) {
const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber);
if (!meta) continue;
try {
await prisma.episode.update({
where: { id: ep.id },
data: {
@ -401,12 +413,18 @@ export class MetadataService {
stillUrl: meta.stillUrl ?? undefined,
runtime: meta.runtime ?? undefined,
tmdbId: meta.tmdbId ?? undefined,
airDate: meta.airDate ? new Date(meta.airDate) : undefined,
airDate: parseAirDate(meta.airDate),
},
});
} catch {
// Column/migration mismatch or concurrent update — skip this episode.
}
}
this.enrichedSeriesSeasons.add(key);
}
} catch {
// Never fail the caller (Stremio meta) because of enrichment.
}
}
private async applyTmdbEpisodeMeta(
@ -414,10 +432,11 @@ export class MetadataService {
episode: EpisodeRow
): Promise<EpisodeRow> {
if (!needsEpisodeEnrichment(episode)) return episode;
try {
const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber);
const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber);
if (!meta) return episode;
return prisma.episode.update({
return await prisma.episode.update({
where: { id: episode.id },
data: {
title: meta.title,
@ -425,9 +444,12 @@ export class MetadataService {
stillUrl: meta.stillUrl ?? undefined,
runtime: meta.runtime ?? undefined,
tmdbId: meta.tmdbId ?? undefined,
airDate: meta.airDate ? new Date(meta.airDate) : undefined,
airDate: parseAirDate(meta.airDate),
},
});
} catch {
return episode;
}
}
private async fetchTmdbSeasonEpisodes(

View file

@ -322,13 +322,6 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
where: {
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: {
seasons: {
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);
const videos = refreshed.seasons.flatMap((season) =>
// Enrich in background — never block or break episode listing on TMDB/DB issues.
void metadata.enrichSeriesEpisodes(series.id).catch(() => undefined);
const sid = seriesId(series.imdbId, series.id);
const videos = series.seasons.flatMap((season) =>
season.episodes
.filter((ep) => ep.mediaFiles.length > 0)
.map((ep) => ({
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,
episode: ep.episodeNumber,
overview: ep.overview ?? undefined,
thumbnail: ep.stillUrl ?? undefined,
released: ep.airDate?.toISOString().slice(0, 10),
runtime: ep.runtime ? `${ep.runtime} min` : undefined,
thumbnail: ep.stillUrl || undefined,
released: formatMetaDate(ep.airDate),
}))
);
return {
id: sid,
type: "series" as const,
name: refreshed.title,
poster: refreshed.posterUrl ?? undefined,
background: refreshed.backdropUrl ?? undefined,
description: refreshed.overview ?? undefined,
releaseInfo: refreshed.year ? String(refreshed.year) : undefined,
genres: refreshed.genres,
name: series.title,
poster: series.posterUrl ?? undefined,
background: series.backdropUrl ?? undefined,
description: series.overview ?? undefined,
releaseInfo: series.year ? String(series.year) : undefined,
genres: series.genres,
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(
type: string,
id: string,