stremio/apps/admin-ui/src/app/settings/page.tsx
Jos Vooges | STH a4dfb1b472 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.
2026-08-25 20:47:41 +02:00

628 lines
21 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface AddonTokenRow {
id: string;
label: string | null;
revoked: boolean;
createdAt: string;
hasIptv?: boolean;
iptvOk?: boolean;
}
interface IptvForm {
label: string;
baseUrl: string;
username: string;
password: string;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
lastOkAt?: string | null;
lastError?: string | null;
hasPassword?: boolean;
}
const emptyIptv = (): IptvForm => ({
label: "",
baseUrl: "",
username: "",
password: "",
enableLive: true,
enableVod: true,
enableSeries: true,
});
const MASTER_URL = "https://master.vonas.nl";
interface ShelfRow {
id: string;
name: string;
slug: string;
kind: "MOVIE" | "SERIES";
sortOrder: number;
enabled: boolean;
scanRootCount: number;
mediaFileCount: number;
}
export default function SettingsPage() {
useAuth();
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
const [addonToken, setAddonToken] = useState("");
const [installUrl, setInstallUrl] = useState("");
const [label, setLabel] = useState("Stremio");
const [iptvTokenId, setIptvTokenId] = useState<string | null>(null);
const [iptv, setIptv] = useState<IptvForm>(emptyIptv());
const [iptvBusy, setIptvBusy] = useState(false);
const [iptvMsg, setIptvMsg] = useState<string | null>(null);
const [iptvErr, setIptvErr] = useState<string | null>(null);
const [shelves, setShelves] = useState<ShelfRow[]>([]);
const [newShelfName, setNewShelfName] = useState("");
const [newShelfKind, setNewShelfKind] = useState<"MOVIE" | "SERIES">("MOVIE");
const loadTokens = useCallback(() => {
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
.then((r) => r.json())
.then((d) => setTokens(d.tokens ?? []));
}, []);
const loadShelves = useCallback(() => {
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
.then((r) => r.json())
.then((d) => setShelves(d.shelves ?? []));
}, []);
useEffect(() => {
loadTokens();
loadShelves();
}, [loadTokens, loadShelves]);
async function createShelf() {
if (!newShelfName.trim()) return;
const res = await fetch("/api/v1/admin/library/shelves", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newShelfName.trim(), kind: newShelfKind }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Aanmaken mislukt");
return;
}
setNewShelfName("");
loadShelves();
}
async function deleteShelf(id: string, name: string) {
if (!confirm(`Plank "${name}" verwijderen?`)) return;
const res = await fetch(`/api/v1/admin/library/shelves/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Verwijderen mislukt");
return;
}
loadShelves();
}
async function createAddonToken() {
const res = await fetch("/api/v1/admin/addon-tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ label }),
});
const data = await res.json();
if (!res.ok) {
alert(data.error?.message ?? "Aanmaken mislukt");
return;
}
setAddonToken(data.token);
setInstallUrl(`${MASTER_URL}/stremio/${data.token}/manifest.json`);
loadTokens();
}
async function revokeToken(id: string) {
if (!confirm("Addon deactiveren? Bestaande Stremio-installaties stoppen met werken.")) return;
await fetch(`/api/v1/admin/addon-tokens/${id}/revoke`, {
method: "POST",
credentials: "include",
});
loadTokens();
}
async function activateToken(id: string) {
await fetch(`/api/v1/admin/addon-tokens/${id}/activate`, {
method: "POST",
credentials: "include",
});
loadTokens();
}
async function deleteToken(id: string) {
if (!confirm("Addon definitief verwijderen? Dit kan niet ongedaan worden gemaakt.")) return;
await fetch(`/api/v1/admin/addon-tokens/${id}`, {
method: "DELETE",
credentials: "include",
});
if (iptvTokenId === id) {
setIptvTokenId(null);
setIptv(emptyIptv());
}
loadTokens();
}
async function openIptv(tokenId: string) {
setIptvTokenId(tokenId);
setIptvMsg(null);
setIptvErr(null);
setIptvBusy(true);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${tokenId}/iptv`, {
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Laden mislukt");
setIptv(emptyIptv());
return;
}
if (data.iptv) {
setIptv({
label: data.iptv.label ?? "",
baseUrl: data.iptv.baseUrl ?? "",
username: data.iptv.username ?? "",
password: "",
enableLive: data.iptv.enableLive ?? true,
enableVod: data.iptv.enableVod ?? true,
enableSeries: data.iptv.enableSeries ?? true,
lastOkAt: data.iptv.lastOkAt,
lastError: data.iptv.lastError,
hasPassword: data.iptv.hasPassword,
});
} else {
setIptv(emptyIptv());
}
} finally {
setIptvBusy(false);
}
}
async function saveIptv() {
if (!iptvTokenId) return;
setIptvBusy(true);
setIptvMsg(null);
setIptvErr(null);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
label: iptv.label || undefined,
baseUrl: iptv.baseUrl,
username: iptv.username,
password: iptv.password || undefined,
enableLive: iptv.enableLive,
enableVod: iptv.enableVod,
enableSeries: iptv.enableSeries,
}),
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setIptvMsg(
`Verbonden · ${data.iptv?.liveCategories ?? 0} live-bouquets. Herlaad de Stremio-addon om Live TV te zien.`
);
setIptv((s) => ({ ...s, password: "", hasPassword: true, lastError: null }));
loadTokens();
await openIptv(iptvTokenId);
} finally {
setIptvBusy(false);
}
}
async function testIptv() {
if (!iptvTokenId) return;
setIptvBusy(true);
setIptvMsg(null);
setIptvErr(null);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv/test`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Test mislukt");
return;
}
setIptvMsg(
`OK · status ${data.status}` +
(data.expDate ? ` · exp ${data.expDate}` : "") +
` · live ${data.liveCategories} · films ${data.vodCategories} · series ${data.seriesCategories}`
);
loadTokens();
} finally {
setIptvBusy(false);
}
}
async function deleteIptv() {
if (!iptvTokenId) return;
if (!confirm("IPTV-userline ontkoppelen van deze addon?")) return;
setIptvBusy(true);
setIptvErr(null);
try {
await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv`, {
method: "DELETE",
credentials: "include",
});
setIptv(emptyIptv());
setIptvMsg("IPTV ontkoppeld");
loadTokens();
} finally {
setIptvBusy(false);
}
}
const activeToken = tokens.find((t) => t.id === iptvTokenId);
return (
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
Bibliotheek-planken (Stremio)
</h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
Maak namen zoals <strong>Kids Films</strong> of <strong>NL Series</strong>. Op Nodes koppel je
scanpaden aan een plank — content van alle nodes met dezelfde plank komt in één Stremio-lijst.
</p>
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", marginBottom: "1rem" }}>
<input
value={newShelfName}
onChange={(e) => setNewShelfName(e.target.value)}
placeholder="bijv. Kids Films"
style={{ flex: "1 1 200px", marginBottom: 0 }}
/>
<select
value={newShelfKind}
onChange={(e) => setNewShelfKind(e.target.value as "MOVIE" | "SERIES")}
style={{
background: "#0f1117",
border: "1px solid #2a2d3a",
color: "#fafafa",
borderRadius: 8,
padding: "0.5rem 0.75rem",
}}
>
<option value="MOVIE">Films</option>
<option value="SERIES">Series</option>
</select>
<button onClick={createShelf}>Plank toevoegen</button>
</div>
<table>
<thead>
<tr>
<th>Naam</th>
<th>Type</th>
<th>Paden</th>
<th>Bestanden</th>
<th></th>
</tr>
</thead>
<tbody>
{shelves.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{s.kind === "MOVIE" ? "Films" : "Series"}</td>
<td>{s.scanRootCount}</td>
<td>{s.mediaFileCount}</td>
<td>
{s.slug !== "films" && s.slug !== "series" && (
<button className="danger" onClick={() => deleteShelf(s.id, s.name)}>
Verwijderen
</button>
)}
</td>
</tr>
))}
{shelves.length === 0 && (
<tr>
<td colSpan={5} style={{ color: "#71717a", textAlign: "center" }}>
Laden…
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Nieuwe Stremio-addon
</h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
Genereer een addon-token om de bibliotheek in Stremio te installeren. Bewaar de URL —
die wordt maar één keer getoond. Koppel daarna optioneel een Xtream-userline voor Live TV + EPG.
</p>
<div className="form-group">
<label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="bijv. Thuis / TV" />
</div>
<button onClick={createAddonToken}>Addon aanmaken</button>
{addonToken && (
<>
<p style={{ fontSize: "0.875rem", color: "#a1a1aa", marginTop: "1rem" }}>
Installatie-URL (nu kopiëren):
</p>
<div className="token-display">{installUrl}</div>
<p style={{ fontSize: "0.875rem", color: "#71717a", marginTop: "0.5rem" }}>
Stremio → Addons → Community Addons → URL invoeren
</p>
</>
)}
</div>
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Bestaande addons
</h2>
<table>
<thead>
<tr>
<th>Label</th>
<th>Status</th>
<th>IPTV</th>
<th>Aangemaakt</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.id} style={iptvTokenId === t.id ? { background: "#14161f" } : undefined}>
<td>{t.label || "—"}</td>
<td>
<span className={`status ${t.revoked ? "revoked" : "online"}`}>
{t.revoked ? "Inactief" : "Actief"}
</span>
</td>
<td>
{t.hasIptv ? (
<span className={`status ${t.iptvOk ? "online" : "revoked"}`}>
{t.iptvOk ? "Live TV" : "Fout"}
</span>
) : (
<span className="status offline">Geen</span>
)}
</td>
<td>{new Date(t.createdAt).toLocaleString()}</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button
onClick={() => openIptv(t.id)}
style={{ background: "#0e7490" }}
title="Xtream / XUI userline"
>
IPTV
</button>
{t.revoked ? (
<button onClick={() => activateToken(t.id)} style={{ background: "#166534" }}>
Activeren
</button>
) : (
<button onClick={() => revokeToken(t.id)} style={{ background: "#b45309" }}>
Deactiveren
</button>
)}
<button className="danger" onClick={() => deleteToken(t.id)}>
Verwijderen
</button>
</td>
</tr>
))}
{tokens.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: "center", color: "#71717a" }}>
Nog geen addons aangemaakt
</td>
</tr>
)}
</tbody>
</table>
<p style={{ fontSize: "0.8rem", color: "#71717a", marginTop: "1rem" }}>
Deactiveren of verwijderen zorgt dat de bijbehorende Stremio-addon niet meer werkt.
De installatie-URL zelf wordt om veiligheidsredenen niet opnieuw getoond.
</p>
</div>
{iptvTokenId && (
<div
className="card"
style={{
marginBottom: "2rem",
borderColor: "#155e75",
background:
"linear-gradient(145deg, #12151c 0%, #152028 55%, #1a1d27 100%)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "1rem",
marginBottom: "1.25rem",
flexWrap: "wrap",
}}
>
<div>
<h2
style={{
fontSize: "1.125rem",
color: "#fafafa",
textTransform: "none",
marginBottom: "0.35rem",
letterSpacing: 0,
}}
>
IPTV · Xtream / XUI
</h2>
<p style={{ color: "#94a3b8", fontSize: "0.875rem", maxWidth: "40rem" }}>
Userline voor addon{" "}
<strong style={{ color: "#e2e8f0" }}>{activeToken?.label || iptvTokenId.slice(0, 8)}</strong>
. Live-zenders met icoon + EPG, plus optioneel IPTV-films en -series. Alleen deze
Stremio-installatie ziet deze line.
</p>
</div>
<button
type="button"
onClick={() => {
setIptvTokenId(null);
setIptv(emptyIptv());
setIptvMsg(null);
setIptvErr(null);
}}
style={{ background: "#374151" }}
>
Sluiten
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "1rem",
}}
>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Label (optioneel)</label>
<input
value={iptv.label}
onChange={(e) => setIptv({ ...iptv, label: e.target.value })}
placeholder="bijv. Thuis XUI"
/>
</div>
<div className="form-group" style={{ marginBottom: 0, gridColumn: "1 / -1" }}>
<label>Server-URL</label>
<input
value={iptv.baseUrl}
onChange={(e) => setIptv({ ...iptv, baseUrl: e.target.value })}
placeholder="http://host:port of https://iptv.example.com"
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Gebruikersnaam</label>
<input
value={iptv.username}
onChange={(e) => setIptv({ ...iptv, username: e.target.value })}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Wachtwoord {iptv.hasPassword ? "(leeg = behouden)" : ""}</label>
<input
type="password"
value={iptv.password}
onChange={(e) => setIptv({ ...iptv, password: e.target.value })}
placeholder={iptv.hasPassword ? "••••••••" : ""}
autoComplete="new-password"
/>
</div>
</div>
<div
style={{
display: "flex",
gap: "1.25rem",
flexWrap: "wrap",
margin: "1.25rem 0",
padding: "0.85rem 1rem",
background: "#0f1117",
borderRadius: 10,
border: "1px solid #2a2d3a",
}}
>
{(
[
["enableLive", "Live TV + EPG"],
["enableVod", "IPTV Films"],
["enableSeries", "IPTV Series"],
] as const
).map(([key, text]) => (
<label
key={key}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
color: "#e4e4e7",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={iptv[key]}
onChange={(e) => setIptv({ ...iptv, [key]: e.target.checked })}
style={{ width: "auto", margin: 0 }}
/>
{text}
</label>
))}
</div>
{(iptv.lastOkAt || iptv.lastError) && (
<p style={{ fontSize: "0.8rem", color: "#71717a", marginBottom: "1rem" }}>
{iptv.lastOkAt && <>Laatst OK: {new Date(iptv.lastOkAt).toLocaleString()} </>}
{iptv.lastError && (
<span style={{ color: "#f87171" }}>· Fout: {iptv.lastError}</span>
)}
</p>
)}
{iptvMsg && (
<p style={{ color: "#6ee7b7", fontSize: "0.875rem", marginBottom: "0.75rem" }}>{iptvMsg}</p>
)}
{iptvErr && (
<p className="error" style={{ marginBottom: "0.75rem" }}>
{iptvErr}
</p>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button onClick={saveIptv} disabled={iptvBusy}>
{iptvBusy ? "Bezig…" : "Opslaan & verbinden"}
</button>
<button
onClick={testIptv}
disabled={iptvBusy || !iptv.hasPassword}
style={{ background: "#166534" }}
>
Testen
</button>
{iptv.hasPassword && (
<button className="danger" onClick={deleteIptv} disabled={iptvBusy}>
Ontkoppelen
</button>
)}
</div>
</div>
)}
</div>
</>
);
}