diff --git a/apps/admin-ui/src/app/dashboard/page.tsx b/apps/admin-ui/src/app/dashboard/page.tsx
index 6e41ce2..9faec8c 100644
--- a/apps/admin-ui/src/app/dashboard/page.tsx
+++ b/apps/admin-ui/src/app/dashboard/page.tsx
@@ -49,6 +49,7 @@ export default function DashboardPage() {
const [error, setError] = useState("");
const [dedupeMsg, setDedupeMsg] = useState("");
const [imdbMsg, setImdbMsg] = useState("");
+ const [repairMsg, setRepairMsg] = useState("");
const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" })
@@ -69,6 +70,31 @@ export default function DashboardPage() {
load();
}, [load]);
+ async function repairLibrary() {
+ if (
+ !confirm(
+ "Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Ontbrekende IMDb-IDs backfillen\n\nDit kan enkele minuten duren."
+ )
+ ) {
+ return;
+ }
+ setRepairMsg("Bezig…");
+ const res = await fetch("/api/v1/admin/library/repair", {
+ method: "POST",
+ credentials: "include",
+ });
+ const json = await res.json();
+ if (!res.ok) {
+ setRepairMsg(json.error?.message ?? "Mislukt");
+ return;
+ }
+ const imdb = json.imdb ?? {};
+ setRepairMsg(
+ `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files weg · IMDb films +${imdb.movies?.updated ?? 0}, series +${imdb.series?.updated ?? 0}. Herlaad daarna de Stremio-addon.`
+ );
+ load();
+ }
+
async function backfillImdb() {
if (
!confirm(
@@ -180,6 +206,26 @@ export default function DashboardPage() {
+ Herstel na pad-wijziging
+
+
+ Gebruik dit als Cinemeta geen streams meer toont na een Unraid pad-wijziging
+ (oude /media/movies-bestanden + dubbele titels zonder IMDb).
+
+
+ {repairMsg && (
+
+ {repairMsg}
+
+ )}
+
Bibliotheek opruimen
diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts
index ba76822..718399e 100644
--- a/apps/master-api/src/admin/routes.ts
+++ b/apps/master-api/src/admin/routes.ts
@@ -45,11 +45,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
prisma.movie.count({ where: { mediaFiles: { some: { available: true } } } }),
prisma.series.count({
where: {
- seasons: {
- some: {
- episodes: { some: { mediaFiles: { some: { available: true } } } },
- },
- },
+ episodes: { some: { mediaFiles: { some: { available: true } } } },
},
}),
prisma.mediaFile.count(),
@@ -463,6 +459,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return result;
});
+ app.post("/api/v1/admin/library/repair", { preHandler: requireAdmin }, async () => {
+ const result = await metadata.repairLibraryAfterPathMigration();
+ return result;
+ });
+
app.get("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async () => {
const { ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();
diff --git a/apps/master-api/src/media/sync.ts b/apps/master-api/src/media/sync.ts
index 646d1f9..dd3584e 100644
--- a/apps/master-api/src/media/sync.ts
+++ b/apps/master-api/src/media/sync.ts
@@ -58,12 +58,28 @@ export class LibrarySyncService {
const keepIds = options.fileIdsSoFar;
const existing = await prisma.mediaFile.findMany({
where: { nodeId },
- select: { id: true, localFileId: true },
+ select: { id: true, localFileId: true, available: true },
});
+ const availableCount = existing.filter((r) => r.available).length;
const toDisable = existing
.filter((row) => !keepIds.has(row.localFileId))
.map((row) => row.id);
+ // Guard: incomplete sync (scan still running) must not wipe the library.
+ if (
+ availableCount > 500 &&
+ keepIds.size < Math.max(100, Math.floor(availableCount * 0.5))
+ ) {
+ console.warn(
+ `Skipping library prune for ${nodeId}: sync has ${keepIds.size} files but ${availableCount} are available (likely incomplete scan)`
+ );
+ await prisma.node.update({
+ where: { id: nodeId },
+ data: { lastRevision: nodeRevision },
+ });
+ return;
+ }
+
for (let i = 0; i < toDisable.length; i += PRUNE_CHUNK) {
const chunk = toDisable.slice(i, i + PRUNE_CHUNK);
await prisma.mediaFile.updateMany({
diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts
index a1c9acc..712eea8 100644
--- a/apps/master-api/src/metadata/service.ts
+++ b/apps/master-api/src/metadata/service.ts
@@ -104,6 +104,45 @@ function normalizeSeriesKey(title: string): string {
.replace(/[^a-z0-9]+/g, "");
}
+type MoviePick = {
+ id: string;
+ imdbId: string | null;
+ tmdbId: number | null;
+ createdAt: Date;
+ _count: { mediaFiles: number };
+};
+
+type SeriesPick = {
+ id: string;
+ imdbId: string | null;
+ tmdbId: number | null;
+ createdAt: Date;
+ _count: { episodes: number };
+};
+
+/** Prefer IMDb → TMDB → most files → oldest (stable Cinemeta identity). */
+function pickBestMovie(group: MoviePick[]): MoviePick {
+ return [...group].sort((a, b) => {
+ if (!!a.imdbId !== !!b.imdbId) return a.imdbId ? -1 : 1;
+ if ((a.tmdbId != null) !== (b.tmdbId != null)) return a.tmdbId != null ? -1 : 1;
+ if (a._count.mediaFiles !== b._count.mediaFiles) {
+ return b._count.mediaFiles - a._count.mediaFiles;
+ }
+ return a.createdAt.getTime() - b.createdAt.getTime();
+ })[0];
+}
+
+function pickBestSeries(group: SeriesPick[]): SeriesPick {
+ return [...group].sort((a, b) => {
+ if (!!a.imdbId !== !!b.imdbId) return a.imdbId ? -1 : 1;
+ if ((a.tmdbId != null) !== (b.tmdbId != null)) return a.tmdbId != null ? -1 : 1;
+ if (a._count.episodes !== b._count.episodes) {
+ return b._count.episodes - a._count.episodes;
+ }
+ return a.createdAt.getTime() - b.createdAt.getTime();
+ })[0];
+}
+
export class MetadataService {
private movieCache = new Map();
private seriesCache = new Map();
@@ -267,6 +306,7 @@ export class MetadataService {
for (const group of movieGroups.values()) {
if (group.length < 2) continue;
const canonical =
+ group.find((m) => m.imdbId != null) ??
group.find((m) => m.tmdbId != null) ??
group.find((m) => m._count.mediaFiles > 0) ??
group[0];
@@ -299,7 +339,10 @@ export class MetadataService {
}
for (const group of seriesGroups.values()) {
if (group.length < 2) continue;
- const canonical = group.find((s) => s.tmdbId != null) ?? group[0];
+ const canonical =
+ group.find((s) => s.imdbId != null) ??
+ group.find((s) => s.tmdbId != null) ??
+ group[0];
for (const orphan of group) {
if (orphan.id === canonical.id) continue;
await this.mergeSeriesInto(canonical.id, orphan.id);
@@ -382,26 +425,36 @@ export class MetadataService {
}
/**
- * 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.
+ * Pull duplicate series rows into the best canonical (prefer IMDb/TMDB)
+ * so Cinemeta tt-streams and local catalogs share one record with all files.
*/
- async consolidateSeries(seriesId: string): Promise {
- const canonical = await prisma.series.findUnique({ where: { id: seriesId } });
- if (!canonical) return 0;
+ async consolidateSeries(seriesId: string): Promise<{ merged: number; canonicalId: string }> {
+ const seed = await prisma.series.findUnique({ where: { id: seriesId } });
+ if (!seed) return { merged: 0, canonicalId: seriesId };
const all = await prisma.series.findMany({
- select: { id: true, title: true, tmdbId: true, imdbId: true },
+ select: {
+ id: true,
+ title: true,
+ tmdbId: true,
+ imdbId: true,
+ createdAt: true,
+ _count: { select: { episodes: 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;
+ const norm = normalizeSeriesKey(seed.title);
+ const group = all.filter((s) => {
+ if (s.id === seed.id) return true;
+ if (seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true;
+ if (seed.imdbId && s.imdbId === seed.imdbId) return true;
+ if (s.tmdbId != null && seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true;
return normalizeSeriesKey(s.title) === norm;
});
+ const canonical = pickBestSeries(group);
let merged = 0;
- for (const orphan of orphans) {
+ for (const orphan of group) {
+ if (orphan.id === canonical.id) continue;
try {
await this.mergeSeriesInto(canonical.id, orphan.id);
merged += 1;
@@ -409,7 +462,7 @@ export class MetadataService {
// unique conflicts / race — skip
}
}
- return merged;
+ return { merged, canonicalId: canonical.id };
}
private async matchMovie(nodeId: string, file: MediaFileInfo): Promise {
@@ -767,39 +820,121 @@ export class MetadataService {
}
/**
- * Pull duplicate movie rows into one canonical so streams see every file.
+ * Pull duplicate movie rows into the best canonical (prefer IMDb/TMDB holders)
+ * so Cinemeta tt-lookups see the new available files after a path migration.
*/
- async consolidateMovies(movieId: string): Promise {
- const canonical = await prisma.movie.findUnique({ where: { id: movieId } });
- if (!canonical) return 0;
+ async consolidateMovies(movieId: string): Promise<{ merged: number; canonicalId: string }> {
+ const seed = await prisma.movie.findUnique({ where: { id: movieId } });
+ if (!seed) return { merged: 0, canonicalId: movieId };
const all = await prisma.movie.findMany({
- select: { id: true, title: true, year: true, tmdbId: true, imdbId: true },
+ select: {
+ id: true,
+ title: true,
+ year: true,
+ tmdbId: true,
+ imdbId: true,
+ createdAt: true,
+ _count: { select: { mediaFiles: true } },
+ },
});
- const norm = normalizeSeriesKey(canonical.title);
- const orphans = all.filter((m) => {
- if (m.id === canonical.id) return false;
- if (canonical.tmdbId != null && m.tmdbId === canonical.tmdbId) return true;
- if (canonical.imdbId && m.imdbId === canonical.imdbId) return true;
+ const norm = normalizeSeriesKey(seed.title);
+ const group = all.filter((m) => {
+ if (m.id === seed.id) return true;
+ if (seed.tmdbId != null && m.tmdbId === seed.tmdbId) return true;
+ if (seed.imdbId && m.imdbId === seed.imdbId) return true;
if (normalizeSeriesKey(m.title) !== norm) return false;
- if (canonical.year && m.year && canonical.year !== m.year) return false;
+ if (seed.year && m.year && seed.year !== m.year) return false;
return true;
});
+ const canonical = pickBestMovie(group);
let merged = 0;
- for (const orphan of orphans) {
+ for (const orphan of group) {
+ if (orphan.id === canonical.id) continue;
try {
await prisma.mediaFile.updateMany({
where: { movieId: orphan.id },
data: { movieId: canonical.id },
});
+ // Prefer keeping IMDb/TMDB metadata on the survivor
+ if ((!canonical.imdbId && orphan.imdbId) || (!canonical.tmdbId && orphan.tmdbId)) {
+ await prisma.movie
+ .update({
+ where: { id: canonical.id },
+ data: {
+ ...(canonical.imdbId ? {} : orphan.imdbId ? { imdbId: orphan.imdbId } : {}),
+ ...(canonical.tmdbId != null
+ ? {}
+ : orphan.tmdbId != null
+ ? { tmdbId: orphan.tmdbId }
+ : {}),
+ },
+ })
+ .catch(() => undefined);
+ }
await prisma.movie.delete({ where: { id: orphan.id } });
merged += 1;
} catch {
// skip conflicts
}
}
- return merged;
+ return { merged, canonicalId: canonical.id };
+ }
+
+ /**
+ * After path migrations: merge duplicates toward IMDb holders, drop dead
+ * unavailable files, then backfill missing IMDb ids.
+ */
+ async repairLibraryAfterPathMigration(): Promise<{
+ moviesMerged: number;
+ seriesMerged: number;
+ staleFilesRemoved: number;
+ imdb: Awaited>;
+ }> {
+ let moviesMerged = 0;
+ let seriesMerged = 0;
+
+ const movies = await prisma.movie.findMany({
+ select: { id: true },
+ orderBy: { createdAt: "asc" },
+ });
+ const seenMovies = new Set();
+ for (const m of movies) {
+ if (seenMovies.has(m.id)) continue;
+ if (!(await prisma.movie.findUnique({ where: { id: m.id }, select: { id: true } }))) {
+ continue;
+ }
+ const { merged, canonicalId } = await this.consolidateMovies(m.id);
+ seenMovies.add(canonicalId);
+ moviesMerged += merged;
+ }
+
+ const seriesList = await prisma.series.findMany({
+ select: { id: true },
+ orderBy: { createdAt: "asc" },
+ });
+ const seenSeries = new Set();
+ for (const s of seriesList) {
+ if (seenSeries.has(s.id)) continue;
+ if (!(await prisma.series.findUnique({ where: { id: s.id }, select: { id: true } }))) {
+ continue;
+ }
+ const { merged, canonicalId } = await this.consolidateSeries(s.id);
+ seenSeries.add(canonicalId);
+ seriesMerged += merged;
+ }
+
+ // Unavailable rows left after the /media/movies → /mnt/user migration
+ const staleFilesRemoved = (
+ await prisma.mediaFile.deleteMany({
+ where: { available: false },
+ })
+ ).count;
+
+ const imdb = await this.backfillAllImdbIds({ delayMs: 80 });
+
+ return { moviesMerged, seriesMerged, staleFilesRemoved, imdb };
}
/** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */
@@ -1056,20 +1191,32 @@ export class MetadataService {
title: { equals: title, mode: "insensitive" },
year,
},
- orderBy: { tmdbId: { sort: "desc", nulls: "last" } },
+ orderBy: [
+ { imdbId: { sort: "desc", nulls: "last" } },
+ { tmdbId: { sort: "desc", nulls: "last" } },
+ { createdAt: "asc" },
+ ],
});
if (withYear) return withYear;
}
return prisma.movie.findFirst({
where: { title: { equals: title, mode: "insensitive" } },
- orderBy: { tmdbId: { sort: "desc", nulls: "last" } },
+ orderBy: [
+ { imdbId: { sort: "desc", nulls: "last" } },
+ { tmdbId: { sort: "desc", nulls: "last" } },
+ { createdAt: "asc" },
+ ],
});
}
private async findExistingSeries(title: string) {
return prisma.series.findFirst({
where: { title: { equals: title, mode: "insensitive" } },
- orderBy: { tmdbId: { sort: "desc", nulls: "last" } },
+ orderBy: [
+ { imdbId: { sort: "desc", nulls: "last" } },
+ { tmdbId: { sort: "desc", nulls: "last" } },
+ { createdAt: "asc" },
+ ],
});
}
diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts
index ea843dd..34931b0 100644
--- a/apps/master-api/src/stremio/routes.ts
+++ b/apps/master-api/src/stremio/routes.ts
@@ -64,7 +64,7 @@ async function buildManifest(tokenId: string | null, config: Config) {
const types = new Set(["movie", "series", ...iptv.types]);
return {
id: "com.mediacluster.library",
- version: "1.6.0",
+ version: "1.6.1",
name: "Media Cluster",
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
@@ -430,23 +430,17 @@ async function getSeriesCatalog(
const shelfSlug = catalogId.startsWith("shelf-") ? catalogId.slice("shelf-".length) : null;
const availableFilter = shelfSlug
? {
- seasons: {
+ episodes: {
some: {
- episodes: {
- some: {
- mediaFiles: {
- some: { available: true, shelf: { slug: shelfSlug, kind: "SERIES" as const } },
- },
- },
+ mediaFiles: {
+ some: { available: true, shelf: { slug: shelfSlug, kind: "SERIES" as const } },
},
},
},
}
: {
- seasons: {
- some: {
- episodes: { some: { mediaFiles: { some: { available: true } } } },
- },
+ episodes: {
+ some: { mediaFiles: { some: { available: true } } },
},
};
@@ -487,7 +481,7 @@ async function getMovieMeta(
log?: { info?: (obj: unknown, msg?: string) => void; warn?: (obj: unknown, msg?: string) => void }
) {
const localUuid = unwrapLocalId(id, "movie");
- const movie = await prisma.movie.findFirst({
+ let movie = await prisma.movie.findFirst({
where: {
OR: [
...(id.startsWith("tt") ? [{ imdbId: id }] : []),
@@ -502,7 +496,11 @@ async function getMovieMeta(
}
try {
- await metadata.consolidateMovies(movie.id);
+ const { canonicalId } = await metadata.consolidateMovies(movie.id);
+ if (canonicalId !== movie.id) {
+ const moved = await prisma.movie.findUnique({ where: { id: canonicalId } });
+ if (moved) movie = moved;
+ }
} catch (err) {
log?.warn?.({ err, movieId: movie.id }, "movie meta: consolidate failed");
}
@@ -562,7 +560,7 @@ async function getSeriesMeta(
const localUuid = unwrapLocalId(id, "series");
// Resolve by our private id only (mcseries-uuid). Also accept legacy mc:series:uuid / bare uuid.
// Do NOT look up by imdbId — that pulls the wrong row after TMDB linking.
- const series = await prisma.series.findFirst({
+ let series = await prisma.series.findFirst({
where: {
OR: [{ id: localUuid }, { id }],
},
@@ -586,8 +584,25 @@ async function getSeriesMeta(
// Fold duplicate series rows into this one so every matched file shows up.
try {
- const merged = await metadata.consolidateSeries(series.id);
- if (merged > 0) {
+ const { merged, canonicalId } = await metadata.consolidateSeries(series.id);
+ if (canonicalId !== series.id) {
+ const moved = await prisma.series.findFirst({
+ where: { id: canonicalId },
+ select: {
+ id: true,
+ title: true,
+ year: true,
+ overview: true,
+ posterUrl: true,
+ backdropUrl: true,
+ logoUrl: true,
+ genres: true,
+ imdbId: true,
+ tmdbId: true,
+ },
+ });
+ if (moved) series = moved;
+ } else if (merged > 0) {
log?.info?.({ seriesId: series.id, merged }, "series meta: consolidated duplicates");
}
} catch (err) {
@@ -807,9 +822,9 @@ async function getStreams(
const movieDbId = movie.id;
try {
- await metadata.consolidateMovies(movieDbId);
+ const { canonicalId } = await metadata.consolidateMovies(movieDbId);
movie = await prisma.movie.findFirst({
- where: { id: movieDbId },
+ where: { id: canonicalId },
include: { mediaFiles: { include: { node: true } } },
});
} catch (err) {
@@ -821,8 +836,8 @@ async function getStreams(
void metadata.ensureMovieImdbId(movie.id).catch(() => undefined);
}
- const files = movie.mediaFiles.filter((f) => f.available);
- const pool = files.length > 0 ? files : movie.mediaFiles;
+ // Never fall back to unavailable legacy paths (/media/movies) — those break playback.
+ const pool = movie.mediaFiles.filter((f) => f.available);
const streams = [];
for (const file of pool) {
@@ -891,8 +906,8 @@ async function getStreams(
}
try {
- await metadata.consolidateSeries(series.id);
- const refreshed = await prisma.series.findUnique({ where: { id: series.id } });
+ const { canonicalId } = await metadata.consolidateSeries(series.id);
+ const refreshed = await prisma.series.findUnique({ where: { id: canonicalId } });
if (refreshed) series = refreshed;
} catch (err) {
log?.warn?.({ err, seriesId: series.id }, "series stream: consolidate failed");
@@ -924,8 +939,7 @@ async function getStreams(
return [];
}
- const files = episode.mediaFiles.filter((f) => f.available);
- const pool = files.length > 0 ? files : episode.mediaFiles;
+ const pool = episode.mediaFiles.filter((f) => f.available);
const streams = [];
for (const file of pool) {