From 0ebaaa1d99d0760a461f59573ed539ceff2b9e60 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 05:08:38 +0200 Subject: [PATCH] Stop using IMDb IDs for library series so Stremio no longer merges with Cinemeta. Always use mcseries-{uuid}, load all episodes with files, unique released dates per episode. Co-authored-by: Cursor --- apps/master-api/src/stremio/routes.ts | 212 ++++++++++++++------------ 1 file changed, 115 insertions(+), 97 deletions(-) diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 6bf363b..f024def 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }]; const MANIFEST = { id: "com.mediacluster.library", - version: "1.3.0", + version: "1.3.1", name: "Media Cluster", description: "Private distributed media library", catalogs: [ @@ -28,28 +28,29 @@ const MANIFEST = { { type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA }, { type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS }, ], - // catalog stays a string; meta/stream need idPrefixes for custom IDs (Stremio docs) + // Never use tt/IMDb as our catalog IDs — that merges with Cinemeta and breaks our episode list. resources: [ "catalog", - { name: "meta", types: ["movie", "series"], idPrefixes: ["tt", "mc", "sth"] }, - { name: "stream", types: ["movie", "series"], idPrefixes: ["tt", "mc", "sth"] }, + { name: "meta", types: ["movie", "series"], idPrefixes: ["mc", "sth"] }, + { name: "stream", types: ["movie", "series"], idPrefixes: ["mc", "sth"] }, ], types: ["movie", "series"], - idPrefixes: ["tt", "mc", "sth"], + idPrefixes: ["mc", "sth"], }; -/** Prefer IMDb IDs (Cinemeta-compatible). Else mcmovie-/mcseries- without extra colons. */ -function movieId(imdbId: string | null | undefined, uuid: string): string { - if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId; +/** + * Always use private library IDs. Do NOT prefer IMDb/tt — Stremio then mixes our + * meta with Cinemeta and the episode list becomes wrong/incomplete. + */ +function movieId(_imdbId: string | null | undefined, uuid: string): string { return `${ID_PREFIX}movie-${uuid}`; } -function seriesId(imdbId: string | null | undefined, uuid: string): string { - if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId; +function seriesId(_imdbId: string | null | undefined, uuid: string): string { return `${ID_PREFIX}series-${uuid}`; } -/** Strip local id prefixes (current + legacy mc:series: / sth:). */ +/** Strip local id prefixes (current + legacy mc:series: / sth: / tt passthrough lookup). */ function unwrapLocalId(id: string, kind: "movie" | "series"): string { return id .replace(new RegExp(`^${ID_PREFIX}${kind}-`), "") @@ -62,15 +63,21 @@ function paramId(raw: string): string { return decodeURIComponent(raw).replace(/\.json$/i, ""); } -/** Stremio requires Video.released (ISO-8601); invalid/missing videos are dropped by the client. */ -function videoReleased(airDate: Date | null | undefined, year: number | null | undefined): string { +/** Stremio requires Video.released (ISO-8601); missing/invalid videos are dropped. */ +function videoReleased( + airDate: Date | null | undefined, + year: number | null | undefined, + season: number, + episode: number +): string { if (airDate && !Number.isNaN(airDate.getTime())) { return airDate.toISOString(); } - if (year && year > 1900) { - return `${year}-01-01T00:00:00.000Z`; - } - return "2000-01-01T00:00:00.000Z"; + // Unique synthetic date per SxxExx so clients never collapse episodes. + const base = year && year > 1900 ? year : 2000; + const d = new Date(Date.UTC(base, 0, 1, 12, 0, 0)); + d.setUTCDate(d.getUTCDate() + Math.max(0, season) * 40 + Math.max(0, episode)); + return d.toISOString(); } export async function registerStremioRoutes(app: FastifyInstance, config: Config) { @@ -348,10 +355,12 @@ async function getSeriesMeta( metadata: MetadataService, log?: { info?: (obj: unknown, msg?: string) => void; warn?: (obj: unknown, msg?: string) => void } ) { - // Core listing: never select optional stillUrl/logoUrl columns here. + const localUuid = unwrapLocalId(id, "series"); + // Resolve by our private id only (mcseries-uuid). Also accept legacy mc:series:uuid / bare uuid. + // Do NOT look up by imdbId — that pulls the wrong row after TMDB linking. const series = await prisma.series.findFirst({ where: { - OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], + OR: [{ id: localUuid }, { id }], }, select: { id: true, @@ -362,32 +371,11 @@ async function getSeriesMeta( backdropUrl: true, genres: true, imdbId: true, - seasons: { - orderBy: { seasonNumber: "asc" }, - select: { - episodes: { - orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], - select: { - id: true, - seasonNumber: true, - episodeNumber: true, - title: true, - overview: true, - airDate: true, - mediaFiles: { - where: { available: true }, - take: 1, - select: { id: true }, - }, - }, - }, - }, - }, }, }); if (!series) { - log?.warn?.({ id }, "series meta: not found"); + log?.warn?.({ id, localUuid }, "series meta: not found"); return null; } @@ -396,76 +384,103 @@ async function getSeriesMeta( const sid = seriesId(series.imdbId, series.id); - let episodes = series.seasons.flatMap((season) => - season.episodes.filter((ep) => ep.mediaFiles.length > 0) - ); + type EpRow = { + id: string; + seasonNumber: number; + episodeNumber: number; + title: string | null; + overview: string | null; + airDate: Date | null; + stillUrl: string | null; + mediaFiles: Array<{ id: string }>; + }; - if (episodes.length === 0) { - const fallback = await prisma.series.findFirst({ - where: { id: series.id }, - select: { - seasons: { - orderBy: { seasonNumber: "asc" }, - select: { - episodes: { - orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], - select: { - id: true, - seasonNumber: true, - episodeNumber: true, - title: true, - overview: true, - airDate: true, - mediaFiles: { take: 1, select: { id: true } }, - }, - }, - }, + async function loadEpisodes(onlyAvailable: boolean, withStill: boolean): Promise { + const mediaFilter = onlyAvailable + ? { some: { available: true as const } } + : { some: {} }; + const mediaSelect = onlyAvailable + ? { where: { available: true as const }, take: 1 as const, select: { id: true as const } } + : { take: 1 as const, select: { id: true as const } }; + + if (withStill) { + return prisma.episode.findMany({ + where: { season: { seriesId: series.id }, mediaFiles: mediaFilter }, + orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], + select: { + id: true, + seasonNumber: true, + episodeNumber: true, + title: true, + overview: true, + airDate: true, + stillUrl: true, + mediaFiles: mediaSelect, }, + }); + } + + const rows = await prisma.episode.findMany({ + where: { season: { seriesId: series.id }, mediaFiles: mediaFilter }, + orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], + select: { + id: true, + seasonNumber: true, + episodeNumber: true, + title: true, + overview: true, + airDate: true, + mediaFiles: mediaSelect, }, }); - episodes = - fallback?.seasons.flatMap((season) => - season.episodes.filter((ep) => ep.mediaFiles.length > 0) - ) ?? []; + return rows.map((ep) => ({ ...ep, stillUrl: null })); + } + + let episodes: EpRow[] = []; + try { + episodes = await loadEpisodes(true, true); + if (episodes.length === 0) episodes = await loadEpisodes(false, true); + } catch { + try { + episodes = await loadEpisodes(true, false); + if (episodes.length === 0) episodes = await loadEpisodes(false, false); + } catch { + episodes = []; + } } - const stillByEpisodeId = new Map(); let logo: string | undefined; try { const art = await prisma.series.findFirst({ where: { id: series.id }, - select: { - logoUrl: true, - seasons: { - select: { - episodes: { select: { id: true, stillUrl: true } }, - }, - }, - }, + select: { logoUrl: true }, }); logo = art?.logoUrl ?? undefined; - for (const season of art?.seasons ?? []) { - for (const ep of season.episodes) { - if (ep.stillUrl) stillByEpisodeId.set(ep.id, ep.stillUrl); - } - } } catch { - // optional columns + // optional } - const videos = episodes.map((ep) => ({ - id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, - title: - ep.title && !/^episode\s+\d+$/i.test(ep.title.trim()) - ? ep.title - : `Episode ${ep.episodeNumber}`, - season: ep.seasonNumber, - episode: ep.episodeNumber, - overview: ep.overview || undefined, - thumbnail: stillByEpisodeId.get(ep.id), - // REQUIRED by Stremio — without this the client drops the video - released: videoReleased(ep.airDate, series.year), - })); + // Deduplicate by S/E (keep first) so Stremio never gets colliding video ids + const seen = new Set(); + const videos = []; + for (const ep of episodes) { + if (ep.mediaFiles.length === 0) continue; + const key = `${ep.seasonNumber}:${ep.episodeNumber}`; + if (seen.has(key)) continue; + seen.add(key); + videos.push({ + id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, + title: + ep.title && !/^episode\s+\d+$/i.test(ep.title.trim()) + ? ep.title + : `Episode ${ep.episodeNumber}`, + season: ep.seasonNumber, + episode: ep.episodeNumber, + overview: ep.overview || undefined, + thumbnail: ep.stillUrl || undefined, + released: videoReleased(ep.airDate, series.year, ep.seasonNumber, ep.episodeNumber), + }); + } log?.info?.( { seriesId: series.id, sid, videoCount: videos.length, requestId: id }, @@ -542,7 +557,10 @@ async function getStreams( const series = await prisma.series.findFirst({ where: { - OR: [{ imdbId: seriesKey }, { id: unwrapLocalId(seriesKey, "series") }], + OR: [ + { id: unwrapLocalId(seriesKey, "series") }, + { id: seriesKey }, + ], }, }); if (!series) return [];