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) {