From a4dfb1b4726bdd803940fd0e42b386732d281412 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 20:47:41 +0200 Subject: [PATCH] Add admin-managed node scan paths, restart, and shared library shelves. Shelves (e.g. Kids Films) are defined in settings; node roots bind paths to shelves so the same Stremio catalog aggregates content across nodes. --- apps/admin-ui/src/app/nodes/page.tsx | 247 +++++++++++++----- apps/admin-ui/src/app/settings/page.tsx | 122 ++++++++- .../migration.sql | 3 + .../migration.sql | 59 +++++ apps/master-api/prisma/schema.prisma | 47 ++++ apps/master-api/src/admin/routes.ts | 76 ++++++ apps/master-api/src/app.ts | 61 +++++ apps/master-api/src/library/shelves.ts | 65 +++++ apps/master-api/src/metadata/service.ts | 4 +- apps/master-api/src/nodes/routes.ts | 154 ++++++++++- apps/master-api/src/stremio/routes.ts | 70 ++++- apps/master-api/src/websocket/manager.ts | 79 +++++- deploy/node/docker-compose.unraid.yml | 16 +- deploy/node/install-unraid.sh | 39 ++- node/media-node/cmd/media-node/main.go | 2 +- node/media-node/internal/config/config.go | 46 +++- node/media-node/internal/control/client.go | 180 ++++++++++++- node/media-node/internal/scanner/scanner.go | 136 ++++++++-- packages/protocol/src/index.ts | 23 ++ packages/shared-types/src/index.ts | 2 + 20 files changed, 1303 insertions(+), 128 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260825193000_node_media_paths/migration.sql create mode 100644 apps/master-api/prisma/migrations/20260825200000_library_shelves/migration.sql create mode 100644 apps/master-api/src/library/shelves.ts diff --git a/apps/admin-ui/src/app/nodes/page.tsx b/apps/admin-ui/src/app/nodes/page.tsx index dd86a72..9bc2000 100644 --- a/apps/admin-ui/src/app/nodes/page.tsx +++ b/apps/admin-ui/src/app/nodes/page.tsx @@ -3,6 +3,14 @@ import { useEffect, useState } from "react"; import { Nav, useAuth } from "@/components/Nav"; +interface ScanRootRow { + id?: string; + path: string; + shelfId: string; + shelfName?: string; + kind?: "movie" | "series"; +} + interface Node { id: string; name: string; @@ -19,14 +27,25 @@ interface Node { libraryFileCount: number; fullScanInterval: string; fullScanAt: string; + moviesPaths: string[]; + seriesPaths: string[]; + scanRoots: ScanRootRow[]; revoked: boolean; } +interface Shelf { + id: string; + name: string; + slug: string; + kind: "MOVIE" | "SERIES"; +} + const MASTER_URL = "https://master.vonas.nl"; export default function NodesPage() { useAuth(); const [nodes, setNodes] = useState([]); + const [shelves, setShelves] = useState([]); const [showEnroll, setShowEnroll] = useState(false); const [enrollName, setEnrollName] = useState(""); const [enrollLocation, setEnrollLocation] = useState(""); @@ -41,6 +60,8 @@ export default function NodesPage() { const [editPublicUrl, setEditPublicUrl] = useState(""); const [editScanInterval, setEditScanInterval] = useState("off"); const [editScanAt, setEditScanAt] = useState("03:30"); + const [editRoots, setEditRoots] = useState([]); + const [editBusy, setEditBusy] = useState(false); function loadNodes() { fetch("/api/v1/admin/nodes", { credentials: "include" }) @@ -48,8 +69,15 @@ export default function NodesPage() { .then((d) => setNodes(d.nodes ?? [])); } + function loadShelves() { + fetch("/api/v1/admin/library/shelves", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => setShelves(d.shelves ?? [])); + } + useEffect(() => { loadNodes(); + loadShelves(); const t = setInterval(loadNodes, 15000); return () => clearInterval(t); }, []); @@ -95,6 +123,20 @@ export default function NodesPage() { alert("Rescan aangevraagd"); } + async function restartNode(id: string) { + if (!confirm("Node herstarten? De container komt terug via restart-policy.")) return; + const res = await fetch(`/api/v1/admin/nodes/${id}/restart`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error?.message ?? "Herstart mislukt"); + return; + } + alert("Herstart aangevraagd"); + } + async function revokeNode(id: string) { if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return; await fetch(`/api/v1/admin/nodes/${id}/revoke`, { @@ -133,29 +175,59 @@ export default function NodesPage() { loadNodes(); } + function openEdit(node: Node) { + setEditId(node.id); + setEditName(node.name); + setEditLocation(node.location ?? ""); + setEditPublicUrl(node.publicStreamUrl); + setEditScanInterval(node.fullScanInterval ?? "off"); + setEditScanAt(node.fullScanAt ?? "03:30"); + if (node.scanRoots?.length) { + setEditRoots(node.scanRoots.map((r) => ({ path: r.path, shelfId: r.shelfId }))); + } else { + const defaults: ScanRootRow[] = []; + const filmShelf = shelves.find((s) => s.slug === "films")?.id ?? ""; + const seriesShelf = shelves.find((s) => s.slug === "series")?.id ?? ""; + for (const p of node.moviesPaths ?? []) defaults.push({ path: p, shelfId: filmShelf }); + for (const p of node.seriesPaths ?? []) defaults.push({ path: p, shelfId: seriesShelf }); + setEditRoots(defaults.length ? defaults : [{ path: "", shelfId: filmShelf }]); + } + } + async function saveEdit() { if (!editId) return; - const res = await fetch(`/api/v1/admin/nodes/${editId}`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: editName, - location: editLocation, - publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""), - fullScanInterval: editScanInterval.trim() || "off", - fullScanAt: editScanAt.trim() || "03:30", - }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - alert(data.error?.message ?? "Opslaan mislukt"); - return; + setEditBusy(true); + try { + const scanRoots = editRoots + .map((r) => ({ path: r.path.trim(), shelfId: r.shelfId })) + .filter((r) => r.path && r.shelfId); + const res = await fetch(`/api/v1/admin/nodes/${editId}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: editName, + location: editLocation, + publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""), + fullScanInterval: editScanInterval.trim() || "off", + fullScanAt: editScanAt.trim() || "03:30", + scanRoots, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error?.message ?? "Opslaan mislukt"); + return; + } + setEditId(null); + loadNodes(); + } finally { + setEditBusy(false); } - setEditId(null); - loadNodes(); } + const defaultMovieShelf = shelves.find((s) => s.slug === "films")?.id ?? shelves.find((s) => s.kind === "MOVIE")?.id ?? ""; + return ( <>