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:
parent
002472e235
commit
a4dfb1b472
20 changed files with 1303 additions and 128 deletions
|
|
@ -3,6 +3,14 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
|
interface ScanRootRow {
|
||||||
|
id?: string;
|
||||||
|
path: string;
|
||||||
|
shelfId: string;
|
||||||
|
shelfName?: string;
|
||||||
|
kind?: "movie" | "series";
|
||||||
|
}
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -19,14 +27,25 @@ interface Node {
|
||||||
libraryFileCount: number;
|
libraryFileCount: number;
|
||||||
fullScanInterval: string;
|
fullScanInterval: string;
|
||||||
fullScanAt: string;
|
fullScanAt: string;
|
||||||
|
moviesPaths: string[];
|
||||||
|
seriesPaths: string[];
|
||||||
|
scanRoots: ScanRootRow[];
|
||||||
revoked: boolean;
|
revoked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Shelf {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
kind: "MOVIE" | "SERIES";
|
||||||
|
}
|
||||||
|
|
||||||
const MASTER_URL = "https://master.vonas.nl";
|
const MASTER_URL = "https://master.vonas.nl";
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
|
const [shelves, setShelves] = useState<Shelf[]>([]);
|
||||||
const [showEnroll, setShowEnroll] = useState(false);
|
const [showEnroll, setShowEnroll] = useState(false);
|
||||||
const [enrollName, setEnrollName] = useState("");
|
const [enrollName, setEnrollName] = useState("");
|
||||||
const [enrollLocation, setEnrollLocation] = useState("");
|
const [enrollLocation, setEnrollLocation] = useState("");
|
||||||
|
|
@ -41,6 +60,8 @@ export default function NodesPage() {
|
||||||
const [editPublicUrl, setEditPublicUrl] = useState("");
|
const [editPublicUrl, setEditPublicUrl] = useState("");
|
||||||
const [editScanInterval, setEditScanInterval] = useState("off");
|
const [editScanInterval, setEditScanInterval] = useState("off");
|
||||||
const [editScanAt, setEditScanAt] = useState("03:30");
|
const [editScanAt, setEditScanAt] = useState("03:30");
|
||||||
|
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
|
||||||
|
const [editBusy, setEditBusy] = useState(false);
|
||||||
|
|
||||||
function loadNodes() {
|
function loadNodes() {
|
||||||
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
||||||
|
|
@ -48,8 +69,15 @@ export default function NodesPage() {
|
||||||
.then((d) => setNodes(d.nodes ?? []));
|
.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(() => {
|
useEffect(() => {
|
||||||
loadNodes();
|
loadNodes();
|
||||||
|
loadShelves();
|
||||||
const t = setInterval(loadNodes, 15000);
|
const t = setInterval(loadNodes, 15000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -95,6 +123,20 @@ export default function NodesPage() {
|
||||||
alert("Rescan aangevraagd");
|
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) {
|
async function revokeNode(id: string) {
|
||||||
if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return;
|
if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return;
|
||||||
await fetch(`/api/v1/admin/nodes/${id}/revoke`, {
|
await fetch(`/api/v1/admin/nodes/${id}/revoke`, {
|
||||||
|
|
@ -133,29 +175,59 @@ export default function NodesPage() {
|
||||||
loadNodes();
|
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() {
|
async function saveEdit() {
|
||||||
if (!editId) return;
|
if (!editId) return;
|
||||||
const res = await fetch(`/api/v1/admin/nodes/${editId}`, {
|
setEditBusy(true);
|
||||||
method: "PATCH",
|
try {
|
||||||
credentials: "include",
|
const scanRoots = editRoots
|
||||||
headers: { "Content-Type": "application/json" },
|
.map((r) => ({ path: r.path.trim(), shelfId: r.shelfId }))
|
||||||
body: JSON.stringify({
|
.filter((r) => r.path && r.shelfId);
|
||||||
name: editName,
|
const res = await fetch(`/api/v1/admin/nodes/${editId}`, {
|
||||||
location: editLocation,
|
method: "PATCH",
|
||||||
publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""),
|
credentials: "include",
|
||||||
fullScanInterval: editScanInterval.trim() || "off",
|
headers: { "Content-Type": "application/json" },
|
||||||
fullScanAt: editScanAt.trim() || "03:30",
|
body: JSON.stringify({
|
||||||
}),
|
name: editName,
|
||||||
});
|
location: editLocation,
|
||||||
if (!res.ok) {
|
publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""),
|
||||||
const data = await res.json().catch(() => ({}));
|
fullScanInterval: editScanInterval.trim() || "off",
|
||||||
alert(data.error?.message ?? "Opslaan mislukt");
|
fullScanAt: editScanAt.trim() || "03:30",
|
||||||
return;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|
@ -173,8 +245,8 @@ export default function NodesPage() {
|
||||||
Unraid-node toevoegen
|
Unraid-node toevoegen
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.9rem" }}>
|
<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.
|
Eenmalig installeren met brede <code>/mnt/user</code>-mount. Daarna: planken in Instellingen,
|
||||||
Geen Docker Compose of image build nodig.
|
scanpaden + plankkeuze hier onder Edit.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Naam</label>
|
<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" />
|
<input value={publicUrl} onChange={(e) => setPublicUrl(e.target.value)} placeholder="https://node1.example.com" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<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" />
|
<input value={moviesPath} onChange={(e) => setMoviesPath(e.target.value)} placeholder="/mnt/user/movies" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<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" />
|
<input value={seriesPath} onChange={(e) => setSeriesPath(e.target.value)} placeholder="/mnt/user/series" />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={createEnrollmentToken}>Token genereren</button>
|
<button onClick={createEnrollmentToken}>Token genereren</button>
|
||||||
|
|
@ -209,9 +281,6 @@ export default function NodesPage() {
|
||||||
<button onClick={copyInstall} style={{ marginTop: "0.75rem" }}>
|
<button onClick={copyInstall} style={{ marginTop: "0.75rem" }}>
|
||||||
{copied ? "Gekopieerd" : "Kopieer install-commando"}
|
{copied ? "Gekopieerd" : "Kopieer install-commando"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
@ -232,37 +301,93 @@ export default function NodesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Publieke stream-URL (NPM)</label>
|
<label>Publieke stream-URL (NPM)</label>
|
||||||
<input
|
<input value={editPublicUrl} onChange={(e) => setEditPublicUrl(e.target.value)} />
|
||||||
value={editPublicUrl}
|
|
||||||
onChange={(e) => setEditPublicUrl(e.target.value)}
|
|
||||||
placeholder="https://node1.example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label>Nachtelijke full scan (HH:MM of off)</label>
|
<label>Nachtelijke full scan (HH:MM of off)</label>
|
||||||
<input
|
<input value={editScanAt} onChange={(e) => setEditScanAt(e.target.value)} placeholder="03:30" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Extra interval-scan (off / 6h / 24h)</label>
|
<label>Extra interval-scan (off / 6h / 24h)</label>
|
||||||
<input
|
<input value={editScanInterval} onChange={(e) => setEditScanInterval(e.target.value)} placeholder="off" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
<button onClick={saveEdit}>Opslaan</button>
|
<button onClick={saveEdit} disabled={editBusy}>
|
||||||
<button onClick={() => setEditId(null)} style={{ background: "#374151" }}>Annuleren</button>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -274,6 +399,7 @@ export default function NodesPage() {
|
||||||
<th>Naam</th>
|
<th>Naam</th>
|
||||||
<th>Locatie</th>
|
<th>Locatie</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Paden</th>
|
||||||
<th>Bestanden</th>
|
<th>Bestanden</th>
|
||||||
<th>Streams</th>
|
<th>Streams</th>
|
||||||
<th>Versie</th>
|
<th>Versie</th>
|
||||||
|
|
@ -291,23 +417,20 @@ export default function NodesPage() {
|
||||||
<td>
|
<td>
|
||||||
<span className={`status ${node.status.toLowerCase()}`}>{node.status}</span>
|
<span className={`status ${node.status.toLowerCase()}`}>{node.status}</span>
|
||||||
</td>
|
</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.libraryFileCount}</td>
|
||||||
<td>{node.activeStreams}</td>
|
<td>{node.activeStreams}</td>
|
||||||
<td>{node.version ?? "-"}</td>
|
<td>{node.version ?? "-"}</td>
|
||||||
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
<button
|
<button onClick={() => openEdit(node)}>Edit</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={() => rescanNode(node.id)}>Rescan</button>
|
<button onClick={() => rescanNode(node.id)}>Rescan</button>
|
||||||
|
<button onClick={() => restartNode(node.id)} style={{ background: "#0e7490" }}>
|
||||||
|
Herstart
|
||||||
|
</button>
|
||||||
{!node.revoked && (
|
{!node.revoked && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => disableNode(node.id)} style={{ background: "#b45309" }}>
|
<button onClick={() => disableNode(node.id)} style={{ background: "#b45309" }}>
|
||||||
|
|
@ -326,7 +449,7 @@ export default function NodesPage() {
|
||||||
))}
|
))}
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} style={{ textAlign: "center", color: "#71717a" }}>
|
<td colSpan={8} style={{ textAlign: "center", color: "#71717a" }}>
|
||||||
Geen nodes gevonden
|
Geen nodes gevonden
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,17 @@ const emptyIptv = (): IptvForm => ({
|
||||||
|
|
||||||
const MASTER_URL = "https://master.vonas.nl";
|
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() {
|
export default function SettingsPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
|
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
|
||||||
|
|
@ -48,6 +59,9 @@ export default function SettingsPage() {
|
||||||
const [iptvBusy, setIptvBusy] = useState(false);
|
const [iptvBusy, setIptvBusy] = useState(false);
|
||||||
const [iptvMsg, setIptvMsg] = useState<string | null>(null);
|
const [iptvMsg, setIptvMsg] = useState<string | null>(null);
|
||||||
const [iptvErr, setIptvErr] = 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(() => {
|
const loadTokens = useCallback(() => {
|
||||||
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
|
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
|
||||||
|
|
@ -55,9 +69,47 @@ export default function SettingsPage() {
|
||||||
.then((d) => setTokens(d.tokens ?? []));
|
.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(() => {
|
useEffect(() => {
|
||||||
loadTokens();
|
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() {
|
async function createAddonToken() {
|
||||||
const res = await fetch("/api/v1/admin/addon-tokens", {
|
const res = await fetch("/api/v1/admin/addon-tokens", {
|
||||||
|
|
@ -230,6 +282,74 @@ export default function SettingsPage() {
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
|
<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" }}>
|
<div className="card" style={{ marginBottom: "2rem" }}>
|
||||||
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
|
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
|
||||||
Nieuwe Stremio-addon
|
Nieuwe Stremio-addon
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -30,6 +30,11 @@ enum LocationType {
|
||||||
REMOTE
|
REMOTE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LibraryShelfKind {
|
||||||
|
MOVIE
|
||||||
|
SERIES
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
|
|
@ -126,6 +131,10 @@ model Node {
|
||||||
fullScanInterval String @default("off") @map("full_scan_interval")
|
fullScanInterval String @default("off") @map("full_scan_interval")
|
||||||
/** Nightly catch-up time HH:MM in node local TZ */
|
/** Nightly catch-up time HH:MM in node local TZ */
|
||||||
fullScanAt String @default("03:30") @map("full_scan_at")
|
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)
|
revoked Boolean @default(false)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
@ -134,10 +143,45 @@ model Node {
|
||||||
credentials NodeCredential?
|
credentials NodeCredential?
|
||||||
mediaFiles MediaFile[]
|
mediaFiles MediaFile[]
|
||||||
playbackSessions PlaybackSession[]
|
playbackSessions PlaybackSession[]
|
||||||
|
scanRoots NodeScanRoot[]
|
||||||
|
|
||||||
@@map("nodes")
|
@@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 {
|
model NodeCredential {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nodeId String @unique @map("node_id")
|
nodeId String @unique @map("node_id")
|
||||||
|
|
@ -246,6 +290,7 @@ model MediaFile {
|
||||||
nodeId String @map("node_id")
|
nodeId String @map("node_id")
|
||||||
movieId String? @map("movie_id")
|
movieId String? @map("movie_id")
|
||||||
episodeId String? @map("episode_id")
|
episodeId String? @map("episode_id")
|
||||||
|
shelfId String? @map("shelf_id")
|
||||||
localFileId String @map("local_file_id")
|
localFileId String @map("local_file_id")
|
||||||
sizeBytes BigInt @map("size_bytes")
|
sizeBytes BigInt @map("size_bytes")
|
||||||
container String?
|
container String?
|
||||||
|
|
@ -265,9 +310,11 @@ model MediaFile {
|
||||||
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
|
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
|
||||||
movie Movie? @relation(fields: [movieId], references: [id])
|
movie Movie? @relation(fields: [movieId], references: [id])
|
||||||
episode Episode? @relation(fields: [episodeId], references: [id])
|
episode Episode? @relation(fields: [episodeId], references: [id])
|
||||||
|
shelf LibraryShelf? @relation(fields: [shelfId], references: [id], onDelete: SetNull)
|
||||||
playbackSessions PlaybackSession[]
|
playbackSessions PlaybackSession[]
|
||||||
|
|
||||||
@@unique([nodeId, localFileId])
|
@@unique([nodeId, localFileId])
|
||||||
|
@@index([shelfId])
|
||||||
@@map("media_files")
|
@@map("media_files")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,82 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return result;
|
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) => {
|
app.get("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,67 @@ async function main() {
|
||||||
console.warn("Could not ensure iptv_lines table:", err);
|
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.
|
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
|
||||||
try {
|
try {
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.$executeRawUnsafe(`
|
||||||
|
|
|
||||||
65
apps/master-api/src/library/shelves.ts
Normal file
65
apps/master-api/src/library/shelves.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1014,7 +1014,7 @@ export class MetadataService {
|
||||||
file: MediaFileInfo,
|
file: MediaFileInfo,
|
||||||
refs: { movieId?: string; episodeId?: string }
|
refs: { movieId?: string; episodeId?: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await prisma.mediaFile.upsert({
|
await prisma.mediaFile.upsert({
|
||||||
where: {
|
where: {
|
||||||
nodeId_localFileId: { nodeId, localFileId: file.localFileId },
|
nodeId_localFileId: { nodeId, localFileId: file.localFileId },
|
||||||
},
|
},
|
||||||
|
|
@ -1023,6 +1023,7 @@ export class MetadataService {
|
||||||
localFileId: file.localFileId,
|
localFileId: file.localFileId,
|
||||||
movieId: refs.movieId,
|
movieId: refs.movieId,
|
||||||
episodeId: refs.episodeId,
|
episodeId: refs.episodeId,
|
||||||
|
shelfId: file.shelfId || null,
|
||||||
sizeBytes: BigInt(file.sizeBytes),
|
sizeBytes: BigInt(file.sizeBytes),
|
||||||
container: file.container,
|
container: file.container,
|
||||||
resolution: file.resolution,
|
resolution: file.resolution,
|
||||||
|
|
@ -1035,6 +1036,7 @@ export class MetadataService {
|
||||||
update: {
|
update: {
|
||||||
movieId: refs.movieId,
|
movieId: refs.movieId,
|
||||||
episodeId: refs.episodeId,
|
episodeId: refs.episodeId,
|
||||||
|
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
|
||||||
sizeBytes: BigInt(file.sizeBytes),
|
sizeBytes: BigInt(file.sizeBytes),
|
||||||
container: file.container,
|
container: file.container,
|
||||||
resolution: file.resolution,
|
resolution: file.resolution,
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,13 @@ export async function registerNodeRoutes(app: FastifyInstance) {
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get("/api/v1/admin/nodes", { preHandler: requireAdmin }, async () => {
|
app.get("/api/v1/admin/nodes", { preHandler: requireAdmin }, async () => {
|
||||||
|
const { ensureDefaultShelves } = await import("../library/shelves");
|
||||||
|
await ensureDefaultShelves();
|
||||||
const nodes = await prisma.node.findMany({
|
const nodes = await prisma.node.findMany({
|
||||||
include: { location: true },
|
include: {
|
||||||
|
location: true,
|
||||||
|
scanRoots: { include: { shelf: true }, orderBy: { path: "asc" } },
|
||||||
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,7 +138,10 @@ export async function registerNodeRoutes(app: FastifyInstance) {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const node = await prisma.node.findUnique({
|
const node = await prisma.node.findUnique({
|
||||||
where: { id },
|
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);
|
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
|
||||||
return { node: serializeNode(node) };
|
return { node: serializeNode(node) };
|
||||||
|
|
@ -148,6 +156,9 @@ export async function registerNodeRoutes(app: FastifyInstance) {
|
||||||
publicStreamUrl?: string;
|
publicStreamUrl?: string;
|
||||||
fullScanInterval?: string;
|
fullScanInterval?: string;
|
||||||
fullScanAt?: string;
|
fullScanAt?: string;
|
||||||
|
moviesPaths?: string[];
|
||||||
|
seriesPaths?: string[];
|
||||||
|
scanRoots?: Array<{ path: string; shelfId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let locationId: string | undefined | null = undefined;
|
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({
|
const node = await prisma.node.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -192,15 +277,33 @@ export async function registerNodeRoutes(app: FastifyInstance) {
|
||||||
? { fullScanInterval: body.fullScanInterval.trim().toLowerCase() }
|
? { fullScanInterval: body.fullScanInterval.trim().toLowerCase() }
|
||||||
: {}),
|
: {}),
|
||||||
...(body.fullScanAt !== undefined ? { fullScanAt: body.fullScanAt.trim() } : {}),
|
...(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");
|
const { nodeConnectionManager } = await import("../websocket/manager");
|
||||||
nodeConnectionManager.sendConfigUpdate(id, {
|
nodeConnectionManager.sendConfigUpdate(id, {
|
||||||
fullScanInterval: node.fullScanInterval,
|
fullScanInterval: node.fullScanInterval,
|
||||||
fullScanAt: node.fullScanAt,
|
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 };
|
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) => {
|
app.post("/api/v1/admin/nodes/:id/revoke", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const node = await prisma.node.update({
|
const node = await prisma.node.update({
|
||||||
|
|
@ -303,8 +417,25 @@ function serializeNode(n: {
|
||||||
libraryFileCount: number;
|
libraryFileCount: number;
|
||||||
fullScanInterval?: string;
|
fullScanInterval?: string;
|
||||||
fullScanAt?: string;
|
fullScanAt?: string;
|
||||||
|
moviesPaths?: string[];
|
||||||
|
seriesPaths?: string[];
|
||||||
revoked: boolean;
|
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 {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
name: n.name,
|
name: n.name,
|
||||||
|
|
@ -323,10 +454,25 @@ function serializeNode(n: {
|
||||||
libraryFileCount: n.libraryFileCount,
|
libraryFileCount: n.libraryFileCount,
|
||||||
fullScanInterval: n.fullScanInterval ?? "off",
|
fullScanInterval: n.fullScanInterval ?? "off",
|
||||||
fullScanAt: n.fullScanAt ?? "03:30",
|
fullScanAt: n.fullScanAt ?? "03:30",
|
||||||
|
moviesPaths: n.moviesPaths ?? [],
|
||||||
|
seriesPaths: n.seriesPaths ?? [],
|
||||||
|
scanRoots,
|
||||||
revoked: n.revoked,
|
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(
|
export async function authenticateNode(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
apiKey: string
|
apiKey: string
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,38 @@ const BASE_CATALOGS = [
|
||||||
{ type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS },
|
{ 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) {
|
async function buildManifest(tokenId: string | null, config: Config) {
|
||||||
const iptv = tokenId ? await buildIptvCatalogs(tokenId, config) : { catalogs: [], types: [] };
|
const iptv = tokenId ? await buildIptvCatalogs(tokenId, config) : { catalogs: [], types: [] };
|
||||||
|
const shelfCatalogs = await buildShelfCatalogs();
|
||||||
const types = new Set(["movie", "series", ...iptv.types]);
|
const types = new Set(["movie", "series", ...iptv.types]);
|
||||||
return {
|
return {
|
||||||
id: "com.mediacluster.library",
|
id: "com.mediacluster.library",
|
||||||
version: "1.5.4",
|
version: "1.6.0",
|
||||||
name: "Media Cluster",
|
name: "Media Cluster",
|
||||||
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
|
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
|
||||||
catalogs: [...BASE_CATALOGS, ...iptv.catalogs],
|
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
|
||||||
resources: [
|
resources: [
|
||||||
"catalog",
|
"catalog",
|
||||||
{
|
{
|
||||||
|
|
@ -335,10 +358,14 @@ async function getMovieCatalog(
|
||||||
search?: string,
|
search?: string,
|
||||||
skip = 0
|
skip = 0
|
||||||
) {
|
) {
|
||||||
const where: Record<string, unknown> = {};
|
const shelfSlug = catalogId.startsWith("shelf-") ? catalogId.slice("shelf-".length) : null;
|
||||||
if (config.HIDE_UNAVAILABLE_TITLES) {
|
const shelfFilter = shelfSlug
|
||||||
where.mediaFiles = { some: { available: true } };
|
? { 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) {
|
if (search) {
|
||||||
where.title = { contains: search, mode: "insensitive" };
|
where.title = { contains: search, mode: "insensitive" };
|
||||||
}
|
}
|
||||||
|
|
@ -400,18 +427,33 @@ async function getSeriesCatalog(
|
||||||
search?: string,
|
search?: string,
|
||||||
skip = 0
|
skip = 0
|
||||||
) {
|
) {
|
||||||
const availableFilter = {
|
const shelfSlug = catalogId.startsWith("shelf-") ? catalogId.slice("shelf-".length) : null;
|
||||||
seasons: {
|
const availableFilter = shelfSlug
|
||||||
some: {
|
? {
|
||||||
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
seasons: {
|
||||||
},
|
some: {
|
||||||
},
|
episodes: {
|
||||||
};
|
some: {
|
||||||
|
mediaFiles: {
|
||||||
|
some: { available: true, shelf: { slug: shelfSlug, kind: "SERIES" as const } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
seasons: {
|
||||||
|
some: {
|
||||||
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let series = await prisma.series.findMany({
|
let series = await prisma.series.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
|
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
|
||||||
...(config.HIDE_UNAVAILABLE_TITLES ? availableFilter : {}),
|
...(config.HIDE_UNAVAILABLE_TITLES || shelfSlug ? availableFilter : {}),
|
||||||
},
|
},
|
||||||
orderBy: [{ title: "asc" }, { year: "asc" }],
|
orderBy: [{ title: "asc" }, { year: "asc" }],
|
||||||
skip,
|
skip,
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,62 @@ class NodeConnectionManager {
|
||||||
|
|
||||||
console.log(`Node connected: ${payload.nodeId} (${payload.hostname})`);
|
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 fullScanInterval = node?.fullScanInterval || "off";
|
||||||
const fullScanAt = node?.fullScanAt || "03:30";
|
const fullScanAt = node?.fullScanAt || "03:30";
|
||||||
const legacySeconds =
|
const legacySeconds =
|
||||||
|
|
@ -197,6 +252,9 @@ class NodeConnectionManager {
|
||||||
return m ? parseInt(m[1], 10) * 3600 : 0;
|
return m ? parseInt(m[1], 10) * 3600 : 0;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const rootsPayload = await getNodeScanRootsPayload(payload.nodeId);
|
||||||
|
const legacy = deriveLegacyPaths(rootsPayload);
|
||||||
|
|
||||||
this.send(
|
this.send(
|
||||||
socket,
|
socket,
|
||||||
createMessage("HELLO_ACK", {
|
createMessage("HELLO_ACK", {
|
||||||
|
|
@ -205,6 +263,13 @@ class NodeConnectionManager {
|
||||||
fullScanIntervalSeconds: legacySeconds,
|
fullScanIntervalSeconds: legacySeconds,
|
||||||
fullScanInterval,
|
fullScanInterval,
|
||||||
fullScanAt,
|
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(
|
sendConfigUpdate(
|
||||||
nodeId: string,
|
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 {
|
): boolean {
|
||||||
return this.sendToNode(nodeId, "CONFIG_UPDATE", payload);
|
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 {
|
createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
|
||||||
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
|
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Voorbeeld voor Unraid (Docker Compose / Compose Manager)
|
# 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:
|
# Build vanaf repo-root:
|
||||||
# docker compose -f deploy/node/docker-compose.unraid.yml build
|
# docker compose -f deploy/node/docker-compose.unraid.yml build
|
||||||
|
|
@ -21,13 +21,15 @@ services:
|
||||||
MEDIA_NODE_PUBLIC_URL: "https://node01.vonas.nl"
|
MEDIA_NODE_PUBLIC_URL: "https://node01.vonas.nl"
|
||||||
# Alleen nodig bij eerste start — daarna credentials-volume bewaren en token weghalen
|
# Alleen nodig bij eerste start — daarna credentials-volume bewaren en token weghalen
|
||||||
MEDIA_NODE_ENROLLMENT_TOKEN: "PLAK_TOKEN_HIER"
|
MEDIA_NODE_ENROLLMENT_TOKEN: "PLAK_TOKEN_HIER"
|
||||||
# Paden BÍNnen de container (zie volumes hieronder)
|
# Bootstrap-paden (host-paden; /mnt/user is 1:1 gemount)
|
||||||
MEDIA_NODE_MOVIES: "/media/movies"
|
MEDIA_NODE_MOVIES: "/mnt/user/movies"
|
||||||
MEDIA_NODE_SERIES: "/media/series"
|
MEDIA_NODE_SERIES: "/mnt/user/series"
|
||||||
|
MEDIA_NODE_CONTAINER_NAME: "media-node"
|
||||||
TZ: "Europe/Amsterdam"
|
TZ: "Europe/Amsterdam"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/media-node/config:/etc/media-node
|
- /mnt/user/appdata/media-node/config:/etc/media-node
|
||||||
- /mnt/user/appdata/media-node/data:/var/lib/media-node
|
- /mnt/user/appdata/media-node/data:/var/lib/media-node
|
||||||
# Pas aan naar jouw Unraid shares (read-only)
|
# Brede mount: nieuwe shares toevoegen via Admin zonder container te herbouwen
|
||||||
- /mnt/user/movies:/media/movies:ro
|
- /mnt/user:/mnt/user:ro
|
||||||
- /mnt/user/series:/media/series:ro
|
# Optioneel: Admin → Herstart via Docker API
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ MOVIES=""
|
||||||
SERIES=""
|
SERIES=""
|
||||||
PORT="${PORT:-8080}"
|
PORT="${PORT:-8080}"
|
||||||
APPDATA="${APPDATA:-/mnt/user/appdata/media-node}"
|
APPDATA="${APPDATA:-/mnt/user/appdata/media-node}"
|
||||||
|
MEDIA_ROOT="${MEDIA_ROOT:-/mnt/user}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
@ -23,8 +24,10 @@ Opties:
|
||||||
--token= Enrollment token uit Admin → Nodes
|
--token= Enrollment token uit Admin → Nodes
|
||||||
--name= Node-naam
|
--name= Node-naam
|
||||||
--public-url= Publieke HTTPS-URL via NPM (poort 8080)
|
--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)
|
--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)
|
--port= Host-poort (default 8080)
|
||||||
--appdata= Appdata-map (default /mnt/user/appdata/media-node)
|
--appdata= Appdata-map (default /mnt/user/appdata/media-node)
|
||||||
--master= Master-URL (default: ${MASTER_URL})
|
--master= Master-URL (default: ${MASTER_URL})
|
||||||
|
|
@ -38,6 +41,7 @@ for arg in "$@"; do
|
||||||
--public-url=*) PUBLIC_URL="${arg#*=}" ;;
|
--public-url=*) PUBLIC_URL="${arg#*=}" ;;
|
||||||
--movies=*) MOVIES="${arg#*=}" ;;
|
--movies=*) MOVIES="${arg#*=}" ;;
|
||||||
--series=*) SERIES="${arg#*=}" ;;
|
--series=*) SERIES="${arg#*=}" ;;
|
||||||
|
--media-root=*) MEDIA_ROOT="${arg#*=}" ;;
|
||||||
--port=*) PORT="${arg#*=}" ;;
|
--port=*) PORT="${arg#*=}" ;;
|
||||||
--appdata=*) APPDATA="${arg#*=}" ;;
|
--appdata=*) APPDATA="${arg#*=}" ;;
|
||||||
--master=*) MASTER_URL="${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:/*$::')"
|
PUBLIC_URL="$(echo -n "$PUBLIC_URL" | sed 's:/*$::')"
|
||||||
MOVIES="$(echo -n "$MOVIES" | tr -d '\r\n')"
|
MOVIES="$(echo -n "$MOVIES" | tr -d '\r\n')"
|
||||||
SERIES="$(echo -n "$SERIES" | 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:/*$::')"
|
MASTER_URL="$(echo -n "$MASTER_URL" | sed 's:/*$::')"
|
||||||
|
|
||||||
if [ -z "$TOKEN" ] || [ -z "$NAME" ] || [ -z "$PUBLIC_URL" ] || [ -z "$MOVIES" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$MEDIA_ROOT" ]; then
|
||||||
|
echo "Fout: media-root bestaat niet: $MEDIA_ROOT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if [ ! -d "$MOVIES" ]; then
|
if [ ! -d "$MOVIES" ]; then
|
||||||
echo "Waarschuwing: films-pad bestaat niet: $MOVIES"
|
echo "Waarschuwing: films-pad bestaat niet: $MOVIES"
|
||||||
fi
|
fi
|
||||||
|
|
@ -107,6 +116,12 @@ if [ -n "$SERIES" ] && [ ! -d "$SERIES" ]; then
|
||||||
echo "Waarschuwing: series-pad bestaat niet: $SERIES"
|
echo "Waarschuwing: series-pad bestaat niet: $SERIES"
|
||||||
fi
|
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)"
|
ARCH="$(uname -m)"
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
x86_64|amd64) BIN_NAME="media-node-linux-amd64" ;;
|
x86_64|amd64) BIN_NAME="media-node-linux-amd64" ;;
|
||||||
|
|
@ -125,21 +140,25 @@ chmod +x "$BIN_PATH"
|
||||||
# Oude container weg
|
# Oude container weg
|
||||||
docker rm -f media-node >/dev/null 2>&1 || true
|
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=(
|
VOLUMES=(
|
||||||
-v "$APPDATA/config:/etc/media-node"
|
-v "$APPDATA/config:/etc/media-node"
|
||||||
-v "$APPDATA/data:/var/lib/media-node"
|
-v "$APPDATA/data:/var/lib/media-node"
|
||||||
-v "$BIN_PATH:/usr/local/bin/media-node:ro"
|
-v "$BIN_PATH:/usr/local/bin/media-node:ro"
|
||||||
-v /etc/ssl/certs:/etc/ssl/certs:ro
|
-v /etc/ssl/certs:/etc/ssl/certs:ro
|
||||||
-v "$MOVIES:/media/movies:ro"
|
-v "$MEDIA_ROOT:$MEDIA_ROOT:ro"
|
||||||
)
|
)
|
||||||
ENV_SERIES=""
|
if [ -S /var/run/docker.sock ]; then
|
||||||
if [ -n "$SERIES" ]; then
|
VOLUMES+=(-v /var/run/docker.sock:/var/run/docker.sock)
|
||||||
VOLUMES+=(-v "$SERIES:/media/series:ro")
|
|
||||||
ENV_SERIES="/media/series"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Container starten..."
|
ENV_SERIES=""
|
||||||
|
if [ -n "$SERIES" ]; then
|
||||||
|
ENV_SERIES="$SERIES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Container starten (media-root=$MEDIA_ROOT)..."
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name media-node \
|
--name media-node \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
|
|
@ -147,11 +166,12 @@ docker run -d \
|
||||||
-e "MEDIA_NODE_NAME=$NAME" \
|
-e "MEDIA_NODE_NAME=$NAME" \
|
||||||
-e "MEDIA_NODE_MASTER_URL=$MASTER_URL" \
|
-e "MEDIA_NODE_MASTER_URL=$MASTER_URL" \
|
||||||
-e "MEDIA_NODE_PUBLIC_URL=$PUBLIC_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_SERIES=$ENV_SERIES" \
|
||||||
-e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \
|
-e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \
|
||||||
-e "MEDIA_NODE_FULL_SCAN_INTERVAL=off" \
|
-e "MEDIA_NODE_FULL_SCAN_INTERVAL=off" \
|
||||||
-e "MEDIA_NODE_FULL_SCAN_AT=03:30" \
|
-e "MEDIA_NODE_FULL_SCAN_AT=03:30" \
|
||||||
|
-e "MEDIA_NODE_CONTAINER_NAME=media-node" \
|
||||||
-e "TZ=${TZ:-Europe/Amsterdam}" \
|
-e "TZ=${TZ:-Europe/Amsterdam}" \
|
||||||
--user 99:100 \
|
--user 99:100 \
|
||||||
"${VOLUMES[@]}" \
|
"${VOLUMES[@]}" \
|
||||||
|
|
@ -163,5 +183,6 @@ echo
|
||||||
echo "Klaar. Logs: docker logs -f media-node"
|
echo "Klaar. Logs: docker logs -f media-node"
|
||||||
echo "Status: in Admin → Nodes (moet online worden)"
|
echo "Status: in Admin → Nodes (moet online worden)"
|
||||||
echo "NPM: $PUBLIC_URL → Unraid-LAN-IP:$PORT (buffering uit, Force SSL)"
|
echo "NPM: $PUBLIC_URL → Unraid-LAN-IP:$PORT (buffering uit, Force SSL)"
|
||||||
|
echo "Paden: Admin → Nodes → Edit (bijv. $MEDIA_ROOT/NL Films)"
|
||||||
echo
|
echo
|
||||||
echo "Na succes: token staat opgeslagen in appdata — herstart zonder nieuwe token is ok."
|
echo "Na succes: token staat opgeslagen in appdata — herstart zonder nieuwe token is ok."
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
"github.com/sthmedia/media-node/internal/streaming"
|
"github.com/sthmedia/media-node/internal/streaming"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.0"
|
var version = "1.2.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,50 @@ type StreamConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaConfig struct {
|
type MediaConfig struct {
|
||||||
Movies []string `yaml:"movies"`
|
Movies []string `yaml:"movies,omitempty"`
|
||||||
Series []string `yaml:"series"`
|
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 {
|
type ScannerConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package control
|
package control
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
@ -183,6 +185,8 @@ func (c *Client) sendHello() error {
|
||||||
"architecture": runtime.GOARCH,
|
"architecture": runtime.GOARCH,
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"publicStreamUrl": c.cfg.Stream.PublicURL,
|
"publicStreamUrl": c.cfg.Stream.PublicURL,
|
||||||
|
"moviesPaths": c.cfg.Media.Movies,
|
||||||
|
"seriesPaths": c.cfg.Media.Series,
|
||||||
})
|
})
|
||||||
return c.write(msg)
|
return c.write(msg)
|
||||||
}
|
}
|
||||||
|
|
@ -255,11 +259,18 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case "HELLO_ACK":
|
case "HELLO_ACK":
|
||||||
var ack struct {
|
var ack struct {
|
||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
FullScanInterval string `json:"fullScanInterval"`
|
FullScanInterval string `json:"fullScanInterval"`
|
||||||
FullScanAt string `json:"fullScanAt"`
|
FullScanAt string `json:"fullScanAt"`
|
||||||
FullScanIntervalSeconds int `json:"fullScanIntervalSeconds"`
|
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)
|
_ = json.Unmarshal(payload, &ack)
|
||||||
if !ack.Accepted {
|
if !ack.Accepted {
|
||||||
|
|
@ -268,7 +279,11 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
|
||||||
}
|
}
|
||||||
log.Println("Connected to Master")
|
log.Println("Connected to Master")
|
||||||
c.applyScannerSettings(ack.FullScanInterval, ack.FullScanAt, ack.FullScanIntervalSeconds)
|
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()
|
go c.sendFullSync()
|
||||||
case "FULL_LIBRARY_SYNC_ACK":
|
case "FULL_LIBRARY_SYNC_ACK":
|
||||||
var p struct {
|
var p struct {
|
||||||
|
|
@ -342,17 +357,166 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
|
||||||
go c.sendFullSync()
|
go c.sendFullSync()
|
||||||
case "CONFIG_UPDATE":
|
case "CONFIG_UPDATE":
|
||||||
var p struct {
|
var p struct {
|
||||||
FullScanInterval string `json:"fullScanInterval"`
|
FullScanInterval string `json:"fullScanInterval"`
|
||||||
FullScanAt string `json:"fullScanAt"`
|
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 {
|
if json.Unmarshal(payload, &p) == nil {
|
||||||
c.applyScannerSettings(p.FullScanInterval, p.FullScanAt, 0)
|
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":
|
case "ERROR":
|
||||||
log.Printf("Master error: %s", string(payload))
|
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) {
|
func (c *Client) applyScannerSettings(interval, at string, legacySeconds int) {
|
||||||
if interval == "" && at == "" && legacySeconds > 0 {
|
if interval == "" && at == "" && legacySeconds > 0 {
|
||||||
interval = fmt.Sprintf("%ds", legacySeconds)
|
interval = fmt.Sprintf("%ds", legacySeconds)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ type MediaFileInfo struct {
|
||||||
ReleaseName string `json:"releaseName"`
|
ReleaseName string `json:"releaseName"`
|
||||||
ModifiedAt string `json:"modifiedAt"`
|
ModifiedAt string `json:"modifiedAt"`
|
||||||
MediaType string `json:"mediaType"`
|
MediaType string `json:"mediaType"`
|
||||||
|
ShelfID string `json:"shelfId,omitempty"`
|
||||||
ParsedMovie map[string]interface{} `json:"parsedMovie,omitempty"`
|
ParsedMovie map[string]interface{} `json:"parsedMovie,omitempty"`
|
||||||
ParsedEpisode map[string]interface{} `json:"parsedEpisode,omitempty"`
|
ParsedEpisode map[string]interface{} `json:"parsedEpisode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +49,7 @@ type Scanner struct {
|
||||||
scanning bool
|
scanning bool
|
||||||
status string
|
status string
|
||||||
resetSchedule chan struct{}
|
resetSchedule chan struct{}
|
||||||
|
reloadWatch chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner {
|
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,
|
onEvents: onEvents,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
resetSchedule: make(chan struct{}, 2),
|
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)
|
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() {
|
func (s *Scanner) runStartupScan() {
|
||||||
log.Println("Startup library inventory scan...")
|
log.Println("Startup library inventory scan...")
|
||||||
s.FullScan()
|
s.FullScan()
|
||||||
|
|
@ -185,11 +223,16 @@ func (s *Scanner) FullScan() {
|
||||||
log.Println("Starting full media scan...")
|
log.Println("Starting full media scan...")
|
||||||
found := make(map[string]bool)
|
found := make(map[string]bool)
|
||||||
|
|
||||||
for _, dir := range s.cfg.Media.Movies {
|
s.mu.Lock()
|
||||||
s.walkRoot(dir, "movie", found)
|
roots := s.cfg.Media.EffectiveRoots()
|
||||||
}
|
s.mu.Unlock()
|
||||||
for _, dir := range s.cfg.Media.Series {
|
|
||||||
s.walkRoot(dir, "episode", found)
|
for _, root := range roots {
|
||||||
|
kind := root.Kind
|
||||||
|
if kind == "series" {
|
||||||
|
kind = "episode"
|
||||||
|
}
|
||||||
|
s.walkRoot(root.Path, kind, root.ShelfID, found)
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _ := s.store.AllFiles()
|
existing, _ := s.store.AllFiles()
|
||||||
|
|
@ -208,7 +251,7 @@ func (s *Scanner) FullScan() {
|
||||||
log.Println("Full scan completed")
|
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 == "" {
|
if dir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -220,25 +263,37 @@ func (s *Scanner) walkRoot(dir, mediaType string, found map[string]bool) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
found[path] = true
|
found[path] = true
|
||||||
s.indexFile(path, info, "FILE_UPDATED", mediaType)
|
s.indexFile(path, info, "FILE_UPDATED", mediaType, shelfID)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) watchFilesystem() {
|
func (s *Scanner) watchFilesystem() {
|
||||||
|
for {
|
||||||
|
s.runWatchSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) runWatchSession() {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("fsnotify unavailable: %v", err)
|
log.Printf("fsnotify unavailable: %v", err)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer watcher.Close()
|
defer watcher.Close()
|
||||||
|
|
||||||
rootType := map[string]string{}
|
rootType := map[string]string{}
|
||||||
addTree := func(dir, kind string) {
|
rootShelf := map[string]string{}
|
||||||
|
addTree := func(dir, kind, shelfID string) {
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
return
|
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 {
|
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
_ = watcher.Add(path)
|
_ = watcher.Add(path)
|
||||||
|
|
@ -246,12 +301,19 @@ func (s *Scanner) watchFilesystem() {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, dir := range s.cfg.Media.Movies {
|
|
||||||
addTree(dir, "movie")
|
s.mu.Lock()
|
||||||
}
|
roots := s.cfg.Media.EffectiveRoots()
|
||||||
for _, dir := range s.cfg.Media.Series {
|
s.mu.Unlock()
|
||||||
addTree(dir, "episode")
|
|
||||||
|
for _, root := range roots {
|
||||||
|
kind := root.Kind
|
||||||
|
if kind == "series" {
|
||||||
|
kind = "episode"
|
||||||
|
}
|
||||||
|
addTree(root.Path, kind, root.ShelfID)
|
||||||
}
|
}
|
||||||
|
log.Printf("Filesystem watch active on %d roots", len(roots))
|
||||||
|
|
||||||
debounce := time.NewTimer(0)
|
debounce := time.NewTimer(0)
|
||||||
<-debounce.C
|
<-debounce.C
|
||||||
|
|
@ -261,6 +323,9 @@ func (s *Scanner) watchFilesystem() {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-s.reloadWatch:
|
||||||
|
log.Println("Reloading filesystem watchers for new media paths...")
|
||||||
|
return
|
||||||
case event, ok := <-watcher.Events:
|
case event, ok := <-watcher.Events:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|
@ -307,7 +372,8 @@ func (s *Scanner) watchFilesystem() {
|
||||||
for path := range pending {
|
for path := range pending {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err == nil && !info.IsDir() && isVideo(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{})
|
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 {
|
func (s *Scanner) detectRootType(path string, rootType map[string]string) string {
|
||||||
clean := filepath.Clean(path)
|
clean := filepath.Clean(path)
|
||||||
for root, kind := range rootType {
|
for root, kind := range rootType {
|
||||||
|
|
@ -330,7 +406,7 @@ func (s *Scanner) detectRootType(path string, rootType map[string]string) string
|
||||||
return detectMediaType(path)
|
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
|
mediaType := preferredType
|
||||||
if mediaType == "" {
|
if mediaType == "" {
|
||||||
mediaType = detectMediaType(path)
|
mediaType = detectMediaType(path)
|
||||||
|
|
@ -347,6 +423,15 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT
|
||||||
}
|
}
|
||||||
|
|
||||||
localFileID := database.FileID(path)
|
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()
|
rev, _ := s.store.NextRevision()
|
||||||
|
|
||||||
lf := database.LocalFile{
|
lf := database.LocalFile{
|
||||||
|
|
@ -361,6 +446,7 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT
|
||||||
_ = s.store.UpsertFile(lf)
|
_ = s.store.UpsertFile(lf)
|
||||||
|
|
||||||
info2 := buildMediaInfo(lf)
|
info2 := buildMediaInfo(lf)
|
||||||
|
info2.ShelfID = shelfID
|
||||||
event := LibraryEvent{
|
event := LibraryEvent{
|
||||||
Type: eventType,
|
Type: eventType,
|
||||||
NodeRevision: rev,
|
NodeRevision: rev,
|
||||||
|
|
@ -394,13 +480,29 @@ func (s *Scanner) handleRemove(path string) {
|
||||||
|
|
||||||
func (s *Scanner) AllMediaFiles() []MediaFileInfo {
|
func (s *Scanner) AllMediaFiles() []MediaFileInfo {
|
||||||
files, _ := s.store.AllFiles()
|
files, _ := s.store.AllFiles()
|
||||||
|
s.mu.Lock()
|
||||||
|
roots := s.cfg.Media.EffectiveRoots()
|
||||||
|
s.mu.Unlock()
|
||||||
result := make([]MediaFileInfo, len(files))
|
result := make([]MediaFileInfo, len(files))
|
||||||
for i, f := range files {
|
for i, f := range files {
|
||||||
result[i] = buildMediaInfo(f)
|
info := buildMediaInfo(f)
|
||||||
|
info.ShelfID = shelfForPath(f.Path, roots)
|
||||||
|
result[i] = info
|
||||||
}
|
}
|
||||||
return result
|
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 {
|
func isVideo(path string) bool {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
return videoExtensions[ext]
|
return videoExtensions[ext]
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type ControlMessageType =
|
||||||
| "REVOKE_PLAYBACK_SESSION"
|
| "REVOKE_PLAYBACK_SESSION"
|
||||||
| "RESCAN"
|
| "RESCAN"
|
||||||
| "CONFIG_UPDATE"
|
| "CONFIG_UPDATE"
|
||||||
|
| "RESTART"
|
||||||
| "PLAYBACK_SESSION_ENDED"
|
| "PLAYBACK_SESSION_ENDED"
|
||||||
| "ERROR";
|
| "ERROR";
|
||||||
|
|
||||||
|
|
@ -40,6 +41,9 @@ export interface HelloPayload {
|
||||||
architecture: string;
|
architecture: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
publicStreamUrl: string;
|
publicStreamUrl: string;
|
||||||
|
/** Current scan roots on the node (seed master if empty) */
|
||||||
|
moviesPaths?: string[];
|
||||||
|
seriesPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HelloAckPayload {
|
export interface HelloAckPayload {
|
||||||
|
|
@ -52,6 +56,13 @@ export interface HelloAckPayload {
|
||||||
fullScanInterval: string;
|
fullScanInterval: string;
|
||||||
/** Nightly catch-up HH:MM, or "off" */
|
/** Nightly catch-up HH:MM, or "off" */
|
||||||
fullScanAt: string;
|
fullScanAt: string;
|
||||||
|
moviesPaths?: string[];
|
||||||
|
seriesPaths?: string[];
|
||||||
|
scanRoots?: Array<{
|
||||||
|
path: string;
|
||||||
|
shelfId: string;
|
||||||
|
kind: "movie" | "series";
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeartbeatPayload extends NodeHeartbeat {}
|
export interface HeartbeatPayload extends NodeHeartbeat {}
|
||||||
|
|
@ -84,6 +95,18 @@ export interface RescanPayload {
|
||||||
export interface ConfigUpdatePayload {
|
export interface ConfigUpdatePayload {
|
||||||
fullScanInterval?: string;
|
fullScanInterval?: string;
|
||||||
fullScanAt?: 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 {
|
export interface PlaybackSessionEndedPayload {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ export interface MediaFileInfo {
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
mediaType: "movie" | "episode";
|
mediaType: "movie" | "episode";
|
||||||
|
/** Library shelf this file belongs to (cross-node catalog) */
|
||||||
|
shelfId?: string;
|
||||||
parsedMovie?: ParsedMovieFilename;
|
parsedMovie?: ParsedMovieFilename;
|
||||||
parsedEpisode?: ParsedSeriesFilename;
|
parsedEpisode?: ParsedSeriesFilename;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue