Filter admin series list by node and show per-node file badges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-09 01:27:39 +02:00
parent a7a7c9dec4
commit 705a01f034
2 changed files with 147 additions and 33 deletions

View file

@ -3,6 +3,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav"; import { Nav, useAuth } from "@/components/Nav";
interface SeriesNode {
id: string;
name: string;
fileCount: number;
}
interface Series { interface Series {
id: string; id: string;
title: string; title: string;
@ -12,6 +18,7 @@ interface Series {
posterUrl?: string | null; posterUrl?: string | null;
episodesWithFiles?: number; episodesWithFiles?: number;
fileCount?: number; fileCount?: number;
nodes?: SeriesNode[];
seasons: Array<{ seasons: Array<{
seasonNumber: number; seasonNumber: number;
episodes: Array<{ episodes: Array<{
@ -23,6 +30,12 @@ interface Series {
}>; }>;
} }
interface NodeOption {
id: string;
name: string;
status?: string;
}
interface MetaHit { interface MetaHit {
tmdbId: number; tmdbId: number;
title: string; title: string;
@ -35,6 +48,8 @@ interface MetaHit {
export default function SeriesPage() { export default function SeriesPage() {
useAuth(); useAuth();
const [series, setSeries] = useState<Series[]>([]); const [series, setSeries] = useState<Series[]>([]);
const [nodes, setNodes] = useState<NodeOption[]>([]);
const [nodeId, setNodeId] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
const [linkSeriesId, setLinkSeriesId] = useState<string | null>(null); const [linkSeriesId, setLinkSeriesId] = useState<string | null>(null);
@ -43,17 +58,31 @@ export default function SeriesPage() {
const [linkBusy, setLinkBusy] = useState(false); const [linkBusy, setLinkBusy] = useState(false);
const [linkMsg, setLinkMsg] = useState(""); const [linkMsg, setLinkMsg] = useState("");
function load(q = search) { function load(q = search, filterNodeId = nodeId) {
const qs = q ? `?search=${encodeURIComponent(q)}` : ""; 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" }) fetch(`/api/v1/admin/series${qs}`, { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setSeries(d.series ?? [])); .then((d) => setSeries(d.series ?? []));
} }
useEffect(() => { 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(); load();
}, []); }, []);
useEffect(() => {
load(search, nodeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]);
function openLink(s: Series) { function openLink(s: Series) {
setLinkSeriesId(s.id); setLinkSeriesId(s.id);
setLinkQuery(s.title); setLinkQuery(s.title);
@ -147,6 +176,7 @@ export default function SeriesPage() {
} }
const activeSeries = series.find((s) => s.id === linkSeriesId); const activeSeries = series.find((s) => s.id === linkSeriesId);
const selectedNodeName = nodes.find((n) => n.id === nodeId)?.name;
return ( return (
<> <>
@ -162,8 +192,22 @@ export default function SeriesPage() {
e.preventDefault(); e.preventDefault();
load(); load();
}} }}
style={{ display: "flex", gap: "0.5rem" }} style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
> >
<select
value={nodeId}
onChange={(e) => setNodeId(e.target.value)}
style={{ marginBottom: 0, minWidth: 180 }}
aria-label="Filter op node"
>
<option value="">Alle nodes</option>
{nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
{n.status === "OFFLINE" ? " (offline)" : ""}
</option>
))}
</select>
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
@ -174,6 +218,12 @@ export default function SeriesPage() {
</form> </form>
</div> </div>
<p className="muted page-lead">
{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.`}
</p>
{series.map((s) => ( {series.map((s) => (
<div key={s.id} className="card"> <div key={s.id} className="card">
<div <div
@ -200,6 +250,25 @@ export default function SeriesPage() {
{s.imdbId ? ` · ${s.imdbId}` : " · geen IMDb"} {s.imdbId ? ` · ${s.imdbId}` : " · geen IMDb"}
{s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""} {s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""}
</div> </div>
{(s.nodes?.length ?? 0) > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 8 }}>
{s.nodes!.map((n) => (
<span
key={n.id}
style={{
fontSize: "0.75rem",
padding: "2px 8px",
borderRadius: 999,
border: "1px solid #2a2d3a",
background: nodeId === n.id ? "#1e293b" : "#12141c",
color: nodeId === n.id ? "#e2e8f0" : "#a1a1aa",
}}
>
{n.name} · {n.fileCount}
</span>
))}
</div>
)}
</div> </div>
</div> </div>
<span style={{ color: "#71717a" }}>{expanded === s.id ? "▲" : "▼"}</span> <span style={{ color: "#71717a" }}>{expanded === s.id ? "▲" : "▼"}</span>
@ -244,7 +313,9 @@ export default function SeriesPage() {
{series.length === 0 && ( {series.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}> <div className="card" style={{ textAlign: "center", color: "#71717a" }}>
Geen series gevonden {nodeId
? "Geen series met bestanden op deze node. Check scan-roots (series-pad) en doe een rescan."
: "Geen series gevonden"}
</div> </div>
)} )}
</div> </div>

View file

@ -186,11 +186,28 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}); });
app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => { 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({ const series = await prisma.series.findMany({
where: query.search where: {
? { title: { contains: query.search, mode: "insensitive" } } AND: [
: undefined, query.search
? { title: { contains: query.search, mode: "insensitive" } }
: {},
nodeId
? {
seasons: {
some: {
episodes: {
some: { mediaFiles: { some: { nodeId } } },
},
},
},
}
: {},
],
},
orderBy: { title: "asc" }, orderBy: { title: "asc" },
include: { include: {
seasons: { seasons: {
@ -198,7 +215,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
episodes: { episodes: {
include: { include: {
mediaFiles: { mediaFiles: {
include: { node: { select: { name: true, status: true } } }, include: {
node: { select: { id: true, name: true, status: true } },
},
}, },
}, },
orderBy: { episodeNumber: "asc" }, orderBy: { episodeNumber: "asc" },
@ -212,15 +231,29 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return { return {
series: series.map((s) => { series: series.map((s) => {
const episodesWithFiles = s.seasons.reduce( const allFiles = s.seasons.flatMap((season) =>
(n, season) => n + season.episodes.filter((ep) => ep.mediaFiles.length > 0).length, season.episodes.flatMap((ep) => ep.mediaFiles)
0
);
const fileCount = s.seasons.reduce(
(n, season) =>
n + season.episodes.reduce((m, ep) => m + ep.mediaFiles.length, 0),
0
); );
const scopedFiles = nodeId ? allFiles.filter((f) => f.nodeId === nodeId) : allFiles;
const nodeMap = new Map<string, { id: string; name: string; fileCount: number }>();
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 { return {
id: s.id, id: s.id,
title: s.title, title: s.title,
@ -229,24 +262,34 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
tmdbId: s.tmdbId, tmdbId: s.tmdbId,
posterUrl: s.posterUrl, posterUrl: s.posterUrl,
episodesWithFiles, episodesWithFiles,
fileCount, fileCount: scopedFiles.length,
nodes: [...nodeMap.values()].sort((a, b) => a.name.localeCompare(b.name)),
seasons: s.seasons.map((season) => ({ seasons: s.seasons.map((season) => ({
seasonNumber: season.seasonNumber, seasonNumber: season.seasonNumber,
episodes: season.episodes.map((ep) => ({ episodes: season.episodes
id: ep.id, .map((ep) => {
seasonNumber: ep.seasonNumber, const files = (nodeId
episodeNumber: ep.episodeNumber, ? ep.mediaFiles.filter((f) => f.nodeId === nodeId)
title: ep.title, : ep.mediaFiles
files: ep.mediaFiles.map((f) => ({ ).map((f) => ({
id: f.id, id: f.id,
node: f.node.name, nodeId: f.nodeId,
nodeStatus: f.node.status, node: f.node.name,
resolution: f.resolution, nodeStatus: f.node.status,
available: f.available, resolution: f.resolution,
releaseName: f.releaseName, 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),
}; };
}), }),
}; };