From 38bd4a822e9f586d127550f21d0744cd8f41d6d2 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 05:22:09 +0200 Subject: [PATCH] Fix series showing only S01E01: consolidate duplicate series and list from files. Merge title/TMDB siblings on meta open, drop defaultVideoId, show episode file counts in admin. Co-authored-by: Cursor --- apps/admin-ui/src/app/series/page.tsx | 5 +- apps/master-api/src/admin/routes.ts | 59 +++++++----- apps/master-api/src/metadata/service.ts | 71 +++++++++++--- apps/master-api/src/stremio/routes.ts | 117 ++++++++++++++---------- 4 files changed, 168 insertions(+), 84 deletions(-) diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index 8ef633c..195d6f6 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -8,6 +8,8 @@ interface Series { title: string; year: number | null; imdbId: string | null; + episodesWithFiles?: number; + fileCount?: number; seasons: Array<{ seasonNumber: number; episodes: Array<{ @@ -43,7 +45,8 @@ export default function SeriesPage() { > {s.title} - {s.seasons.length} season(s) · {expanded === s.id ? "▲" : "▼"} + {s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden ·{" "} + {s.seasons.length} seizoen(en) · {expanded === s.id ? "▲" : "▼"} {expanded === s.id && s.seasons.map((season) => ( diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index c8d80a7..175dc40 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -157,31 +157,44 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) }); return { - series: series.map((s) => ({ - id: s.id, - title: s.title, - year: s.year, - imdbId: s.imdbId, - tmdbId: s.tmdbId, - posterUrl: s.posterUrl, - seasons: s.seasons.map((season) => ({ - seasonNumber: season.seasonNumber, - episodes: season.episodes.map((ep) => ({ - id: ep.id, - seasonNumber: ep.seasonNumber, - episodeNumber: ep.episodeNumber, - title: ep.title, - files: ep.mediaFiles.map((f) => ({ - id: f.id, - node: f.node.name, - nodeStatus: f.node.status, - resolution: f.resolution, - available: f.available, - releaseName: f.releaseName, + series: series.map((s) => { + const episodesWithFiles = s.seasons.reduce( + (n, season) => n + season.episodes.filter((ep) => ep.mediaFiles.length > 0).length, + 0 + ); + const fileCount = s.seasons.reduce( + (n, season) => + n + season.episodes.reduce((m, ep) => m + ep.mediaFiles.length, 0), + 0 + ); + return { + id: s.id, + title: s.title, + year: s.year, + imdbId: s.imdbId, + tmdbId: s.tmdbId, + posterUrl: s.posterUrl, + episodesWithFiles, + fileCount, + seasons: s.seasons.map((season) => ({ + seasonNumber: season.seasonNumber, + episodes: season.episodes.map((ep) => ({ + id: ep.id, + seasonNumber: ep.seasonNumber, + episodeNumber: ep.episodeNumber, + title: ep.title, + files: ep.mediaFiles.map((f) => ({ + id: f.id, + node: f.node.name, + nodeStatus: f.node.status, + resolution: f.resolution, + available: f.available, + releaseName: f.releaseName, + })), })), })), - })), - })), + }; + }), }; }); diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index da5ad9e..5d730ba 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -63,6 +63,16 @@ function parseAirDate(raw?: string): Date | undefined { return d; } +/** Collapse punctuation/spacing so "13 Reasons Why" == "13_Reasons_Why". */ +function normalizeSeriesKey(title: string): string { + return title + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/&/g, "and") + .replace(/[^a-z0-9]+/g, ""); +} + export class MetadataService { private movieCache = new Map(); private seriesCache = new Map(); @@ -128,7 +138,12 @@ export class MetadataService { }); const seriesGroups = new Map(); for (const s of seriesList) { - const key = s.title.trim().toLowerCase(); + const key = + s.tmdbId != null + ? `tmdb:${s.tmdbId}` + : s.imdbId + ? `imdb:${s.imdbId}` + : `title:${normalizeSeriesKey(s.title)}`; const list = seriesGroups.get(key) ?? []; list.push(s); seriesGroups.set(key, list); @@ -158,6 +173,7 @@ export class MetadataService { } private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise { + if (canonicalId === orphanId) return; const orphanSeasons = await prisma.season.findMany({ where: { seriesId: orphanId }, include: { episodes: { include: { mediaFiles: true } } }, @@ -216,6 +232,37 @@ export class MetadataService { await prisma.series.delete({ where: { id: orphanId } }); } + /** + * Pull duplicate series rows (same TMDB/IMDb/normalized title) into one canonical + * so Stremio meta sees every episode file, not just the TMDB shell with one match. + */ + async consolidateSeries(seriesId: string): Promise { + const canonical = await prisma.series.findUnique({ where: { id: seriesId } }); + if (!canonical) return 0; + + const all = await prisma.series.findMany({ + select: { id: true, title: true, tmdbId: true, imdbId: true }, + }); + const norm = normalizeSeriesKey(canonical.title); + const orphans = all.filter((s) => { + if (s.id === canonical.id) return false; + if (canonical.tmdbId != null && s.tmdbId === canonical.tmdbId) return true; + if (canonical.imdbId && s.imdbId === canonical.imdbId) return true; + return normalizeSeriesKey(s.title) === norm; + }); + + let merged = 0; + for (const orphan of orphans) { + try { + await this.mergeSeriesInto(canonical.id, orphan.id); + merged += 1; + } catch { + // unique conflicts / race — skip + } + } + return merged; + } + private async matchMovie(nodeId: string, file: MediaFileInfo): Promise { const parsed = file.parsedMovie!; const title = parsed.title.trim(); @@ -589,19 +636,19 @@ export class MetadataService { } private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise { - const titles = Array.from( - new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean)) + const keys = new Set( + [canonical.title, localTitle] + .map((t) => normalizeSeriesKey(t.trim())) + .filter(Boolean) ); - const orphans = await prisma.series.findMany({ - where: { - id: { not: canonical.id }, - tmdbId: null, - OR: titles.map((t) => ({ - title: { equals: t, mode: "insensitive" as const }, - })), - }, + if (keys.size === 0) return; + + const candidates = await prisma.series.findMany({ + where: { id: { not: canonical.id }, tmdbId: null }, + select: { id: true, title: true }, }); - for (const orphan of orphans) { + for (const orphan of candidates) { + if (!keys.has(normalizeSeriesKey(orphan.title))) continue; await this.mergeSeriesInto(canonical.id, orphan.id); } } diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index adfe54c..e076822 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.4.0", + version: "1.4.1", name: "Media Cluster", description: "Private distributed media library", catalogs: [ @@ -385,13 +385,24 @@ async function getSeriesMeta( return null; } + // Fold duplicate series rows into this one so every matched file shows up. + try { + const merged = await metadata.consolidateSeries(series.id); + if (merged > 0) { + log?.info?.({ seriesId: series.id, merged }, "series meta: consolidated duplicates"); + } + } catch (err) { + log?.warn?.({ err, seriesId: series.id }, "series meta: consolidate failed"); + } + const seriesDbId = series.id; void metadata.ensureSeriesArtwork(seriesDbId).catch(() => undefined); void metadata.enrichSeriesEpisodes(seriesDbId).catch(() => undefined); const sid = seriesId(series.imdbId, seriesDbId); - type EpRow = { + // Build from media files (source of truth), not episode rows that may lack files. + type FileEp = { id: string; seasonNumber: number; episodeNumber: number; @@ -399,63 +410,74 @@ async function getSeriesMeta( overview: string | null; airDate: Date | null; stillUrl: string | null; - mediaFiles: Array<{ id: string }>; + available: boolean; + releaseName: string; }; - async function loadEpisodes(onlyAvailable: boolean): Promise { - const mediaFilter = onlyAvailable - ? { some: { available: true as const } } - : { some: {} }; - const mediaSelect = onlyAvailable - ? { where: { available: true as const }, take: 1 as const, select: { id: true as const } } - : { take: 1 as const, select: { id: true as const } }; - - // Match on episode.seriesId OR season.seriesId — either side can drift after merges. - const rows = await prisma.episode.findMany({ + let fileEps: FileEp[] = []; + try { + const rows = await prisma.mediaFile.findMany({ where: { - OR: [{ seriesId: seriesDbId }, { season: { seriesId: seriesDbId } }], - mediaFiles: mediaFilter, + episode: { + OR: [{ seriesId: seriesDbId }, { season: { seriesId: seriesDbId } }], + }, }, - orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], select: { - id: true, - seasonNumber: true, - episodeNumber: true, - title: true, - overview: true, - airDate: true, - mediaFiles: mediaSelect, + available: true, + releaseName: true, + episode: { + select: { + id: true, + seasonNumber: true, + episodeNumber: true, + title: true, + overview: true, + airDate: true, + }, + }, }, }); - return rows.map((ep) => ({ ...ep, stillUrl: null as string | null })); + fileEps = rows + .filter((r) => r.episode != null) + .map((r) => ({ + id: r.episode!.id, + seasonNumber: r.episode!.seasonNumber, + episodeNumber: r.episode!.episodeNumber, + title: r.episode!.title, + overview: r.episode!.overview, + airDate: r.episode!.airDate, + stillUrl: null as string | null, + available: r.available, + releaseName: r.releaseName, + })); + } catch (err) { + log?.warn?.({ err, seriesId: seriesDbId }, "series meta: mediaFile query failed"); + fileEps = []; } - let episodes: EpRow[] = []; - try { - episodes = await loadEpisodes(true); - if (episodes.length === 0) episodes = await loadEpisodes(false); - } catch { - episodes = []; - } + // Prefer available files; if none, still list whatever is linked. + const preferred = fileEps.some((e) => e.available) + ? fileEps.filter((e) => e.available) + : fileEps; - // Stills are optional — never let still_url break or filter the episode list. const stillById = new Map(); - if (episodes.length > 0) { + const epIds = [...new Set(preferred.map((e) => e.id))]; + if (epIds.length > 0) { try { const artRows = await prisma.episode.findMany({ - where: { id: { in: episodes.map((ep) => ep.id) } }, + where: { id: { in: epIds } }, select: { id: true, stillUrl: true }, }); for (const row of artRows) stillById.set(row.id, row.stillUrl); } catch { - // ignore missing column / errors + // optional column } } let logo: string | undefined; try { const art = await prisma.series.findFirst({ - where: { id: series.id }, + where: { id: seriesDbId }, select: { logoUrl: true }, }); logo = art?.logoUrl ?? undefined; @@ -463,17 +485,18 @@ async function getSeriesMeta( // optional } - // Deduplicate by S/E (keep first) so Stremio never gets colliding video ids const seen = new Set(); const videos = []; - for (const ep of episodes) { - if (ep.mediaFiles.length === 0) continue; - // Skip specials (S00) — Stremio often hides/misplaces them; keep numbered seasons. + // Sort by S/E for stable Stremio season UI + preferred.sort( + (a, b) => a.seasonNumber - b.seasonNumber || a.episodeNumber - b.episodeNumber + ); + for (const ep of preferred) { 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; + const still = stillById.get(ep.id); videos.push({ id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, title: @@ -483,7 +506,6 @@ async function getSeriesMeta( season: ep.seasonNumber, episode: ep.episodeNumber, overview: ep.overview || 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, @@ -492,10 +514,11 @@ async function getSeriesMeta( log?.info?.( { - seriesId: series.id, + seriesId: seriesDbId, sid, videoCount: videos.length, - episodeRows: episodes.length, + fileRows: fileEps.length, + sample: preferred.slice(0, 5).map((e) => e.releaseName), requestId: id, }, "series meta" @@ -512,10 +535,8 @@ async function getSeriesMeta( releaseInfo: series.year ? `${series.year}-` : undefined, genres: series.genres, videos, - behaviorHints: { - hasScheduledVideos: false, - ...(videos[0] ? { defaultVideoId: videos[0].id } : {}), - }, + // Do NOT set defaultVideoId — that jumps Stremio straight into one episode's streams. + behaviorHints: { hasScheduledVideos: false }, }; }