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 WHERE e.season_id = s.id
AND e.series_id IS DISTINCT FROM s.series_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) { } 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 () => { const shutdown = async () => {

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.1", version: "1.4.0",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -63,20 +63,26 @@ function paramId(raw: string): string {
return decodeURIComponent(raw).replace(/\.json$/i, ""); 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( function videoReleased(
airDate: Date | null | undefined, airDate: Date | null | undefined,
year: number | null | undefined, year: number | null | undefined,
season: number, season: number,
episode: number episode: number
): string { ): string {
const s = Math.max(0, season);
const e = Math.max(0, episode);
if (airDate && !Number.isNaN(airDate.getTime())) { 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 base = year && year > 1900 ? year : 2000;
const d = new Date(Date.UTC(base, 0, 1, 12, 0, 0)); 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(); return d.toISOString();
} }
@ -395,7 +401,7 @@ async function getSeriesMeta(
mediaFiles: Array<{ id: string }>; mediaFiles: Array<{ id: string }>;
}; };
async function loadEpisodes(onlyAvailable: boolean, withStill: boolean): Promise<EpRow[]> { async function loadEpisodes(onlyAvailable: boolean): Promise<EpRow[]> {
const mediaFilter = onlyAvailable const mediaFilter = onlyAvailable
? { some: { available: true as const } } ? { some: { available: true as const } }
: { some: {} }; : { some: {} };
@ -403,25 +409,12 @@ async function getSeriesMeta(
? { where: { available: true as const }, take: 1 as const, select: { id: true as const } } ? { where: { available: true as const }, take: 1 as const, select: { id: true as const } }
: { take: 1 as const, select: { id: true as const } }; : { take: 1 as const, select: { id: true as const } };
if (withStill) { // Match on episode.seriesId OR season.seriesId — either side can drift after merges.
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,
},
});
}
const rows = await prisma.episode.findMany({ 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" }], orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: { select: {
id: true, id: true,
@ -433,19 +426,28 @@ async function getSeriesMeta(
mediaFiles: mediaSelect, mediaFiles: mediaSelect,
}, },
}); });
return rows.map((ep) => ({ ...ep, stillUrl: null })); return rows.map((ep) => ({ ...ep, stillUrl: null as string | null }));
} }
let episodes: EpRow[] = []; let episodes: EpRow[] = [];
try { try {
episodes = await loadEpisodes(true, true); episodes = await loadEpisodes(true);
if (episodes.length === 0) episodes = await loadEpisodes(false, true); if (episodes.length === 0) episodes = await loadEpisodes(false);
} catch { } 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 { try {
episodes = await loadEpisodes(true, false); const artRows = await prisma.episode.findMany({
if (episodes.length === 0) episodes = await loadEpisodes(false, false); 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 { } catch {
episodes = []; // ignore missing column / errors
} }
} }
@ -465,9 +467,12 @@ async function getSeriesMeta(
const videos = []; const videos = [];
for (const ep of episodes) { for (const ep of episodes) {
if (ep.mediaFiles.length === 0) continue; 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}`; const key = `${ep.seasonNumber}:${ep.episodeNumber}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const still = stillById.get(ep.id) ?? ep.stillUrl;
videos.push({ videos.push({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
title: title:
@ -477,13 +482,21 @@ async function getSeriesMeta(
season: ep.seasonNumber, season: ep.seasonNumber,
episode: ep.episodeNumber, episode: ep.episodeNumber,
overview: ep.overview || undefined, 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), released: videoReleased(ep.airDate, series.year, ep.seasonNumber, ep.episodeNumber),
available: true,
}); });
} }
log?.info?.( 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" "series meta"
); );
@ -498,9 +511,10 @@ async function getSeriesMeta(
releaseInfo: series.year ? `${series.year}-` : undefined, releaseInfo: series.year ? `${series.year}-` : undefined,
genres: series.genres, genres: series.genres,
videos, videos,
behaviorHints: videos[0] behaviorHints: {
? { defaultVideoId: videos[0].id, hasScheduledVideos: false } hasScheduledVideos: false,
: undefined, ...(videos[0] ? { defaultVideoId: videos[0].id } : {}),
},
}; };
} }