From 705a01f034325b49c7c2be84a97887ca899e65db Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 9 Sep 2026 01:27:39 +0200 Subject: [PATCH] Filter admin series list by node and show per-node file badges. Co-authored-by: Cursor --- apps/admin-ui/src/app/series/page.tsx | 79 +++++++++++++++++++- apps/master-api/src/admin/routes.ts | 101 ++++++++++++++++++-------- 2 files changed, 147 insertions(+), 33 deletions(-) diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index cd714cd..eb0b39a 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -3,6 +3,12 @@ import { useEffect, useState } from "react"; import { Nav, useAuth } from "@/components/Nav"; +interface SeriesNode { + id: string; + name: string; + fileCount: number; +} + interface Series { id: string; title: string; @@ -12,6 +18,7 @@ interface Series { posterUrl?: string | null; episodesWithFiles?: number; fileCount?: number; + nodes?: SeriesNode[]; seasons: Array<{ seasonNumber: number; episodes: Array<{ @@ -23,6 +30,12 @@ interface Series { }>; } +interface NodeOption { + id: string; + name: string; + status?: string; +} + interface MetaHit { tmdbId: number; title: string; @@ -35,6 +48,8 @@ interface MetaHit { export default function SeriesPage() { useAuth(); const [series, setSeries] = useState([]); + const [nodes, setNodes] = useState([]); + const [nodeId, setNodeId] = useState(""); const [search, setSearch] = useState(""); const [expanded, setExpanded] = useState(null); const [linkSeriesId, setLinkSeriesId] = useState(null); @@ -43,17 +58,31 @@ export default function SeriesPage() { const [linkBusy, setLinkBusy] = useState(false); const [linkMsg, setLinkMsg] = useState(""); - function load(q = search) { - const qs = q ? `?search=${encodeURIComponent(q)}` : ""; + function load(q = search, filterNodeId = nodeId) { + const params = new URLSearchParams(); + if (q.trim()) params.set("search", q.trim()); + if (filterNodeId) params.set("nodeId", filterNodeId); + const qs = params.toString() ? `?${params}` : ""; fetch(`/api/v1/admin/series${qs}`, { credentials: "include" }) .then((r) => r.json()) .then((d) => setSeries(d.series ?? [])); } 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); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeId]); + function openLink(s: Series) { setLinkSeriesId(s.id); setLinkQuery(s.title); @@ -147,6 +176,7 @@ export default function SeriesPage() { } const activeSeries = series.find((s) => s.id === linkSeriesId); + const selectedNodeName = nodes.find((n) => n.id === nodeId)?.name; return ( <> @@ -162,8 +192,22 @@ export default function SeriesPage() { e.preventDefault(); load(); }} - style={{ display: "flex", gap: "0.5rem" }} + style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }} > + setSearch(e.target.value)} @@ -174,6 +218,12 @@ export default function SeriesPage() { +

+ {nodeId + ? `Alleen series met bestanden op ${selectedNodeName ?? "deze node"} (${series.length}).` + : `${series.length} series over alle nodes. Kies een node om per locatie te bekijken.`} +

+ {series.map((s) => (
+ {(s.nodes?.length ?? 0) > 0 && ( +
+ {s.nodes!.map((n) => ( + + {n.name} · {n.fileCount} + + ))} +
+ )}
{expanded === s.id ? "▲" : "▼"} @@ -244,7 +313,9 @@ export default function SeriesPage() { {series.length === 0 && (
- Geen series gevonden + {nodeId + ? "Geen series met bestanden op deze node. Check scan-roots (series-pad) en doe een rescan." + : "Geen series gevonden"}
)} diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 236a492..cbd1144 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -186,11 +186,28 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) }); app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => { - const query = request.query as { search?: string }; + const query = request.query as { search?: string; nodeId?: string }; + const nodeId = query.nodeId?.trim() || undefined; + const series = await prisma.series.findMany({ - where: query.search - ? { title: { contains: query.search, mode: "insensitive" } } - : undefined, + where: { + AND: [ + query.search + ? { title: { contains: query.search, mode: "insensitive" } } + : {}, + nodeId + ? { + seasons: { + some: { + episodes: { + some: { mediaFiles: { some: { nodeId } } }, + }, + }, + }, + } + : {}, + ], + }, orderBy: { title: "asc" }, include: { seasons: { @@ -198,7 +215,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) episodes: { include: { mediaFiles: { - include: { node: { select: { name: true, status: true } } }, + include: { + node: { select: { id: true, name: true, status: true } }, + }, }, }, orderBy: { episodeNumber: "asc" }, @@ -212,15 +231,29 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return { series: series.map((s) => { - const episodesWithFiles = s.seasons.reduce( - (n, season) => n + season.episodes.filter((ep) => ep.mediaFiles.length > 0).length, - 0 - ); - const fileCount = s.seasons.reduce( - (n, season) => - n + season.episodes.reduce((m, ep) => m + ep.mediaFiles.length, 0), - 0 + const allFiles = s.seasons.flatMap((season) => + season.episodes.flatMap((ep) => ep.mediaFiles) ); + const scopedFiles = nodeId ? allFiles.filter((f) => f.nodeId === nodeId) : allFiles; + const nodeMap = new Map(); + for (const f of allFiles) { + const cur = nodeMap.get(f.nodeId); + if (cur) cur.fileCount += 1; + else nodeMap.set(f.nodeId, { id: f.nodeId, name: f.node.name, fileCount: 1 }); + } + + const episodesWithFiles = s.seasons.reduce((n, season) => { + return ( + n + + season.episodes.filter((ep) => { + const files = nodeId + ? ep.mediaFiles.filter((f) => f.nodeId === nodeId) + : ep.mediaFiles; + return files.length > 0; + }).length + ); + }, 0); + return { id: s.id, title: s.title, @@ -229,24 +262,34 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) tmdbId: s.tmdbId, posterUrl: s.posterUrl, episodesWithFiles, - fileCount, + fileCount: scopedFiles.length, + nodes: [...nodeMap.values()].sort((a, b) => a.name.localeCompare(b.name)), seasons: s.seasons.map((season) => ({ seasonNumber: season.seasonNumber, - episodes: season.episodes.map((ep) => ({ - id: ep.id, - seasonNumber: ep.seasonNumber, - episodeNumber: ep.episodeNumber, - title: ep.title, - files: ep.mediaFiles.map((f) => ({ - id: f.id, - node: f.node.name, - nodeStatus: f.node.status, - resolution: f.resolution, - available: f.available, - releaseName: f.releaseName, - })), - })), - })), + episodes: season.episodes + .map((ep) => { + const files = (nodeId + ? ep.mediaFiles.filter((f) => f.nodeId === nodeId) + : ep.mediaFiles + ).map((f) => ({ + id: f.id, + nodeId: f.nodeId, + node: f.node.name, + nodeStatus: f.node.status, + resolution: f.resolution, + available: f.available, + releaseName: f.releaseName, + })); + return { + id: ep.id, + seasonNumber: ep.seasonNumber, + episodeNumber: ep.episodeNumber, + title: ep.title, + files, + }; + }) + .filter((ep) => !nodeId || ep.files.length > 0), + })).filter((season) => !nodeId || season.episodes.length > 0), }; }), };