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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 11:24:02 +02:00
parent 38bd4a822e
commit 82bf5f5844
2 changed files with 156 additions and 38 deletions

View file

@ -282,6 +282,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl, posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl, backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id, tmdbId: tmdb.id,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
}, },
update: { update: {
title: tmdb.title, title: tmdb.title,
@ -289,6 +290,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl, posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl, backdropUrl: tmdb.backdropUrl,
year: tmdb.year, year: tmdb.year,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
}, },
}); });
await this.absorbOrphanMovies(movie, title, year); await this.absorbOrphanMovies(movie, title, year);
@ -603,30 +605,77 @@ export class MetadataService {
return metas; return metas;
} }
/**
* Pull duplicate movie rows into one canonical so streams see every file.
*/
async consolidateMovies(movieId: string): Promise<number> {
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<void> {
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( private async absorbOrphanMovies(
canonical: MovieRow, canonical: MovieRow,
localTitle: string, localTitle: string,
year?: number year?: number
): Promise<void> { ): Promise<void> {
const titles = Array.from( const keys = new Set(
new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean)) [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: { where: {
id: { not: canonical.id }, id: { not: canonical.id },
tmdbId: null, tmdbId: null,
AND: [ ...(year ? { OR: [{ year }, { year: null }] } : {}),
{
OR: titles.map((t) => ({
title: { equals: t, mode: "insensitive" as const },
})),
},
...(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({ await prisma.mediaFile.updateMany({
where: { movieId: orphan.id }, where: { movieId: orphan.id },
data: { movieId: canonical.id }, data: { movieId: canonical.id },
@ -791,6 +840,7 @@ export class MetadataService {
poster_path?: string; poster_path?: string;
backdrop_path?: string; backdrop_path?: string;
}; };
const imdbId = await this.fetchTmdbMovieImdbId(tmdbId);
const meta: TmdbMeta = { const meta: TmdbMeta = {
id: item.id, id: item.id,
title: item.title || "Onbekend", title: item.title || "Onbekend",
@ -802,11 +852,24 @@ export class MetadataService {
backdropUrl: item.backdrop_path backdropUrl: item.backdrop_path
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
: undefined, : undefined,
imdbId,
}; };
this.movieCache.set(tmdbId, meta); this.movieCache.set(tmdbId, meta);
return meta; return meta;
} }
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
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<TmdbMeta | null> { private async getTmdbSeriesDetails(tmdbId: number): Promise<TmdbMeta | null> {
const cached = this.seriesCache.get(tmdbId); const cached = this.seriesCache.get(tmdbId);
if (cached) return cached; if (cached) return cached;
@ -891,6 +954,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl, posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl, backdropUrl: tmdb.backdropUrl,
year: tmdb.year, year: tmdb.year,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
}, },
}); });
} }

View file

@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = { const MANIFEST = {
id: "com.mediacluster.library", id: "com.mediacluster.library",
version: "1.4.1", version: "1.4.2",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -28,21 +28,24 @@ const MANIFEST = {
{ type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA }, { type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA },
{ type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS }, { 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: [ resources: [
"catalog", "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: "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"], types: ["movie", "series"],
idPrefixes: ["mc", "sth"], idPrefixes: ["mc", "sth", "tt"],
}; };
/** /**
* Always use private library IDs. Do NOT prefer IMDb/tt — Stremio then mixes our * Movies: prefer IMDb so Stremio uses Cinemeta for detail + our addon for streams.
* meta with Cinemeta and the episode list becomes wrong/incomplete. * 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}`; return `${ID_PREFIX}movie-${uuid}`;
} }
@ -160,7 +163,7 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
const id = paramId((request.params as { "*": string })["*"] ?? ""); const id = paramId((request.params as { "*": string })["*"] ?? "");
if (type === "movie") { if (type === "movie") {
const meta = await getMovieMeta(id); const meta = await getMovieMeta(id, metadata, request.log);
return { meta: meta ?? { id, type: "movie", name: "Unknown" } }; return { meta: meta ?? { id, type: "movie", name: "Unknown" } };
} }
if (type === "series") { if (type === "series") {
@ -177,7 +180,7 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const id = paramId((request.params as { "*": string })["*"] ?? ""); 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 }; 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({ const movie = await prisma.movie.findFirst({
where: { 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 { return {
id: movieId(movie.imdbId, movie.id), id: movieId(row.imdbId, row.id),
type: "movie" as const, type: "movie" as const,
name: movie.title, name: row.title,
poster: movie.posterUrl ?? undefined, poster: row.posterUrl ?? undefined,
background: movie.backdropUrl ?? undefined, background: row.backdropUrl ?? undefined,
description: movie.overview ?? undefined, description: row.overview ?? undefined,
releaseInfo: movie.year ? String(movie.year) : undefined, releaseInfo: row.year ? String(row.year) : undefined,
runtime: movie.runtime ? `${movie.runtime} min` : undefined, runtime: row.runtime ? `${row.runtime} min` : undefined,
genres: movie.genres, genres: row.genres,
imdbRating: undefined,
}; };
} }
@ -545,25 +572,48 @@ async function getStreams(
id: string, id: string,
userId: string, userId: string,
playback: PlaybackService, playback: PlaybackService,
metadata: MetadataService,
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void } log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
) { ) {
if (type === "movie") { if (type === "movie") {
const movie = await prisma.movie.findFirst({ const localUuid = unwrapLocalId(id, "movie");
where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "movie") }] }, let movie = await prisma.movie.findFirst({
where: {
OR: [
...(id.startsWith("tt") ? [{ imdbId: id }] : []),
{ id: localUuid },
{ id },
],
},
include: { include: {
mediaFiles: { mediaFiles: {
where: { available: true },
include: { node: 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 []; if (!movie) return [];
const files = movie.mediaFiles.filter((f) => f.available);
const pool = files.length > 0 ? files : movie.mediaFiles;
const streams = []; const streams = [];
for (const file of movie.mediaFiles) { for (const file of pool) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue; if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try { try {
// Master only creates the session; Stremio streams directly from the node.
const session = await playback.createSession(file.id, userId); const session = await playback.createSession(file.id, userId);
log?.info?.( log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl }, { 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?.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; return streams;
} }