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,
|
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 } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue