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.
This commit is contained in:
Jos Vooges | STH 2026-08-25 20:47:41 +02:00
parent 002472e235
commit a4dfb1b472
20 changed files with 1303 additions and 128 deletions

View file

@ -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<Node[]>([]);
const [shelves, setShelves] = useState<Shelf[]>([]);
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<ScanRootRow[]>([]);
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,8 +175,32 @@ 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;
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",
@ -145,6 +211,7 @@ export default function NodesPage() {
publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""),
fullScanInterval: editScanInterval.trim() || "off",
fullScanAt: editScanAt.trim() || "03:30",
scanRoots,
}),
});
if (!res.ok) {
@ -154,7 +221,12 @@ export default function NodesPage() {
}
setEditId(null);
loadNodes();
} finally {
setEditBusy(false);
}
}
const defaultMovieShelf = shelves.find((s) => s.slug === "films")?.id ?? shelves.find((s) => s.kind === "MOVIE")?.id ?? "";
return (
<>
@ -173,8 +245,8 @@ export default function NodesPage() {
Unraid-node toevoegen
</h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.9rem" }}>
Genereer een token, vul de paden in, kopieer het commando en plak het in de Unraid-terminal.
Geen Docker Compose of image build nodig.
Eenmalig installeren met brede <code>/mnt/user</code>-mount. Daarna: planken in Instellingen,
scanpaden + plankkeuze hier onder Edit.
</p>
<div className="form-group">
<label>Naam</label>
@ -189,11 +261,11 @@ export default function NodesPage() {
<input value={publicUrl} onChange={(e) => setPublicUrl(e.target.value)} placeholder="https://node1.example.com" />
</div>
<div className="form-group">
<label>Unraid films-pad</label>
<label>Eerste films-pad (bootstrap)</label>
<input value={moviesPath} onChange={(e) => setMoviesPath(e.target.value)} placeholder="/mnt/user/movies" />
</div>
<div className="form-group">
<label>Unraid series-pad (optioneel)</label>
<label>Eerste series-pad (optioneel)</label>
<input value={seriesPath} onChange={(e) => setSeriesPath(e.target.value)} placeholder="/mnt/user/series" />
</div>
<button onClick={createEnrollmentToken}>Token genereren</button>
@ -209,9 +281,6 @@ export default function NodesPage() {
<button onClick={copyInstall} style={{ marginTop: "0.75rem" }}>
{copied ? "Gekopieerd" : "Kopieer install-commando"}
</button>
<p style={{ color: "#71717a", fontSize: "0.8rem", marginTop: "0.75rem" }}>
NPM: {publicUrl || "node-URL"} → Unraid LAN-IP:8080, Force SSL, buffering uit.
</p>
</>
)}
</div>
@ -232,37 +301,93 @@ export default function NodesPage() {
</div>
<div className="form-group">
<label>Publieke stream-URL (NPM)</label>
<input
value={editPublicUrl}
onChange={(e) => setEditPublicUrl(e.target.value)}
placeholder="https://node1.example.com"
/>
<input value={editPublicUrl} onChange={(e) => setEditPublicUrl(e.target.value)} />
</div>
<h3 style={{ fontSize: "0.95rem", color: "#e4e4e7", margin: "1.25rem 0 0.75rem" }}>
Scanpaden → Stremio-plank
</h3>
<p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginBottom: "0.75rem" }}>
Planken beheer je onder Instellingen. Zelfde plank op meerdere nodes = één Stremio-lijst.
</p>
{editRoots.map((row, idx) => (
<div
key={idx}
style={{
display: "grid",
gridTemplateColumns: "1fr 180px auto",
gap: "0.5rem",
marginBottom: "0.5rem",
alignItems: "center",
}}
>
<input
value={row.path}
onChange={(e) => {
const next = [...editRoots];
next[idx] = { ...next[idx], path: e.target.value };
setEditRoots(next);
}}
placeholder="/mnt/user/Kids Films"
style={{ marginBottom: 0 }}
/>
<select
value={row.shelfId}
onChange={(e) => {
const next = [...editRoots];
next[idx] = { ...next[idx], shelfId: e.target.value };
setEditRoots(next);
}}
style={{
background: "#0f1117",
border: "1px solid #2a2d3a",
color: "#fafafa",
borderRadius: 8,
padding: "0.5rem 0.75rem",
}}
>
<option value="">Kies plank…</option>
{shelves.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.kind === "MOVIE" ? "films" : "series"})
</option>
))}
</select>
<button
type="button"
className="danger"
onClick={() => setEditRoots(editRoots.filter((_, i) => i !== idx))}
>
×
</button>
</div>
))}
<button
type="button"
onClick={() => setEditRoots([...editRoots, { path: "", shelfId: defaultMovieShelf }])}
style={{ background: "#374151", marginBottom: "1rem" }}
>
Pad toevoegen
</button>
<div className="form-group">
<label>Nachtelijke full scan (HH:MM of off)</label>
<input
value={editScanAt}
onChange={(e) => setEditScanAt(e.target.value)}
placeholder="03:30"
/>
<p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginTop: 6 }}>
Catch-up scan in de tijdzone van de node (Unraid TZ). Standaard 03:30.
</p>
<input value={editScanAt} onChange={(e) => setEditScanAt(e.target.value)} placeholder="03:30" />
</div>
<div className="form-group">
<label>Extra interval-scan (off / 6h / 24h)</label>
<input
value={editScanInterval}
onChange={(e) => setEditScanInterval(e.target.value)}
placeholder="off"
/>
<p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginTop: 6 }}>
Laat op <code>off</code>: toevoegen/verwijderen gaat via realtime watch. Admin-Rescan blijft beschikbaar.
</p>
<input value={editScanInterval} onChange={(e) => setEditScanInterval(e.target.value)} placeholder="off" />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={saveEdit}>Opslaan</button>
<button onClick={() => setEditId(null)} style={{ background: "#374151" }}>Annuleren</button>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button onClick={saveEdit} disabled={editBusy}>
{editBusy ? "Bezig…" : "Opslaan & toepassen"}
</button>
<button onClick={() => restartNode(editId)} style={{ background: "#0e7490" }}>
Herstart
</button>
<button onClick={() => setEditId(null)} style={{ background: "#374151" }}>
Annuleren
</button>
</div>
</div>
)}
@ -274,6 +399,7 @@ export default function NodesPage() {
<th>Naam</th>
<th>Locatie</th>
<th>Status</th>
<th>Paden</th>
<th>Bestanden</th>
<th>Streams</th>
<th>Versie</th>
@ -291,23 +417,20 @@ export default function NodesPage() {
<td>
<span className={`status ${node.status.toLowerCase()}`}>{node.status}</span>
</td>
<td style={{ fontSize: "0.75rem", color: "#a1a1aa" }}>
{node.scanRoots?.length
? `${node.scanRoots.length} roots`
: `${(node.moviesPaths?.length ?? 0) + (node.seriesPaths?.length ?? 0)} paden`}
</td>
<td>{node.libraryFileCount}</td>
<td>{node.activeStreams}</td>
<td>{node.version ?? "-"}</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button
onClick={() => {
setEditId(node.id);
setEditName(node.name);
setEditLocation(node.location ?? "");
setEditPublicUrl(node.publicStreamUrl);
setEditScanInterval(node.fullScanInterval ?? "off");
setEditScanAt(node.fullScanAt ?? "03:30");
}}
>
Edit
</button>
<button onClick={() => openEdit(node)}>Edit</button>
<button onClick={() => rescanNode(node.id)}>Rescan</button>
<button onClick={() => restartNode(node.id)} style={{ background: "#0e7490" }}>
Herstart
</button>
{!node.revoked && (
<>
<button onClick={() => disableNode(node.id)} style={{ background: "#b45309" }}>
@ -326,7 +449,7 @@ export default function NodesPage() {
))}
{nodes.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: "center", color: "#71717a" }}>
<td colSpan={8} style={{ textAlign: "center", color: "#71717a" }}>
Geen nodes gevonden
</td>
</tr>

View file

@ -37,6 +37,17 @@ const emptyIptv = (): IptvForm => ({
const MASTER_URL = "https://master.vonas.nl";
interface ShelfRow {
id: string;
name: string;
slug: string;
kind: "MOVIE" | "SERIES";
sortOrder: number;
enabled: boolean;
scanRootCount: number;
mediaFileCount: number;
}
export default function SettingsPage() {
useAuth();
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
@ -48,6 +59,9 @@ export default function SettingsPage() {
const [iptvBusy, setIptvBusy] = useState(false);
const [iptvMsg, setIptvMsg] = useState<string | null>(null);
const [iptvErr, setIptvErr] = useState<string | null>(null);
const [shelves, setShelves] = useState<ShelfRow[]>([]);
const [newShelfName, setNewShelfName] = useState("");
const [newShelfKind, setNewShelfKind] = useState<"MOVIE" | "SERIES">("MOVIE");
const loadTokens = useCallback(() => {
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
@ -55,9 +69,47 @@ export default function SettingsPage() {
.then((d) => setTokens(d.tokens ?? []));
}, []);
const loadShelves = useCallback(() => {
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
.then((r) => r.json())
.then((d) => setShelves(d.shelves ?? []));
}, []);
useEffect(() => {
loadTokens();
}, [loadTokens]);
loadShelves();
}, [loadTokens, loadShelves]);
async function createShelf() {
if (!newShelfName.trim()) return;
const res = await fetch("/api/v1/admin/library/shelves", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newShelfName.trim(), kind: newShelfKind }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Aanmaken mislukt");
return;
}
setNewShelfName("");
loadShelves();
}
async function deleteShelf(id: string, name: string) {
if (!confirm(`Plank "${name}" verwijderen?`)) return;
const res = await fetch(`/api/v1/admin/library/shelves/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Verwijderen mislukt");
return;
}
loadShelves();
}
async function createAddonToken() {
const res = await fetch("/api/v1/admin/addon-tokens", {
@ -230,6 +282,74 @@ export default function SettingsPage() {
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
Bibliotheek-planken (Stremio)
</h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
Maak namen zoals <strong>Kids Films</strong> of <strong>NL Series</strong>. Op Nodes koppel je
scanpaden aan een plank — content van alle nodes met dezelfde plank komt in één Stremio-lijst.
</p>
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1rem" }}>
<input
value={newShelfName}
onChange={(e) => setNewShelfName(e.target.value)}
placeholder="bijv. Kids Films"
style={{ flex: "1 1 200px", marginBottom: 0 }}
/>
<select
value={newShelfKind}
onChange={(e) => setNewShelfKind(e.target.value as "MOVIE" | "SERIES")}
style={{
background: "#0f1117",
border: "1px solid #2a2d3a",
color: "#fafafa",
borderRadius: 8,
padding: "0.5rem 0.75rem",
}}
>
<option value="MOVIE">Films</option>
<option value="SERIES">Series</option>
</select>
<button onClick={createShelf}>Plank toevoegen</button>
</div>
<table>
<thead>
<tr>
<th>Naam</th>
<th>Type</th>
<th>Paden</th>
<th>Bestanden</th>
<th></th>
</tr>
</thead>
<tbody>
{shelves.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{s.kind === "MOVIE" ? "Films" : "Series"}</td>
<td>{s.scanRootCount}</td>
<td>{s.mediaFileCount}</td>
<td>
{s.slug !== "films" && s.slug !== "series" && (
<button className="danger" onClick={() => deleteShelf(s.id, s.name)}>
Verwijderen
</button>
)}
</td>
</tr>
))}
{shelves.length === 0 && (
<tr>
<td colSpan={5} style={{ color: "#71717a", textAlign: "center" }}>
Laden…
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Nieuwe Stremio-addon

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "movies_paths" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "series_paths" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -0,0 +1,59 @@
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "LibraryShelfKind" AS ENUM ('MOVIE', 'SERIES');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- CreateTable
CREATE TABLE IF NOT EXISTS "library_shelves" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "LibraryShelfKind" NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "library_shelves_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "library_shelves_slug_key" ON "library_shelves"("slug");
CREATE TABLE IF NOT EXISTS "node_scan_roots" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"path" TEXT NOT NULL,
"shelf_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "node_scan_roots_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "node_scan_roots_node_id_path_key" ON "node_scan_roots"("node_id", "path");
CREATE INDEX IF NOT EXISTS "node_scan_roots_shelf_id_idx" ON "node_scan_roots"("shelf_id");
ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "shelf_id" TEXT;
CREATE INDEX IF NOT EXISTS "media_files_shelf_id_idx" ON "media_files"("shelf_id");
DO $$ BEGIN
ALTER TABLE "node_scan_roots" ADD CONSTRAINT "node_scan_roots_node_id_fkey"
FOREIGN KEY ("node_id") REFERENCES "nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
ALTER TABLE "node_scan_roots" ADD CONSTRAINT "node_scan_roots_shelf_id_fkey"
FOREIGN KEY ("shelf_id") REFERENCES "library_shelves"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
ALTER TABLE "media_files" ADD CONSTRAINT "media_files_shelf_id_fkey"
FOREIGN KEY ("shelf_id") REFERENCES "library_shelves"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- Default shelves
INSERT INTO "library_shelves" ("id", "name", "slug", "kind", "sort_order", "enabled", "created_at", "updated_at")
VALUES
('00000000-0000-4000-8000-000000000001', 'Films', 'films', 'MOVIE', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('00000000-0000-4000-8000-000000000002', 'Series', 'series', 'SERIES', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;

View file

@ -30,6 +30,11 @@ enum LocationType {
REMOTE
}
enum LibraryShelfKind {
MOVIE
SERIES
}
model User {
id String @id @default(uuid())
email String @unique
@ -126,6 +131,10 @@ model Node {
fullScanInterval String @default("off") @map("full_scan_interval")
/** Nightly catch-up time HH:MM in node local TZ */
fullScanAt String @default("03:30") @map("full_scan_at")
/** Host/container scan roots for movies (e.g. /mnt/user/NL Films) — legacy; prefer scanRoots */
moviesPaths String[] @default([]) @map("movies_paths")
/** Host/container scan roots for series — legacy; prefer scanRoots */
seriesPaths String[] @default([]) @map("series_paths")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -134,10 +143,45 @@ model Node {
credentials NodeCredential?
mediaFiles MediaFile[]
playbackSessions PlaybackSession[]
scanRoots NodeScanRoot[]
@@map("nodes")
}
/** Named Stremio catalog / shelf shared across all nodes (e.g. Kids Films). */
model LibraryShelf {
id String @id @default(uuid())
name String
slug String @unique
kind LibraryShelfKind
sortOrder Int @default(0) @map("sort_order")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
scanRoots NodeScanRoot[]
mediaFiles MediaFile[]
@@map("library_shelves")
}
/** One filesystem root on a node bound to a library shelf. */
model NodeScanRoot {
id String @id @default(uuid())
nodeId String @map("node_id")
path String
shelfId String @map("shelf_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
shelf LibraryShelf @relation(fields: [shelfId], references: [id], onDelete: Restrict)
@@unique([nodeId, path])
@@index([shelfId])
@@map("node_scan_roots")
}
model NodeCredential {
id String @id @default(uuid())
nodeId String @unique @map("node_id")
@ -246,6 +290,7 @@ model MediaFile {
nodeId String @map("node_id")
movieId String? @map("movie_id")
episodeId String? @map("episode_id")
shelfId String? @map("shelf_id")
localFileId String @map("local_file_id")
sizeBytes BigInt @map("size_bytes")
container String?
@ -265,9 +310,11 @@ model MediaFile {
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
movie Movie? @relation(fields: [movieId], references: [id])
episode Episode? @relation(fields: [episodeId], references: [id])
shelf LibraryShelf? @relation(fields: [shelfId], references: [id], onDelete: SetNull)
playbackSessions PlaybackSession[]
@@unique([nodeId, localFileId])
@@index([shelfId])
@@map("media_files")
}

View file

@ -463,6 +463,82 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return result;
});
app.get("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async () => {
const { ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();
const shelves = await prisma.libraryShelf.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
include: { _count: { select: { scanRoots: true, mediaFiles: true } } },
});
return {
shelves: shelves.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
kind: s.kind,
sortOrder: s.sortOrder,
enabled: s.enabled,
scanRootCount: s._count.scanRoots,
mediaFileCount: s._count.mediaFiles,
})),
};
});
app.post("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { name?: string; kind?: "MOVIE" | "SERIES"; sortOrder?: number };
if (!body.name?.trim()) throw new AppError("INVALID_REQUEST", "name required", 400);
if (body.kind !== "MOVIE" && body.kind !== "SERIES") {
throw new AppError("INVALID_REQUEST", "kind must be MOVIE or SERIES", 400);
}
const { slugifyShelfName, ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();
let slug = slugifyShelfName(body.name);
const existing = await prisma.libraryShelf.findUnique({ where: { slug } });
if (existing) slug = `${slug}-${Date.now().toString(36)}`;
const shelf = await prisma.libraryShelf.create({
data: {
name: body.name.trim(),
slug,
kind: body.kind,
sortOrder: body.sortOrder ?? 10,
},
});
return { shelf };
});
app.patch("/api/v1/admin/library/shelves/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
name?: string;
enabled?: boolean;
sortOrder?: number;
};
const data: Record<string, unknown> = {};
if (body.name !== undefined) {
const name = body.name.trim();
if (!name) throw new AppError("INVALID_REQUEST", "name required", 400);
data.name = name;
}
if (body.enabled !== undefined) data.enabled = body.enabled;
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
const shelf = await prisma.libraryShelf.update({ where: { id }, data });
return { shelf };
});
app.delete("/api/v1/admin/library/shelves/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
if (id === "00000000-0000-4000-8000-000000000001" || id === "00000000-0000-4000-8000-000000000002") {
throw new AppError("INVALID_REQUEST", "Standaard Films/Series planken kunnen niet weg", 400);
}
const roots = await prisma.nodeScanRoot.count({ where: { shelfId: id } });
if (roots > 0) {
throw new AppError("IN_USE", "Plank heeft nog scanpaden — ontkoppel die eerst", 400);
}
await prisma.mediaFile.updateMany({ where: { shelfId: id }, data: { shelfId: null } });
await prisma.libraryShelf.delete({ where: { id } });
return { ok: true };
});
app.get("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;

View file

@ -118,6 +118,67 @@ async function main() {
console.warn("Could not ensure iptv_lines table:", err);
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "movies_paths" TEXT[] DEFAULT ARRAY[]::TEXT[]`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "series_paths" TEXT[] DEFAULT ARRAY[]::TEXT[]`
);
} catch (err) {
console.warn("Could not ensure node media path columns:", err);
}
try {
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "LibraryShelfKind" AS ENUM ('MOVIE', 'SERIES');
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "library_shelves" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "LibraryShelfKind" NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "library_shelves_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "library_shelves_slug_key" ON "library_shelves"("slug")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "node_scan_roots" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"path" TEXT NOT NULL,
"shelf_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "node_scan_roots_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "node_scan_roots_node_id_path_key" ON "node_scan_roots"("node_id", "path")`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "shelf_id" TEXT`
);
await prisma.$executeRawUnsafe(`
INSERT INTO "library_shelves" ("id", "name", "slug", "kind", "sort_order", "enabled", "created_at", "updated_at")
VALUES
('00000000-0000-4000-8000-000000000001', 'Films', 'films', 'MOVIE', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('00000000-0000-4000-8000-000000000002', 'Series', 'series', 'SERIES', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING
`);
} catch (err) {
console.warn("Could not ensure library shelves:", err);
}
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
try {
await prisma.$executeRawUnsafe(`

View file

@ -0,0 +1,65 @@
import { prisma } from "../database/client";
export const DEFAULT_MOVIE_SHELF_ID = "00000000-0000-4000-8000-000000000001";
export const DEFAULT_SERIES_SHELF_ID = "00000000-0000-4000-8000-000000000002";
export function slugifyShelfName(name: string): string {
return name
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "shelf";
}
export async function ensureDefaultShelves() {
await prisma.libraryShelf.upsert({
where: { slug: "films" },
create: {
id: DEFAULT_MOVIE_SHELF_ID,
name: "Films",
slug: "films",
kind: "MOVIE",
sortOrder: 0,
},
update: {},
});
await prisma.libraryShelf.upsert({
where: { slug: "series" },
create: {
id: DEFAULT_SERIES_SHELF_ID,
name: "Series",
slug: "series",
kind: "SERIES",
sortOrder: 1,
},
update: {},
});
}
export async function getNodeScanRootsPayload(nodeId: string) {
const roots = await prisma.nodeScanRoot.findMany({
where: { nodeId },
include: { shelf: true },
orderBy: { path: "asc" },
});
return roots.map((r) => ({
path: r.path,
shelfId: r.shelfId,
kind: (r.shelf.kind === "SERIES" ? "series" : "movie") as "movie" | "series",
shelfName: r.shelf.name,
shelfSlug: r.shelf.slug,
}));
}
/** Keep legacy string arrays in sync for older tooling / HELLO seed. */
export function deriveLegacyPaths(
roots: Array<{ path: string; kind: "movie" | "series" }>
) {
return {
moviesPaths: roots.filter((r) => r.kind === "movie").map((r) => r.path),
seriesPaths: roots.filter((r) => r.kind === "series").map((r) => r.path),
};
}

View file

@ -1023,6 +1023,7 @@ export class MetadataService {
localFileId: file.localFileId,
movieId: refs.movieId,
episodeId: refs.episodeId,
shelfId: file.shelfId || null,
sizeBytes: BigInt(file.sizeBytes),
container: file.container,
resolution: file.resolution,
@ -1035,6 +1036,7 @@ export class MetadataService {
update: {
movieId: refs.movieId,
episodeId: refs.episodeId,
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
sizeBytes: BigInt(file.sizeBytes),
container: file.container,
resolution: file.resolution,

View file

@ -119,8 +119,13 @@ export async function registerNodeRoutes(app: FastifyInstance) {
);
app.get("/api/v1/admin/nodes", { preHandler: requireAdmin }, async () => {
const { ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();
const nodes = await prisma.node.findMany({
include: { location: true },
include: {
location: true,
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
},
orderBy: { name: "asc" },
});
@ -133,7 +138,10 @@ export async function registerNodeRoutes(app: FastifyInstance) {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({
where: { id },
include: { location: true },
include: {
location: true,
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
},
});
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
return { node: serializeNode(node) };
@ -148,6 +156,9 @@ export async function registerNodeRoutes(app: FastifyInstance) {
publicStreamUrl?: string;
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
scanRoots?: Array<{ path: string; shelfId: string }>;
};
let locationId: string | undefined | null = undefined;
@ -181,6 +192,80 @@ export async function registerNodeRoutes(app: FastifyInstance) {
}
}
const {
ensureDefaultShelves,
DEFAULT_MOVIE_SHELF_ID,
DEFAULT_SERIES_SHELF_ID,
deriveLegacyPaths,
getNodeScanRootsPayload,
} = await import("../library/shelves");
await ensureDefaultShelves();
let replacedRoots = false;
if (body.scanRoots !== undefined) {
const cleaned: Array<{ path: string; shelfId: string }> = [];
const seen = new Set<string>();
for (const row of body.scanRoots) {
const path = row.path.trim().replace(/\/+$/, "") || row.path.trim();
if (!path || !row.shelfId || seen.has(path)) continue;
seen.add(path);
cleaned.push({ path, shelfId: row.shelfId });
}
for (const row of cleaned) {
const shelf = await prisma.libraryShelf.findUnique({ where: { id: row.shelfId } });
if (!shelf) throw new AppError("INVALID_REQUEST", `Onbekende plank: ${row.shelfId}`, 400);
}
await prisma.$transaction(async (tx) => {
await tx.nodeScanRoot.deleteMany({ where: { nodeId: id } });
if (cleaned.length) {
await tx.nodeScanRoot.createMany({
data: cleaned.map((r) => ({ nodeId: id, path: r.path, shelfId: r.shelfId })),
});
}
});
replacedRoots = true;
} else if (body.moviesPaths !== undefined || body.seriesPaths !== undefined) {
// Legacy path lists → bind to default Films/Series shelves
const movies =
body.moviesPaths !== undefined
? normalizePathList(body.moviesPaths)
: undefined;
const series =
body.seriesPaths !== undefined
? normalizePathList(body.seriesPaths)
: undefined;
const existing = await prisma.nodeScanRoot.findMany({ where: { nodeId: id } });
const next: Array<{ path: string; shelfId: string }> = [];
if (movies) {
for (const p of movies) next.push({ path: p, shelfId: DEFAULT_MOVIE_SHELF_ID });
} else {
for (const r of existing) {
const shelf = await prisma.libraryShelf.findUnique({ where: { id: r.shelfId } });
if (shelf?.kind === "MOVIE") next.push({ path: r.path, shelfId: r.shelfId });
}
}
if (series) {
for (const p of series) next.push({ path: p, shelfId: DEFAULT_SERIES_SHELF_ID });
} else {
for (const r of existing) {
const shelf = await prisma.libraryShelf.findUnique({ where: { id: r.shelfId } });
if (shelf?.kind === "SERIES") next.push({ path: r.path, shelfId: r.shelfId });
}
}
await prisma.$transaction(async (tx) => {
await tx.nodeScanRoot.deleteMany({ where: { nodeId: id } });
if (next.length) {
await tx.nodeScanRoot.createMany({
data: next.map((r) => ({ nodeId: id, path: r.path, shelfId: r.shelfId })),
});
}
});
replacedRoots = true;
}
const rootsPayload = await getNodeScanRootsPayload(id);
const legacy = deriveLegacyPaths(rootsPayload);
const node = await prisma.node.update({
where: { id },
data: {
@ -192,15 +277,33 @@ export async function registerNodeRoutes(app: FastifyInstance) {
? { fullScanInterval: body.fullScanInterval.trim().toLowerCase() }
: {}),
...(body.fullScanAt !== undefined ? { fullScanAt: body.fullScanAt.trim() } : {}),
...(replacedRoots
? { moviesPaths: legacy.moviesPaths, seriesPaths: legacy.seriesPaths }
: {}),
},
include: {
location: true,
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
},
include: { location: true },
});
if (body.fullScanInterval !== undefined || body.fullScanAt !== undefined) {
const shouldPushConfig =
body.fullScanInterval !== undefined ||
body.fullScanAt !== undefined ||
replacedRoots;
if (shouldPushConfig) {
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.sendConfigUpdate(id, {
fullScanInterval: node.fullScanInterval,
fullScanAt: node.fullScanAt,
moviesPaths: legacy.moviesPaths,
seriesPaths: legacy.seriesPaths,
scanRoots: rootsPayload.map((r) => ({
path: r.path,
shelfId: r.shelfId,
kind: r.kind,
})),
});
}
@ -245,6 +348,17 @@ export async function registerNodeRoutes(app: FastifyInstance) {
return { ok: true };
});
app.post("/api/v1/admin/nodes/:id/restart", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({ where: { id } });
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
const sent = nodeConnectionManager.sendRestart(id, "admin");
if (!sent) throw new AppError("NODE_OFFLINE", "Node is not connected", 503);
return { ok: true };
});
app.post("/api/v1/admin/nodes/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.update({
@ -303,8 +417,25 @@ function serializeNode(n: {
libraryFileCount: number;
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
revoked: boolean;
scanRoots?: Array<{
id: string;
path: string;
shelfId: string;
shelf: { id: string; name: string; slug: string; kind: string };
}>;
}) {
const scanRoots =
n.scanRoots?.map((r) => ({
id: r.id,
path: r.path,
shelfId: r.shelfId,
shelfName: r.shelf.name,
shelfSlug: r.shelf.slug,
kind: r.shelf.kind === "SERIES" ? ("series" as const) : ("movie" as const),
})) ?? [];
return {
id: n.id,
name: n.name,
@ -323,10 +454,25 @@ function serializeNode(n: {
libraryFileCount: n.libraryFileCount,
fullScanInterval: n.fullScanInterval ?? "off",
fullScanAt: n.fullScanAt ?? "03:30",
moviesPaths: n.moviesPaths ?? [],
seriesPaths: n.seriesPaths ?? [],
scanRoots,
revoked: n.revoked,
};
}
function normalizePathList(raw: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const p = item.trim().replace(/\/+$/, "") || item.trim();
if (!p || seen.has(p)) continue;
seen.add(p);
out.push(p);
}
return out;
}
export async function authenticateNode(
nodeId: string,
apiKey: string

View file

@ -36,15 +36,38 @@ const BASE_CATALOGS = [
{ type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS },
];
async function buildShelfCatalogs() {
try {
const { ensureDefaultShelves } = await import("../library/shelves");
await ensureDefaultShelves();
const shelves = await prisma.libraryShelf.findMany({
where: { enabled: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
// Skip default Films/Series — already in BASE_CATALOGS as movies/series
return shelves
.filter((s) => s.slug !== "films" && s.slug !== "series")
.map((s) => ({
type: s.kind === "SERIES" ? "series" : "movie",
id: `shelf-${s.slug}`,
name: s.name,
extra: SEARCH_SKIP_EXTRAS,
}));
} catch {
return [];
}
}
async function buildManifest(tokenId: string | null, config: Config) {
const iptv = tokenId ? await buildIptvCatalogs(tokenId, config) : { catalogs: [], types: [] };
const shelfCatalogs = await buildShelfCatalogs();
const types = new Set(["movie", "series", ...iptv.types]);
return {
id: "com.mediacluster.library",
version: "1.5.4",
version: "1.6.0",
name: "Media Cluster",
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
catalogs: [...BASE_CATALOGS, ...iptv.catalogs],
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
resources: [
"catalog",
{
@ -335,10 +358,14 @@ async function getMovieCatalog(
search?: string,
skip = 0
) {
const where: Record<string, unknown> = {};
if (config.HIDE_UNAVAILABLE_TITLES) {
where.mediaFiles = { some: { available: true } };
}
const shelfSlug = catalogId.startsWith("shelf-") ? catalogId.slice("shelf-".length) : null;
const shelfFilter = shelfSlug
? { mediaFiles: { some: { available: true, shelf: { slug: shelfSlug, kind: "MOVIE" as const } } } }
: config.HIDE_UNAVAILABLE_TITLES
? { mediaFiles: { some: { available: true } } }
: {};
const where: Record<string, unknown> = { ...shelfFilter };
if (search) {
where.title = { contains: search, mode: "insensitive" };
}
@ -400,7 +427,22 @@ async function getSeriesCatalog(
search?: string,
skip = 0
) {
const availableFilter = {
const shelfSlug = catalogId.startsWith("shelf-") ? catalogId.slice("shelf-".length) : null;
const availableFilter = shelfSlug
? {
seasons: {
some: {
episodes: {
some: {
mediaFiles: {
some: { available: true, shelf: { slug: shelfSlug, kind: "SERIES" as const } },
},
},
},
},
},
}
: {
seasons: {
some: {
episodes: { some: { mediaFiles: { some: { available: true } } } },
@ -411,7 +453,7 @@ async function getSeriesCatalog(
let series = await prisma.series.findMany({
where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
...(config.HIDE_UNAVAILABLE_TITLES ? availableFilter : {}),
...(config.HIDE_UNAVAILABLE_TITLES || shelfSlug ? availableFilter : {}),
},
orderBy: [{ title: "asc" }, { year: "asc" }],
skip,

View file

@ -186,7 +186,62 @@ class NodeConnectionManager {
console.log(`Node connected: ${payload.nodeId} (${payload.hostname})`);
const node = await prisma.node.findUnique({ where: { id: payload.nodeId } });
let node = await prisma.node.findUnique({
where: { id: payload.nodeId },
include: { scanRoots: { include: { shelf: true } } },
});
const { ensureDefaultShelves, DEFAULT_MOVIE_SHELF_ID, DEFAULT_SERIES_SHELF_ID, getNodeScanRootsPayload, deriveLegacyPaths } =
await import("../library/shelves");
await ensureDefaultShelves();
// Seed master path lists from node env/config when admin has not set them yet.
if (
node &&
(node.scanRoots?.length ?? 0) === 0 &&
(node.moviesPaths?.length ?? 0) === 0 &&
(node.seriesPaths?.length ?? 0) === 0 &&
((payload.moviesPaths?.length ?? 0) > 0 || (payload.seriesPaths?.length ?? 0) > 0)
) {
const rows: Array<{ nodeId: string; path: string; shelfId: string }> = [];
for (const p of payload.moviesPaths ?? []) {
rows.push({ nodeId: payload.nodeId, path: p, shelfId: DEFAULT_MOVIE_SHELF_ID });
}
for (const p of payload.seriesPaths ?? []) {
rows.push({ nodeId: payload.nodeId, path: p, shelfId: DEFAULT_SERIES_SHELF_ID });
}
if (rows.length) {
await prisma.nodeScanRoot.createMany({ data: rows });
const legacy = deriveLegacyPaths(
rows.map((r) => ({
path: r.path,
kind: r.shelfId === DEFAULT_SERIES_SHELF_ID ? ("series" as const) : ("movie" as const),
}))
);
node = await prisma.node.update({
where: { id: payload.nodeId },
data: { moviesPaths: legacy.moviesPaths, seriesPaths: legacy.seriesPaths },
include: { scanRoots: { include: { shelf: true } } },
});
}
} else if (node && (node.scanRoots?.length ?? 0) === 0 && ((node.moviesPaths?.length ?? 0) > 0 || (node.seriesPaths?.length ?? 0) > 0)) {
// Migrate legacy arrays → scan roots once
const rows: Array<{ nodeId: string; path: string; shelfId: string }> = [];
for (const p of node.moviesPaths) {
rows.push({ nodeId: node.id, path: p, shelfId: DEFAULT_MOVIE_SHELF_ID });
}
for (const p of node.seriesPaths) {
rows.push({ nodeId: node.id, path: p, shelfId: DEFAULT_SERIES_SHELF_ID });
}
if (rows.length) {
await prisma.nodeScanRoot.createMany({ data: rows });
node = await prisma.node.findUnique({
where: { id: payload.nodeId },
include: { scanRoots: { include: { shelf: true } } },
});
}
}
const fullScanInterval = node?.fullScanInterval || "off";
const fullScanAt = node?.fullScanAt || "03:30";
const legacySeconds =
@ -197,6 +252,9 @@ class NodeConnectionManager {
return m ? parseInt(m[1], 10) * 3600 : 0;
})();
const rootsPayload = await getNodeScanRootsPayload(payload.nodeId);
const legacy = deriveLegacyPaths(rootsPayload);
this.send(
socket,
createMessage("HELLO_ACK", {
@ -205,6 +263,13 @@ class NodeConnectionManager {
fullScanIntervalSeconds: legacySeconds,
fullScanInterval,
fullScanAt,
moviesPaths: legacy.moviesPaths,
seriesPaths: legacy.seriesPaths,
scanRoots: rootsPayload.map((r) => ({
path: r.path,
shelfId: r.shelfId,
kind: r.kind,
})),
})
);
}
@ -343,11 +408,21 @@ class NodeConnectionManager {
sendConfigUpdate(
nodeId: string,
payload: { fullScanInterval?: string; fullScanAt?: string }
payload: {
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
scanRoots?: Array<{ path: string; shelfId: string; kind: "movie" | "series" }>;
}
): boolean {
return this.sendToNode(nodeId, "CONFIG_UPDATE", payload);
}
sendRestart(nodeId: string, reason = "admin"): boolean {
return this.sendToNode(nodeId, "RESTART", { reason });
}
createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
}

View file

@ -1,5 +1,5 @@
# Voorbeeld voor Unraid (Docker Compose / Compose Manager)
# Pas paden, token en public URL aan vóór starten.
# Na install: scanpaden beheer je vanaf Admin → Nodes (master is leidend).
#
# Build vanaf repo-root:
# docker compose -f deploy/node/docker-compose.unraid.yml build
@ -21,13 +21,15 @@ services:
MEDIA_NODE_PUBLIC_URL: "https://node01.vonas.nl"
# Alleen nodig bij eerste start — daarna credentials-volume bewaren en token weghalen
MEDIA_NODE_ENROLLMENT_TOKEN: "PLAK_TOKEN_HIER"
# Paden BÍNnen de container (zie volumes hieronder)
MEDIA_NODE_MOVIES: "/media/movies"
MEDIA_NODE_SERIES: "/media/series"
# Bootstrap-paden (host-paden; /mnt/user is 1:1 gemount)
MEDIA_NODE_MOVIES: "/mnt/user/movies"
MEDIA_NODE_SERIES: "/mnt/user/series"
MEDIA_NODE_CONTAINER_NAME: "media-node"
TZ: "Europe/Amsterdam"
volumes:
- /mnt/user/appdata/media-node/config:/etc/media-node
- /mnt/user/appdata/media-node/data:/var/lib/media-node
# Pas aan naar jouw Unraid shares (read-only)
- /mnt/user/movies:/media/movies:ro
- /mnt/user/series:/media/series:ro
# Brede mount: nieuwe shares toevoegen via Admin zonder container te herbouwen
- /mnt/user:/mnt/user:ro
# Optioneel: Admin → Herstart via Docker API
- /var/run/docker.sock:/var/run/docker.sock

View file

@ -10,6 +10,7 @@ MOVIES=""
SERIES=""
PORT="${PORT:-8080}"
APPDATA="${APPDATA:-/mnt/user/appdata/media-node}"
MEDIA_ROOT="${MEDIA_ROOT:-/mnt/user}"
usage() {
cat <<EOF
@ -23,8 +24,10 @@ Opties:
--token= Enrollment token uit Admin → Nodes
--name= Node-naam
--public-url= Publieke HTTPS-URL via NPM (poort 8080)
--movies= Unraid-pad naar films (share)
--movies= Unraid-pad naar films (share) — bootstrap; later via Admin uitbreiden
--series= Unraid-pad naar series (optioneel)
--media-root= Host-root die gemount wordt (default /mnt/user) zodat nieuwe shares
vanaf Admin toegevoegd kunnen worden zonder herinstall
--port= Host-poort (default 8080)
--appdata= Appdata-map (default /mnt/user/appdata/media-node)
--master= Master-URL (default: ${MASTER_URL})
@ -38,6 +41,7 @@ for arg in "$@"; do
--public-url=*) PUBLIC_URL="${arg#*=}" ;;
--movies=*) MOVIES="${arg#*=}" ;;
--series=*) SERIES="${arg#*=}" ;;
--media-root=*) MEDIA_ROOT="${arg#*=}" ;;
--port=*) PORT="${arg#*=}" ;;
--appdata=*) APPDATA="${arg#*=}" ;;
--master=*) MASTER_URL="${arg#*=}" ;;
@ -93,6 +97,7 @@ NAME="$(echo -n "$NAME" | tr -d '\r\n')"
PUBLIC_URL="$(echo -n "$PUBLIC_URL" | sed 's:/*$::')"
MOVIES="$(echo -n "$MOVIES" | tr -d '\r\n')"
SERIES="$(echo -n "$SERIES" | tr -d '\r\n')"
MEDIA_ROOT="$(echo -n "$MEDIA_ROOT" | sed 's:/*$::')"
MASTER_URL="$(echo -n "$MASTER_URL" | sed 's:/*$::')"
if [ -z "$TOKEN" ] || [ -z "$NAME" ] || [ -z "$PUBLIC_URL" ] || [ -z "$MOVIES" ]; then
@ -100,6 +105,10 @@ if [ -z "$TOKEN" ] || [ -z "$NAME" ] || [ -z "$PUBLIC_URL" ] || [ -z "$MOVIES" ]
exit 1
fi
if [ ! -d "$MEDIA_ROOT" ]; then
echo "Fout: media-root bestaat niet: $MEDIA_ROOT"
exit 1
fi
if [ ! -d "$MOVIES" ]; then
echo "Waarschuwing: films-pad bestaat niet: $MOVIES"
fi
@ -107,6 +116,12 @@ if [ -n "$SERIES" ] && [ ! -d "$SERIES" ]; then
echo "Waarschuwing: series-pad bestaat niet: $SERIES"
fi
# Bootstrap paths must live under media-root so they are visible in-container
case "$MOVIES" in
"$MEDIA_ROOT"|"$MEDIA_ROOT"/*) ;;
*) echo "Waarschuwing: --movies ligt niet onder --media-root ($MEDIA_ROOT). Admin-paden gebruiken host-paden onder die root." ;;
esac
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) BIN_NAME="media-node-linux-amd64" ;;
@ -125,21 +140,25 @@ chmod +x "$BIN_PATH"
# Oude container weg
docker rm -f media-node >/dev/null 2>&1 || true
# Alpine met gemounte static binary — geen custom image build
# Broad media-root mount: nieuwe Unraid-shares zijn zichtbaar zonder herinstall.
# docker.sock: Admin → Herstart kan de container herstarten.
VOLUMES=(
-v "$APPDATA/config:/etc/media-node"
-v "$APPDATA/data:/var/lib/media-node"
-v "$BIN_PATH:/usr/local/bin/media-node:ro"
-v /etc/ssl/certs:/etc/ssl/certs:ro
-v "$MOVIES:/media/movies:ro"
-v "$MEDIA_ROOT:$MEDIA_ROOT:ro"
)
ENV_SERIES=""
if [ -n "$SERIES" ]; then
VOLUMES+=(-v "$SERIES:/media/series:ro")
ENV_SERIES="/media/series"
if [ -S /var/run/docker.sock ]; then
VOLUMES+=(-v /var/run/docker.sock:/var/run/docker.sock)
fi
echo "Container starten..."
ENV_SERIES=""
if [ -n "$SERIES" ]; then
ENV_SERIES="$SERIES"
fi
echo "Container starten (media-root=$MEDIA_ROOT)..."
docker run -d \
--name media-node \
--restart unless-stopped \
@ -147,11 +166,12 @@ docker run -d \
-e "MEDIA_NODE_NAME=$NAME" \
-e "MEDIA_NODE_MASTER_URL=$MASTER_URL" \
-e "MEDIA_NODE_PUBLIC_URL=$PUBLIC_URL" \
-e "MEDIA_NODE_MOVIES=/media/movies" \
-e "MEDIA_NODE_MOVIES=$MOVIES" \
-e "MEDIA_NODE_SERIES=$ENV_SERIES" \
-e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \
-e "MEDIA_NODE_FULL_SCAN_INTERVAL=off" \
-e "MEDIA_NODE_FULL_SCAN_AT=03:30" \
-e "MEDIA_NODE_CONTAINER_NAME=media-node" \
-e "TZ=${TZ:-Europe/Amsterdam}" \
--user 99:100 \
"${VOLUMES[@]}" \
@ -163,5 +183,6 @@ echo
echo "Klaar. Logs: docker logs -f media-node"
echo "Status: in Admin → Nodes (moet online worden)"
echo "NPM: $PUBLIC_URL → Unraid-LAN-IP:$PORT (buffering uit, Force SSL)"
echo "Paden: Admin → Nodes → Edit (bijv. $MEDIA_ROOT/NL Films)"
echo
echo "Na succes: token staat opgeslagen in appdata — herstart zonder nieuwe token is ok."

View file

@ -24,7 +24,7 @@ import (
"github.com/sthmedia/media-node/internal/streaming"
)
var version = "1.0.0"
var version = "1.2.0"
func main() {
if len(os.Args) < 2 {

View file

@ -42,8 +42,50 @@ type StreamConfig struct {
}
type MediaConfig struct {
Movies []string `yaml:"movies"`
Series []string `yaml:"series"`
Movies []string `yaml:"movies,omitempty"`
Series []string `yaml:"series,omitempty"`
Roots []MediaRoot `yaml:"roots,omitempty"`
}
type MediaRoot struct {
Path string `yaml:"path"`
ShelfID string `yaml:"shelf_id"`
Kind string `yaml:"kind"` // movie | series (episode)
}
// EffectiveRoots returns structured roots; falls back to legacy Movies/Series lists.
func (m MediaConfig) EffectiveRoots() []MediaRoot {
if len(m.Roots) > 0 {
out := make([]MediaRoot, 0, len(m.Roots))
for _, r := range m.Roots {
path := strings.TrimSpace(r.Path)
if path == "" {
continue
}
kind := strings.ToLower(strings.TrimSpace(r.Kind))
if kind == "series" || kind == "episode" {
kind = "episode"
} else {
kind = "movie"
}
out = append(out, MediaRoot{Path: path, ShelfID: r.ShelfID, Kind: kind})
}
return out
}
var out []MediaRoot
for _, p := range m.Movies {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, MediaRoot{Path: p, Kind: "movie"})
}
}
for _, p := range m.Series {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, MediaRoot{Path: p, Kind: "episode"})
}
}
return out
}
type ScannerConfig struct {

View file

@ -1,10 +1,12 @@
package control
import (
"context"
"encoding/json"
"fmt"
"log"
"math/rand"
"net"
"net/http"
"os"
"runtime"
@ -183,6 +185,8 @@ func (c *Client) sendHello() error {
"architecture": runtime.GOARCH,
"hostname": hostname,
"publicStreamUrl": c.cfg.Stream.PublicURL,
"moviesPaths": c.cfg.Media.Movies,
"seriesPaths": c.cfg.Media.Series,
})
return c.write(msg)
}
@ -260,6 +264,13 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
FullScanInterval string `json:"fullScanInterval"`
FullScanAt string `json:"fullScanAt"`
FullScanIntervalSeconds int `json:"fullScanIntervalSeconds"`
MoviesPaths []string `json:"moviesPaths"`
SeriesPaths []string `json:"seriesPaths"`
ScanRoots []struct {
Path string `json:"path"`
ShelfID string `json:"shelfId"`
Kind string `json:"kind"`
} `json:"scanRoots"`
}
_ = json.Unmarshal(payload, &ack)
if !ack.Accepted {
@ -268,7 +279,11 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
}
log.Println("Connected to Master")
c.applyScannerSettings(ack.FullScanInterval, ack.FullScanAt, ack.FullScanIntervalSeconds)
// Must run async so readLoop can receive FULL_LIBRARY_SYNC_ACK
if len(ack.ScanRoots) > 0 {
c.applyScanRoots(ack.ScanRoots)
} else if len(ack.MoviesPaths) > 0 || len(ack.SeriesPaths) > 0 {
c.applyMediaPaths(ack.MoviesPaths, ack.SeriesPaths)
}
go c.sendFullSync()
case "FULL_LIBRARY_SYNC_ACK":
var p struct {
@ -344,15 +359,164 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
var p struct {
FullScanInterval string `json:"fullScanInterval"`
FullScanAt string `json:"fullScanAt"`
MoviesPaths []string `json:"moviesPaths"`
SeriesPaths []string `json:"seriesPaths"`
ScanRoots []struct {
Path string `json:"path"`
ShelfID string `json:"shelfId"`
Kind string `json:"kind"`
} `json:"scanRoots"`
}
if json.Unmarshal(payload, &p) == nil {
c.applyScannerSettings(p.FullScanInterval, p.FullScanAt, 0)
if p.ScanRoots != nil {
c.applyScanRoots(p.ScanRoots)
} else {
c.applyMediaPaths(p.MoviesPaths, p.SeriesPaths)
}
}
case "RESTART":
go c.doRestart()
case "ERROR":
log.Printf("Master error: %s", string(payload))
}
}
func (c *Client) applyMediaPaths(movies, series []string) {
// nil means "not provided" — keep current. Empty slice means clear (rare).
if movies == nil && series == nil {
return
}
nextMovies := c.cfg.Media.Movies
nextSeries := c.cfg.Media.Series
if movies != nil {
nextMovies = normalizePaths(movies)
}
if series != nil {
nextSeries = normalizePaths(series)
}
if pathsEqual(nextMovies, c.cfg.Media.Movies) && pathsEqual(nextSeries, c.cfg.Media.Series) && len(c.cfg.Media.Roots) == 0 {
return
}
c.scanner.UpdateMediaPaths(nextMovies, nextSeries)
c.cfg.Media.Movies = nextMovies
c.cfg.Media.Series = nextSeries
c.cfg.Media.Roots = nil
if c.configPath != "" {
if err := config.Save(c.configPath, c.cfg); err != nil {
log.Printf("Failed to save media paths: %v", err)
}
}
}
func (c *Client) applyScanRoots(rows []struct {
Path string `json:"path"`
ShelfID string `json:"shelfId"`
Kind string `json:"kind"`
}) {
roots := make([]config.MediaRoot, 0, len(rows))
for _, r := range rows {
path := strings.TrimSpace(r.Path)
path = strings.TrimRight(path, "/")
if path == "" {
continue
}
kind := strings.ToLower(strings.TrimSpace(r.Kind))
if kind == "series" || kind == "episode" {
kind = "episode"
} else {
kind = "movie"
}
roots = append(roots, config.MediaRoot{Path: path, ShelfID: r.ShelfID, Kind: kind})
}
c.scanner.UpdateMediaRoots(roots)
c.cfg.Media.Roots = roots
var movies, series []string
for _, r := range roots {
if r.Kind == "episode" {
series = append(series, r.Path)
} else {
movies = append(movies, r.Path)
}
}
c.cfg.Media.Movies = movies
c.cfg.Media.Series = series
if c.configPath != "" {
if err := config.Save(c.configPath, c.cfg); err != nil {
log.Printf("Failed to save media roots: %v", err)
}
}
}
func normalizePaths(in []string) []string {
seen := map[string]bool{}
var out []string
for _, p := range in {
p = strings.TrimSpace(p)
p = strings.TrimRight(p, "/")
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return out
}
func pathsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func (c *Client) doRestart() {
log.Println("RESTART requested by master")
name := strings.TrimSpace(os.Getenv("MEDIA_NODE_CONTAINER_NAME"))
if name == "" {
name = "media-node"
}
if _, err := os.Stat("/var/run/docker.sock"); err == nil {
if err := dockerRestartContainer(name); err != nil {
log.Printf("Docker restart via sock failed (%v); exiting for restart policy", err)
} else {
log.Printf("Docker restart requested for %s", name)
time.Sleep(2 * time.Second)
}
}
os.Exit(0)
}
func dockerRestartContainer(name string) error {
httpc := http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock")
},
},
Timeout: 15 * time.Second,
}
url := "http://localhost/containers/" + name + "/restart?t=5"
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return err
}
res, err := httpc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("docker API status %d", res.StatusCode)
}
return nil
}
func (c *Client) applyScannerSettings(interval, at string, legacySeconds int) {
if interval == "" && at == "" && legacySeconds > 0 {
interval = fmt.Sprintf("%ds", legacySeconds)

View file

@ -28,6 +28,7 @@ type MediaFileInfo struct {
ReleaseName string `json:"releaseName"`
ModifiedAt string `json:"modifiedAt"`
MediaType string `json:"mediaType"`
ShelfID string `json:"shelfId,omitempty"`
ParsedMovie map[string]interface{} `json:"parsedMovie,omitempty"`
ParsedEpisode map[string]interface{} `json:"parsedEpisode,omitempty"`
}
@ -48,6 +49,7 @@ type Scanner struct {
scanning bool
status string
resetSchedule chan struct{}
reloadWatch chan struct{}
}
func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner {
@ -57,6 +59,7 @@ func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scan
onEvents: onEvents,
status: "idle",
resetSchedule: make(chan struct{}, 2),
reloadWatch: make(chan struct{}, 1),
}
}
@ -95,6 +98,41 @@ func (s *Scanner) UpdateSchedule(interval, at string) {
log.Printf("Scanner schedule updated: interval=%s at=%s", s.cfg.Scanner.FullScanInterval, s.cfg.Scanner.FullScanAt)
}
// UpdateMediaPaths replaces movie/series roots, rebuilds fsnotify watchers, and rescans.
func (s *Scanner) UpdateMediaPaths(movies, series []string) {
roots := make([]config.MediaRoot, 0, len(movies)+len(series))
for _, p := range movies {
roots = append(roots, config.MediaRoot{Path: p, Kind: "movie"})
}
for _, p := range series {
roots = append(roots, config.MediaRoot{Path: p, Kind: "episode"})
}
s.UpdateMediaRoots(roots)
}
// UpdateMediaRoots replaces scan roots (with optional shelf IDs).
func (s *Scanner) UpdateMediaRoots(roots []config.MediaRoot) {
s.mu.Lock()
s.cfg.Media.Roots = append([]config.MediaRoot{}, roots...)
var movies, series []string
for _, r := range roots {
if r.Kind == "episode" || r.Kind == "series" {
series = append(series, r.Path)
} else {
movies = append(movies, r.Path)
}
}
s.cfg.Media.Movies = movies
s.cfg.Media.Series = series
s.mu.Unlock()
select {
case s.reloadWatch <- struct{}{}:
default:
}
log.Printf("Media roots updated: %d roots", len(roots))
go s.FullScan()
}
func (s *Scanner) runStartupScan() {
log.Println("Startup library inventory scan...")
s.FullScan()
@ -185,11 +223,16 @@ func (s *Scanner) FullScan() {
log.Println("Starting full media scan...")
found := make(map[string]bool)
for _, dir := range s.cfg.Media.Movies {
s.walkRoot(dir, "movie", found)
s.mu.Lock()
roots := s.cfg.Media.EffectiveRoots()
s.mu.Unlock()
for _, root := range roots {
kind := root.Kind
if kind == "series" {
kind = "episode"
}
for _, dir := range s.cfg.Media.Series {
s.walkRoot(dir, "episode", found)
s.walkRoot(root.Path, kind, root.ShelfID, found)
}
existing, _ := s.store.AllFiles()
@ -208,7 +251,7 @@ func (s *Scanner) FullScan() {
log.Println("Full scan completed")
}
func (s *Scanner) walkRoot(dir, mediaType string, found map[string]bool) {
func (s *Scanner) walkRoot(dir, mediaType, shelfID string, found map[string]bool) {
if dir == "" {
return
}
@ -220,25 +263,37 @@ func (s *Scanner) walkRoot(dir, mediaType string, found map[string]bool) {
return nil
}
found[path] = true
s.indexFile(path, info, "FILE_UPDATED", mediaType)
s.indexFile(path, info, "FILE_UPDATED", mediaType, shelfID)
return nil
})
}
func (s *Scanner) watchFilesystem() {
for {
s.runWatchSession()
}
}
func (s *Scanner) runWatchSession() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Printf("fsnotify unavailable: %v", err)
time.Sleep(5 * time.Second)
return
}
defer watcher.Close()
rootType := map[string]string{}
addTree := func(dir, kind string) {
rootShelf := map[string]string{}
addTree := func(dir, kind, shelfID string) {
if dir == "" {
return
}
rootType[filepath.Clean(dir)] = kind
clean := filepath.Clean(dir)
rootType[clean] = kind
if shelfID != "" {
rootShelf[clean] = shelfID
}
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
_ = watcher.Add(path)
@ -246,12 +301,19 @@ func (s *Scanner) watchFilesystem() {
return nil
})
}
for _, dir := range s.cfg.Media.Movies {
addTree(dir, "movie")
s.mu.Lock()
roots := s.cfg.Media.EffectiveRoots()
s.mu.Unlock()
for _, root := range roots {
kind := root.Kind
if kind == "series" {
kind = "episode"
}
for _, dir := range s.cfg.Media.Series {
addTree(dir, "episode")
addTree(root.Path, kind, root.ShelfID)
}
log.Printf("Filesystem watch active on %d roots", len(roots))
debounce := time.NewTimer(0)
<-debounce.C
@ -261,6 +323,9 @@ func (s *Scanner) watchFilesystem() {
for {
select {
case <-s.reloadWatch:
log.Println("Reloading filesystem watchers for new media paths...")
return
case event, ok := <-watcher.Events:
if !ok {
return
@ -307,7 +372,8 @@ func (s *Scanner) watchFilesystem() {
for path := range pending {
info, err := os.Stat(path)
if err == nil && !info.IsDir() && isVideo(path) {
s.indexFile(path, info, "FILE_ADDED", s.detectRootType(path, rootType))
kind, shelf := s.detectRootMeta(path, rootType, rootShelf)
s.indexFile(path, info, "FILE_ADDED", kind, shelf)
}
}
pending = make(map[string]struct{})
@ -320,6 +386,16 @@ func (s *Scanner) watchFilesystem() {
}
}
func (s *Scanner) detectRootMeta(path string, rootType, rootShelf map[string]string) (kind, shelfID string) {
clean := filepath.Clean(path)
for root, k := range rootType {
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
return k, rootShelf[root]
}
}
return detectMediaType(path), ""
}
func (s *Scanner) detectRootType(path string, rootType map[string]string) string {
clean := filepath.Clean(path)
for root, kind := range rootType {
@ -330,7 +406,7 @@ func (s *Scanner) detectRootType(path string, rootType map[string]string) string
return detectMediaType(path)
}
func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredType string) {
func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredType, shelfID string) {
mediaType := preferredType
if mediaType == "" {
mediaType = detectMediaType(path)
@ -347,6 +423,15 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT
}
localFileID := database.FileID(path)
file := MediaFileInfo{
LocalFileID: localFileID,
SizeBytes: info.Size(),
Container: strings.TrimPrefix(filepath.Ext(path), "."),
ReleaseName: filepath.Base(path),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
MediaType: mediaType,
ShelfID: shelfID,
}
rev, _ := s.store.NextRevision()
lf := database.LocalFile{
@ -361,6 +446,7 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT
_ = s.store.UpsertFile(lf)
info2 := buildMediaInfo(lf)
info2.ShelfID = shelfID
event := LibraryEvent{
Type: eventType,
NodeRevision: rev,
@ -394,13 +480,29 @@ func (s *Scanner) handleRemove(path string) {
func (s *Scanner) AllMediaFiles() []MediaFileInfo {
files, _ := s.store.AllFiles()
s.mu.Lock()
roots := s.cfg.Media.EffectiveRoots()
s.mu.Unlock()
result := make([]MediaFileInfo, len(files))
for i, f := range files {
result[i] = buildMediaInfo(f)
info := buildMediaInfo(f)
info.ShelfID = shelfForPath(f.Path, roots)
result[i] = info
}
return result
}
func shelfForPath(path string, roots []config.MediaRoot) string {
clean := filepath.Clean(path)
for _, r := range roots {
root := filepath.Clean(r.Path)
if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) {
return r.ShelfID
}
}
return ""
}
func isVideo(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return videoExtensions[ext]

View file

@ -22,6 +22,7 @@ export type ControlMessageType =
| "REVOKE_PLAYBACK_SESSION"
| "RESCAN"
| "CONFIG_UPDATE"
| "RESTART"
| "PLAYBACK_SESSION_ENDED"
| "ERROR";
@ -40,6 +41,9 @@ export interface HelloPayload {
architecture: string;
hostname: string;
publicStreamUrl: string;
/** Current scan roots on the node (seed master if empty) */
moviesPaths?: string[];
seriesPaths?: string[];
}
export interface HelloAckPayload {
@ -52,6 +56,13 @@ export interface HelloAckPayload {
fullScanInterval: string;
/** Nightly catch-up HH:MM, or "off" */
fullScanAt: string;
moviesPaths?: string[];
seriesPaths?: string[];
scanRoots?: Array<{
path: string;
shelfId: string;
kind: "movie" | "series";
}>;
}
export interface HeartbeatPayload extends NodeHeartbeat {}
@ -84,6 +95,18 @@ export interface RescanPayload {
export interface ConfigUpdatePayload {
fullScanInterval?: string;
fullScanAt?: string;
moviesPaths?: string[];
seriesPaths?: string[];
/** Preferred: path + shelf binding for Stremio catalogs */
scanRoots?: Array<{
path: string;
shelfId: string;
kind: "movie" | "series";
}>;
}
export interface RestartPayload {
reason?: string;
}
export interface PlaybackSessionEndedPayload {

View file

@ -70,6 +70,8 @@ export interface MediaFileInfo {
releaseName: string;
modifiedAt: string;
mediaType: "movie" | "episode";
/** Library shelf this file belongs to (cross-node catalog) */
shelfId?: string;
parsedMovie?: ParsedMovieFilename;
parsedEpisode?: ParsedSeriesFilename;
}