Filter admin series list by node and show per-node file badges.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a7a7c9dec4
commit
705a01f034
2 changed files with 147 additions and 33 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
AND: [
|
||||||
|
query.search
|
||||||
? { title: { contains: query.search, mode: "insensitive" } }
|
? { title: { contains: query.search, mode: "insensitive" } }
|
||||||
: undefined,
|
: {},
|
||||||
|
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(
|
const scopedFiles = nodeId ? allFiles.filter((f) => f.nodeId === nodeId) : allFiles;
|
||||||
(n, season) =>
|
const nodeMap = new Map<string, { id: string; name: string; fileCount: number }>();
|
||||||
n + season.episodes.reduce((m, ep) => m + ep.mediaFiles.length, 0),
|
for (const f of allFiles) {
|
||||||
0
|
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,
|
||||||
|
nodeId: f.nodeId,
|
||||||
node: f.node.name,
|
node: f.node.name,
|
||||||
nodeStatus: f.node.status,
|
nodeStatus: f.node.status,
|
||||||
resolution: f.resolution,
|
resolution: f.resolution,
|
||||||
available: f.available,
|
available: f.available,
|
||||||
releaseName: f.releaseName,
|
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),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue