stremio/apps/admin-ui/src/app/scripts/page.tsx

679 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav";
type TweakMeta = {
key: string;
label: string;
hint: string;
kind: "number" | "boolean";
unit?: string;
};
type OdidoTweaks = Record<string, number | boolean>;
type OdidoAccount = {
id: string;
label: string;
username: string;
hasPin: boolean;
enabled: boolean;
channelCount: number;
healthStatus: "ok" | "dead" | "unknown";
healthCheckedAt: string | null;
healthError: string | null;
};
type OdidoSettings = {
configured: boolean;
enabled: boolean;
hasWvd: boolean;
wvdPath: string;
wvdUploadedAt: string | null;
lastSyncAt: string | null;
lastSyncSummary: Record<string, unknown> | null;
lastMpdRefreshAt: string | null;
lastMpdRefreshSummary: Record<string, unknown> | null;
accounts: OdidoAccount[];
accountCount: number;
channelOwnerCount: number;
tweaks: OdidoTweaks;
tweakDefaults: OdidoTweaks;
tweakMeta: TweakMeta[];
};
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result ?? "");
const comma = result.indexOf(",");
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = () => reject(new Error("Bestand lezen mislukt"));
reader.readAsDataURL(file);
});
}
export default function ScriptsPage() {
useAuth();
const [odido, setOdido] = useState<OdidoSettings | null>(null);
const [wvdPath, setWvdPath] = useState("");
const [enabled, setEnabled] = useState(true);
const [tweaks, setTweaks] = useState<OdidoTweaks>({});
const [showTweaks, setShowTweaks] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const wvdInputRef = useRef<HTMLInputElement>(null);
const [newLabel, setNewLabel] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newPin, setNewPin] = useState("");
const applySettings = useCallback((d: OdidoSettings) => {
setOdido(d);
setWvdPath(d.wvdPath ?? "");
setEnabled(d.enabled !== false);
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
}, []);
const load = useCallback(async () => {
const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" });
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt");
applySettings(d);
}, [applySettings]);
useEffect(() => {
void load().catch((e) => setErr(String(e.message ?? e)));
}, [load]);
async function saveOdido(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/odido", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wvdPath: wvdPath.trim(), enabled, tweaks }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
applySettings(d);
setMsg("Odido-instellingen opgeslagen");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function addAccount(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/odido/accounts", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: newLabel.trim() || undefined,
username: newUsername.trim(),
pin: newPin.trim(),
enabled: true,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt");
applySettings(d);
setNewLabel("");
setNewUsername("");
setNewPin("");
setMsg("Odido-account toegevoegd");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function toggleAccount(account: OdidoAccount) {
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/scripts/odido/accounts/${account.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !account.enabled }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Account bijwerken mislukt");
applySettings(d);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function removeAccount(account: OdidoAccount) {
if (!confirm(`Account “${account.label || account.username}” verwijderen?`)) return;
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch(`/api/v1/admin/scripts/odido/accounts/${account.id}`, {
method: "DELETE",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
applySettings(d);
setMsg("Account verwijderd");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function probeAccounts(accountId?: string) {
setBusy(true);
setErr(null);
setMsg(null);
try {
const url = accountId
? `/api/v1/admin/scripts/odido/accounts/${accountId}/probe`
: "/api/v1/admin/scripts/odido/accounts/probe";
const r = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: accountId ? undefined : JSON.stringify({}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt");
applySettings(d);
const list: OdidoAccount[] = d.accounts ?? [];
const ok = list.filter((a) => a.healthStatus === "ok").length;
const dead = list.filter((a) => a.healthStatus === "dead").length;
setMsg(`Accountstatus: ${ok} actief · ${dead} dood`);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function healthBadge(a: OdidoAccount) {
if (!a.enabled) {
return (
<span className="muted" title="Uitgeschakeld" style={{ fontSize: "0.85rem" }}>
uit
</span>
);
}
if (a.healthStatus === "ok") {
return (
<span
className="ok-banner"
title={a.healthCheckedAt ? `Gecontroleerd ${new Date(a.healthCheckedAt).toLocaleString("nl-NL")}` : "Actief"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
actief
</span>
);
}
if (a.healthStatus === "dead") {
return (
<span
className="err-banner"
title={a.healthError || "Login/heartbeat mislukt"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
dood
</span>
);
}
return (
<span className="muted" style={{ fontSize: "0.85rem" }} title="Nog niet gecontroleerd">
onbekend
</span>
);
}
async function uploadWvd(file: File | null) {
if (!file) return;
setBusy(true);
setErr(null);
setMsg(null);
try {
if (file.size > 2 * 1024 * 1024) {
throw new Error("WVD-bestand mag max. 2 MB zijn");
}
const contentBase64 = await readFileAsBase64(file);
const r = await fetch("/api/v1/admin/scripts/odido/wvd", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentBase64 }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "WVD-upload mislukt");
applySettings(d);
setMsg(`WVD.wvd geüpload (${d.bytes ?? file.size} bytes)`);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
if (wvdInputRef.current) wvdInputRef.current.value = "";
}
}
async function refreshMpds() {
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/odido/refresh-mpd", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "MPD-refresh mislukt");
setMsg(
`MPD-cache vernieuwd: ${d.refreshed ?? 0} ok · ${d.failed ?? 0} fout · ${d.skipped ?? 0} skip (${d.durationMs ?? "?"} ms)`
);
await load();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function flushMpds() {
if (!confirm("Alle gecachte Odido-MPD’s wissen? Volgende play haalt dan verse URLs.")) return;
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/odido/flush-mpd", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Flush mislukt");
setMsg(`MPD-cache geleegd (${d.cleared ?? 0} zenders)`);
await load();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function resetTweaks() {
if (odido?.tweakDefaults) setTweaks({ ...odido.tweakDefaults });
}
const summary = odido?.lastSyncSummary;
const refreshSummary = odido?.lastMpdRefreshSummary;
const meta = odido?.tweakMeta ?? [];
const accounts = odido?.accounts ?? [];
return (
<>
<Nav />
<main className="page">
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Integraties</div>
<h1>Scripts</h1>
</div>
</div>
<p className="muted page-lead">
Provider-scripts voor live-lijsten. Meerdere Odido-accounts: sync bouwt één unieke
zenderlijst (geen dubbels); overlap wordt round-robin verdeeld bij play/refresh. Koppel
daarna een lijst onder <Link href="/live-lists">Live-lijsten</Link>.
</p>
{msg && (
<p className="ok-banner" role="status">
{msg}
</p>
)}
{err && (
<p className="err-banner" role="alert">
{err}
</p>
)}
<div className="card" style={{ maxWidth: 720 }}>
<h2 style={{ marginTop: 0 }}>Odido</h2>
<p className="muted" style={{ marginTop: 0 }}>
Accounts met verschillende abonnementen vullen elkaar aan. Gedeelde zenders worden
load-balanced; unieke zenders komen van het account dat ze wél heeft.
</p>
<h3 style={{ marginBottom: "0.5rem", display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
Accounts
<button type="button" disabled={busy || accounts.length === 0} onClick={() => void probeAccounts()}>
Status controleren
</button>
</h3>
{accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg hieronder je eerste klantnummer toe.</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1rem" }}>
{accounts.map((a) => (
<li
key={a.id}
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
justifyContent: "space-between",
padding: "0.6rem 0",
borderBottom: "1px solid var(--border, #333)",
}}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<strong>{a.label || a.username}</strong>
{healthBadge(a)}
<span className="muted" style={{ fontSize: "0.85rem" }}>
{a.username}
{a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""}
</span>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{a.id !== "legacy" && (
<>
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>
Check
</button>
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"}
</button>
<button type="button" disabled={busy} onClick={() => void removeAccount(a)}>
Verwijderen
</button>
</>
)}
</div>
</li>
))}
</ul>
)}
<form onSubmit={addAccount} className="form-group" style={{ marginBottom: "1.5rem" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: "0.75rem",
}}
>
<label className="field">
Label (optioneel)
<input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="bijv. Thuis / Zakelijk"
disabled={busy}
/>
</label>
<label className="field">
Klantnummer
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="TV-klantnummer"
disabled={busy}
required
autoComplete="username"
/>
</label>
<label className="field">
Pincode
<input
type="password"
value={newPin}
onChange={(e) => setNewPin(e.target.value)}
placeholder="4-cijferig"
disabled={busy}
required
autoComplete="current-password"
/>
</label>
</div>
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: 8 }}>
Account toevoegen
</button>
</form>
<form onSubmit={saveOdido} className="form-group">
<div className="field">
<span>WVD.wvd (Widevine device — gedeeld)</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 6 }}>
<input
ref={wvdInputRef}
type="file"
accept=".wvd,application/octet-stream"
disabled={busy}
onChange={(e) => void uploadWvd(e.target.files?.[0] ?? null)}
/>
</div>
<p className="muted" style={{ margin: "6px 0 0", fontSize: "0.85rem" }}>
{odido?.hasWvd ? (
<>
Aanwezig op server
{odido.wvdUploadedAt
? ` · geüpload ${new Date(odido.wvdUploadedAt).toLocaleString("nl-NL")}`
: ""}
{odido.wvdPath ? ` · ${odido.wvdPath}` : ""}
</>
) : (
<>Nog niet geüpload — kies je lokale WVD.wvd (max. 2 MB).</>
)}
</p>
</div>
<label className="field">
Pad op de server (optioneel)
<input
value={wvdPath}
onChange={(e) => setWvdPath(e.target.value)}
placeholder="/data/odido/WVD.wvd"
disabled={busy}
/>
</label>
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={busy}
/>
Odido-integratie actief
</label>
<div style={{ marginTop: "1.25rem" }}>
<button
type="button"
className="btn-secondary"
onClick={() => setShowTweaks((v) => !v)}
disabled={busy}
>
{showTweaks ? "Geavanceerd verbergen" : "Geavanceerd / tweaks tonen"}
</button>
</div>
{showTweaks && (
<div
style={{
marginTop: "1rem",
padding: "1rem",
border: "1px solid var(--border, #333)",
borderRadius: 8,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<h3 style={{ margin: 0 }}>Tweak-opties</h3>
<button type="button" disabled={busy} onClick={resetTweaks}>
Defaults herstellen
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "0.75rem 1rem",
marginTop: "0.75rem",
}}
>
{meta.map((item) => {
const value = tweaks[item.key];
if (item.kind === "boolean") {
return (
<label
key={item.key}
className="field"
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
>
<input
type="checkbox"
checked={value === true}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({ ...prev, [item.key]: e.target.checked }))
}
style={{ marginTop: 4 }}
/>
<span>
<strong style={{ fontWeight: 600 }}>{item.label}</strong>
<br />
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</span>
</label>
);
}
return (
<label key={item.key} className="field">
{item.label}
{item.unit ? ` (${item.unit})` : ""}
<input
type="number"
value={typeof value === "number" ? value : ""}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({
...prev,
[item.key]: e.target.value === "" ? 0 : Number(e.target.value),
}))
}
/>
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</label>
);
})}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: "1rem" }}>
<button type="button" disabled={busy} onClick={() => void refreshMpds()}>
MPD-cache nu vernieuwen
</button>
<button type="button" disabled={busy} onClick={() => void flushMpds()}>
MPD-cache legen
</button>
</div>
</div>
)}
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: "1rem" }}>
Opslaan
</button>
</form>
<div className="muted" style={{ marginTop: "1rem", fontSize: "0.9rem" }}>
Status:{" "}
{odido?.configured ? (
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
geconfigureerd ({odido.accountCount} account
{odido.accountCount === 1 ? "" : "s"}
{odido.channelOwnerCount
? ` · ${odido.channelOwnerCount} zenders na sync`
: ""}
)
</span>
) : (
<span>nog niet compleet (minstens 1 account + WVD)</span>
)}
{odido?.lastSyncAt && (
<>
<br />
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")}
{summary && (
<>
{" "}
(
{[
summary.uniqueChannels != null
? `${String(summary.uniqueChannels)} uniek`
: null,
summary.overlapChannels != null
? `${String(summary.overlapChannels)} overlap`
: null,
summary.added != null ? `+${String(summary.added)}` : null,
summary.updated != null ? `~${String(summary.updated)}` : null,
]
.filter(Boolean)
.join(" · ") || "ok"}
)
</>
)}
</>
)}
{odido?.lastMpdRefreshAt && (
<>
<br />
Laatste MPD-refresh: {new Date(odido.lastMpdRefreshAt).toLocaleString("nl-NL")}
{refreshSummary && (
<>
{" "}
(
{[
refreshSummary.refreshed != null
? `${String(refreshSummary.refreshed)} ok`
: null,
refreshSummary.failed != null
? `${String(refreshSummary.failed)} fout`
: null,
]
.filter(Boolean)
.join(" · ")}
)
</>
)}
</>
)}
</div>
</div>
</div>
</main>
</>
);
}