Shelves (e.g. Kids Films) are defined in settings; node roots bind paths to shelves so the same Stremio catalog aggregates content across nodes.
628 lines
21 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|