From fba94b656c7a17ab9733eb1e8f34969e0af9d2a8 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 9 Sep 2026 01:50:42 +0200 Subject: [PATCH] Add bulk relink of a series folder to the correct TMDB/IMDb show. Co-authored-by: Cursor --- apps/admin-ui/src/app/series/page.tsx | 297 +++++++++++++++++++++++- apps/master-api/src/admin/routes.ts | 31 +++ apps/master-api/src/metadata/service.ts | 190 +++++++++++++++ 3 files changed, 515 insertions(+), 3 deletions(-) diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index bd69145..605966e 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -95,6 +95,16 @@ export default function SeriesPage() { const [linkHits, setLinkHits] = useState([]); const [linkBusy, setLinkBusy] = useState(false); const [linkMsg, setLinkMsg] = useState(""); + const [folderRelink, setFolderRelink] = useState<{ + nodeId: string; + nodeName: string; + folder: string; + fileCount: number; + } | null>(null); + const [folderQuery, setFolderQuery] = useState(""); + const [folderHits, setFolderHits] = useState([]); + const [folderBusy, setFolderBusy] = useState(false); + const [folderMsg, setFolderMsg] = useState(""); function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) { const params = new URLSearchParams(); @@ -172,6 +182,126 @@ export default function SeriesPage() { void searchMeta(s.title); } + function openFolderRelink(opts: { + nodeId: string; + nodeName: string; + folder: string; + fileCount: number; + }) { + const folderName = opts.folder.split("/").filter(Boolean).pop() || opts.folder; + setFolderRelink(opts); + setFolderQuery(folderName); + setFolderHits([]); + setFolderMsg(""); + void searchFolderMeta(folderName); + } + + async function searchFolderMeta(q = folderQuery) { + const query = q.trim(); + if (!query) return; + setFolderBusy(true); + setFolderMsg(""); + try { + const res = await fetch( + `/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(query)}`, + { credentials: "include" } + ); + const data = await res.json(); + if (!res.ok) { + setFolderMsg(data.error?.message ?? "Zoeken mislukt"); + setFolderHits([]); + return; + } + setFolderHits(data.results ?? []); + if (!(data.results ?? []).length) setFolderMsg("Geen resultaten"); + } finally { + setFolderBusy(false); + } + } + + async function applyFolderRelink(hit: MetaHit) { + if (!folderRelink) return; + if ( + !confirm( + `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nop ${folderRelink.nodeName}\nherkoppelen naar “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?` + ) + ) { + return; + } + setFolderBusy(true); + setFolderMsg("Hele map herkoppelen…"); + try { + const res = await fetch(`/api/v1/admin/library/relink-folder`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodeId: folderRelink.nodeId, + folder: folderRelink.folder, + tmdbId: hit.tmdbId, + ...(hit.imdbId ? { imdbId: hit.imdbId } : {}), + }), + }); + const data = await res.json(); + if (!res.ok) { + setFolderMsg(data.error?.message ?? "Herkoppelen mislukt"); + return; + } + setMsg( + `Map herkoppeld → ${data.seriesTitle}: ${data.moved}/${data.total} bestanden` + + (data.failed ? ` (${data.failed} mislukt)` : "") + ); + setFolderRelink(null); + setExpanded(data.seriesId ?? expanded); + load(); + } finally { + setFolderBusy(false); + } + } + + async function applyFolderImdbDirect() { + const imdb = folderQuery.trim(); + if (!/^tt\d+$/i.test(imdb) || !folderRelink) { + setFolderMsg("Vul een IMDb-id in (bijv. tt14424422) of zoek eerst op titel."); + return; + } + if ( + !confirm( + `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nherkoppelen via ${imdb}?` + ) + ) { + return; + } + setFolderBusy(true); + setFolderMsg("Hele map herkoppelen via IMDb…"); + try { + const res = await fetch(`/api/v1/admin/library/relink-folder`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodeId: folderRelink.nodeId, + folder: folderRelink.folder, + imdbId: imdb, + }), + }); + const data = await res.json(); + if (!res.ok) { + setFolderMsg(data.error?.message ?? "Herkoppelen mislukt"); + return; + } + setMsg( + `Map herkoppeld → ${data.seriesTitle}: ${data.moved}/${data.total} bestanden` + + (data.failed ? ` (${data.failed} mislukt)` : "") + ); + setFolderRelink(null); + setExpanded(data.seriesId ?? expanded); + load(); + } finally { + setFolderBusy(false); + } + } + async function searchMeta(q = linkQuery) { const query = q.trim(); if (!query) return; @@ -418,9 +548,35 @@ export default function SeriesPage() { {(n.folders?.length ?? 0) > 0 ? (
{n.folders!.map((f) => ( -
- {f.folder}{" "} - ({f.fileCount}) +
+ + {f.folder}{" "} + ({f.fileCount}) + +
))}
@@ -637,6 +793,141 @@ export default function SeriesPage() {
)} + + {folderRelink && ( +
!folderBusy && setFolderRelink(null)} + > +
e.stopPropagation()} + > +

+ Hele map herkoppelen +

+

+ Node: {folderRelink.nodeName} +
+ Map: {folderRelink.folder} +
+ {folderRelink.fileCount} bestanden — kies de juiste serie (niet de verkeerde TMDB-hit). + Seizoen/aflevering blijft uit het pad. +

+
{ + e.preventDefault(); + void searchFolderMeta(); + }} + style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }} + > + setFolderQuery(e.target.value)} + placeholder="Titel of IMDb-id (tt…)" + style={{ flex: 1, minWidth: 200, marginBottom: 0 }} + autoFocus + /> + + +
+ {folderMsg && ( +

{folderMsg}

+ )} +
+ {folderHits.map((hit) => ( + + ))} +
+
+ +
+
+
+ )} ); } diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 5bb85bd..ab7532b 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1056,6 +1056,37 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) } }); + /** + * Hele host-map op één node herkoppelen aan een TMDB/IMDb-serie. + * Body: { nodeId, folder, tmdbId? | imdbId? } + */ + app.post("/api/v1/admin/library/relink-folder", { preHandler: requireAdmin }, async (request) => { + const body = request.body as { + nodeId?: string; + folder?: string; + tmdbId?: number; + imdbId?: string; + }; + if (!body.nodeId?.trim() || !body.folder?.trim()) { + throw new AppError("INVALID_REQUEST", "nodeId en folder verplicht", 400); + } + if (body.tmdbId == null && !body.imdbId?.trim()) { + throw new AppError("INVALID_REQUEST", "tmdbId of imdbId verplicht", 400); + } + try { + const result = await metadata.relinkFolderToSeries({ + nodeId: body.nodeId.trim(), + folder: body.folder.trim(), + tmdbId: body.tmdbId, + imdbId: body.imdbId?.trim(), + }); + return { ok: true, ...result }; + } catch (err) { + const message = err instanceof Error ? err.message : "Herkoppelen mislukt"; + throw new AppError("RELINK_FAILED", message, 400); + } + }); + 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/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 510bed0..39ceb4c 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -1,5 +1,17 @@ import { prisma } from "../database/client"; import type { MediaFileInfo } from "@media-cluster/shared-types"; +import { parseSeriesFromPath } from "./filename-parser"; + +function normalizeFolderPrefix(folder: string): string { + return folder.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function pathUnderFolder(filePath: string | null | undefined, folder: string): boolean { + if (!filePath) return false; + const p = filePath.replace(/\\/g, "/"); + const f = normalizeFolderPrefix(folder); + return p === f || p.startsWith(`${f}/`); +} interface TmdbSearchResult { results: Array<{ @@ -1502,6 +1514,184 @@ export class MetadataService { return { seriesId: target.id, merged }; } + /** + * Admin: alle media-bestanden onder een host-map op één node herkoppelen + * aan een gekozen TMDB/IMDb-serie. S/E komt uit pad/bestandsnaam. + */ + async relinkFolderToSeries(opts: { + nodeId: string; + folder: string; + tmdbId?: number; + imdbId?: string; + }): Promise<{ + seriesId: string; + seriesTitle: string; + moved: number; + failed: number; + total: number; + }> { + if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd"); + const folder = normalizeFolderPrefix(opts.folder); + if (!folder) throw new Error("Map-pad is verplicht"); + + let tmdbId = opts.tmdbId; + if (!tmdbId && opts.imdbId) { + const hit = await this.findTmdbByImdb("series", opts.imdbId.trim()); + tmdbId = hit?.tmdbId; + } + if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op"); + + const meta = await this.getTmdbSeriesDetails(tmdbId); + if (!meta) throw new Error("TMDB-serie niet gevonden"); + + const extras = await this.getStremioMetaExtras( + "series", + meta.id, + meta.imdbId, + meta.logoUrl + ); + + let series = + (await prisma.series.findFirst({ + where: { + OR: [ + { tmdbId: meta.id }, + ...(meta.imdbId ? [{ imdbId: meta.imdbId }] : []), + ], + }, + })) ?? + (await prisma.series.create({ + data: { + title: meta.title, + year: meta.year ?? null, + overview: meta.overview ?? null, + posterUrl: meta.posterUrl ?? null, + backdropUrl: meta.backdropUrl ?? null, + logoUrl: meta.logoUrl ?? null, + tmdbId: meta.id, + ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), + ...(extras.genres?.length ? { genres: extras.genres } : {}), + }, + })); + + series = await prisma.series.update({ + where: { id: series.id }, + data: { + title: meta.title, + year: meta.year ?? null, + overview: meta.overview ?? null, + posterUrl: meta.posterUrl ?? null, + backdropUrl: meta.backdropUrl ?? null, + logoUrl: meta.logoUrl ?? null, + tmdbId: meta.id, + ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), + ...(extras.genres?.length ? { genres: extras.genres } : {}), + }, + }); + + const files = await prisma.mediaFile.findMany({ + where: { + nodeId: opts.nodeId, + path: { not: null }, + }, + select: { + id: true, + path: true, + localFileId: true, + sizeBytes: true, + container: true, + resolution: true, + videoCodec: true, + audioCodec: true, + releaseName: true, + lastSeen: true, + shelfId: true, + }, + }); + + const underFolder = files.filter((f) => pathUnderFolder(f.path, folder)); + let moved = 0; + let failed = 0; + + for (const file of underFolder) { + const sourcePath = file.path!.trim(); + const parsed = parseSeriesFromPath(sourcePath); + if (!parsed?.season || !parsed?.episode) { + failed += 1; + continue; + } + + try { + let season = await prisma.season.findUnique({ + where: { + seriesId_seasonNumber: { + seriesId: series.id, + seasonNumber: parsed.season, + }, + }, + }); + if (!season) { + season = await prisma.season.create({ + data: { + seriesId: series.id, + seasonNumber: parsed.season, + }, + }); + } + + let episode = await prisma.episode.findUnique({ + where: { + seriesId_seasonNumber_episodeNumber: { + seriesId: series.id, + seasonNumber: parsed.season, + episodeNumber: parsed.episode, + }, + }, + }); + if (!episode) { + episode = await prisma.episode.create({ + data: { + seriesId: series.id, + seasonId: season.id, + seasonNumber: parsed.season, + episodeNumber: parsed.episode, + title: `Episode ${parsed.episode}`, + }, + }); + } + + if (series.tmdbId && this.tmdbApiKey) { + episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode); + } + + await prisma.mediaFile.update({ + where: { id: file.id }, + data: { + movieId: null, + episodeId: episode.id, + available: true, + }, + }); + moved += 1; + } catch { + failed += 1; + } + } + + this.refreshedSeriesIds.delete(series.id); + await this.refreshSeriesNl(series.id, meta.id).catch(() => undefined); + await this.ensureSeriesArtwork(series.id).catch(() => undefined); + await this.enrichSeriesEpisodes(series.id).catch(() => undefined); + + return { + seriesId: series.id, + seriesTitle: series.title, + moved, + failed, + total: underFolder.length, + }; + } + private async findTmdbByImdb( type: "movie" | "series", imdbId: string