diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 72e9767..3b5b5c2 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -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(); private seriesCache = new Map(); @@ -365,47 +372,58 @@ export class MetadataService { /** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */ async enrichSeriesEpisodes(seriesId: string): Promise { if (!this.tmdbApiKey) return; - const series = await prisma.series.findUnique({ - where: { id: seriesId }, - include: { - episodes: { - select: { - id: true, - seasonNumber: true, - episodeNumber: true, - title: true, - overview: true, - stillUrl: true, + try { + const series = await prisma.series.findUnique({ + where: { id: seriesId }, + include: { + episodes: { + select: { + id: true, + seasonNumber: true, + episodeNumber: true, + title: 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( - series.episodes.filter((ep) => needsEpisodeEnrichment(ep)).map((ep) => ep.seasonNumber) - ); - for (const seasonNumber of seasons) { - const key = `${series.id}:${seasonNumber}`; - if (this.enrichedSeriesSeasons.has(key)) continue; - const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber); - if (!metas.length) continue; - for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) { - const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber); - if (!meta) continue; - await prisma.episode.update({ - where: { id: ep.id }, - data: { - title: meta.title, - overview: meta.overview ?? undefined, - stillUrl: meta.stillUrl ?? undefined, - runtime: meta.runtime ?? undefined, - tmdbId: meta.tmdbId ?? undefined, - airDate: meta.airDate ? new Date(meta.airDate) : undefined, - }, - }); + const seasons = new Set( + series.episodes.filter((ep) => needsEpisodeEnrichment(ep)).map((ep) => ep.seasonNumber) + ); + for (const seasonNumber of seasons) { + const key = `${series.id}:${seasonNumber}`; + if (this.enrichedSeriesSeasons.has(key)) continue; + const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber); + 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: { + title: meta.title, + 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 ): Promise { if (!needsEpisodeEnrichment(episode)) return episode; - 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({ - where: { id: episode.id }, - data: { - title: meta.title, - overview: meta.overview ?? undefined, - stillUrl: meta.stillUrl ?? undefined, - runtime: meta.runtime ?? undefined, - tmdbId: meta.tmdbId ?? undefined, - airDate: meta.airDate ? new Date(meta.airDate) : undefined, - }, - }); + try { + const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber); + const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber); + if (!meta) return episode; + return await prisma.episode.update({ + where: { id: episode.id }, + data: { + title: meta.title, + overview: meta.overview ?? undefined, + stillUrl: meta.stillUrl ?? undefined, + runtime: meta.runtime ?? undefined, + tmdbId: meta.tmdbId ?? undefined, + airDate: parseAirDate(meta.airDate), + }, + }); + } catch { + return episode; + } } private async fetchTmdbSeasonEpisodes( diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index f463615..2263b00 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -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,