diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index a7f4a16..006c45e 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -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 () => { diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index f024def..6f06dcc 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -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 { + async function loadEpisodes(onlyAvailable: boolean): Promise { 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,19 +426,28 @@ 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); + 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(); + if (episodes.length > 0) { try { - episodes = await loadEpisodes(true, false); - if (episodes.length === 0) episodes = await loadEpisodes(false, false); + 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 { - episodes = []; + // ignore missing column / errors } } @@ -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 } : {}), + }, }; }