Fix series episode list: private IDs, unique released, dual episode lookup.

Bump addon to 1.4.0 so Stremio refreshes catalog away from IMDb/Cinemeta merges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 05:11:58 +02:00
parent 0ebaaa1d99
commit 1c76b0cd32
2 changed files with 58 additions and 36 deletions

View file

@ -99,8 +99,16 @@ async function main() {
WHERE e.season_id = s.id
AND e.series_id IS DISTINCT FROM s.series_id
`);
await prisma.$executeRawUnsafe(`
UPDATE episodes e
SET season_id = s.id
FROM seasons s
WHERE s.series_id = e.series_id
AND s.season_number = e.season_number
AND e.season_id IS DISTINCT FROM s.id
`);
} catch (err) {
console.warn("Could not repair episode series_id drift:", err);
console.warn("Could not repair episode series/season drift:", err);
}
const shutdown = async () => {

View file

@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = {
id: "com.mediacluster.library",
version: "1.3.1",
version: "1.4.0",
name: "Media Cluster",
description: "Private distributed media library",
catalogs: [
@ -63,20 +63,26 @@ function paramId(raw: string): string {
return decodeURIComponent(raw).replace(/\.json$/i, "");
}
/** Stremio requires Video.released (ISO-8601); missing/invalid videos are dropped. */
/**
* Stremio requires Video.released (ISO-8601); missing/invalid videos are dropped.
* Always unique per S/E — identical `released` values make some clients collapse the list.
*/
function videoReleased(
airDate: Date | null | undefined,
year: number | null | undefined,
season: number,
episode: number
): string {
const s = Math.max(0, season);
const e = Math.max(0, episode);
if (airDate && !Number.isNaN(airDate.getTime())) {
return airDate.toISOString();
const d = new Date(airDate.getTime());
d.setUTCHours(12, s, Math.min(e, 59), 0);
return d.toISOString();
}
// 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));
d.setUTCDate(d.getUTCDate() + s * 40 + e);
return d.toISOString();
}
@ -395,7 +401,7 @@ async function getSeriesMeta(
mediaFiles: Array<{ id: string }>;
};
async function loadEpisodes(onlyAvailable: boolean, withStill: boolean): Promise<EpRow[]> {
async function loadEpisodes(onlyAvailable: boolean): Promise<EpRow[]> {
const mediaFilter = onlyAvailable
? { some: { available: true as const } }
: { some: {} };
@ -403,25 +409,12 @@ async function getSeriesMeta(
? { 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,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
stillUrl: true,
mediaFiles: mediaSelect,
},
});
}
// Match on episode.seriesId OR season.seriesId — either side can drift after merges.
const rows = await prisma.episode.findMany({
where: { season: { seriesId: series.id }, mediaFiles: mediaFilter },
where: {
OR: [{ seriesId: series.id }, { season: { seriesId: series.id } }],
mediaFiles: mediaFilter,
},
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
@ -433,20 +426,29 @@ async function getSeriesMeta(
mediaFiles: mediaSelect,
},
});
return rows.map((ep) => ({ ...ep, stillUrl: null }));
return rows.map((ep) => ({ ...ep, stillUrl: null as string | 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);
episodes = await loadEpisodes(true);
if (episodes.length === 0) episodes = await loadEpisodes(false);
} catch {
episodes = [];
}
// Stills are optional — never let still_url break or filter the episode list.
const stillById = new Map<string, string | null>();
if (episodes.length > 0) {
try {
const artRows = await prisma.episode.findMany({
where: { id: { in: episodes.map((ep) => ep.id) } },
select: { id: true, stillUrl: true },
});
for (const row of artRows) stillById.set(row.id, row.stillUrl);
} catch {
// ignore missing column / errors
}
}
let logo: string | undefined;
@ -465,9 +467,12 @@ async function getSeriesMeta(
const videos = [];
for (const ep of episodes) {
if (ep.mediaFiles.length === 0) continue;
// Skip specials (S00) — Stremio often hides/misplaces them; keep numbered seasons.
if (ep.seasonNumber <= 0 || ep.episodeNumber <= 0) continue;
const key = `${ep.seasonNumber}:${ep.episodeNumber}`;
if (seen.has(key)) continue;
seen.add(key);
const still = stillById.get(ep.id) ?? ep.stillUrl;
videos.push({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
title:
@ -477,13 +482,21 @@ async function getSeriesMeta(
season: ep.seasonNumber,
episode: ep.episodeNumber,
overview: ep.overview || undefined,
thumbnail: ep.stillUrl || undefined,
// Prefer Metahub-sized stills only when we have them; omit rather than break listing.
thumbnail: still || undefined,
released: videoReleased(ep.airDate, series.year, ep.seasonNumber, ep.episodeNumber),
available: true,
});
}
log?.info?.(
{ seriesId: series.id, sid, videoCount: videos.length, requestId: id },
{
seriesId: series.id,
sid,
videoCount: videos.length,
episodeRows: episodes.length,
requestId: id,
},
"series meta"
);
@ -498,9 +511,10 @@ async function getSeriesMeta(
releaseInfo: series.year ? `${series.year}-` : undefined,
genres: series.genres,
videos,
behaviorHints: videos[0]
? { defaultVideoId: videos[0].id, hasScheduledVideos: false }
: undefined,
behaviorHints: {
hasScheduledVideos: false,
...(videos[0] ? { defaultVideoId: videos[0].id } : {}),
},
};
}