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