Add per-node library reset that purges DB content and rescans.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-09 02:34:28 +02:00
parent 3b9b95469b
commit 308160aa56
2 changed files with 168 additions and 0 deletions

View file

@ -154,6 +154,35 @@ export default function NodesPage() {
alert("Rescan aangevraagd");
}
async function resetLibraryNode(id: string, name: string) {
if (
!confirm(
`Bibliotheek van “${name}” volledig wissen uit de database en opnieuw scannen?\n\n` +
`Alle films/series-bestanden van deze node verdwijnen uit Master. ` +
`Titels die alleen op deze node stonden worden opgeruimd. Daarna start een volle rescan.\n\n` +
`Dit kan niet ongedaan worden gemaakt.`
)
) {
return;
}
if (!confirm(`Laatste check: echt resetten voor “${name}”?`)) return;
const res = await fetch(`/api/v1/admin/nodes/${id}/reset-library`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
alert(data.error?.message ?? "Reset mislukt");
return;
}
alert(
data.message ??
`Klaar: ${data.filesRemoved ?? 0} files, ${data.moviesRemoved ?? 0} films, ${data.seriesRemoved ?? 0} series verwijderd. Rescan gestart.`
);
loadNodes();
}
async function restartNode(id: string) {
if (!confirm("Node herstarten? De container komt terug via restart-policy.")) return;
const res = await fetch(`/api/v1/admin/nodes/${id}/restart`, {
@ -554,6 +583,13 @@ export default function NodesPage() {
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button onClick={() => openEdit(node)}>Edit</button>
<button onClick={() => rescanNode(node.id)}>Rescan</button>
<button
className="danger"
onClick={() => resetLibraryNode(node.id, node.name)}
title="Wis alle content van deze node uit de DB en scan opnieuw"
>
Bibliotheek reset
</button>
{node.version !== targetNodeVersion && (
<button
onClick={() => upgradeNode(node.id, node.name)}

View file

@ -366,6 +366,138 @@ export async function registerNodeRoutes(app: FastifyInstance) {
return { ok: true };
});
/**
* Wis alle media van deze node uit de master-DB, ruim wezen-films/series op,
* reset revisie en start een volledige rescan (nieuwe match-logica).
*/
app.post("/api/v1/admin/nodes/:id/reset-library", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({ where: { id } });
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
if (!nodeConnectionManager.isOnline(id)) {
throw new AppError("NODE_OFFLINE", "Node is niet verbonden — start de node eerst", 503);
}
const files = await prisma.mediaFile.findMany({
where: { nodeId: id },
select: { id: true, movieId: true, episodeId: true },
});
const fileIds = files.map((f) => f.id);
const touchedMovieIds = [...new Set(files.map((f) => f.movieId).filter(Boolean))] as string[];
const touchedEpisodeIds = [...new Set(files.map((f) => f.episodeId).filter(Boolean))] as string[];
// Playback sessions + media files
const chunkSize = 500;
for (let i = 0; i < fileIds.length; i += chunkSize) {
const chunk = fileIds.slice(i, i + chunkSize);
await prisma.playbackSession.deleteMany({ where: { mediaFileId: { in: chunk } } });
await prisma.mediaFile.deleteMany({ where: { id: { in: chunk } } });
}
// Movies die nergens meer bestanden hebben
let moviesRemoved = 0;
if (touchedMovieIds.length > 0) {
for (const movieId of touchedMovieIds) {
const left = await prisma.mediaFile.count({ where: { movieId } });
if (left > 0) continue;
await prisma.libraryMatch.deleteMany({ where: { movieId } });
await prisma.watchProgress.deleteMany({ where: { movieId } });
await prisma.viewerFavorite.deleteMany({
where: { mediaType: "MOVIE", mediaId: movieId },
});
await prisma.librarySubtitle.deleteMany({ where: { movieId } });
await prisma.movie.delete({ where: { id: movieId } }).catch(() => undefined);
moviesRemoved += 1;
}
}
// Episodes / series opruimen
let seriesRemoved = 0;
const seriesIds = new Set<string>();
if (touchedEpisodeIds.length > 0) {
const eps = await prisma.episode.findMany({
where: { id: { in: touchedEpisodeIds } },
select: { id: true, seriesId: true },
});
for (const ep of eps) seriesIds.add(ep.seriesId);
for (const episodeId of touchedEpisodeIds) {
const left = await prisma.mediaFile.count({ where: { episodeId } });
if (left > 0) continue;
await prisma.libraryMatch.deleteMany({ where: { episodeId } });
await prisma.watchProgress.deleteMany({ where: { episodeId } });
await prisma.episode.delete({ where: { id: episodeId } }).catch(() => undefined);
}
}
for (const seriesId of seriesIds) {
const leftFiles = await prisma.mediaFile.count({
where: { episode: { seriesId } },
});
if (leftFiles > 0) continue;
await prisma.libraryMatch.deleteMany({ where: { seriesId } });
await prisma.watchProgress.deleteMany({ where: { seriesId } });
await prisma.viewerFavorite.deleteMany({
where: { mediaType: "SERIES", mediaId: seriesId },
});
await prisma.episode.deleteMany({ where: { seriesId } });
await prisma.season.deleteMany({ where: { seriesId } });
await prisma.series.delete({ where: { id: seriesId } }).catch(() => undefined);
seriesRemoved += 1;
}
// Ook films/series die alleen op deze node stonden maar via andere refs — wezen zonder files
const orphanMovies = await prisma.movie.findMany({
where: { mediaFiles: { none: {} } },
select: { id: true },
take: 2000,
});
for (const m of orphanMovies) {
await prisma.libraryMatch.deleteMany({ where: { movieId: m.id } });
await prisma.watchProgress.deleteMany({ where: { movieId: m.id } });
await prisma.movie.delete({ where: { id: m.id } }).catch(() => undefined);
moviesRemoved += 1;
}
const orphanSeries = await prisma.series.findMany({
where: { episodes: { none: { mediaFiles: { some: {} } } } },
select: { id: true },
take: 2000,
});
for (const s of orphanSeries) {
await prisma.libraryMatch.deleteMany({ where: { seriesId: s.id } });
await prisma.watchProgress.deleteMany({ where: { seriesId: s.id } });
await prisma.episode.deleteMany({ where: { seriesId: s.id } });
await prisma.season.deleteMany({ where: { seriesId: s.id } });
await prisma.series.delete({ where: { id: s.id } }).catch(() => undefined);
seriesRemoved += 1;
}
await prisma.node.update({
where: { id },
data: { lastRevision: 0, libraryFileCount: 0 },
});
const sent = nodeConnectionManager.sendRescan(id, true);
if (!sent) {
throw new AppError(
"RESCAN_FAILED",
"Database gewist, maar rescan kon niet worden gestuurd (node offline?)",
503
);
}
return {
ok: true,
filesRemoved: fileIds.length,
moviesRemoved,
seriesRemoved,
message: `Bibliotheek van ${node.name} gewist (${fileIds.length} files). Volledige rescan gestart.`,
};
});
app.post("/api/v1/admin/nodes/:id/restart", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({ where: { id } });