stremio/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx
Jos Vooges | STH 8be634f783 Show Viaplay subscription type on each account after add/check.
Auto-probe on account add and always render an Abonnement row, with session-cookie fallback when overview is unavailable.
2026-09-22 22:37:40 +02:00

677 lines
22 KiB
TypeScript

"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
type TweakMeta = {
key: string;
label: string;
hint: string;
kind: "number" | "boolean" | "string";
unit?: string;
};
type ViaplayTweaks = Record<string, number | boolean | string>;
type ViaplayAccount = {
id: string;
label: string;
email: string;
hasSession: boolean;
hasAccessToken: boolean;
hasProfileId: boolean;
hasPassword: boolean;
enabled: boolean;
eventCount: number;
healthStatus: "ok" | "dead" | "unknown";
healthCheckedAt: string | null;
healthError: string | null;
subscriptionSummary: string | null;
partnerName: string | null;
overviewCheckedAt: string | null;
};
type ViaplaySettings = {
configured: boolean;
enabled: boolean;
accounts: ViaplayAccount[];
accountCount: number;
eventCount: number;
eventOwnerCount: number;
lastSyncAt: string | null;
lastSyncSummary: Record<string, unknown> | null;
lastPreLiveAt: string | null;
lastPreLiveSummary: Record<string, unknown> | null;
wvdPath: string;
wvdExists: boolean;
tweaks: ViaplayTweaks;
tweakDefaults: ViaplayTweaks;
tweakMeta: TweakMeta[];
};
export type ViaplayOverview = {
configured: boolean;
enabled: boolean;
accountCount: number;
eventCount: number;
lastSyncAt: string | null;
};
export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) => void }) {
const [settings, setSettings] = useState<ViaplaySettings | null>(null);
const [enabled, setEnabled] = useState(true);
const [tweaks, setTweaks] = useState<ViaplayTweaks>({});
const [showTweaks, setShowTweaks] = useState(false);
const [showCookies, setShowCookies] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [newLabel, setNewLabel] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newSession, setNewSession] = useState("");
const [newAccessToken, setNewAccessToken] = useState("");
const [newProfileId, setNewProfileId] = useState("");
const applySettings = useCallback(
(d: ViaplaySettings) => {
setSettings(d);
setEnabled(d.enabled !== false);
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
onChanged?.({
configured: d.configured,
enabled: d.enabled !== false,
accountCount: d.accountCount,
eventCount: d.eventCount,
lastSyncAt: d.lastSyncAt,
});
},
[onChanged]
);
const load = useCallback(async () => {
const r = await fetch("/api/v1/admin/scripts/viaplay", { credentials: "include" });
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Viaplay-instellingen laden mislukt");
applySettings(d);
}, [applySettings]);
useEffect(() => {
void load().catch((e) => setErr(String(e.message ?? e)));
}, [load]);
async function saveSettings(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, tweaks }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
applySettings(d);
setMsg("Viaplay-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/viaplay/accounts", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: newLabel.trim() || undefined,
email: newEmail.trim(),
session: newSession.trim() || undefined,
accessToken: newAccessToken.trim() || undefined,
profileId: newProfileId.trim() || undefined,
enabled: true,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt");
const emailKey = newEmail.trim().toLowerCase();
applySettings(d);
setNewLabel("");
setNewEmail("");
setNewSession("");
setNewAccessToken("");
setNewProfileId("");
const added = (d.accounts as ViaplayAccount[] | undefined)?.find(
(a) => a.email === emailKey
);
const sub = added?.subscriptionSummary;
setMsg(
sub
? `Account toegevoegd · abonnement: ${sub}`
: "Account toegevoegd — abonnement nog niet zichtbaar, druk Check"
);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function toggleAccount(account: ViaplayAccount) {
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/scripts/viaplay/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: ViaplayAccount) {
if (!confirm(`Account “${account.label || account.email}” verwijderen?`)) return;
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch(`/api/v1/admin/scripts/viaplay/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/viaplay/accounts/${accountId}/probe`
: "/api/v1/admin/scripts/viaplay/accounts/probe";
const r = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(accountId ? { accountId } : {}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
d?.error?.message || d?.message || `Statuscheck mislukt (${r.status})`
);
}
applySettings(d);
const list: ViaplayAccount[] = d.accounts ?? [];
const ok = list.filter((a) => a.healthStatus === "ok").length;
const dead = list.filter((a) => a.healthStatus === "dead").length;
const sample = list.find((a) => a.id === accountId) || list.find((a) => a.subscriptionSummary);
const sub = sample?.subscriptionSummary ? ` · ${sample.subscriptionSummary}` : "";
setMsg(`Accountstatus: ${ok} actief · ${dead} probleem${sub}`);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function runSync() {
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay/sync", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Sync mislukt");
setMsg(`Viaplay-agenda: ${d.events ?? 0} events · ${d.daysOk ?? 0}/${d.daysTried ?? 0} dagen (${d.durationMs ?? "?"} ms)`);
await load();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function runPreLive() {
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay/prelive", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Pre-live mislukt");
setMsg(
`Pre-live: ${d.refreshed ?? 0} refreshed · ${d.failed ?? 0} failed · ${d.checked ?? 0} checked (${d.durationMs ?? "?"} ms)`
);
await load();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function healthBadge(a: ViaplayAccount) {
if (!a.enabled) {
return (
<span className="muted" style={{ fontSize: "0.85rem" }}>
uit
</span>
);
}
if (a.healthStatus === "ok") {
return (
<span
className="ok-banner"
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
actief
</span>
);
}
if (a.healthStatus === "dead") {
return (
<span
className="err-banner"
title={a.healthError || "Sessie ongeldig"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
probleem
</span>
);
}
return (
<span className="muted" style={{ fontSize: "0.85rem" }}>
onbekend
</span>
);
}
function resetTweaks() {
if (settings?.tweakDefaults) setTweaks({ ...settings.tweakDefaults });
}
const accounts = settings?.accounts ?? [];
const meta = settings?.tweakMeta ?? [];
const summary = settings?.lastSyncSummary;
return (
<>
{msg && (
<p className="ok-banner" role="status">
{msg}
</p>
)}
{err && (
<p className="err-banner" role="alert">
{err}
</p>
)}
<div className="card" style={{ maxWidth: 760 }}>
<h2 style={{ marginTop: 0 }}>Viaplay</h2>
<p className="muted" style={{ marginTop: 0 }}>
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Voeg een account toe met
sessie-token, access token en optioneel profile-id (uit een ingelogde browsersessie).
Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en
licenties cache-first (±5 min vóór start).
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
<button type="button" className="btn-primary" disabled={busy} onClick={() => void runSync()}>
Agenda syncen
</button>
<button
type="button"
disabled={busy || accounts.length === 0}
onClick={() => void runPreLive()}
>
Pre-live vernieuwen
</button>
<button
type="button"
disabled={busy || accounts.length === 0}
onClick={() => void probeAccounts()}
>
Accounts controleren
</button>
</div>
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
{accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg e-mail + sessie-tokens 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={{ flex: "1 1 280px", minWidth: 0 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<strong>{a.label || a.email}</strong>
{healthBadge(a)}
<span className="muted" style={{ fontSize: "0.85rem" }}>
{a.email}
{a.eventCount ? ` · ${a.eventCount} events` : ""}
</span>
</div>
<div
style={{
marginTop: 8,
padding: "8px 10px",
borderRadius: 6,
background: "var(--surface-2, rgba(127,127,127,0.12))",
fontSize: "0.9rem",
}}
>
<div>
<span className="muted">Abonnement: </span>
{a.subscriptionSummary ? (
<strong>{a.subscriptionSummary}</strong>
) : (
<span className="muted">nog niet opgehaald — druk Check</span>
)}
</div>
{a.partnerName && a.subscriptionSummary && !a.subscriptionSummary.includes(a.partnerName) ? (
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 2 }}>
Partner: {a.partnerName}
</div>
) : null}
{a.healthError ? (
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 4 }}>
{a.healthError}
</div>
) : null}
</div>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<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(180px, 1fr))",
gap: "0.75rem",
}}
>
<label className="field">
Label
<input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="bijv. Thuis"
disabled={busy}
/>
</label>
<label className="field">
E-mail
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
disabled={busy}
autoComplete="username"
/>
</label>
</div>
<button
type="button"
className="btn-secondary"
style={{ marginTop: 10 }}
onClick={() => setShowCookies((v) => !v)}
>
{showCookies ? "Tokens verbergen" : "Sessie-tokens plakken (vereist)"}
</button>
{showCookies && (
<div style={{ marginTop: 10, display: "grid", gap: "0.75rem" }}>
<label className="field">
session
<textarea
value={newSession}
onChange={(e) => setNewSession(e.target.value)}
rows={2}
disabled={busy}
placeholder="session-waarde uit ingelogde browsersessie"
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
/>
</label>
<label className="field">
accessToken
<textarea
value={newAccessToken}
onChange={(e) => setNewAccessToken(e.target.value)}
rows={2}
disabled={busy}
placeholder="access token"
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
/>
</label>
<label className="field">
profileId (optioneel)
<input
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
disabled={busy}
placeholder="profile id"
/>
</label>
</div>
)}
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: 10 }}>
Account toevoegen
</button>
</form>
<form onSubmit={saveSettings} className="form-group">
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={busy}
/>
Viaplay-integratie actief
</label>
<div style={{ marginTop: "1rem" }}>
<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>
<p className="muted" style={{ fontSize: "0.85rem", marginTop: 8 }}>
WVD: {settings?.wvdExists ? "gevonden" : "ontbreekt"} ·{" "}
<code style={{ fontSize: "0.8rem" }}>{settings?.wvdPath}</code>
</p>
<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={item.kind === "number" ? "number" : "text"}
value={value == null ? "" : String(value)}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({
...prev,
[item.key]:
item.kind === "number"
? e.target.value === ""
? 0
: Number(e.target.value)
: e.target.value,
}))
}
/>
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</label>
);
})}
</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:{" "}
{settings?.configured ? (
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
geconfigureerd ({settings.accountCount} account
{settings.accountCount === 1 ? "" : "s"}
{settings.eventCount ? ` · ${settings.eventCount} events` : ""}
)
</span>
) : (
<span>nog niet compleet (account + sessie-tokens)</span>
)}
{settings?.lastSyncAt && (
<>
<br />
Laatste agenda-sync: {new Date(settings.lastSyncAt).toLocaleString("nl-NL")}
{summary && (
<>
{" "}
(
{[
summary.events != null ? `${String(summary.events)} events` : null,
summary.daysOk != null ? `${String(summary.daysOk)} dagen` : null,
]
.filter(Boolean)
.join(" · ") || "ok"}
)
</>
)}
</>
)}
{settings?.lastPreLiveAt && (
<>
<br />
Laatste pre-live: {new Date(settings.lastPreLiveAt).toLocaleString("nl-NL")}
</>
)}
</div>
</div>
</>
);
}