diff --git a/apps/master-api/prisma/migrations/20260825050000_ensure_episode_still_url/migration.sql b/apps/master-api/prisma/migrations/20260825050000_ensure_episode_still_url/migration.sql new file mode 100644 index 0000000..68e7930 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260825050000_ensure_episode_still_url/migration.sql @@ -0,0 +1,2 @@ +-- Ensure still_url exists even if earlier migrate state drifted +ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "still_url" TEXT; diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 0b3849e..074cee7 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -78,6 +78,15 @@ async function main() { nodeConnectionManager.init(config); await ensureAdminUser(config); + // Safety net if migrate history drifted — series meta must not 500. + try { + await prisma.$executeRawUnsafe( + `ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "still_url" TEXT` + ); + } catch (err) { + console.warn("Could not ensure episodes.still_url:", err); + } + const shutdown = async () => { console.log("Shutting down..."); nodeConnectionManager.shutdown(); diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 2263b00..4be5b41 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -318,41 +318,27 @@ async function getMovieMeta(id: string) { } async function getSeriesMeta(id: string, metadata: MetadataService) { - const series = await prisma.series.findFirst({ - where: { - OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], - }, - include: { - seasons: { - include: { - episodes: { - orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], - include: { mediaFiles: { where: { available: true }, take: 1 } }, - }, - }, - orderBy: { seasonNumber: "asc" }, - }, - }, - }); + const series = await loadSeriesForMeta(id); if (!series) return null; - // Enrich in background — never block or break episode listing on TMDB/DB issues. + // Enrich in background — never block or break episode listing. 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\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: formatMetaDate(ep.airDate), - })) - ); + const videos = series.episodes + .filter((ep) => ep.mediaFiles.length > 0) + .map((ep) => ({ + id: `${sid}:${ep.seasonNumber}:${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: formatMetaDate(ep.airDate), + })); return { id: sid, @@ -367,6 +353,115 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { }; } +/** Load series + episodes without depending on optional still_url column. */ +async function loadSeriesForMeta(id: string): Promise<{ + id: string; + title: string; + year: number | null; + overview: string | null; + posterUrl: string | null; + backdropUrl: string | null; + genres: string[]; + imdbId: string | null; + episodes: Array<{ + seasonNumber: number; + episodeNumber: number; + title: string | null; + overview: string | null; + airDate: Date | null; + stillUrl: string | null; + mediaFiles: Array<{ id: string }>; + }>; +} | null> { + const where = { + OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], + }; + + const available = await querySeriesEpisodes(where, true); + if (available && available.episodes.some((ep) => ep.mediaFiles.length > 0)) { + return available; + } + + // Fallback: list episodes that still have files even if marked unavailable + // (e.g. brief node disconnect), so Stremio is not empty. + const anyFiles = await querySeriesEpisodes(where, false); + return anyFiles ?? available; +} + +async function querySeriesEpisodes( + where: { OR: Array> }, + onlyAvailable: boolean +) { + const mediaFilesArgs = onlyAvailable + ? { where: { available: true as const }, take: 1 as const, select: { id: true as const } } + : { take: 1 as const, select: { id: true as const } }; + + try { + const row = await prisma.series.findFirst({ + where, + select: { + id: true, + title: true, + year: true, + overview: true, + posterUrl: true, + backdropUrl: true, + genres: true, + imdbId: true, + episodes: { + orderBy: [{ seasonNumber: "asc" as const }, { episodeNumber: "asc" as const }], + select: { + seasonNumber: true, + episodeNumber: true, + title: true, + overview: true, + airDate: true, + stillUrl: true, + mediaFiles: mediaFilesArgs, + }, + }, + }, + }); + if (row) return row; + } catch { + // still_url missing — retry without that field + } + + try { + const row = await prisma.series.findFirst({ + where, + select: { + id: true, + title: true, + year: true, + overview: true, + posterUrl: true, + backdropUrl: true, + genres: true, + imdbId: true, + episodes: { + orderBy: [{ seasonNumber: "asc" as const }, { episodeNumber: "asc" as const }], + select: { + seasonNumber: true, + episodeNumber: true, + title: true, + overview: true, + airDate: true, + mediaFiles: mediaFilesArgs, + }, + }, + }, + }); + if (!row) return null; + return { + ...row, + episodes: row.episodes.map((ep) => ({ ...ep, stillUrl: null as string | null })), + }; + } catch { + return null; + } +} + function formatMetaDate(value: Date | null | undefined): string | undefined { if (!value) return undefined; const t = value.getTime();