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 = {
|
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 [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue