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:
parent
9c3fd94f03
commit
52e2be7296
2 changed files with 95 additions and 71 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue