Repair Cinemeta/IMDb linking after path migration and harden library sync.

This commit is contained in:
Jos Vooges | STH 2026-08-25 22:02:54 +02:00
parent 68440f4081
commit 887f750f92
5 changed files with 285 additions and 61 deletions

View file

@ -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() {
</div>
<div className="card" style={{ marginTop: "1.5rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none" }}>
Herstel na pad-wijziging
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
Gebruik dit als Cinemeta geen streams meer toont na een Unraid pad-wijziging
(oude /media/movies-bestanden + dubbele titels zonder IMDb).
</p>
<button onClick={repairLibrary}>Bibliotheek herstellen</button>
{repairMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
{repairMsg}
</p>
)}
<h2
style={{
fontSize: "1rem",
color: "#fafafa",
textTransform: "none",
marginTop: "1.5rem",
}}
>
Bibliotheek opruimen
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>

View file

@ -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();

View file

@ -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({

View file

@ -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<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>();
@ -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<number> {
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<string | null> {
@ -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<number> {
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<ReturnType<MetadataService["backfillAllImdbIds"]>>;
}> {
let moviesMerged = 0;
let seriesMerged = 0;
const movies = await prisma.movie.findMany({
select: { id: true },
orderBy: { createdAt: "asc" },
});
const seenMovies = new Set<string>();
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<string>();
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" },
],
});
}

View file

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