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