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:
parent
0ebaaa1d99
commit
1c76b0cd32
2 changed files with 58 additions and 36 deletions
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue