diff --git a/apps/admin-ui/src/app/movies/page.tsx b/apps/admin-ui/src/app/movies/page.tsx index 2c30b9d..94cc66f 100644 --- a/apps/admin-ui/src/app/movies/page.tsx +++ b/apps/admin-ui/src/app/movies/page.tsx @@ -3,8 +3,21 @@ import { useEffect, useState } from "react"; import { Nav, useAuth } from "@/components/Nav"; +interface MovieFolder { + folder: string; + fileCount: number; +} + +interface MovieNode { + id: string; + name: string; + fileCount: number; + folders?: MovieFolder[]; +} + interface MovieFile { id: string; + nodeId?: string; node: string; nodeStatus: string; resolution: string | null; @@ -12,7 +25,10 @@ interface MovieFile { videoCodec: string | null; audioCodec: string | null; releaseName: string; + path?: string | null; + folder?: string | null; available: boolean; + looksLikeSeries?: boolean; } interface Movie { @@ -26,9 +42,16 @@ interface Movie { fileCount: number; nodeCount: number; qualities: string[]; + nodes?: MovieNode[]; files: MovieFile[]; } +interface NodeOption { + id: string; + name: string; + status?: string; +} + interface MetaHit { tmdbId: number; title: string; @@ -54,25 +77,55 @@ function formatBytes(bytes: string | number | null | undefined): string { export default function MoviesPage() { useAuth(); const [movies, setMovies] = useState([]); + const [nodes, setNodes] = useState([]); + const [nodeId, setNodeId] = useState(""); const [search, setSearch] = useState(""); + const [pathSearch, setPathSearch] = useState(""); const [expanded, setExpanded] = useState(null); + const [msg, setMsg] = useState(""); + const [busyFileId, setBusyFileId] = useState(null); const [linkMovieId, setLinkMovieId] = useState(null); const [linkQuery, setLinkQuery] = useState(""); 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 load(q = search) { - const qs = q ? `?search=${encodeURIComponent(q)}` : ""; + function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) { + const params = new URLSearchParams(); + if (q.trim()) params.set("search", q.trim()); + if (filterNodeId) params.set("nodeId", filterNodeId); + if (pathQ.trim()) params.set("path", pathQ.trim()); + const qs = params.toString() ? `?${params}` : ""; fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" }) .then((r) => r.json()) .then((d) => setMovies(d.movies ?? [])); } useEffect(() => { + fetch("/api/v1/admin/nodes", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { + const list = (d.nodes ?? []) as NodeOption[]; + setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name))); + }); load(); }, []); + useEffect(() => { + load(search, nodeId, pathSearch); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeId]); + async function ignoreFile(fileId: string) { if (!confirm("Dit bestand negeren?")) return; await fetch(`/api/v1/admin/media-files/${fileId}/match`, { @@ -84,12 +137,27 @@ export default function MoviesPage() { load(); } - async function refreshMeta(fileId: string) { - await fetch(`/api/v1/admin/media-files/${fileId}/refresh-metadata`, { - method: "POST", - credentials: "include", - }); - load(); + async function rematchFile(fileId: string) { + setBusyFileId(fileId); + setMsg(""); + try { + const res = await fetch(`/api/v1/admin/media-files/${fileId}/rematch`, { + method: "POST", + credentials: "include", + }); + const data = await res.json(); + if (!res.ok) { + setMsg(data.error?.message ?? "Rematch mislukt"); + return; + } + const label = data.series + ? `serie ${data.series.title} S${String(data.series.seasonNumber).padStart(2, "0")}E${String(data.series.episodeNumber).padStart(2, "0")}` + : data.movie?.title ?? "onbekend"; + setMsg(`Opnieuw gekoppeld → ${label}`); + load(); + } finally { + setBusyFileId(null); + } } function openLink(m: Movie) { @@ -100,6 +168,20 @@ export default function MoviesPage() { void searchMeta(m.year ? `${m.title} ${m.year}` : m.title); } + function openFolderAsSeries(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 searchSeriesMeta(folderName); + } + async function searchMeta(q = linkQuery) { const query = q.trim(); if (!query) return; @@ -123,6 +205,29 @@ export default function MoviesPage() { } } + async function searchSeriesMeta(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 applyLink(hit: MetaHit) { if (!linkMovieId) return; setLinkBusy(true); @@ -184,6 +289,87 @@ export default function MoviesPage() { } } + async function applyFolderAsSeries(hit: MetaHit) { + if (!folderRelink) return; + if ( + !confirm( + `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nop ${folderRelink.nodeName}\nals SERIE herkoppelen naar “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?\n\nZe verdwijnen dan uit Films.` + ) + ) { + return; + } + setFolderBusy(true); + setFolderMsg("Map herkoppelen als serie…"); + 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 → serie “${data.seriesTitle}”: ${data.moved}/${data.total} bestanden` + + (data.failed ? ` (${data.failed} mislukt — check SxxExx / Season-map)` : "") + ); + setFolderRelink(null); + load(); + } finally { + setFolderBusy(false); + } + } + + async function applyFolderImdbAsSeries() { + const imdb = folderQuery.trim(); + if (!/^tt\d+$/i.test(imdb) || !folderRelink) { + setFolderMsg("Vul een IMDb-id van de serie in (tt…) of zoek op titel."); + return; + } + if ( + !confirm( + `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nals SERIE herkoppelen via ${imdb}?` + ) + ) { + return; + } + setFolderBusy(true); + setFolderMsg("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 → serie “${data.seriesTitle}”: ${data.moved}/${data.total} bestanden` + + (data.failed ? ` (${data.failed} mislukt)` : "") + ); + setFolderRelink(null); + load(); + } finally { + setFolderBusy(false); + } + } + const activeMovie = movies.find((m) => m.id === linkMovieId); return ( @@ -200,106 +386,221 @@ export default function MoviesPage() { e.preventDefault(); load(); }} - style={{ display: "flex", gap: "0.5rem" }} + style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }} > + setSearch(e.target.value)} - placeholder="Zoeken..." - style={{ width: 240, marginBottom: 0 }} + placeholder="Zoek titel..." + style={{ width: 180, marginBottom: 0 }} + /> + setPathSearch(e.target.value)} + placeholder="Zoek map/pad…" + style={{ width: 220, marginBottom: 0 }} /> - {movies.map((m) => ( -
-
setExpanded(expanded === m.id ? null : m.id)} - > -
- {m.posterUrl && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
- {m.title} - {m.year ? ` (${m.year})` : ""} -
- {m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount}{" "} - bestand(en) - {m.imdbId ? ` · ${m.imdbId}` : " · geen IMDb"} - {m.tmdbId ? ` · TMDB ${m.tmdbId}` : ""} +

+ Serie per ongeluk als film? Open de titel → bij de map{" "} + Als serie herkoppelen. Vereist + node 1.3.7+ met paden (anders eerst upgraden + rescan). +

+ + {msg && ( +

+ {msg} +

+ )} + + {movies.map((m) => { + const looksSeries = m.files.some((f) => f.looksLikeSeries); + return ( +
+
setExpanded(expanded === m.id ? null : m.id)} + > +
+ {m.posterUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +
+ {m.title} + {m.year ? ` (${m.year})` : ""} + {looksSeries && ( + + lijkt op serie + + )} +
+ {m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount}{" "} + bestand(en) + {m.imdbId ? ` · ${m.imdbId}` : " · geen IMDb"} + {m.tmdbId ? ` · TMDB ${m.tmdbId}` : ""} +
+ {(m.nodes?.length ?? 0) > 0 && ( +
+ {m.nodes!.map((n) => ( +
+ + {n.name} · {n.fileCount} files + + {(n.folders?.length ?? 0) > 0 ? ( +
+ {n.folders!.map((f) => ( +
+ + {f.folder}{" "} + ({f.fileCount}) + + +
+ ))} +
+ ) : ( +
+ Map onbekend — node 1.3.7+ + rescan +
+ )} +
+ ))} +
+ )}
+ {expanded === m.id ? "▲" : "▼"}
- {expanded === m.id ? "▲" : "▼"} -
- {expanded === m.id && ( -
- {m.overview && ( -

{m.overview}

- )} -
- -
- - - - - - - - - - - - - - {m.files.map((f) => ( - - - - - - - - + {expanded === m.id && ( +
+ {m.overview && ( +

{m.overview}

+ )} +
+ +
+
NodeReleaseKwaliteitGrootteCodecStatusActies
{f.node} - {f.releaseName} - {f.resolution ?? "-"}{formatBytes(f.sizeBytes)}{[f.videoCodec, f.audioCodec].filter(Boolean).join(" / ") || "-"} - - {f.available ? "AVAILABLE" : "UNAVAILABLE"} - - - - -
+ + + + + + + + - ))} - -
NodePad / releaseKwaliteitGrootteStatusActies
-
- )} -
- ))} + + + {m.files.map((f) => ( + + {f.node} + +
{f.path || f.releaseName}
+ {f.looksLikeSeries && ( +
pad lijkt op serie
+ )} + + {f.resolution ?? "-"} + {formatBytes(f.sizeBytes)} + + + {f.available ? "AVAILABLE" : "UNAVAILABLE"} + + + + + + + + ))} + + +
+ )} +
+ ); + })} {movies.length === 0 && (
@@ -407,21 +708,6 @@ export default function MoviesPage() { TMDB {hit.tmdbId} {hit.imdbId ? ` · ${hit.imdbId}` : ""}
- {hit.overview && ( -
- {hit.overview} -
- )}
))} @@ -434,6 +720,126 @@ export default function MoviesPage() {
)} + + {folderRelink && ( +
!folderBusy && setFolderRelink(null)} + > +
e.stopPropagation()} + > +

+ Map als serie herkoppelen +

+

+ Node: {folderRelink.nodeName} +
+ Map: {folderRelink.folder} +
+ {folderRelink.fileCount} bestanden verhuizen van Films → Series. S/E uit pad (Season 01 / + SxxExx). +

+
{ + e.preventDefault(); + void searchSeriesMeta(); + }} + style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }} + > + setFolderQuery(e.target.value)} + placeholder="Serietitel 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 ab7532b..7fbab0b 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -15,6 +15,7 @@ import { parseMovieFromPath, parseSeriesFromPath, seriesFolderFromPath, + libraryFolderFromPath, } from "../metadata/filename-parser"; export async function registerAdminRoutes(app: FastifyInstance, config: Config) { @@ -135,11 +136,27 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) }); app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => { - const query = request.query as { search?: string }; + const query = request.query as { search?: string; nodeId?: string; path?: string }; + const nodeId = query.nodeId?.trim() || undefined; + const pathQ = query.path?.trim() || undefined; + const searchQ = query.search?.trim() || undefined; + const movies = await prisma.movie.findMany({ - where: query.search - ? { title: { contains: query.search, mode: "insensitive" } } - : undefined, + where: { + AND: [ + searchQ ? { title: { contains: searchQ, mode: "insensitive" } } : {}, + nodeId || pathQ + ? { + mediaFiles: { + some: { + ...(nodeId ? { nodeId } : {}), + ...(pathQ ? { path: { contains: pathQ, mode: "insensitive" } } : {}), + }, + }, + } + : {}, + ], + }, orderBy: { title: "asc" }, include: { mediaFiles: { @@ -150,31 +167,67 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) }); return { - movies: movies.map((m) => ({ - id: m.id, - title: m.title, - year: m.year, - imdbId: m.imdbId, - tmdbId: m.tmdbId, - overview: m.overview, - posterUrl: m.posterUrl, - fileCount: m.mediaFiles.length, - nodeCount: new Set(m.mediaFiles.map((f) => f.nodeId)).size, - qualities: [...new Set(m.mediaFiles.map((f) => f.resolution).filter(Boolean))], - files: m.mediaFiles.map((f) => ({ - id: f.id, - nodeId: f.nodeId, - node: f.node.name, - nodeStatus: f.node.status, - resolution: f.resolution, - sizeBytes: f.sizeBytes.toString(), - videoCodec: f.videoCodec, - audioCodec: f.audioCodec, - hdrType: f.hdrType, - releaseName: f.releaseName, - available: f.available, - })), - })), + movies: movies.map((m) => { + const files = m.mediaFiles.filter((f) => { + if (nodeId && f.nodeId !== nodeId) return false; + if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) return false; + return true; + }); + + const nodeMap = new Map< + string, + { id: string; name: string; fileCount: number; folders: Map } + >(); + for (const f of m.mediaFiles) { + let cur = nodeMap.get(f.nodeId); + if (!cur) { + cur = { id: f.nodeId, name: f.node.name, fileCount: 0, folders: new Map() }; + nodeMap.set(f.nodeId, cur); + } + cur.fileCount += 1; + const folder = f.path ? libraryFolderFromPath(f.path) : null; + if (folder) cur.folders.set(folder, (cur.folders.get(folder) ?? 0) + 1); + } + + return { + id: m.id, + title: m.title, + year: m.year, + imdbId: m.imdbId, + tmdbId: m.tmdbId, + overview: m.overview, + posterUrl: m.posterUrl, + fileCount: files.length, + nodeCount: nodeMap.size, + qualities: [...new Set(files.map((f) => f.resolution).filter(Boolean))], + nodes: [...nodeMap.values()] + .map((n) => ({ + id: n.id, + name: n.name, + fileCount: n.fileCount, + folders: [...n.folders.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([folder, fileCount]) => ({ folder, fileCount })), + })) + .sort((a, b) => a.name.localeCompare(b.name)), + files: files.map((f) => ({ + id: f.id, + nodeId: f.nodeId, + node: f.node.name, + nodeStatus: f.node.status, + resolution: f.resolution, + sizeBytes: f.sizeBytes.toString(), + videoCodec: f.videoCodec, + audioCodec: f.audioCodec, + hdrType: f.hdrType, + releaseName: f.releaseName, + path: f.path, + folder: f.path ? libraryFolderFromPath(f.path) : null, + available: f.available, + looksLikeSeries: f.path ? Boolean(parseSeriesFromPath(f.path)) : false, + })), + }; + }), }; }); diff --git a/apps/master-api/src/metadata/filename-parser.ts b/apps/master-api/src/metadata/filename-parser.ts index 12376d6..327d3dc 100644 --- a/apps/master-api/src/metadata/filename-parser.ts +++ b/apps/master-api/src/metadata/filename-parser.ts @@ -162,6 +162,11 @@ export function seriesFolderFromPath(filePath: string): string | null { return dir === "." ? null : dir; } +/** Best-effort library folder for admin grouping (series-aware). */ +export function libraryFolderFromPath(filePath: string): string | null { + return seriesFolderFromPath(filePath); +} + export function detectMediaType(filename: string): "movie" | "episode" | null { if (!isVideoFile(filename)) return null; if (parseSeriesFilename(filename) || parseSeriesFromPath(filename)) return "episode";