Stop using IMDb IDs for library series so Stremio no longer merges with Cinemeta.

Always use mcseries-{uuid}, load all episodes with files, unique released dates per episode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 05:08:38 +02:00
parent 6974ac39e8
commit 0ebaaa1d99

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.3.0", version: "1.3.1",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -28,28 +28,29 @@ 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 },
], ],
// catalog stays a string; meta/stream need idPrefixes for custom IDs (Stremio docs) // Never use tt/IMDb as our catalog IDs — that merges with Cinemeta and breaks our episode list.
resources: [ resources: [
"catalog", "catalog",
{ name: "meta", types: ["movie", "series"], idPrefixes: ["tt", "mc", "sth"] }, { name: "meta", types: ["movie", "series"], idPrefixes: ["mc", "sth"] },
{ name: "stream", types: ["movie", "series"], idPrefixes: ["tt", "mc", "sth"] }, { name: "stream", types: ["movie", "series"], idPrefixes: ["mc", "sth"] },
], ],
types: ["movie", "series"], types: ["movie", "series"],
idPrefixes: ["tt", "mc", "sth"], idPrefixes: ["mc", "sth"],
}; };
/** Prefer IMDb IDs (Cinemeta-compatible). Else mcmovie-/mcseries- without extra colons. */ /**
function movieId(imdbId: string | null | undefined, uuid: string): string { * Always use private library IDs. Do NOT prefer IMDb/tt — Stremio then mixes our
if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId; * meta with Cinemeta and the episode list becomes wrong/incomplete.
*/
function movieId(_imdbId: string | null | undefined, uuid: string): string {
return `${ID_PREFIX}movie-${uuid}`; return `${ID_PREFIX}movie-${uuid}`;
} }
function seriesId(imdbId: string | null | undefined, uuid: string): string { function seriesId(_imdbId: string | null | undefined, uuid: string): string {
if (imdbId && /^tt\d+$/i.test(imdbId)) return imdbId;
return `${ID_PREFIX}series-${uuid}`; return `${ID_PREFIX}series-${uuid}`;
} }
/** Strip local id prefixes (current + legacy mc:series: / sth:). */ /** Strip local id prefixes (current + legacy mc:series: / sth: / tt passthrough lookup). */
function unwrapLocalId(id: string, kind: "movie" | "series"): string { function unwrapLocalId(id: string, kind: "movie" | "series"): string {
return id return id
.replace(new RegExp(`^${ID_PREFIX}${kind}-`), "") .replace(new RegExp(`^${ID_PREFIX}${kind}-`), "")
@ -62,15 +63,21 @@ function paramId(raw: string): string {
return decodeURIComponent(raw).replace(/\.json$/i, ""); return decodeURIComponent(raw).replace(/\.json$/i, "");
} }
/** Stremio requires Video.released (ISO-8601); invalid/missing videos are dropped by the client. */ /** Stremio requires Video.released (ISO-8601); missing/invalid videos are dropped. */
function videoReleased(airDate: Date | null | undefined, year: number | null | undefined): string { function videoReleased(
airDate: Date | null | undefined,
year: number | null | undefined,
season: number,
episode: number
): string {
if (airDate && !Number.isNaN(airDate.getTime())) { if (airDate && !Number.isNaN(airDate.getTime())) {
return airDate.toISOString(); return airDate.toISOString();
} }
if (year && year > 1900) { // Unique synthetic date per SxxExx so clients never collapse episodes.
return `${year}-01-01T00:00:00.000Z`; const base = year && year > 1900 ? year : 2000;
} const d = new Date(Date.UTC(base, 0, 1, 12, 0, 0));
return "2000-01-01T00:00:00.000Z"; d.setUTCDate(d.getUTCDate() + Math.max(0, season) * 40 + Math.max(0, episode));
return d.toISOString();
} }
export async function registerStremioRoutes(app: FastifyInstance, config: Config) { export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
@ -348,10 +355,12 @@ async function getSeriesMeta(
metadata: MetadataService, metadata: MetadataService,
log?: { info?: (obj: unknown, msg?: string) => void; warn?: (obj: unknown, msg?: string) => void } log?: { info?: (obj: unknown, msg?: string) => void; warn?: (obj: unknown, msg?: string) => void }
) { ) {
// Core listing: never select optional stillUrl/logoUrl columns here. const localUuid = unwrapLocalId(id, "series");
// Resolve by our private id only (mcseries-uuid). Also accept legacy mc:series:uuid / bare uuid.
// Do NOT look up by imdbId — that pulls the wrong row after TMDB linking.
const series = await prisma.series.findFirst({ const series = await prisma.series.findFirst({
where: { where: {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], OR: [{ id: localUuid }, { id }],
}, },
select: { select: {
id: true, id: true,
@ -362,32 +371,11 @@ async function getSeriesMeta(
backdropUrl: true, backdropUrl: true,
genres: true, genres: true,
imdbId: true, imdbId: true,
seasons: {
orderBy: { seasonNumber: "asc" },
select: {
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles: {
where: { available: true },
take: 1,
select: { id: true },
},
},
},
},
},
}, },
}); });
if (!series) { if (!series) {
log?.warn?.({ id }, "series meta: not found"); log?.warn?.({ id, localUuid }, "series meta: not found");
return null; return null;
} }
@ -396,76 +384,103 @@ async function getSeriesMeta(
const sid = seriesId(series.imdbId, series.id); const sid = seriesId(series.imdbId, series.id);
let episodes = series.seasons.flatMap((season) => type EpRow = {
season.episodes.filter((ep) => ep.mediaFiles.length > 0) id: string;
); seasonNumber: number;
episodeNumber: number;
title: string | null;
overview: string | null;
airDate: Date | null;
stillUrl: string | null;
mediaFiles: Array<{ id: string }>;
};
if (episodes.length === 0) { async function loadEpisodes(onlyAvailable: boolean, withStill: boolean): Promise<EpRow[]> {
const fallback = await prisma.series.findFirst({ const mediaFilter = onlyAvailable
where: { id: series.id }, ? { some: { available: true as const } }
select: { : { some: {} };
seasons: { const mediaSelect = onlyAvailable
orderBy: { seasonNumber: "asc" }, ? { where: { available: true as const }, take: 1 as const, select: { id: true as const } }
select: { : { take: 1 as const, select: { id: true as const } };
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], if (withStill) {
select: { return prisma.episode.findMany({
id: true, where: { season: { seriesId: series.id }, mediaFiles: mediaFilter },
seasonNumber: true, orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
episodeNumber: true, select: {
title: true, id: true,
overview: true, seasonNumber: true,
airDate: true, episodeNumber: true,
mediaFiles: { take: 1, select: { id: true } }, title: true,
}, overview: true,
}, airDate: true,
}, stillUrl: true,
mediaFiles: mediaSelect,
}, },
});
}
const rows = await prisma.episode.findMany({
where: { season: { seriesId: series.id }, mediaFiles: mediaFilter },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles: mediaSelect,
}, },
}); });
episodes = return rows.map((ep) => ({ ...ep, stillUrl: null }));
fallback?.seasons.flatMap((season) => }
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
) ?? []; let episodes: EpRow[] = [];
try {
episodes = await loadEpisodes(true, true);
if (episodes.length === 0) episodes = await loadEpisodes(false, true);
} catch {
try {
episodes = await loadEpisodes(true, false);
if (episodes.length === 0) episodes = await loadEpisodes(false, false);
} catch {
episodes = [];
}
} }
const stillByEpisodeId = new Map<string, string>();
let logo: string | undefined; let logo: string | undefined;
try { try {
const art = await prisma.series.findFirst({ const art = await prisma.series.findFirst({
where: { id: series.id }, where: { id: series.id },
select: { select: { logoUrl: true },
logoUrl: true,
seasons: {
select: {
episodes: { select: { id: true, stillUrl: true } },
},
},
},
}); });
logo = art?.logoUrl ?? undefined; logo = art?.logoUrl ?? undefined;
for (const season of art?.seasons ?? []) {
for (const ep of season.episodes) {
if (ep.stillUrl) stillByEpisodeId.set(ep.id, ep.stillUrl);
}
}
} catch { } catch {
// optional columns // optional
} }
const videos = episodes.map((ep) => ({ // Deduplicate by S/E (keep first) so Stremio never gets colliding video ids
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, const seen = new Set<string>();
title: const videos = [];
ep.title && !/^episode\s+\d+$/i.test(ep.title.trim()) for (const ep of episodes) {
? ep.title if (ep.mediaFiles.length === 0) continue;
: `Episode ${ep.episodeNumber}`, const key = `${ep.seasonNumber}:${ep.episodeNumber}`;
season: ep.seasonNumber, if (seen.has(key)) continue;
episode: ep.episodeNumber, seen.add(key);
overview: ep.overview || undefined, videos.push({
thumbnail: stillByEpisodeId.get(ep.id), id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
// REQUIRED by Stremio — without this the client drops the video title:
released: videoReleased(ep.airDate, series.year), ep.title && !/^episode\s+\d+$/i.test(ep.title.trim())
})); ? ep.title
: `Episode ${ep.episodeNumber}`,
season: ep.seasonNumber,
episode: ep.episodeNumber,
overview: ep.overview || undefined,
thumbnail: ep.stillUrl || undefined,
released: videoReleased(ep.airDate, series.year, ep.seasonNumber, ep.episodeNumber),
});
}
log?.info?.( log?.info?.(
{ seriesId: series.id, sid, videoCount: videos.length, requestId: id }, { seriesId: series.id, sid, videoCount: videos.length, requestId: id },
@ -542,7 +557,10 @@ async function getStreams(
const series = await prisma.series.findFirst({ const series = await prisma.series.findFirst({
where: { where: {
OR: [{ imdbId: seriesKey }, { id: unwrapLocalId(seriesKey, "series") }], OR: [
{ id: unwrapLocalId(seriesKey, "series") },
{ id: seriesKey },
],
}, },
}); });
if (!series) return []; if (!series) return [];