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:
parent
38bd4a822e
commit
82bf5f5844
2 changed files with 156 additions and 38 deletions
|
|
@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue