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:
parent
6974ac39e8
commit
0ebaaa1d99
1 changed files with 115 additions and 97 deletions
|
|
@ -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 [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue