From 82bf5f584471a34fdd54c071af743293860b2d2c Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 11:24:02 +0200 Subject: [PATCH] Restore movie streams via IMDb IDs while keeping series on private IDs. Claim tt for streams only, store movie imdb from TMDB, consolidate duplicate movie rows. Co-authored-by: Cursor --- apps/master-api/src/metadata/service.ts | 88 +++++++++++++++++--- apps/master-api/src/stremio/routes.ts | 106 ++++++++++++++++++------ 2 files changed, 156 insertions(+), 38 deletions(-) diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 5d730ba..680cfbf 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -282,6 +282,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, + ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, update: { title: tmdb.title, @@ -289,6 +290,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, + ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }); await this.absorbOrphanMovies(movie, title, year); @@ -603,30 +605,77 @@ export class MetadataService { return metas; } + /** + * Pull duplicate movie rows into one canonical so streams see every file. + */ + async consolidateMovies(movieId: string): Promise { + const canonical = await prisma.movie.findUnique({ where: { id: movieId } }); + if (!canonical) return 0; + + const all = await prisma.movie.findMany({ + select: { id: true, title: true, year: true, tmdbId: true, imdbId: true }, + }); + const norm = normalizeSeriesKey(canonical.title); + const orphans = all.filter((m) => { + if (m.id === canonical.id) return false; + if (canonical.tmdbId != null && m.tmdbId === canonical.tmdbId) return true; + if (canonical.imdbId && m.imdbId === canonical.imdbId) return true; + if (normalizeSeriesKey(m.title) !== norm) return false; + if (canonical.year && m.year && canonical.year !== m.year) return false; + return true; + }); + + let merged = 0; + for (const orphan of orphans) { + try { + await prisma.mediaFile.updateMany({ + where: { movieId: orphan.id }, + data: { movieId: canonical.id }, + }); + await prisma.movie.delete({ where: { id: orphan.id } }); + merged += 1; + } catch { + // skip conflicts + } + } + return merged; + } + + /** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */ + async ensureMovieImdbId(movieId: string): Promise { + if (!this.tmdbApiKey) return; + const movie = await prisma.movie.findUnique({ where: { id: movieId } }); + if (!movie?.tmdbId || movie.imdbId) return; + const imdbId = await this.fetchTmdbMovieImdbId(movie.tmdbId); + if (!imdbId) return; + await prisma.movie + .update({ where: { id: movieId }, data: { imdbId } }) + .catch(() => undefined); + } + private async absorbOrphanMovies( canonical: MovieRow, localTitle: string, year?: number ): Promise { - const titles = Array.from( - new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean)) + const keys = new Set( + [canonical.title, localTitle] + .map((t) => normalizeSeriesKey(t.trim())) + .filter(Boolean) ); - const orphans = await prisma.movie.findMany({ + if (keys.size === 0) return; + + const candidates = await prisma.movie.findMany({ where: { id: { not: canonical.id }, tmdbId: null, - AND: [ - { - OR: titles.map((t) => ({ - title: { equals: t, mode: "insensitive" as const }, - })), - }, - ...(year ? [{ OR: [{ year }, { year: null }] }] : []), - ], + ...(year ? { OR: [{ year }, { year: null }] } : {}), }, + select: { id: true, title: true }, }); - for (const orphan of orphans) { + for (const orphan of candidates) { + if (!keys.has(normalizeSeriesKey(orphan.title))) continue; await prisma.mediaFile.updateMany({ where: { movieId: orphan.id }, data: { movieId: canonical.id }, @@ -791,6 +840,7 @@ export class MetadataService { poster_path?: string; backdrop_path?: string; }; + const imdbId = await this.fetchTmdbMovieImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, title: item.title || "Onbekend", @@ -802,11 +852,24 @@ export class MetadataService { backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, + imdbId, }; this.movieCache.set(tmdbId, meta); return meta; } + private async fetchTmdbMovieImdbId(tmdbId: number): Promise { + if (!this.tmdbApiKey) return undefined; + const params = new URLSearchParams({ api_key: this.tmdbApiKey }); + const res = await fetch( + `https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}` + ); + if (!res.ok) return undefined; + const data = (await res.json()) as { imdb_id?: string | null }; + const id = data.imdb_id?.trim(); + return id && /^tt\d+$/i.test(id) ? id : undefined; + } + private async getTmdbSeriesDetails(tmdbId: number): Promise { const cached = this.seriesCache.get(tmdbId); if (cached) return cached; @@ -891,6 +954,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, + ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }); } diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index e076822..259698f 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.4.1", + version: "1.4.2", name: "Media Cluster", description: "Private distributed media library", catalogs: [ @@ -28,21 +28,24 @@ 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 }, ], - // Never use tt/IMDb as our catalog IDs — that merges with Cinemeta and breaks our episode list. resources: [ "catalog", + // Meta: never claim tt for series (Cinemeta merge breaks episodes). Movies without + // imdb use mcmovie-*; movies with imdb use tt and get meta from Cinemeta. { name: "meta", types: ["movie", "series"], idPrefixes: ["mc", "sth"] }, - { name: "stream", types: ["movie", "series"], idPrefixes: ["mc", "sth"] }, + // Streams: also claim tt so local movie files attach to IMDb / Cinemeta detail pages. + { name: "stream", types: ["movie", "series"], idPrefixes: ["mc", "sth", "tt"] }, ], types: ["movie", "series"], - idPrefixes: ["mc", "sth"], + idPrefixes: ["mc", "sth", "tt"], }; /** - * Always use private library IDs. Do NOT prefer IMDb/tt — Stremio then mixes our - * meta with Cinemeta and the episode list becomes wrong/incomplete. + * Movies: prefer IMDb so Stremio uses Cinemeta for detail + our addon for streams. + * Series: always private IDs (never tt) — otherwise episode lists merge with Cinemeta. */ -function movieId(_imdbId: string | null | undefined, uuid: string): string { +function movieId(imdbId: string | null | undefined, uuid: string): string { + if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId; return `${ID_PREFIX}movie-${uuid}`; } @@ -160,7 +163,7 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config const id = paramId((request.params as { "*": string })["*"] ?? ""); if (type === "movie") { - const meta = await getMovieMeta(id); + const meta = await getMovieMeta(id, metadata, request.log); return { meta: meta ?? { id, type: "movie", name: "Unknown" } }; } if (type === "series") { @@ -177,7 +180,7 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config return reply.status(401).send({ error: "Invalid addon token" }); } const id = paramId((request.params as { "*": string })["*"] ?? ""); - const streams = await getStreams(type, id, userId, playback, request.log); + const streams = await getStreams(type, id, userId, playback, metadata, request.log); return { streams }; }); @@ -335,24 +338,48 @@ async function getSeriesCatalog( })); } -async function getMovieMeta(id: string) { +async function getMovieMeta( + id: string, + metadata: MetadataService, + log?: { info?: (obj: unknown, msg?: string) => void; warn?: (obj: unknown, msg?: string) => void } +) { + const localUuid = unwrapLocalId(id, "movie"); const movie = await prisma.movie.findFirst({ where: { - OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }], + OR: [ + ...(id.startsWith("tt") ? [{ imdbId: id }] : []), + { id: localUuid }, + { id }, + ], }, }); - if (!movie) return null; + if (!movie) { + log?.warn?.({ id, localUuid }, "movie meta: not found"); + return null; + } + + try { + await metadata.consolidateMovies(movie.id); + } catch (err) { + log?.warn?.({ err, movieId: movie.id }, "movie meta: consolidate failed"); + } + + // Refresh IMDb id in background so next catalog page can use tt + Cinemeta. + void metadata.ensureMovieImdbId(movie.id).catch(() => undefined); + + const fresh = await prisma.movie.findUnique({ where: { id: movie.id } }); + const row = fresh ?? movie; + return { - id: movieId(movie.imdbId, movie.id), + id: movieId(row.imdbId, row.id), type: "movie" as const, - name: movie.title, - poster: movie.posterUrl ?? undefined, - background: movie.backdropUrl ?? undefined, - description: movie.overview ?? undefined, - releaseInfo: movie.year ? String(movie.year) : undefined, - runtime: movie.runtime ? `${movie.runtime} min` : undefined, - genres: movie.genres, - imdbRating: undefined, + name: row.title, + poster: row.posterUrl ?? undefined, + background: row.backdropUrl ?? undefined, + description: row.overview ?? undefined, + releaseInfo: row.year ? String(row.year) : undefined, + runtime: row.runtime ? `${row.runtime} min` : undefined, + genres: row.genres, }; } @@ -545,25 +572,48 @@ async function getStreams( id: string, userId: string, playback: PlaybackService, + metadata: MetadataService, log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void } ) { if (type === "movie") { - const movie = await prisma.movie.findFirst({ - where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }] }, + const localUuid = unwrapLocalId(id, "movie"); + let movie = await prisma.movie.findFirst({ + where: { + OR: [ + ...(id.startsWith("tt") ? [{ imdbId: id }] : []), + { id: localUuid }, + { id }, + ], + }, include: { mediaFiles: { - where: { available: true }, include: { node: true }, }, }, }); + if (!movie) { + log?.warn?.({ id, localUuid }, "movie stream: not found"); + return []; + } + + try { + await metadata.consolidateMovies(movie.id); + movie = await prisma.movie.findFirst({ + where: { id: movie.id }, + include: { mediaFiles: { include: { node: true } } }, + }); + } catch (err) { + log?.warn?.({ err, movieId: movie.id }, "movie stream: consolidate failed"); + } if (!movie) return []; + const files = movie.mediaFiles.filter((f) => f.available); + const pool = files.length > 0 ? files : movie.mediaFiles; + const streams = []; - for (const file of movie.mediaFiles) { + for (const file of pool) { if (file.node.status !== "ONLINE" || file.node.revoked) continue; try { - // Master only creates the session; Stremio streams directly from the node. const session = await playback.createSession(file.id, userId); log?.info?.( { mediaFileId: file.id, streamUrl: session.streamUrl }, @@ -581,6 +631,10 @@ async function getStreams( log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed"); } } + log?.info?.( + { movieId: movie.id, requestId: id, streamCount: streams.length, fileCount: pool.length }, + "movie streams" + ); return streams; }