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;
year: number | null;
imdbId: string | null;
episodesWithFiles?: number;
fileCount?: number;
seasons: Array<{
seasonNumber: number;
episodes: Array<{
@ -43,7 +45,8 @@ export default function SeriesPage() {
>
<strong>{s.title}</strong>
<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>
</div>
{expanded === s.id && s.seasons.map((season) => (

View file

@ -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,
})),
})),
})),
})),
})),
};
}),
};
});

View file

@ -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<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>();
@ -128,7 +138,12 @@ export class MetadataService {
});
const seriesGroups = new Map<string, typeof 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) ?? [];
list.push(s);
seriesGroups.set(key, list);
@ -158,6 +173,7 @@ export class MetadataService {
}
private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise<void> {
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<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> {
const parsed = file.parsedMovie!;
const title = parsed.title.trim();
@ -589,19 +636,19 @@ export class MetadataService {
}
private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise<void> {
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);
}
}

View file

@ -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<EpRow[]> {
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<string, string | null>();
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<string>();
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 },
};
}