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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 05:22:09 +02:00
parent 8b3ef15dc9
commit 38bd4a822e
4 changed files with 168 additions and 84 deletions

View file

@ -8,6 +8,8 @@ interface Series {
title: string; title: string;
year: number | null; year: number | null;
imdbId: string | null; imdbId: string | null;
episodesWithFiles?: number;
fileCount?: number;
seasons: Array<{ seasons: Array<{
seasonNumber: number; seasonNumber: number;
episodes: Array<{ episodes: Array<{
@ -43,7 +45,8 @@ export default function SeriesPage() {
> >
<strong>{s.title}</strong> <strong>{s.title}</strong>
<span style={{ color: "#71717a" }}> <span style={{ color: "#71717a" }}>
{s.seasons.length} season(s) · {expanded === s.id ? "▲" : "▼"} {s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden ·{" "}
{s.seasons.length} seizoen(en) · {expanded === s.id ? "▲" : "▼"}
</span> </span>
</div> </div>
{expanded === s.id && s.seasons.map((season) => ( {expanded === s.id && s.seasons.map((season) => (

View file

@ -157,13 +157,25 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}); });
return { return {
series: series.map((s) => ({ 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, id: s.id,
title: s.title, title: s.title,
year: s.year, year: s.year,
imdbId: s.imdbId, imdbId: s.imdbId,
tmdbId: s.tmdbId, tmdbId: s.tmdbId,
posterUrl: s.posterUrl, posterUrl: s.posterUrl,
episodesWithFiles,
fileCount,
seasons: s.seasons.map((season) => ({ seasons: s.seasons.map((season) => ({
seasonNumber: season.seasonNumber, seasonNumber: season.seasonNumber,
episodes: season.episodes.map((ep) => ({ episodes: season.episodes.map((ep) => ({
@ -181,7 +193,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
})), })),
})), })),
})), })),
})), };
}),
}; };
}); });

View file

@ -63,6 +63,16 @@ function parseAirDate(raw?: string): Date | undefined {
return d; 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 { export class MetadataService {
private movieCache = new Map<number, TmdbMeta>(); private movieCache = new Map<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>(); private seriesCache = new Map<number, TmdbMeta>();
@ -128,7 +138,12 @@ export class MetadataService {
}); });
const seriesGroups = new Map<string, typeof seriesList>(); const seriesGroups = new Map<string, typeof seriesList>();
for (const s of seriesList) { 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) ?? []; const list = seriesGroups.get(key) ?? [];
list.push(s); list.push(s);
seriesGroups.set(key, list); seriesGroups.set(key, list);
@ -158,6 +173,7 @@ export class MetadataService {
} }
private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise<void> { private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise<void> {
if (canonicalId === orphanId) return;
const orphanSeasons = await prisma.season.findMany({ const orphanSeasons = await prisma.season.findMany({
where: { seriesId: orphanId }, where: { seriesId: orphanId },
include: { episodes: { include: { mediaFiles: true } } }, include: { episodes: { include: { mediaFiles: true } } },
@ -216,6 +232,37 @@ export class MetadataService {
await prisma.series.delete({ where: { id: orphanId } }); 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<number> {
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<string | null> { private async matchMovie(nodeId: string, file: MediaFileInfo): Promise<string | null> {
const parsed = file.parsedMovie!; const parsed = file.parsedMovie!;
const title = parsed.title.trim(); const title = parsed.title.trim();
@ -589,19 +636,19 @@ export class MetadataService {
} }
private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise<void> { private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise<void> {
const titles = Array.from( const keys = new Set(
new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean)) [canonical.title, localTitle]
.map((t) => normalizeSeriesKey(t.trim()))
.filter(Boolean)
); );
const orphans = await prisma.series.findMany({ if (keys.size === 0) return;
where: {
id: { not: canonical.id }, const candidates = await prisma.series.findMany({
tmdbId: null, where: { id: { not: canonical.id }, tmdbId: null },
OR: titles.map((t) => ({ select: { id: true, title: true },
title: { equals: t, mode: "insensitive" as const },
})),
},
}); });
for (const orphan of orphans) { for (const orphan of candidates) {
if (!keys.has(normalizeSeriesKey(orphan.title))) continue;
await this.mergeSeriesInto(canonical.id, orphan.id); await this.mergeSeriesInto(canonical.id, orphan.id);
} }
} }

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.4.0", version: "1.4.1",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -385,13 +385,24 @@ async function getSeriesMeta(
return null; 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; const seriesDbId = series.id;
void metadata.ensureSeriesArtwork(seriesDbId).catch(() => undefined); void metadata.ensureSeriesArtwork(seriesDbId).catch(() => undefined);
void metadata.enrichSeriesEpisodes(seriesDbId).catch(() => undefined); void metadata.enrichSeriesEpisodes(seriesDbId).catch(() => undefined);
const sid = seriesId(series.imdbId, seriesDbId); 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; id: string;
seasonNumber: number; seasonNumber: number;
episodeNumber: number; episodeNumber: number;
@ -399,24 +410,22 @@ async function getSeriesMeta(
overview: string | null; overview: string | null;
airDate: Date | null; airDate: Date | null;
stillUrl: string | null; stillUrl: string | null;
mediaFiles: Array<{ id: string }>; available: boolean;
releaseName: string;
}; };
async function loadEpisodes(onlyAvailable: boolean): Promise<EpRow[]> { let fileEps: FileEp[] = [];
const mediaFilter = onlyAvailable try {
? { some: { available: true as const } } const rows = await prisma.mediaFile.findMany({
: { 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({
where: { where: {
episode: {
OR: [{ seriesId: seriesDbId }, { season: { seriesId: seriesDbId } }], OR: [{ seriesId: seriesDbId }, { season: { seriesId: seriesDbId } }],
mediaFiles: mediaFilter,
}, },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], },
select: {
available: true,
releaseName: true,
episode: {
select: { select: {
id: true, id: true,
seasonNumber: true, seasonNumber: true,
@ -424,38 +433,51 @@ async function getSeriesMeta(
title: true, title: true,
overview: true, overview: true,
airDate: true, airDate: true,
mediaFiles: mediaSelect, },
},
}, },
}); });
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[] = []; // Prefer available files; if none, still list whatever is linked.
try { const preferred = fileEps.some((e) => e.available)
episodes = await loadEpisodes(true); ? fileEps.filter((e) => e.available)
if (episodes.length === 0) episodes = await loadEpisodes(false); : fileEps;
} catch {
episodes = [];
}
// Stills are optional — never let still_url break or filter the episode list.
const stillById = new Map<string, string | null>(); const stillById = new Map<string, string | null>();
if (episodes.length > 0) { const epIds = [...new Set(preferred.map((e) => e.id))];
if (epIds.length > 0) {
try { try {
const artRows = await prisma.episode.findMany({ const artRows = await prisma.episode.findMany({
where: { id: { in: episodes.map((ep) => ep.id) } }, where: { id: { in: epIds } },
select: { id: true, stillUrl: true }, select: { id: true, stillUrl: true },
}); });
for (const row of artRows) stillById.set(row.id, row.stillUrl); for (const row of artRows) stillById.set(row.id, row.stillUrl);
} catch { } catch {
// ignore missing column / errors // optional column
} }
} }
let logo: string | undefined; let logo: string | undefined;
try { try {
const art = await prisma.series.findFirst({ const art = await prisma.series.findFirst({
where: { id: series.id }, where: { id: seriesDbId },
select: { logoUrl: true }, select: { logoUrl: true },
}); });
logo = art?.logoUrl ?? undefined; logo = art?.logoUrl ?? undefined;
@ -463,17 +485,18 @@ async function getSeriesMeta(
// optional // optional
} }
// Deduplicate by S/E (keep first) so Stremio never gets colliding video ids
const seen = new Set<string>(); const seen = new Set<string>();
const videos = []; const videos = [];
for (const ep of episodes) { // Sort by S/E for stable Stremio season UI
if (ep.mediaFiles.length === 0) continue; preferred.sort(
// Skip specials (S00) — Stremio often hides/misplaces them; keep numbered seasons. (a, b) => a.seasonNumber - b.seasonNumber || a.episodeNumber - b.episodeNumber
);
for (const ep of preferred) {
if (ep.seasonNumber <= 0 || ep.episodeNumber <= 0) continue; 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; const still = stillById.get(ep.id);
videos.push({ videos.push({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`, id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
title: title:
@ -483,7 +506,6 @@ async function getSeriesMeta(
season: ep.seasonNumber, season: ep.seasonNumber,
episode: ep.episodeNumber, episode: ep.episodeNumber,
overview: ep.overview || undefined, overview: ep.overview || undefined,
// Prefer Metahub-sized stills only when we have them; omit rather than break listing.
thumbnail: still || undefined, 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, available: true,
@ -492,10 +514,11 @@ async function getSeriesMeta(
log?.info?.( log?.info?.(
{ {
seriesId: series.id, seriesId: seriesDbId,
sid, sid,
videoCount: videos.length, videoCount: videos.length,
episodeRows: episodes.length, fileRows: fileEps.length,
sample: preferred.slice(0, 5).map((e) => e.releaseName),
requestId: id, requestId: id,
}, },
"series meta" "series meta"
@ -512,10 +535,8 @@ async function getSeriesMeta(
releaseInfo: series.year ? `${series.year}-` : undefined, releaseInfo: series.year ? `${series.year}-` : undefined,
genres: series.genres, genres: series.genres,
videos, videos,
behaviorHints: { // Do NOT set defaultVideoId — that jumps Stremio straight into one episode's streams.
hasScheduledVideos: false, behaviorHints: { hasScheduledVideos: false },
...(videos[0] ? { defaultVideoId: videos[0].id } : {}),
},
}; };
} }