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,
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<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(
canonical: MovieRow,
localTitle: string,
year?: number
): Promise<void> {
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<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> {
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 } : {}),
},
});
}

View file

@ -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;
}