Add F1TV Scripts integration with multi-account sync and Events merge.
Tabbed Scripts UI keeps Odido and F1TV separate; F1TV syncs calendar sessions into Events and exposes hub play APIs for upcoming Android TV work.
This commit is contained in:
parent
852a91cbed
commit
8685870358
15 changed files with 3557 additions and 716 deletions
601
apps/admin-ui/src/app/scripts/F1tvPanel.tsx
Normal file
601
apps/admin-ui/src/app/scripts/F1tvPanel.tsx
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type TweakMeta = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
kind: "number" | "boolean" | "string";
|
||||||
|
unit?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type F1tvTweaks = Record<string, number | boolean | string>;
|
||||||
|
|
||||||
|
type F1tvAccount = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
email: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
hasAscendonToken: boolean;
|
||||||
|
hasEntitlementToken: boolean;
|
||||||
|
hasSessionId: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
sessionCount: number;
|
||||||
|
healthStatus: "ok" | "dead" | "unknown";
|
||||||
|
healthCheckedAt: string | null;
|
||||||
|
healthError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type F1tvSettings = {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
accounts: F1tvAccount[];
|
||||||
|
accountCount: number;
|
||||||
|
contentOwnerCount: number;
|
||||||
|
meetingCount: number;
|
||||||
|
sessionCount: number;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastSyncSummary: Record<string, unknown> | null;
|
||||||
|
tweaks: F1tvTweaks;
|
||||||
|
tweakDefaults: F1tvTweaks;
|
||||||
|
tweakMeta: TweakMeta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type F1tvOverview = {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
accountCount: number;
|
||||||
|
sessionCount: number;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void }) {
|
||||||
|
const [settings, setSettings] = useState<F1tvSettings | null>(null);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [tweaks, setTweaks] = useState<F1tvTweaks>({});
|
||||||
|
const [showTweaks, setShowTweaks] = useState(false);
|
||||||
|
const [showTokens, setShowTokens] = 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 [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newAscendon, setNewAscendon] = useState("");
|
||||||
|
const [newEntitlement, setNewEntitlement] = useState("");
|
||||||
|
const [newSessionId, setNewSessionId] = useState("");
|
||||||
|
|
||||||
|
const applySettings = useCallback(
|
||||||
|
(d: F1tvSettings) => {
|
||||||
|
setSettings(d);
|
||||||
|
setEnabled(d.enabled !== false);
|
||||||
|
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
|
||||||
|
onChanged?.({
|
||||||
|
configured: d.configured,
|
||||||
|
enabled: d.enabled !== false,
|
||||||
|
accountCount: d.accountCount,
|
||||||
|
sessionCount: d.sessionCount,
|
||||||
|
lastSyncAt: d.lastSyncAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChanged]
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/f1tv", { credentials: "include" });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "F1TV-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/f1tv", {
|
||||||
|
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("F1TV-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/f1tv/accounts", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
label: newLabel.trim() || undefined,
|
||||||
|
email: newEmail.trim(),
|
||||||
|
password: newPassword.trim() || undefined,
|
||||||
|
ascendontoken: newAscendon.trim() || undefined,
|
||||||
|
entitlementtoken: newEntitlement.trim() || undefined,
|
||||||
|
sessionId: newSessionId.trim() || undefined,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt");
|
||||||
|
applySettings(d);
|
||||||
|
setNewLabel("");
|
||||||
|
setNewEmail("");
|
||||||
|
setNewPassword("");
|
||||||
|
setNewAscendon("");
|
||||||
|
setNewEntitlement("");
|
||||||
|
setNewSessionId("");
|
||||||
|
setMsg("F1TV-account toegevoegd");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAccount(account: F1tvAccount) {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/admin/scripts/f1tv/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: F1tvAccount) {
|
||||||
|
if (!confirm(`Account “${account.label || account.email}” verwijderen?`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/admin/scripts/f1tv/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/f1tv/accounts/${accountId}/probe`
|
||||||
|
: "/api/v1/admin/scripts/f1tv/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: F1tvAccount[] = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSync() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/f1tv/sync", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Sync mislukt");
|
||||||
|
setMsg(
|
||||||
|
`F1TV-sync: ${d.sessions ?? 0} sessies · ${d.meetings ?? 0} meetings · ${d.liveEvents ?? 0} live-events · ${d.replays ?? 0} replays (${d.durationMs ?? "?"} ms)`
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthBadge(a: F1tvAccount) {
|
||||||
|
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 || "Tokens ongeldig"}
|
||||||
|
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
|
||||||
|
>
|
||||||
|
dood
|
||||||
|
</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 }}>F1TV</h2>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
Events + F1-hub. Imperva blokkeert server-login: plak{" "}
|
||||||
|
<code>ascendontoken</code> + <code>entitlementtoken</code> uit DevTools (na browser-login).
|
||||||
|
Meerdere accounts: sessies worden als unie samengevoegd; play round-robint over owners.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
|
||||||
|
<button type="button" className="btn-primary" disabled={busy} onClick={() => void runSync()}>
|
||||||
|
Kalender syncen
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={busy || accounts.length === 0} onClick={() => void probeAccounts()}>
|
||||||
|
Tokens controleren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<p className="muted">Nog geen accounts — voeg e-mail + 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={{ 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.hasAscendonToken && a.hasEntitlementToken ? "tokens ok" : "tokens missen"}
|
||||||
|
{a.sessionCount ? ` · ${a.sessionCount} sessies` : ""}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
<label className="field">
|
||||||
|
Wachtwoord (optioneel)
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
onClick={() => setShowTokens((v) => !v)}
|
||||||
|
>
|
||||||
|
{showTokens ? "Tokens verbergen" : "Tokens plakken (vereist)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showTokens && (
|
||||||
|
<div style={{ marginTop: 10, display: "grid", gap: "0.75rem" }}>
|
||||||
|
<label className="field">
|
||||||
|
ascendontoken
|
||||||
|
<textarea
|
||||||
|
value={newAscendon}
|
||||||
|
onChange={(e) => setNewAscendon(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder="eyJ…"
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
entitlementtoken
|
||||||
|
<textarea
|
||||||
|
value={newEntitlement}
|
||||||
|
onChange={(e) => setNewEntitlement(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder="eyJ…"
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
sessionid (optioneel)
|
||||||
|
<input
|
||||||
|
value={newSessionId}
|
||||||
|
onChange={(e) => setNewSessionId(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder="WEB-…"
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
F1TV-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>
|
||||||
|
<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.sessionCount ? ` · ${settings.sessionCount} sessies` : ""}
|
||||||
|
{settings.meetingCount ? ` · ${settings.meetingCount} meetings` : ""}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>nog niet compleet (account + tokens)</span>
|
||||||
|
)}
|
||||||
|
{settings?.lastSyncAt && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
Laatste sync: {new Date(settings.lastSyncAt).toLocaleString("nl-NL")}
|
||||||
|
{summary && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
(
|
||||||
|
{[
|
||||||
|
summary.sessions != null ? `${String(summary.sessions)} sessies` : null,
|
||||||
|
summary.liveEvents != null ? `${String(summary.liveEvents)} live` : null,
|
||||||
|
summary.replays != null ? `${String(summary.replays)} replay` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "ok"}
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
677
apps/admin-ui/src/app/scripts/OdidoPanel.tsx
Normal file
677
apps/admin-ui/src/app/scripts/OdidoPanel.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
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 type OdidoOverview = {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
accountCount: number;
|
||||||
|
channelOwnerCount: number;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OdidoPanel({ onChanged }: { onChanged?: (o: OdidoOverview) => void }) {
|
||||||
|
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 ?? {});
|
||||||
|
onChanged?.({
|
||||||
|
configured: !!d.configured,
|
||||||
|
enabled: d.enabled !== false,
|
||||||
|
accountCount: d.accountCount ?? 0,
|
||||||
|
channelOwnerCount: d.channelOwnerCount ?? 0,
|
||||||
|
lastSyncAt: d.lastSyncAt ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChanged]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/admin-ui/src/app/scripts/ScriptsPageInner.tsx
Normal file
175
apps/admin-ui/src/app/scripts/ScriptsPageInner.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
import { OdidoPanel, type OdidoOverview } from "./OdidoPanel";
|
||||||
|
import { F1tvPanel, type F1tvOverview } from "./F1tvPanel";
|
||||||
|
|
||||||
|
type ScriptTab = "odido" | "f1tv";
|
||||||
|
|
||||||
|
const TABS: Array<{ id: ScriptTab; label: string; short: string }> = [
|
||||||
|
{ id: "odido", label: "Odido", short: "Odido" },
|
||||||
|
{ id: "f1tv", label: "F1TV", short: "F1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ScriptsPageInner() {
|
||||||
|
useAuth();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const tabParam = search.get("tab");
|
||||||
|
const active: ScriptTab = tabParam === "f1tv" ? "f1tv" : "odido";
|
||||||
|
|
||||||
|
const [odidoOverview, setOdidoOverview] = useState<OdidoOverview | null>(null);
|
||||||
|
const [f1tvOverview, setF1tvOverview] = useState<F1tvOverview | null>(null);
|
||||||
|
|
||||||
|
const loadOverviews = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [o, f] = await Promise.all([
|
||||||
|
fetch("/api/v1/admin/scripts/odido", { credentials: "include" }).then((r) => r.json()),
|
||||||
|
fetch("/api/v1/admin/scripts/f1tv", { credentials: "include" }).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
if (o && !o.error) {
|
||||||
|
setOdidoOverview({
|
||||||
|
configured: !!o.configured,
|
||||||
|
enabled: o.enabled !== false,
|
||||||
|
accountCount: o.accountCount ?? 0,
|
||||||
|
channelOwnerCount: o.channelOwnerCount ?? 0,
|
||||||
|
lastSyncAt: o.lastSyncAt ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (f && !f.error) {
|
||||||
|
setF1tvOverview({
|
||||||
|
configured: !!f.configured,
|
||||||
|
enabled: f.enabled !== false,
|
||||||
|
accountCount: f.accountCount ?? 0,
|
||||||
|
sessionCount: f.sessionCount ?? 0,
|
||||||
|
lastSyncAt: f.lastSyncAt ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* panels load own errors */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOverviews();
|
||||||
|
}, [loadOverviews]);
|
||||||
|
|
||||||
|
function setTab(id: ScriptTab) {
|
||||||
|
const url = id === "odido" ? "/scripts" : `/scripts?tab=${id}`;
|
||||||
|
router.replace(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 per integratie. Kies een script hieronder. Live-lijsten:{" "}
|
||||||
|
<Link href="/live-lists">Live-lijsten</Link>
|
||||||
|
{" · "}
|
||||||
|
Events: Settings → IPTV.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginBottom: "1.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="card"
|
||||||
|
onClick={() => setTab("odido")}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: active === "odido" ? "var(--border-strong, #666)" : undefined,
|
||||||
|
outline: active === "odido" ? "1px solid var(--accent, #4af)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700 }}>Odido</div>
|
||||||
|
<div className="muted" style={{ fontSize: "0.85rem", marginTop: 4 }}>
|
||||||
|
Live-TV zenders
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: "0.85rem" }}>
|
||||||
|
{odidoOverview == null ? (
|
||||||
|
<span className="muted">…</span>
|
||||||
|
) : odidoOverview.configured ? (
|
||||||
|
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
|
||||||
|
{odidoOverview.accountCount} acc
|
||||||
|
{odidoOverview.channelOwnerCount
|
||||||
|
? ` · ${odidoOverview.channelOwnerCount} zenders`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">niet klaar</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="card"
|
||||||
|
onClick={() => setTab("f1tv")}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: active === "f1tv" ? "var(--border-strong, #666)" : undefined,
|
||||||
|
outline: active === "f1tv" ? "1px solid var(--accent, #4af)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700 }}>F1TV</div>
|
||||||
|
<div className="muted" style={{ fontSize: "0.85rem", marginTop: 4 }}>
|
||||||
|
Events & terugkijken
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: "0.85rem" }}>
|
||||||
|
{f1tvOverview == null ? (
|
||||||
|
<span className="muted">…</span>
|
||||||
|
) : f1tvOverview.configured ? (
|
||||||
|
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
|
||||||
|
{f1tvOverview.accountCount} acc
|
||||||
|
{f1tvOverview.sessionCount ? ` · ${f1tvOverview.sessionCount} sessies` : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">niet klaar</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="settings-tabs" aria-label="Scripts">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className={active === t.id ? "active" : undefined}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
<span className="settings-tab-full">{t.label}</span>
|
||||||
|
<span className="settings-tab-short">{t.short}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{active === "odido" ? (
|
||||||
|
<OdidoPanel onChanged={setOdidoOverview} />
|
||||||
|
) : (
|
||||||
|
<F1tvPanel onChanged={setF1tvOverview} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,679 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import ScriptsPageInner from "./ScriptsPageInner";
|
||||||
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() {
|
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 (
|
return (
|
||||||
<>
|
<Suspense
|
||||||
<Nav />
|
fallback={
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<div className="container">
|
<div className="container muted">Scripts laden…</div>
|
||||||
<div className="page-header">
|
</main>
|
||||||
<div>
|
}
|
||||||
<div className="page-kicker">Integraties</div>
|
>
|
||||||
<h1>Scripts</h1>
|
<ScriptsPageInner />
|
||||||
</div>
|
</Suspense>
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1712,6 +1712,104 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- F1TV script/provider settings ---
|
||||||
|
app.get("/api/v1/admin/scripts/f1tv", { preHandler: requireAdmin }, async () => {
|
||||||
|
const { getF1tvSettingsPublic } = await import("../f1tv/settings");
|
||||||
|
return getF1tvSettingsPublic();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/v1/admin/scripts/f1tv", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const body = request.body as {
|
||||||
|
enabled?: boolean;
|
||||||
|
tweaks?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const { upsertF1tvSettings } = await import("../f1tv/settings");
|
||||||
|
return upsertF1tvSettings({
|
||||||
|
enabled: body.enabled,
|
||||||
|
tweaks: body.tweaks as import("../f1tv/tweaks").F1tvTweaks | undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/scripts/f1tv/accounts", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const body = request.body as {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
label?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
ascendontoken?: string;
|
||||||
|
entitlementtoken?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
};
|
||||||
|
if (!body.email?.trim()) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "email verplicht", 400);
|
||||||
|
}
|
||||||
|
const { addF1tvAccount } = await import("../f1tv/settings");
|
||||||
|
return addF1tvAccount(config.SESSION_SECRET, {
|
||||||
|
email: body.email,
|
||||||
|
password: body.password,
|
||||||
|
label: body.label,
|
||||||
|
enabled: body.enabled,
|
||||||
|
ascendontoken: body.ascendontoken,
|
||||||
|
entitlementtoken: body.entitlementtoken,
|
||||||
|
sessionId: body.sessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
"/api/v1/admin/scripts/f1tv/accounts/:accountId",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { accountId } = request.params as { accountId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
label?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
ascendontoken?: string;
|
||||||
|
entitlementtoken?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
clearTokens?: boolean;
|
||||||
|
};
|
||||||
|
const { updateF1tvAccount } = await import("../f1tv/settings");
|
||||||
|
return updateF1tvAccount(config.SESSION_SECRET, accountId, body);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
"/api/v1/admin/scripts/f1tv/accounts/:accountId",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { accountId } = request.params as { accountId: string };
|
||||||
|
const { removeF1tvAccount } = await import("../f1tv/settings");
|
||||||
|
return removeF1tvAccount(config.SESSION_SECRET, accountId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin/scripts/f1tv/accounts/probe",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const body = (request.body ?? {}) as { accountId?: string };
|
||||||
|
const { probeF1tvAccounts } = await import("../f1tv/settings");
|
||||||
|
return probeF1tvAccounts(config.SESSION_SECRET, body.accountId?.trim() || undefined);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin/scripts/f1tv/accounts/:accountId/probe",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { accountId } = request.params as { accountId: string };
|
||||||
|
const { probeF1tvAccounts } = await import("../f1tv/settings");
|
||||||
|
return probeF1tvAccounts(config.SESSION_SECRET, accountId);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/scripts/f1tv/sync", { preHandler: requireAdmin }, async () => {
|
||||||
|
const { syncF1tvCatalog } = await import("../f1tv/sync");
|
||||||
|
return syncF1tvCatalog(config.SESSION_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Custom ClearKey/DASH live channel lists ---
|
// --- Custom ClearKey/DASH live channel lists ---
|
||||||
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
||||||
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
||||||
|
|
|
||||||
198
apps/master-api/src/f1tv/accounts.ts
Normal file
198
apps/master-api/src/f1tv/accounts.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||||
|
|
||||||
|
export type F1tvAccountHealth = "ok" | "dead" | "unknown";
|
||||||
|
|
||||||
|
export type F1tvAccountStored = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
email: string;
|
||||||
|
/** Optioneel — voor latere browser-assisted login. */
|
||||||
|
passwordEnc?: string;
|
||||||
|
ascendontokenEnc?: string;
|
||||||
|
entitlementtokenEnc?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
healthStatus?: F1tvAccountHealth;
|
||||||
|
healthCheckedAt?: string;
|
||||||
|
healthError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type F1tvAccountPublic = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
email: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
hasAscendonToken: boolean;
|
||||||
|
hasEntitlementToken: boolean;
|
||||||
|
hasSessionId: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
sessionCount: number;
|
||||||
|
healthStatus: F1tvAccountHealth;
|
||||||
|
healthCheckedAt: string | null;
|
||||||
|
healthError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type F1tvAccountCreds = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
email: string;
|
||||||
|
password: string | null;
|
||||||
|
ascendontoken: string | null;
|
||||||
|
entitlementtoken: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** contentId → accountIds die dit item mogen spelen. */
|
||||||
|
export type F1tvContentOwners = Record<string, string[]>;
|
||||||
|
|
||||||
|
const rrCursor = new Map<string, number>();
|
||||||
|
|
||||||
|
export function accountToPublic(
|
||||||
|
account: F1tvAccountStored,
|
||||||
|
contentOwners: F1tvContentOwners
|
||||||
|
): F1tvAccountPublic {
|
||||||
|
let sessionCount = 0;
|
||||||
|
for (const owners of Object.values(contentOwners)) {
|
||||||
|
if (owners.includes(account.id)) sessionCount += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
label: account.label || account.email,
|
||||||
|
email: account.email,
|
||||||
|
hasPassword: !!account.passwordEnc,
|
||||||
|
hasAscendonToken: !!account.ascendontokenEnc,
|
||||||
|
hasEntitlementToken: !!account.entitlementtokenEnc,
|
||||||
|
hasSessionId: !!account.sessionId?.trim(),
|
||||||
|
enabled: account.enabled !== false,
|
||||||
|
sessionCount,
|
||||||
|
healthStatus: account.healthStatus ?? "unknown",
|
||||||
|
healthCheckedAt: account.healthCheckedAt ?? null,
|
||||||
|
healthError: account.healthError ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptAccount(
|
||||||
|
account: F1tvAccountStored,
|
||||||
|
sessionSecret: string
|
||||||
|
): F1tvAccountCreds {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
label: account.label || account.email,
|
||||||
|
email: account.email,
|
||||||
|
password: account.passwordEnc
|
||||||
|
? decryptSecret(account.passwordEnc, sessionSecret)
|
||||||
|
: null,
|
||||||
|
ascendontoken: account.ascendontokenEnc
|
||||||
|
? decryptSecret(account.ascendontokenEnc, sessionSecret)
|
||||||
|
: null,
|
||||||
|
entitlementtoken: account.entitlementtokenEnc
|
||||||
|
? decryptSecret(account.entitlementtokenEnc, sessionSecret)
|
||||||
|
: null,
|
||||||
|
sessionId: account.sessionId?.trim() || null,
|
||||||
|
enabled: account.enabled !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccountStored(
|
||||||
|
sessionSecret: string,
|
||||||
|
input: {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
label?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
id?: string;
|
||||||
|
ascendontoken?: string;
|
||||||
|
entitlementtoken?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
): F1tvAccountStored {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
if (!email) throw new Error("E-mail verplicht");
|
||||||
|
const password = input.password?.trim();
|
||||||
|
const ascendontoken = input.ascendontoken?.trim();
|
||||||
|
const entitlementtoken = input.entitlementtoken?.trim();
|
||||||
|
if (!password && !ascendontoken && !entitlementtoken) {
|
||||||
|
throw new Error("Wachtwoord of tokens verplicht");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: input.id?.trim() || randomUUID(),
|
||||||
|
label: (input.label ?? "").trim() || email,
|
||||||
|
email,
|
||||||
|
passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined,
|
||||||
|
ascendontokenEnc: ascendontoken
|
||||||
|
? encryptSecret(ascendontoken, sessionSecret)
|
||||||
|
: undefined,
|
||||||
|
entitlementtokenEnc: entitlementtoken
|
||||||
|
? encryptSecret(entitlementtoken, sessionSecret)
|
||||||
|
: undefined,
|
||||||
|
sessionId: input.sessionId?.trim() || undefined,
|
||||||
|
enabled: input.enabled !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAccountsJson(raw: unknown): F1tvAccountStored[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: F1tvAccountStored[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const id = String(row.id ?? "").trim();
|
||||||
|
const email = String(row.email ?? "").trim().toLowerCase();
|
||||||
|
if (!id || !email) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
label: String(row.label ?? "").trim() || email,
|
||||||
|
email,
|
||||||
|
passwordEnc: String(row.passwordEnc ?? "").trim() || undefined,
|
||||||
|
ascendontokenEnc: String(row.ascendontokenEnc ?? "").trim() || undefined,
|
||||||
|
entitlementtokenEnc: String(row.entitlementtokenEnc ?? "").trim() || undefined,
|
||||||
|
sessionId: String(row.sessionId ?? "").trim() || undefined,
|
||||||
|
enabled: row.enabled !== false,
|
||||||
|
healthStatus:
|
||||||
|
row.healthStatus === "ok" ||
|
||||||
|
row.healthStatus === "dead" ||
|
||||||
|
row.healthStatus === "unknown"
|
||||||
|
? row.healthStatus
|
||||||
|
: undefined,
|
||||||
|
healthCheckedAt:
|
||||||
|
typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined,
|
||||||
|
healthError: typeof row.healthError === "string" ? row.healthError : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContentOwners(raw: unknown): F1tvContentOwners {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
|
const out: F1tvContentOwners = {};
|
||||||
|
for (const [contentId, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (!contentId.trim()) continue;
|
||||||
|
const ids = Array.isArray(value)
|
||||||
|
? value.map((v) => String(v).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (ids.length) out[contentId] = [...new Set(ids)];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickAccountIdForContent(
|
||||||
|
contentId: string,
|
||||||
|
ownerIds: string[],
|
||||||
|
enabledAccountIds: string[]
|
||||||
|
): string | null {
|
||||||
|
const pool = (ownerIds.length ? ownerIds : enabledAccountIds).filter((id) =>
|
||||||
|
enabledAccountIds.includes(id)
|
||||||
|
);
|
||||||
|
if (!pool.length) return null;
|
||||||
|
const key = contentId || "__global__";
|
||||||
|
const cursor = rrCursor.get(key) ?? 0;
|
||||||
|
const chosen = pool[cursor % pool.length];
|
||||||
|
rrCursor.set(key, cursor + 1);
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountHasPlayTokens(account: F1tvAccountStored): boolean {
|
||||||
|
return !!(account.ascendontokenEnc && account.entitlementtokenEnc);
|
||||||
|
}
|
||||||
206
apps/master-api/src/f1tv/client.ts
Normal file
206
apps/master-api/src/f1tv/client.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
const F1TV_ORIGIN = "https://f1tv.formula1.com";
|
||||||
|
const DEFAULT_UA =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
export type F1tvRequestAuth = {
|
||||||
|
ascendontoken?: string | null;
|
||||||
|
entitlementtoken?: string | null;
|
||||||
|
sessionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deviceInfoHeader(): string {
|
||||||
|
return "device=web;screen=browser;os=windows;browser=chrome;browserVersion=133;osVersion=11;appVersion=54.2.1;playerVersion=8.212.0;p=true;tms=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSessionId(existing?: string | null): string {
|
||||||
|
const t = existing?.trim();
|
||||||
|
if (t) return t;
|
||||||
|
return `WEB-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aglBase(locale: string, platform: string): string {
|
||||||
|
return `${F1TV_ORIGIN}/2.0/R/${locale}/${platform}/ALL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aglBaseV1(locale: string, platform: string): string {
|
||||||
|
return `${F1TV_ORIGIN}/1.0/R/${locale}/${platform}/ALL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aglBaseV3(locale: string, platform: string): string {
|
||||||
|
return `${F1TV_ORIGIN}/3.0/R/${locale}/${platform}/ALL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aglBaseV4(locale: string, platform: string): string {
|
||||||
|
return `${F1TV_ORIGIN}/4.0/R/${locale}/${platform}/ALL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function f1tvFetch(
|
||||||
|
url: string,
|
||||||
|
opts: {
|
||||||
|
method?: string;
|
||||||
|
auth?: F1tvRequestAuth;
|
||||||
|
timeoutMs?: number;
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ status: number; json: unknown; text: string }> {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? 45_000);
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json, text/plain, */*",
|
||||||
|
"User-Agent": DEFAULT_UA,
|
||||||
|
Origin: F1TV_ORIGIN,
|
||||||
|
Referer: `${F1TV_ORIGIN}/`,
|
||||||
|
"x-f1-device-info": deviceInfoHeader(),
|
||||||
|
};
|
||||||
|
const sessionId = ensureSessionId(opts.auth?.sessionId);
|
||||||
|
headers.sessionid = sessionId;
|
||||||
|
if (opts.auth?.ascendontoken) headers.ascendontoken = opts.auth.ascendontoken;
|
||||||
|
if (opts.auth?.entitlementtoken) headers.entitlementtoken = opts.auth.entitlementtoken;
|
||||||
|
if (opts.body) headers["Content-Type"] = opts.contentType || "application/json";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: opts.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
body: opts.body,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: unknown = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
json = null;
|
||||||
|
}
|
||||||
|
return { status: res.status, json, text };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntitlement(input: {
|
||||||
|
ascendontoken: string;
|
||||||
|
sessionId?: string | null;
|
||||||
|
locale: string;
|
||||||
|
platform: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const url = `${aglBaseV1(input.locale, input.platform)}/USER/ENTITLEMENT`;
|
||||||
|
const { status, json } = await f1tvFetch(url, {
|
||||||
|
auth: { ascendontoken: input.ascendontoken, sessionId: input.sessionId },
|
||||||
|
timeoutMs: input.timeoutMs,
|
||||||
|
});
|
||||||
|
if (status >= 400) {
|
||||||
|
throw new Error(`Entitlement HTTP ${status}`);
|
||||||
|
}
|
||||||
|
const obj = json as { resultCode?: string; resultObj?: { entitlementToken?: string } };
|
||||||
|
if (obj?.resultCode && obj.resultCode !== "OK") {
|
||||||
|
throw new Error(`Entitlement ${obj.resultCode}`);
|
||||||
|
}
|
||||||
|
const token = obj?.resultObj?.entitlementToken?.trim();
|
||||||
|
return token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPage(input: {
|
||||||
|
pageId: number;
|
||||||
|
auth: F1tvRequestAuth;
|
||||||
|
locale: string;
|
||||||
|
platform: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<unknown> {
|
||||||
|
const url = `${aglBase(input.locale, input.platform)}/PAGE/${input.pageId}/PREMIUM/2`;
|
||||||
|
const { status, json, text } = await f1tvFetch(url, {
|
||||||
|
auth: input.auth,
|
||||||
|
timeoutMs: input.timeoutMs,
|
||||||
|
});
|
||||||
|
if (status >= 400) {
|
||||||
|
throw new Error(`PAGE ${input.pageId} HTTP ${status}: ${text.slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContentPlay(input: {
|
||||||
|
contentId: number;
|
||||||
|
channelId?: number | null;
|
||||||
|
auth: F1tvRequestAuth;
|
||||||
|
locale: string;
|
||||||
|
/** Play preferably via WEB_HLS (clear CMAF HLS in replay HAR). */
|
||||||
|
playPlatform?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<{
|
||||||
|
entitlementToken: string | null;
|
||||||
|
feeds: Array<{ channelId: number | null; url: string; uuid: string | null }>;
|
||||||
|
raw: unknown;
|
||||||
|
}> {
|
||||||
|
const platform = input.playPlatform || "WEB_HLS";
|
||||||
|
const channelQ =
|
||||||
|
input.channelId != null && Number.isFinite(input.channelId)
|
||||||
|
? String(input.channelId)
|
||||||
|
: "";
|
||||||
|
const url = `${aglBaseV3(input.locale, platform)}/CONTENT/PLAY?channelId=${encodeURIComponent(channelQ)}&contentId=${input.contentId}&player=player_tm`;
|
||||||
|
const { status, json, text } = await f1tvFetch(url, {
|
||||||
|
auth: input.auth,
|
||||||
|
timeoutMs: input.timeoutMs,
|
||||||
|
});
|
||||||
|
if (status >= 400) {
|
||||||
|
throw new Error(`PLAY HTTP ${status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const obj = json as {
|
||||||
|
resultCode?: string;
|
||||||
|
message?: string;
|
||||||
|
resultObj?: {
|
||||||
|
entitlementToken?: string;
|
||||||
|
url?: string;
|
||||||
|
tme?: {
|
||||||
|
feeds?: Array<{
|
||||||
|
uuid?: string;
|
||||||
|
url?: string;
|
||||||
|
metadata?: { channel_id?: number };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (obj.resultCode && obj.resultCode !== "OK") {
|
||||||
|
throw new Error(`PLAY ${obj.resultCode}: ${obj.message || ""}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeds: Array<{ channelId: number | null; url: string; uuid: string | null }> = [];
|
||||||
|
for (const feed of obj.resultObj?.tme?.feeds ?? []) {
|
||||||
|
const feedUrl = String(feed.url ?? "").trim();
|
||||||
|
if (!feedUrl) continue;
|
||||||
|
feeds.push({
|
||||||
|
channelId:
|
||||||
|
typeof feed.metadata?.channel_id === "number" ? feed.metadata.channel_id : null,
|
||||||
|
url: feedUrl,
|
||||||
|
uuid: feed.uuid?.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const legacyUrl = String(obj.resultObj?.url ?? "").trim();
|
||||||
|
if (!feeds.length && legacyUrl) {
|
||||||
|
feeds.push({ channelId: input.channelId ?? null, url: legacyUrl, uuid: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entitlementToken: obj.resultObj?.entitlementToken?.trim() || null,
|
||||||
|
feeds,
|
||||||
|
raw: json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickDefaultFeed(
|
||||||
|
feeds: Array<{ channelId: number | null; url: string }>,
|
||||||
|
preferredChannelId?: number | null
|
||||||
|
): { channelId: number | null; url: string } | null {
|
||||||
|
if (!feeds.length) return null;
|
||||||
|
if (preferredChannelId != null) {
|
||||||
|
const hit = feeds.find((f) => f.channelId === preferredChannelId);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
// F1 LIVE world feed is commonly channel 1033.
|
||||||
|
const live = feeds.find((f) => f.channelId === 1033);
|
||||||
|
if (live) return live;
|
||||||
|
return feeds[0];
|
||||||
|
}
|
||||||
166
apps/master-api/src/f1tv/events.ts
Normal file
166
apps/master-api/src/f1tv/events.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import type { ScheduleEventPublic } from "../viewer/schedule-events";
|
||||||
|
import { loadF1tvConfigOnly } from "./settings";
|
||||||
|
import type { F1tvMeetingSnapshot, F1tvSessionSnapshot } from "./tweaks";
|
||||||
|
|
||||||
|
function phaseFor(
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
state: string | null,
|
||||||
|
nowMs: number
|
||||||
|
): "live" | "upcoming" | "ended" {
|
||||||
|
const st = (state || "").toLowerCase();
|
||||||
|
if (st === "live" || (st.includes("live") && !st.includes("pre"))) {
|
||||||
|
if (nowMs <= endMs + 15 * 60_000) return "live";
|
||||||
|
}
|
||||||
|
if (nowMs < startMs) return "upcoming";
|
||||||
|
if (nowMs > endMs) return "ended";
|
||||||
|
return "live";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEventId(contentId: number): string {
|
||||||
|
return `f1tv:${contentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionToPublic(
|
||||||
|
session: F1tvSessionSnapshot,
|
||||||
|
nowMs: number
|
||||||
|
): ScheduleEventPublic {
|
||||||
|
const start = new Date(session.startMs).toISOString();
|
||||||
|
const stop = new Date(session.endMs).toISOString();
|
||||||
|
const phase = phaseFor(session.startMs, session.endMs, session.state, nowMs);
|
||||||
|
const playable = session.kind === "live_event" && phase === "live";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: toEventId(session.contentId),
|
||||||
|
name: session.title,
|
||||||
|
description: session.meetingName,
|
||||||
|
provider: "f1tv",
|
||||||
|
category: "racing",
|
||||||
|
eventType: session.kind === "replay" ? "replay" : "live",
|
||||||
|
subtitle: session.series,
|
||||||
|
channel: "F1 TV",
|
||||||
|
network: "F1 TV",
|
||||||
|
isLive: phase === "live",
|
||||||
|
phase,
|
||||||
|
playable,
|
||||||
|
status: session.state,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
startLocal: null,
|
||||||
|
stopLocal: null,
|
||||||
|
sportBucket: "racing",
|
||||||
|
sportLabel: "Racing",
|
||||||
|
imageLandscape: session.imageUrl,
|
||||||
|
imageBackdrop: session.imageUrl,
|
||||||
|
imagePortrait: null,
|
||||||
|
logoUrl: null,
|
||||||
|
images: session.imageUrl ? [session.imageUrl] : [],
|
||||||
|
audioLanguages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upcoming + live F1 sessions for the main Events tab (no replays). */
|
||||||
|
export async function listF1tvScheduleEvents(
|
||||||
|
nowMs = Date.now()
|
||||||
|
): Promise<ScheduleEventPublic[]> {
|
||||||
|
const config = await loadF1tvConfigOnly();
|
||||||
|
if (!config?.sessions?.length) return [];
|
||||||
|
const out: ScheduleEventPublic[] = [];
|
||||||
|
for (const session of config.sessions) {
|
||||||
|
if (session.kind !== "live_event") continue;
|
||||||
|
const pub = sessionToPublic(session, nowMs);
|
||||||
|
if (pub.phase === "ended") continue;
|
||||||
|
out.push(pub);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getF1tvScheduleEvent(
|
||||||
|
eventId: string,
|
||||||
|
nowMs = Date.now()
|
||||||
|
): Promise<ScheduleEventPublic | null> {
|
||||||
|
const m = /^f1tv:(\d+)$/i.exec(eventId.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const contentId = Number(m[1]);
|
||||||
|
const config = await loadF1tvConfigOnly();
|
||||||
|
const session = config?.sessions.find((s) => s.contentId === contentId);
|
||||||
|
if (!session) return null;
|
||||||
|
return sessionToPublic(session, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type F1tvHomePayload = {
|
||||||
|
generatedAt: string;
|
||||||
|
live: ScheduleEventPublic[];
|
||||||
|
upcoming: ScheduleEventPublic[];
|
||||||
|
recentReplays: ScheduleEventPublic[];
|
||||||
|
meetings: F1tvMeetingSnapshot[];
|
||||||
|
nextMeeting: F1tvMeetingSnapshot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getF1tvHome(nowMs = Date.now()): Promise<F1tvHomePayload> {
|
||||||
|
const config = await loadF1tvConfigOnly();
|
||||||
|
const sessions = config?.sessions ?? [];
|
||||||
|
const meetings = config?.meetings ?? [];
|
||||||
|
|
||||||
|
const live: ScheduleEventPublic[] = [];
|
||||||
|
const upcoming: ScheduleEventPublic[] = [];
|
||||||
|
const recentReplays: ScheduleEventPublic[] = [];
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const pub = sessionToPublic(session, nowMs);
|
||||||
|
if (session.kind === "replay") {
|
||||||
|
if (pub.phase === "ended" || session.startMs < nowMs) {
|
||||||
|
recentReplays.push(pub);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pub.phase === "live") live.push(pub);
|
||||||
|
else if (pub.phase === "upcoming") upcoming.push(pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentReplays.sort((a, b) => Date.parse(b.start) - Date.parse(a.start));
|
||||||
|
upcoming.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||||
|
|
||||||
|
let nextMeeting: F1tvMeetingSnapshot | null = null;
|
||||||
|
const upcomingMeetingKeys = new Set(upcoming.map((e) => {
|
||||||
|
const s = sessions.find((x) => toEventId(x.contentId) === e.id);
|
||||||
|
return s?.meetingKey;
|
||||||
|
}));
|
||||||
|
for (const meeting of meetings) {
|
||||||
|
if (upcomingMeetingKeys.has(meeting.meetingKey)) {
|
||||||
|
nextMeeting = meeting;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!nextMeeting && meetings.length) {
|
||||||
|
nextMeeting = meetings[meetings.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date(nowMs).toISOString(),
|
||||||
|
live,
|
||||||
|
upcoming: upcoming.slice(0, 40),
|
||||||
|
recentReplays: recentReplays.slice(0, 24),
|
||||||
|
meetings,
|
||||||
|
nextMeeting,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getF1tvMeetingDetail(
|
||||||
|
meetingKey: string,
|
||||||
|
nowMs = Date.now()
|
||||||
|
): Promise<{
|
||||||
|
meeting: F1tvMeetingSnapshot | null;
|
||||||
|
sessions: ScheduleEventPublic[];
|
||||||
|
} | null> {
|
||||||
|
const config = await loadF1tvConfigOnly();
|
||||||
|
if (!config) return null;
|
||||||
|
const key = meetingKey.trim();
|
||||||
|
const meeting = config.meetings.find((m) => m.meetingKey === key) ?? null;
|
||||||
|
const sessions = config.sessions
|
||||||
|
.filter((s) => s.meetingKey === key)
|
||||||
|
.map((s) => sessionToPublic(s, nowMs))
|
||||||
|
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||||
|
if (!meeting && !sessions.length) return null;
|
||||||
|
return { meeting, sessions };
|
||||||
|
}
|
||||||
79
apps/master-api/src/f1tv/play.ts
Normal file
79
apps/master-api/src/f1tv/play.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import { pickAccountIdForContent } from "./accounts";
|
||||||
|
import { ensureSessionId, fetchContentPlay, pickDefaultFeed } from "./client";
|
||||||
|
import { loadF1tvSettings } from "./settings";
|
||||||
|
import type { F1tvSessionSnapshot } from "./tweaks";
|
||||||
|
|
||||||
|
function detectFormat(url: string): "hls" | "dash" {
|
||||||
|
const u = url.toLowerCase();
|
||||||
|
if (u.includes(".m3u8") || u.includes("m3u8") || u.includes("WEB_HLS")) return "hls";
|
||||||
|
return "dash";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveF1tvPlay(
|
||||||
|
sessionSecret: string,
|
||||||
|
contentId: number,
|
||||||
|
opts?: { channelId?: number | null; allowReplay?: boolean }
|
||||||
|
): Promise<{
|
||||||
|
contentId: number;
|
||||||
|
name: string;
|
||||||
|
streamUrl: string;
|
||||||
|
format: "hls" | "dash";
|
||||||
|
channelId: number | null;
|
||||||
|
feedCount: number;
|
||||||
|
session: F1tvSessionSnapshot | null;
|
||||||
|
}> {
|
||||||
|
const { accounts, config } = await loadF1tvSettings(sessionSecret);
|
||||||
|
const session =
|
||||||
|
config.sessions.find((s) => s.contentId === contentId) ?? null;
|
||||||
|
|
||||||
|
if (session?.kind === "replay" && opts?.allowReplay === false) {
|
||||||
|
throw new AppError("GONE", "Replay is niet beschikbaar via Events", 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledIds = accounts.map((a) => a.id);
|
||||||
|
const owners = config.contentOwners[String(contentId)] ?? session?.accountIds ?? [];
|
||||||
|
const accountId = pickAccountIdForContent(String(contentId), owners, enabledIds);
|
||||||
|
const account = accounts.find((a) => a.id === accountId) ?? accounts[0];
|
||||||
|
if (!account?.ascendontoken || !account.entitlementtoken) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Geen F1TV-tokens voor play", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredChannel =
|
||||||
|
opts?.channelId ?? session?.defaultChannelId ?? 1033;
|
||||||
|
|
||||||
|
const played = await fetchContentPlay({
|
||||||
|
contentId,
|
||||||
|
channelId: preferredChannel,
|
||||||
|
auth: {
|
||||||
|
ascendontoken: account.ascendontoken,
|
||||||
|
entitlementtoken: account.entitlementtoken,
|
||||||
|
sessionId: ensureSessionId(account.sessionId),
|
||||||
|
},
|
||||||
|
locale: config.locale,
|
||||||
|
playPlatform: "WEB_HLS",
|
||||||
|
timeoutMs: config.syncTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feed = pickDefaultFeed(played.feeds, preferredChannel);
|
||||||
|
if (!feed?.url) {
|
||||||
|
throw new AppError("NOT_READY", "Geen F1TV-stream-URL ontvangen", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentId,
|
||||||
|
name: session?.title ?? `F1TV ${contentId}`,
|
||||||
|
streamUrl: feed.url,
|
||||||
|
format: detectFormat(feed.url),
|
||||||
|
channelId: feed.channelId,
|
||||||
|
feedCount: played.feeds.length,
|
||||||
|
session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseF1tvEventId(eventId: string): number | null {
|
||||||
|
const m = /^f1tv:(\d+)$/i.exec(eventId.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
360
apps/master-api/src/f1tv/settings.ts
Normal file
360
apps/master-api/src/f1tv/settings.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
import { encryptSecret } from "../security/crypto";
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import {
|
||||||
|
accountHasPlayTokens,
|
||||||
|
accountToPublic,
|
||||||
|
createAccountStored,
|
||||||
|
decryptAccount,
|
||||||
|
parseAccountsJson,
|
||||||
|
parseContentOwners,
|
||||||
|
type F1tvAccountCreds,
|
||||||
|
type F1tvAccountStored,
|
||||||
|
type F1tvContentOwners,
|
||||||
|
} from "./accounts";
|
||||||
|
import {
|
||||||
|
mergeF1tvTweaks,
|
||||||
|
F1TV_TWEAK_DEFAULTS,
|
||||||
|
F1TV_TWEAK_META,
|
||||||
|
type F1tvMeetingSnapshot,
|
||||||
|
type F1tvSessionSnapshot,
|
||||||
|
type F1tvTweaks,
|
||||||
|
} from "./tweaks";
|
||||||
|
|
||||||
|
const F1TV_ID = "f1tv";
|
||||||
|
|
||||||
|
export type F1tvConfig = F1tvTweaks & {
|
||||||
|
accounts: F1tvAccountStored[];
|
||||||
|
contentOwners: F1tvContentOwners;
|
||||||
|
meetings: F1tvMeetingSnapshot[];
|
||||||
|
sessions: F1tvSessionSnapshot[];
|
||||||
|
lastSyncAt?: string;
|
||||||
|
lastSyncSummary?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseConfig(raw: string | null | undefined): F1tvConfig {
|
||||||
|
let parsed: Partial<F1tvConfig> & Record<string, unknown> = {};
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as Partial<F1tvConfig> & Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
parsed = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tweaks = mergeF1tvTweaks(parsed);
|
||||||
|
return {
|
||||||
|
...tweaks,
|
||||||
|
accounts: parseAccountsJson(parsed.accounts),
|
||||||
|
contentOwners: parseContentOwners(parsed.contentOwners),
|
||||||
|
meetings: Array.isArray(parsed.meetings)
|
||||||
|
? (parsed.meetings as F1tvMeetingSnapshot[])
|
||||||
|
: [],
|
||||||
|
sessions: Array.isArray(parsed.sessions)
|
||||||
|
? (parsed.sessions as F1tvSessionSnapshot[])
|
||||||
|
: [],
|
||||||
|
lastSyncAt: typeof parsed.lastSyncAt === "string" ? parsed.lastSyncAt : undefined,
|
||||||
|
lastSyncSummary:
|
||||||
|
parsed.lastSyncSummary && typeof parsed.lastSyncSummary === "object"
|
||||||
|
? (parsed.lastSyncSummary as Record<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUsableAccount(accounts: F1tvAccountStored[]): boolean {
|
||||||
|
return accounts.some(
|
||||||
|
(a) => a.enabled !== false && !!a.email && (accountHasPlayTokens(a) || !!a.passwordEnc)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(config: F1tvConfig, enabled: boolean) {
|
||||||
|
const primary = config.accounts.find((a) => a.enabled) ?? config.accounts[0];
|
||||||
|
await prisma.integrationSetting.upsert({
|
||||||
|
where: { id: F1TV_ID },
|
||||||
|
create: {
|
||||||
|
id: F1TV_ID,
|
||||||
|
username: primary?.email ?? null,
|
||||||
|
passwordEnc: primary?.passwordEnc ?? null,
|
||||||
|
configJson: JSON.stringify(config),
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
username: primary?.email ?? null,
|
||||||
|
passwordEnc: primary?.passwordEnc ?? null,
|
||||||
|
configJson: JSON.stringify(config),
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getF1tvSettingsPublic() {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
const config = parseConfig(row?.configJson);
|
||||||
|
const accounts = config.accounts.map((a) => accountToPublic(a, config.contentOwners));
|
||||||
|
const configured = hasUsableAccount(config.accounts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
enabled: row?.enabled ?? true,
|
||||||
|
accounts,
|
||||||
|
accountCount: accounts.filter((a) => a.enabled).length,
|
||||||
|
contentOwnerCount: Object.keys(config.contentOwners).length,
|
||||||
|
meetingCount: config.meetings.length,
|
||||||
|
sessionCount: config.sessions.length,
|
||||||
|
lastSyncAt: config.lastSyncAt ?? null,
|
||||||
|
lastSyncSummary: config.lastSyncSummary ?? null,
|
||||||
|
tweaks: mergeF1tvTweaks(config),
|
||||||
|
tweakDefaults: F1TV_TWEAK_DEFAULTS,
|
||||||
|
tweakMeta: F1TV_TWEAK_META,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertF1tvSettings(input: {
|
||||||
|
enabled?: boolean;
|
||||||
|
tweaks?: Partial<F1tvTweaks>;
|
||||||
|
}) {
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
if (input.tweaks) {
|
||||||
|
Object.assign(config, mergeF1tvTweaks({ ...config, ...input.tweaks }));
|
||||||
|
}
|
||||||
|
await persist(config, input.enabled ?? existing?.enabled ?? true);
|
||||||
|
return getF1tvSettingsPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addF1tvAccount(
|
||||||
|
sessionSecret: string,
|
||||||
|
input: {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
label?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
ascendontoken?: string;
|
||||||
|
entitlementtoken?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
if (config.accounts.some((a) => a.email === email)) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Dit e-mailadres staat al in de lijst", 400);
|
||||||
|
}
|
||||||
|
let account: F1tvAccountStored;
|
||||||
|
try {
|
||||||
|
account = createAccountStored(sessionSecret, input);
|
||||||
|
} catch (e) {
|
||||||
|
throw new AppError("INVALID_REQUEST", (e as Error).message, 400);
|
||||||
|
}
|
||||||
|
config.accounts.push(account);
|
||||||
|
await persist(config, existing?.enabled ?? true);
|
||||||
|
return getF1tvSettingsPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateF1tvAccount(
|
||||||
|
sessionSecret: string,
|
||||||
|
accountId: string,
|
||||||
|
input: {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
label?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
ascendontoken?: string;
|
||||||
|
entitlementtoken?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
clearTokens?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
const idx = config.accounts.findIndex((a) => a.id === accountId);
|
||||||
|
if (idx < 0) throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
|
||||||
|
const current = config.accounts[idx];
|
||||||
|
const email =
|
||||||
|
input.email !== undefined ? input.email.trim().toLowerCase() : current.email;
|
||||||
|
if (!email) throw new AppError("INVALID_REQUEST", "E-mail verplicht", 400);
|
||||||
|
if (config.accounts.some((a, i) => i !== idx && a.email === email)) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Dit e-mailadres staat al in de lijst", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ascendontokenEnc = current.ascendontokenEnc;
|
||||||
|
let entitlementtokenEnc = current.entitlementtokenEnc;
|
||||||
|
let sessionId = current.sessionId;
|
||||||
|
if (input.clearTokens) {
|
||||||
|
ascendontokenEnc = undefined;
|
||||||
|
entitlementtokenEnc = undefined;
|
||||||
|
sessionId = undefined;
|
||||||
|
}
|
||||||
|
if (input.ascendontoken?.trim()) {
|
||||||
|
ascendontokenEnc = encryptSecret(input.ascendontoken.trim(), sessionSecret);
|
||||||
|
}
|
||||||
|
if (input.entitlementtoken?.trim()) {
|
||||||
|
entitlementtokenEnc = encryptSecret(input.entitlementtoken.trim(), sessionSecret);
|
||||||
|
}
|
||||||
|
if (input.sessionId !== undefined) {
|
||||||
|
sessionId = input.sessionId.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.accounts[idx] = {
|
||||||
|
...current,
|
||||||
|
email,
|
||||||
|
label:
|
||||||
|
input.label !== undefined
|
||||||
|
? input.label.trim() || email
|
||||||
|
: current.label || email,
|
||||||
|
enabled: input.enabled !== undefined ? input.enabled : current.enabled,
|
||||||
|
passwordEnc: input.password?.trim()
|
||||||
|
? encryptSecret(input.password.trim(), sessionSecret)
|
||||||
|
: current.passwordEnc,
|
||||||
|
ascendontokenEnc,
|
||||||
|
entitlementtokenEnc,
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
await persist(config, existing?.enabled ?? true);
|
||||||
|
return getF1tvSettingsPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeF1tvAccount(sessionSecret: string, accountId: string) {
|
||||||
|
void sessionSecret;
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
const next = config.accounts.filter((a) => a.id !== accountId);
|
||||||
|
if (next.length === config.accounts.length) {
|
||||||
|
throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
|
||||||
|
}
|
||||||
|
config.accounts = next;
|
||||||
|
const owners: F1tvContentOwners = {};
|
||||||
|
for (const [contentId, ids] of Object.entries(config.contentOwners)) {
|
||||||
|
const filtered = ids.filter((id) => id !== accountId);
|
||||||
|
if (filtered.length) owners[contentId] = filtered;
|
||||||
|
}
|
||||||
|
config.contentOwners = owners;
|
||||||
|
for (const session of config.sessions) {
|
||||||
|
session.accountIds = session.accountIds.filter((id) => id !== accountId);
|
||||||
|
}
|
||||||
|
await persist(config, existing?.enabled ?? true);
|
||||||
|
return getF1tvSettingsPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeF1tvAccounts(sessionSecret: string, accountId?: string) {
|
||||||
|
const { fetchEntitlement, ensureSessionId } = await import("./client");
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
const targets = accountId
|
||||||
|
? config.accounts.filter((a) => a.id === accountId)
|
||||||
|
: config.accounts;
|
||||||
|
if (accountId && !targets.length) {
|
||||||
|
throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const account of targets) {
|
||||||
|
if (account.enabled === false) {
|
||||||
|
account.healthStatus = "unknown";
|
||||||
|
account.healthCheckedAt = now;
|
||||||
|
account.healthError = "Uitgeschakeld";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const creds = decryptAccount(account, sessionSecret);
|
||||||
|
if (!creds.ascendontoken) {
|
||||||
|
account.healthStatus = "dead";
|
||||||
|
account.healthCheckedAt = now;
|
||||||
|
account.healthError =
|
||||||
|
"Geen ascendontoken — plak tokens uit de browser (Imperva blokkeert server-login)";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sessionId = ensureSessionId(creds.sessionId);
|
||||||
|
account.sessionId = sessionId;
|
||||||
|
const entitlement = await fetchEntitlement({
|
||||||
|
ascendontoken: creds.ascendontoken,
|
||||||
|
sessionId,
|
||||||
|
locale: config.locale,
|
||||||
|
platform: config.platform,
|
||||||
|
timeoutMs: config.syncTimeoutMs,
|
||||||
|
});
|
||||||
|
if (!entitlement) {
|
||||||
|
account.healthStatus = "dead";
|
||||||
|
account.healthCheckedAt = now;
|
||||||
|
account.healthError = "Entitlement mislukt";
|
||||||
|
} else {
|
||||||
|
account.entitlementtokenEnc = encryptSecret(entitlement, sessionSecret);
|
||||||
|
account.healthStatus = "ok";
|
||||||
|
account.healthCheckedAt = now;
|
||||||
|
account.healthError = undefined;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
account.healthStatus = "dead";
|
||||||
|
account.healthCheckedAt = now;
|
||||||
|
account.healthError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await persist(config, existing?.enabled ?? true);
|
||||||
|
return getF1tvSettingsPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadedF1tvSettings = {
|
||||||
|
accounts: F1tvAccountCreds[];
|
||||||
|
config: F1tvConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadF1tvSettings(sessionSecret: string): Promise<LoadedF1tvSettings> {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
if (!row?.enabled) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "F1TV-integratie is uitgeschakeld", 400);
|
||||||
|
}
|
||||||
|
const config = parseConfig(row.configJson);
|
||||||
|
const accounts = config.accounts
|
||||||
|
.filter((a) => a.enabled !== false)
|
||||||
|
.map((a) => {
|
||||||
|
try {
|
||||||
|
return decryptAccount(a, sessionSecret);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((a): a is F1tvAccountCreds => !!a && !!a.ascendontoken && !!a.entitlementtoken);
|
||||||
|
|
||||||
|
if (!accounts.length) {
|
||||||
|
throw new AppError(
|
||||||
|
"INVALID_REQUEST",
|
||||||
|
"Geen actief F1TV-account met tokens — voeg tokens toe onder Scripts → F1TV",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { accounts, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadF1tvConfigOnly(): Promise<F1tvConfig | null> {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
if (!row?.enabled) return null;
|
||||||
|
return parseConfig(row.configJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isF1tvIntegrationEnabled(): Promise<boolean> {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
if (!row?.enabled) return false;
|
||||||
|
return hasUsableAccount(parseConfig(row.configJson).accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveF1tvSyncResult(
|
||||||
|
summary: Record<string, unknown>,
|
||||||
|
data: {
|
||||||
|
meetings: F1tvMeetingSnapshot[];
|
||||||
|
sessions: F1tvSessionSnapshot[];
|
||||||
|
contentOwners: F1tvContentOwners;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
|
||||||
|
if (!row) return;
|
||||||
|
const config = parseConfig(row.configJson);
|
||||||
|
config.lastSyncAt = new Date().toISOString();
|
||||||
|
config.lastSyncSummary = summary;
|
||||||
|
config.meetings = data.meetings;
|
||||||
|
config.sessions = data.sessions;
|
||||||
|
config.contentOwners = data.contentOwners;
|
||||||
|
await prisma.integrationSetting.update({
|
||||||
|
where: { id: F1TV_ID },
|
||||||
|
data: { configJson: JSON.stringify(config) },
|
||||||
|
});
|
||||||
|
}
|
||||||
276
apps/master-api/src/f1tv/sync.ts
Normal file
276
apps/master-api/src/f1tv/sync.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import type { F1tvContentOwners } from "./accounts";
|
||||||
|
import { decryptAccount } from "./accounts";
|
||||||
|
import { ensureSessionId, fetchPage } from "./client";
|
||||||
|
import {
|
||||||
|
loadF1tvSettings,
|
||||||
|
saveF1tvSyncResult,
|
||||||
|
type F1tvConfig,
|
||||||
|
} from "./settings";
|
||||||
|
import type { F1tvMeetingSnapshot, F1tvSessionSnapshot } from "./tweaks";
|
||||||
|
|
||||||
|
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||||
|
return v && typeof v === "object" && !Array.isArray(v)
|
||||||
|
? (v as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: unknown, visit: (n: Record<string, unknown>) => void) {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) walk(item, visit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rec = node as Record<string, unknown>;
|
||||||
|
visit(rec);
|
||||||
|
for (const value of Object.values(rec)) walk(value, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageIdFromUri(uri: string | null | undefined): number | null {
|
||||||
|
if (!uri) return null;
|
||||||
|
const m = uri.match(/\/PAGE\/(\d+)/i);
|
||||||
|
return m ? Number(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMeetings(pageJson: unknown): F1tvMeetingSnapshot[] {
|
||||||
|
const out: F1tvMeetingSnapshot[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
walk(pageJson, (node) => {
|
||||||
|
const meta = asRecord(node.metadata) ?? node;
|
||||||
|
const contentType = String(meta.contentType ?? "").toUpperCase();
|
||||||
|
const contentSubtype = String(meta.contentSubtype ?? "").toUpperCase();
|
||||||
|
if (contentType !== "BUNDLE" || contentSubtype !== "MEETING") return;
|
||||||
|
const emf = asRecord(meta.emfAttributes) ?? {};
|
||||||
|
const meetingKey = String(emf.MeetingKey ?? meta.MeetingKey ?? "").trim();
|
||||||
|
const contentId = Number(meta.contentId ?? node.contentId ?? 0);
|
||||||
|
if (!meetingKey || !contentId) return;
|
||||||
|
if (seen.has(meetingKey)) return;
|
||||||
|
seen.add(meetingKey);
|
||||||
|
const actions = Array.isArray(node.actions) ? node.actions : [];
|
||||||
|
let pageUri: string | null = null;
|
||||||
|
for (const action of actions) {
|
||||||
|
const a = asRecord(action);
|
||||||
|
const uri = String(a?.uri ?? "").trim();
|
||||||
|
if (/\/PAGE\/\d+/i.test(uri)) {
|
||||||
|
pageUri = uri;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
meetingKey,
|
||||||
|
contentId,
|
||||||
|
title: String(meta.title ?? meta.label ?? `Meeting ${meetingKey}`).trim(),
|
||||||
|
pageId: pageIdFromUri(pageUri),
|
||||||
|
pageUri,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesAllowed(
|
||||||
|
series: string,
|
||||||
|
includeFormula2: boolean,
|
||||||
|
includeSupportSeries: boolean
|
||||||
|
): boolean {
|
||||||
|
const s = series.toUpperCase();
|
||||||
|
if (!s || s.includes("FORMULA 1") || s === "F1") return true;
|
||||||
|
if (s.includes("FORMULA 2") || s === "F2") return includeFormula2;
|
||||||
|
return includeSupportSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSessions(
|
||||||
|
pageJson: unknown,
|
||||||
|
meeting: F1tvMeetingSnapshot,
|
||||||
|
accountId: string,
|
||||||
|
opts: { includeFormula2: boolean; includeSupportSeries: boolean }
|
||||||
|
): F1tvSessionSnapshot[] {
|
||||||
|
const out: F1tvSessionSnapshot[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
walk(pageJson, (node) => {
|
||||||
|
const meta = asRecord(node.metadata);
|
||||||
|
if (!meta) return;
|
||||||
|
const contentType = String(meta.contentType ?? "").toUpperCase();
|
||||||
|
if (contentType !== "VIDEO") return;
|
||||||
|
const contentSubtype = String(meta.contentSubtype ?? "").toUpperCase();
|
||||||
|
const kind =
|
||||||
|
contentSubtype === "LIVE_EVENT"
|
||||||
|
? "live_event"
|
||||||
|
: contentSubtype === "REPLAY"
|
||||||
|
? "replay"
|
||||||
|
: null;
|
||||||
|
if (!kind) return;
|
||||||
|
const contentId = Number(meta.contentId ?? node.contentId ?? 0);
|
||||||
|
if (!contentId || seen.has(contentId)) return;
|
||||||
|
const emf = asRecord(meta.emfAttributes) ?? {};
|
||||||
|
const series = String(emf.Series ?? meta.series ?? "").trim();
|
||||||
|
if (!seriesAllowed(series, opts.includeFormula2, opts.includeSupportSeries)) return;
|
||||||
|
const startMs = Number(emf.sessionStartDate ?? meta.sessionStartDate ?? 0);
|
||||||
|
const endMs = Number(emf.sessionEndDate ?? meta.sessionEndDate ?? 0);
|
||||||
|
if (!Number.isFinite(startMs) || startMs <= 0) return;
|
||||||
|
seen.add(contentId);
|
||||||
|
|
||||||
|
const additional = Array.isArray(meta.additionalStreams)
|
||||||
|
? meta.additionalStreams
|
||||||
|
: [];
|
||||||
|
let defaultChannelId: number | null = null;
|
||||||
|
for (const stream of additional) {
|
||||||
|
const s = asRecord(stream);
|
||||||
|
const channelId = Number(s?.channelId ?? 0);
|
||||||
|
const title = String(s?.title ?? s?.identifier ?? "").toUpperCase();
|
||||||
|
if (channelId === 1033 || title.includes("F1 LIVE") || title === "PRES") {
|
||||||
|
defaultChannelId = channelId || 1033;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (defaultChannelId == null && channelId) defaultChannelId = channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
contentId,
|
||||||
|
kind,
|
||||||
|
title: String(meta.title ?? meta.label ?? `Content ${contentId}`).trim(),
|
||||||
|
meetingKey: String(emf.MeetingKey ?? meeting.meetingKey).trim() || meeting.meetingKey,
|
||||||
|
meetingName:
|
||||||
|
String(emf.Meeting_Name ?? meeting.title).trim() || meeting.title,
|
||||||
|
meetingPageId: meeting.pageId,
|
||||||
|
series: series || "FORMULA 1",
|
||||||
|
state: String(emf.state ?? meta.state ?? "").trim() || null,
|
||||||
|
startMs,
|
||||||
|
endMs: Number.isFinite(endMs) && endMs > 0 ? endMs : startMs + 2 * 3600_000,
|
||||||
|
imageUrl:
|
||||||
|
String(meta.pictureUrl ?? meta.imageUrl ?? meta.embargoImage ?? "").trim() ||
|
||||||
|
null,
|
||||||
|
defaultChannelId,
|
||||||
|
accountIds: [accountId],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMeetingsForSync(
|
||||||
|
meetings: F1tvMeetingSnapshot[],
|
||||||
|
limit: number
|
||||||
|
): F1tvMeetingSnapshot[] {
|
||||||
|
// Prefer meetings with pageId; keep calendar order, take up to limit from end
|
||||||
|
// (recent/upcoming often at the end) plus any without dates.
|
||||||
|
const withPage = meetings.filter((m) => m.pageId != null);
|
||||||
|
if (withPage.length <= limit) return withPage;
|
||||||
|
// Take a window around "now": last `limit` entries (season progresses left→right).
|
||||||
|
return withPage.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type F1tvSyncSummary = {
|
||||||
|
accounts: number;
|
||||||
|
meetings: number;
|
||||||
|
sessions: number;
|
||||||
|
liveEvents: number;
|
||||||
|
replays: number;
|
||||||
|
durationMs: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function syncF1tvCatalog(sessionSecret: string): Promise<F1tvSyncSummary> {
|
||||||
|
const started = Date.now();
|
||||||
|
const { accounts, config } = await loadF1tvSettings(sessionSecret);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const meetingsByKey = new Map<string, F1tvMeetingSnapshot>();
|
||||||
|
const sessionsById = new Map<number, F1tvSessionSnapshot>();
|
||||||
|
const contentOwners: F1tvContentOwners = {};
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const auth = {
|
||||||
|
ascendontoken: account.ascendontoken,
|
||||||
|
entitlementtoken: account.entitlementtoken,
|
||||||
|
sessionId: ensureSessionId(account.sessionId),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const season = await fetchPage({
|
||||||
|
pageId: config.seasonPageId,
|
||||||
|
auth,
|
||||||
|
locale: config.locale,
|
||||||
|
platform: config.platform,
|
||||||
|
timeoutMs: config.syncTimeoutMs,
|
||||||
|
});
|
||||||
|
for (const meeting of extractMeetings(season)) {
|
||||||
|
const prev = meetingsByKey.get(meeting.meetingKey);
|
||||||
|
if (!prev || (!prev.pageId && meeting.pageId)) {
|
||||||
|
meetingsByKey.set(meeting.meetingKey, meeting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${account.email}: kalender — ${(err as Error).message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = selectMeetingsForSync(
|
||||||
|
[...meetingsByKey.values()],
|
||||||
|
config.syncMeetingLimit
|
||||||
|
);
|
||||||
|
for (const meeting of selected) {
|
||||||
|
if (!meeting.pageId) continue;
|
||||||
|
try {
|
||||||
|
const page = await fetchPage({
|
||||||
|
pageId: meeting.pageId,
|
||||||
|
auth,
|
||||||
|
locale: config.locale,
|
||||||
|
platform: config.platform,
|
||||||
|
timeoutMs: config.syncTimeoutMs,
|
||||||
|
});
|
||||||
|
const sessions = extractSessions(page, meeting, account.id, {
|
||||||
|
includeFormula2: config.includeFormula2,
|
||||||
|
includeSupportSeries: config.includeSupportSeries,
|
||||||
|
});
|
||||||
|
for (const session of sessions) {
|
||||||
|
const existing = sessionsById.get(session.contentId);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.accountIds.includes(account.id)) {
|
||||||
|
existing.accountIds.push(account.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionsById.set(session.contentId, session);
|
||||||
|
}
|
||||||
|
const owners = contentOwners[String(session.contentId)] ?? [];
|
||||||
|
if (!owners.includes(account.id)) owners.push(account.id);
|
||||||
|
contentOwners[String(session.contentId)] = owners;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(
|
||||||
|
`${account.email}: meeting ${meeting.meetingKey} — ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetings = [...meetingsByKey.values()];
|
||||||
|
const sessions = [...sessionsById.values()].sort((a, b) => a.startMs - b.startMs);
|
||||||
|
const summary: F1tvSyncSummary = {
|
||||||
|
accounts: accounts.length,
|
||||||
|
meetings: meetings.length,
|
||||||
|
sessions: sessions.length,
|
||||||
|
liveEvents: sessions.filter((s) => s.kind === "live_event").length,
|
||||||
|
replays: sessions.filter((s) => s.kind === "replay").length,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
errors: errors.slice(0, 20),
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveF1tvSyncResult(summary, { meetings, sessions, contentOwners });
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getF1tvSnapshot(): Promise<{
|
||||||
|
config: F1tvConfig;
|
||||||
|
} | null> {
|
||||||
|
const { loadF1tvConfigOnly } = await import("./settings");
|
||||||
|
const config = await loadF1tvConfigOnly();
|
||||||
|
if (!config) return null;
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSyncReady(config: F1tvConfig) {
|
||||||
|
if (!config.sessions.length) {
|
||||||
|
throw new AppError(
|
||||||
|
"NOT_READY",
|
||||||
|
"Nog geen F1TV-sessies — voer Sync uit onder Scripts → F1TV",
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apps/master-api/src/f1tv/tweaks.ts
Normal file
131
apps/master-api/src/f1tv/tweaks.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export type F1tvTweaks = {
|
||||||
|
/** Seizoenskalender PAGE-id (2026 = 12343). */
|
||||||
|
seasonPageId: number;
|
||||||
|
/** Locale in AGL-paden (NLD/ENG/…). */
|
||||||
|
locale: string;
|
||||||
|
/** Platform-segment in AGL-paden. */
|
||||||
|
platform: string;
|
||||||
|
/** Max meetings om sessies voor te syncen. */
|
||||||
|
syncMeetingLimit: number;
|
||||||
|
/** Sync timeout per HTTP-call (ms). */
|
||||||
|
syncTimeoutMs: number;
|
||||||
|
/** Include FORMULA 2 sessies in Events/hub. */
|
||||||
|
includeFormula2: boolean;
|
||||||
|
/** Include FORMULA 3 / Academy / support series. */
|
||||||
|
includeSupportSeries: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const F1TV_TWEAK_DEFAULTS: F1tvTweaks = {
|
||||||
|
seasonPageId: 12343,
|
||||||
|
locale: "NLD",
|
||||||
|
platform: "WEB_DASH",
|
||||||
|
syncMeetingLimit: 8,
|
||||||
|
syncTimeoutMs: 45_000,
|
||||||
|
includeFormula2: false,
|
||||||
|
includeSupportSeries: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const F1TV_TWEAK_META: Array<{
|
||||||
|
key: keyof F1tvTweaks;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
kind: "number" | "boolean" | "string";
|
||||||
|
unit?: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "seasonPageId",
|
||||||
|
label: "Seizoens-PAGE id",
|
||||||
|
hint: "F1TV PAGE-id voor de kalender (2026 ≈ 12343).",
|
||||||
|
kind: "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "locale",
|
||||||
|
label: "Locale",
|
||||||
|
hint: "AGL-locale, bijv. NLD of ENG.",
|
||||||
|
kind: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "platform",
|
||||||
|
label: "Platform",
|
||||||
|
hint: "Catalogus-platform (WEB_DASH). Play gebruikt WEB_HLS.",
|
||||||
|
kind: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "syncMeetingLimit",
|
||||||
|
label: "Meeting-limiet",
|
||||||
|
hint: "Hoeveel meetings (rond vandaag) volledig worden gesynchroniseerd.",
|
||||||
|
kind: "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "syncTimeoutMs",
|
||||||
|
label: "HTTP-timeout",
|
||||||
|
hint: "Timeout per F1TV-request tijdens sync.",
|
||||||
|
kind: "number",
|
||||||
|
unit: "ms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "includeFormula2",
|
||||||
|
label: "Formule 2 meenemen",
|
||||||
|
hint: "F2-sessies in Events/hub tonen.",
|
||||||
|
kind: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "includeSupportSeries",
|
||||||
|
label: "Support-series meenemen",
|
||||||
|
hint: "F3 / Academy / andere series naast F1.",
|
||||||
|
kind: "boolean",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mergeF1tvTweaks(raw: Partial<F1tvTweaks> | Record<string, unknown>): F1tvTweaks {
|
||||||
|
const d = F1TV_TWEAK_DEFAULTS;
|
||||||
|
const n = (v: unknown, fallback: number) => {
|
||||||
|
const x = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(x) && x > 0 ? Math.floor(x) : fallback;
|
||||||
|
};
|
||||||
|
const s = (v: unknown, fallback: string) => {
|
||||||
|
const t = String(v ?? "").trim();
|
||||||
|
return t || fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
seasonPageId: n(raw.seasonPageId, d.seasonPageId),
|
||||||
|
locale: s(raw.locale, d.locale).toUpperCase(),
|
||||||
|
platform: s(raw.platform, d.platform).toUpperCase(),
|
||||||
|
syncMeetingLimit: Math.min(40, Math.max(1, n(raw.syncMeetingLimit, d.syncMeetingLimit))),
|
||||||
|
syncTimeoutMs: Math.min(120_000, Math.max(5_000, n(raw.syncTimeoutMs, d.syncTimeoutMs))),
|
||||||
|
includeFormula2: raw.includeFormula2 === true,
|
||||||
|
includeSupportSeries: raw.includeSupportSeries === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type F1tvSessionKind = "live_event" | "replay";
|
||||||
|
|
||||||
|
export type F1tvSessionSnapshot = {
|
||||||
|
contentId: number;
|
||||||
|
kind: F1tvSessionKind;
|
||||||
|
title: string;
|
||||||
|
meetingKey: string;
|
||||||
|
meetingName: string;
|
||||||
|
meetingPageId: number | null;
|
||||||
|
series: string;
|
||||||
|
state: string | null;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
defaultChannelId: number | null;
|
||||||
|
accountIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type F1tvMeetingSnapshot = {
|
||||||
|
meetingKey: string;
|
||||||
|
contentId: number;
|
||||||
|
title: string;
|
||||||
|
pageId: number | null;
|
||||||
|
pageUri: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function newSessionId(): string {
|
||||||
|
return `WEB-${randomUUID()}`;
|
||||||
|
}
|
||||||
|
|
@ -874,6 +874,92 @@ export function registerViewerRoutes(
|
||||||
return getScheduleEventPlay(config, decodeURIComponent(id), access, auth.viewerId);
|
return getScheduleEventPlay(config, decodeURIComponent(id), access, auth.viewerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- F1TV hub (Android TV later; API nu al beschikbaar) ---
|
||||||
|
app.get("/api/v1/client/f1/home", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
if (!access.enabled) {
|
||||||
|
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
|
||||||
|
}
|
||||||
|
const disabled = await import("./schedule-events").then(async (m) => {
|
||||||
|
const { getIptvSettings } = await import("../settings/iptv");
|
||||||
|
const s = await getIptvSettings(config);
|
||||||
|
return new Set(s.liveEventsDisabledProviders.map((p) => p.toLowerCase()));
|
||||||
|
});
|
||||||
|
if (disabled.has("f1tv")) {
|
||||||
|
throw new AppError("NOT_FOUND", "F1TV is uitgeschakeld", 404);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
access.allowedProviders &&
|
||||||
|
access.allowedProviders.length > 0 &&
|
||||||
|
!access.allowedProviders.includes("f1tv")
|
||||||
|
) {
|
||||||
|
throw new AppError("NOT_FOUND", "F1TV is niet beschikbaar voor dit account", 404);
|
||||||
|
}
|
||||||
|
const { getF1tvHome } = await import("../f1tv/events");
|
||||||
|
return getF1tvHome();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/f1/meetings/:meetingKey", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const { meetingKey } = request.params as { meetingKey: string };
|
||||||
|
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
if (!access.enabled) {
|
||||||
|
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
|
||||||
|
}
|
||||||
|
const { getF1tvMeetingDetail } = await import("../f1tv/events");
|
||||||
|
const detail = await getF1tvMeetingDetail(decodeURIComponent(meetingKey));
|
||||||
|
if (!detail) throw new AppError("NOT_FOUND", "Meeting niet gevonden", 404);
|
||||||
|
return detail;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/client/f1/content/:contentId/play", async (request) => {
|
||||||
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const { contentId: rawId } = request.params as { contentId: string };
|
||||||
|
const contentId = Number(rawId);
|
||||||
|
if (!Number.isFinite(contentId)) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Ongeldig content-id", 400);
|
||||||
|
}
|
||||||
|
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
if (!access.enabled) {
|
||||||
|
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
|
||||||
|
}
|
||||||
|
const { resolveF1tvPlay } = await import("../f1tv/play");
|
||||||
|
const played = await resolveF1tvPlay(config.SESSION_SECRET, contentId, {
|
||||||
|
allowReplay: true,
|
||||||
|
});
|
||||||
|
let watchSessionId: string | null = null;
|
||||||
|
try {
|
||||||
|
const { touchClientWatch } = await import("./client-watches");
|
||||||
|
const watch = touchClientWatch({
|
||||||
|
viewerUserId: auth.viewerId,
|
||||||
|
kind: "event",
|
||||||
|
resourceId: `f1tv:${contentId}`,
|
||||||
|
title: played.name,
|
||||||
|
provider: "f1tv",
|
||||||
|
});
|
||||||
|
watchSessionId = watch.id;
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
eventId: `f1tv:${contentId}`,
|
||||||
|
name: played.name,
|
||||||
|
logoUrl: null,
|
||||||
|
streamUrl: played.streamUrl,
|
||||||
|
format: played.format,
|
||||||
|
fallbackStreamUrl: null,
|
||||||
|
fallbackFormat: null,
|
||||||
|
drm: null,
|
||||||
|
channelId: played.channelId,
|
||||||
|
feedCount: played.feedCount,
|
||||||
|
watchSessionId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lichte presence-heartbeat voor Live Events / custom live-lists.
|
* Lichte presence-heartbeat voor Live Events / custom live-lists.
|
||||||
* Fire-and-forget vanaf de client; failures mogen genegeerd worden.
|
* Fire-and-forget vanaf de client; failures mogen genegeerd worden.
|
||||||
|
|
|
||||||
|
|
@ -686,6 +686,8 @@ export async function resolveViewerLiveEventsAccess(
|
||||||
config: Config
|
config: Config
|
||||||
): Promise<LiveEventsAccessFilter> {
|
): Promise<LiveEventsAccessFilter> {
|
||||||
const feedOn = await eventsFeedEnabled(config);
|
const feedOn = await eventsFeedEnabled(config);
|
||||||
|
const { isF1tvIntegrationEnabled } = await import("../f1tv/settings");
|
||||||
|
const f1tvOn = await isF1tvIntegrationEnabled();
|
||||||
const viewer = await prisma.viewerUser.findUnique({
|
const viewer = await prisma.viewerUser.findUnique({
|
||||||
where: { id: viewerId },
|
where: { id: viewerId },
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -695,7 +697,13 @@ export async function resolveViewerLiveEventsAccess(
|
||||||
liveEventsProviders: true,
|
liveEventsProviders: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!viewer || !viewer.enabled || !viewer.appAccess || !viewer.liveEventsEnabled || !feedOn) {
|
if (
|
||||||
|
!viewer ||
|
||||||
|
!viewer.enabled ||
|
||||||
|
!viewer.appAccess ||
|
||||||
|
!viewer.liveEventsEnabled ||
|
||||||
|
(!feedOn && !f1tvOn)
|
||||||
|
) {
|
||||||
return { enabled: false, allowedProviders: null };
|
return { enabled: false, allowedProviders: null };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -788,20 +796,28 @@ export function invalidateScheduleEventsCache() {
|
||||||
|
|
||||||
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
|
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
|
||||||
export async function discoverScheduleEventProviders(config: Config): Promise<string[]> {
|
export async function discoverScheduleEventProviders(config: Config): Promise<string[]> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
try {
|
try {
|
||||||
const raw = await loadRawEvents(config);
|
const raw = await loadRawEvents(config);
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const e of raw.events) {
|
for (const e of raw.events) {
|
||||||
const p = (str(e.provider) || "unknown").toLowerCase();
|
const p = (str(e.provider) || "unknown").toLowerCase();
|
||||||
if (seen.has(p)) continue;
|
if (seen.has(p)) continue;
|
||||||
seen.add(p);
|
seen.add(p);
|
||||||
out.push(p);
|
out.push(p);
|
||||||
}
|
}
|
||||||
return out.sort((a, b) => a.localeCompare(b, "nl"));
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
/* feed optioneel als F1TV aan staat */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const { isF1tvIntegrationEnabled } = await import("../f1tv/settings");
|
||||||
|
if (await isF1tvIntegrationEnabled()) {
|
||||||
|
if (!seen.has("f1tv")) out.push("f1tv");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.localeCompare(b, "nl"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScheduleEvents(
|
export async function listScheduleEvents(
|
||||||
|
|
@ -813,17 +829,40 @@ export async function listScheduleEvents(
|
||||||
providers: string[];
|
providers: string[];
|
||||||
}> {
|
}> {
|
||||||
assertAccessEnabled(access);
|
assertAccessEnabled(access);
|
||||||
const raw = await loadRawEvents(config);
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const mapped = raw.events
|
let generatedAt: string | null = null;
|
||||||
.map((e) => toPublic(e, nowMs))
|
let mapped: ScheduleEventPublic[] = [];
|
||||||
.filter((e): e is ScheduleEventPublic => e != null)
|
|
||||||
.filter((e) => e.phase !== "ended")
|
try {
|
||||||
.sort((a, b) => {
|
const raw = await loadRawEvents(config);
|
||||||
if (a.phase === "live" && b.phase !== "live") return -1;
|
generatedAt = raw.generatedAt;
|
||||||
if (b.phase === "live" && a.phase !== "live") return 1;
|
mapped = raw.events
|
||||||
return Date.parse(a.start) - Date.parse(b.start);
|
.map((e) => toPublic(e, nowMs))
|
||||||
});
|
.filter((e): e is ScheduleEventPublic => e != null)
|
||||||
|
.filter((e) => e.phase !== "ended");
|
||||||
|
} catch {
|
||||||
|
/* FAAB-feed optioneel wanneer F1TV events levert */
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { listF1tvScheduleEvents } = await import("../f1tv/events");
|
||||||
|
const f1 = await listF1tvScheduleEvents(nowMs);
|
||||||
|
if (f1.length) {
|
||||||
|
const existingIds = new Set(mapped.map((e) => e.id));
|
||||||
|
for (const event of f1) {
|
||||||
|
if (!existingIds.has(event.id)) mapped.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* F1TV sync mag ontbreken */
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped.sort((a, b) => {
|
||||||
|
if (a.phase === "live" && b.phase !== "live") return -1;
|
||||||
|
if (b.phase === "live" && a.phase !== "live") return 1;
|
||||||
|
return Date.parse(a.start) - Date.parse(b.start);
|
||||||
|
});
|
||||||
|
|
||||||
const disabledGlobal = await globalDisabledProviders(config);
|
const disabledGlobal = await globalDisabledProviders(config);
|
||||||
// Ziggo Gracenote + ESPN Watch art → sport|channel gap-fill → category backdrops
|
// Ziggo Gracenote + ESPN Watch art → sport|channel gap-fill → category backdrops
|
||||||
const enriched = await enrichEspnWatchArt(await enrichZiggoGoArt(mapped));
|
const enriched = await enrichEspnWatchArt(await enrichZiggoGoArt(mapped));
|
||||||
|
|
@ -838,7 +877,7 @@ export async function listScheduleEvents(
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generatedAt: raw.generatedAt,
|
generatedAt,
|
||||||
events,
|
events,
|
||||||
providers,
|
providers,
|
||||||
};
|
};
|
||||||
|
|
@ -851,22 +890,38 @@ export async function getScheduleEvent(
|
||||||
): Promise<ScheduleEventPublic> {
|
): Promise<ScheduleEventPublic> {
|
||||||
assertAccessEnabled(access);
|
assertAccessEnabled(access);
|
||||||
const id = str(eventId);
|
const id = str(eventId);
|
||||||
|
const disabledGlobal = await globalDisabledProviders(config);
|
||||||
|
|
||||||
|
if (id.toLowerCase().startsWith("f1tv:")) {
|
||||||
|
const { getF1tvScheduleEvent } = await import("../f1tv/events");
|
||||||
|
const pub = await getF1tvScheduleEvent(id);
|
||||||
|
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
||||||
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
}
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
|
||||||
const listed = await listScheduleEvents(config, access);
|
const listed = await listScheduleEvents(config, access);
|
||||||
const hit = listed.events.find((e) => e.id === id);
|
const hit = listed.events.find((e) => e.id === id);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
|
|
||||||
// Ended events still resolvable for detail (no play) if provider allowed
|
// Ended events still resolvable for detail (no play) if provider allowed
|
||||||
const raw = await loadRawEvents(config);
|
try {
|
||||||
const nowMs = Date.now();
|
const raw = await loadRawEvents(config);
|
||||||
const found = raw.events.find((e) => str(e.id) === id);
|
const nowMs = Date.now();
|
||||||
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
const found = raw.events.find((e) => str(e.id) === id);
|
||||||
const pub = toPublic(found, nowMs);
|
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
const pub = toPublic(found, nowMs);
|
||||||
const disabledGlobal = await globalDisabledProviders(config);
|
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
||||||
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
}
|
||||||
|
return pub;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err;
|
||||||
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
}
|
}
|
||||||
return pub;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getScheduleEventPlay(
|
export async function getScheduleEventPlay(
|
||||||
|
|
@ -877,6 +932,59 @@ export async function getScheduleEventPlay(
|
||||||
) {
|
) {
|
||||||
assertAccessEnabled(access);
|
assertAccessEnabled(access);
|
||||||
const id = str(eventId);
|
const id = str(eventId);
|
||||||
|
const disabledGlobal = await globalDisabledProviders(config);
|
||||||
|
|
||||||
|
async function attachWatch(
|
||||||
|
pub: ScheduleEventPublic
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!viewerUserId) return null;
|
||||||
|
try {
|
||||||
|
const { touchClientWatch } = await import("./client-watches");
|
||||||
|
const watch = touchClientWatch({
|
||||||
|
viewerUserId,
|
||||||
|
kind: "event",
|
||||||
|
resourceId: pub.id,
|
||||||
|
title: pub.name,
|
||||||
|
provider: pub.provider,
|
||||||
|
});
|
||||||
|
return watch.id;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.toLowerCase().startsWith("f1tv:")) {
|
||||||
|
const { getF1tvScheduleEvent } = await import("../f1tv/events");
|
||||||
|
const { parseF1tvEventId, resolveF1tvPlay } = await import("../f1tv/play");
|
||||||
|
const pub = await getF1tvScheduleEvent(id);
|
||||||
|
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
||||||
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
}
|
||||||
|
if (pub.phase === "ended") {
|
||||||
|
throw new AppError("GONE", "Dit event is afgelopen", 410);
|
||||||
|
}
|
||||||
|
if (pub.phase === "upcoming") {
|
||||||
|
throw new AppError("NOT_READY", "Dit event is nog niet begonnen", 409);
|
||||||
|
}
|
||||||
|
const contentId = parseF1tvEventId(id);
|
||||||
|
if (contentId == null) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
const played = await resolveF1tvPlay(config.SESSION_SECRET, contentId, {
|
||||||
|
allowReplay: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
eventId: pub.id,
|
||||||
|
name: pub.name,
|
||||||
|
logoUrl: pub.logoUrl ?? pub.imagePortrait,
|
||||||
|
streamUrl: played.streamUrl,
|
||||||
|
format: played.format,
|
||||||
|
fallbackStreamUrl: null,
|
||||||
|
fallbackFormat: null,
|
||||||
|
drm: null,
|
||||||
|
watchSessionId: await attachWatch(pub),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const raw = await loadRawEvents(config);
|
const raw = await loadRawEvents(config);
|
||||||
const found = raw.events.find((e) => str(e.id) === id);
|
const found = raw.events.find((e) => str(e.id) === id);
|
||||||
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
|
@ -885,7 +993,6 @@ export async function getScheduleEventPlay(
|
||||||
const pub = toPublic(found, nowMs);
|
const pub = toPublic(found, nowMs);
|
||||||
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
|
||||||
const disabledGlobal = await globalDisabledProviders(config);
|
|
||||||
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
|
||||||
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
}
|
}
|
||||||
|
|
@ -911,23 +1018,6 @@ export async function getScheduleEventPlay(
|
||||||
// FAAB-veld heet vaak `mpd` ook als de URL een m3u8 is — nooit blind "dash" aannemen.
|
// FAAB-veld heet vaak `mpd` ook als de URL een m3u8 is — nooit blind "dash" aannemen.
|
||||||
const format = detectLiveManifestFormat(streamUrl);
|
const format = detectLiveManifestFormat(streamUrl);
|
||||||
|
|
||||||
let watchSessionId: string | null = null;
|
|
||||||
if (viewerUserId) {
|
|
||||||
try {
|
|
||||||
const { touchClientWatch } = await import("./client-watches");
|
|
||||||
const watch = touchClientWatch({
|
|
||||||
viewerUserId,
|
|
||||||
kind: "event",
|
|
||||||
resourceId: pub.id,
|
|
||||||
title: pub.name,
|
|
||||||
provider: pub.provider,
|
|
||||||
});
|
|
||||||
watchSessionId = watch.id;
|
|
||||||
} catch {
|
|
||||||
// Presence is best-effort; play must not fail.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eventId: pub.id,
|
eventId: pub.id,
|
||||||
name: pub.name,
|
name: pub.name,
|
||||||
|
|
@ -940,7 +1030,7 @@ export async function getScheduleEventPlay(
|
||||||
type: "clearkey" as const,
|
type: "clearkey" as const,
|
||||||
keys,
|
keys,
|
||||||
},
|
},
|
||||||
watchSessionId,
|
watchSessionId: await attachWatch(pub),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
359
docs/f1tv-events-design.md
Normal file
359
docs/f1tv-events-design.md
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
# F1TV → Events & F1 Hub — Technisch design
|
||||||
|
|
||||||
|
Status: **backend MVP gebouwd** (Scripts + sync + Events-merge + hub-API). Android TV hub volgt.
|
||||||
|
Datum: 2026-09-22
|
||||||
|
Doel: F1 live-sessies én terugkijken in de app, met een dedicated F1-hub onder Events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Antwoord op “kan terugkijken ook?”
|
||||||
|
|
||||||
|
**Ja.** De `spelend.har` bewijst het al: we speelden **2026 Grand Prix van Nederland** als `VIDEO/REPLAY` af via:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /3.0/R/{locale}/WEB_HLS/ALL/CONTENT/PLAY?contentId={id}&player=player_tm
|
||||||
|
→ HLS URL op ott-video-fer-cf.formula1.com
|
||||||
|
→ fMP4/CMAF-segmenten (de .mp4’s die je zag)
|
||||||
|
```
|
||||||
|
|
||||||
|
In die capture: **geen Widevine / geen EXT-X-KEY** — clear HLS. Playback voor Events is daardoor eenvoudiger dan Odido (geen CDM voor dit pad).
|
||||||
|
|
||||||
|
Conclusie:
|
||||||
|
|
||||||
|
| Content | Discovery | Play | In scope? |
|
||||||
|
|---------|-----------|------|-----------|
|
||||||
|
| Aankomende sessies (`LIVE_EVENT` / `Pre-Live`) | Meeting-PAGE + start/stop | Later via zelfde PLAY | Ja — Events + hub |
|
||||||
|
| Live sessie (`state: Live`) | Zelfde | PLAY (HLS; live mogelijk DASH+DRM) | Ja — MVP live; DRM-pad valideren tijdens raceweekend |
|
||||||
|
| Terugkijken (`REPLAY`) | Meeting-PAGE / VIDEO-detail | Zelfde PLAY (bewezen) | Ja — F1-hub, niet in de algemene Events-agenda |
|
||||||
|
|
||||||
|
Terugkijken hoort **niet** in de gewone Events-lijst (die filtert `ended` weg en is “nu/straks”). Het hoort wél in de **F1-hub** (agenda + archief).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Context in huidige architectuur
|
||||||
|
|
||||||
|
### Wat Events nu is
|
||||||
|
|
||||||
|
- Bron: externe FAAB JSON (`liveEventsUrl`)
|
||||||
|
- API: `GET /client/events`, `/:id`, `/:id/play`
|
||||||
|
- Play: pre-resolved `streamUrl` + ClearKey keys; alleen `phase === "live"`
|
||||||
|
- Clients (iOS / Android TV): sport-chips → shelves → detail → player
|
||||||
|
- F1 valt nu alleen onder sport-bucket `racing` als FAAB het levert — **geen F1TV-provider**
|
||||||
|
|
||||||
|
### Wat Odido is (niet het Events-model)
|
||||||
|
|
||||||
|
- 24/7 zenders, `LiveChannel`, sync worker, Widevine→ClearKey
|
||||||
|
- Herbruikbaar: `IntegrationSetting`, encrypted secrets, admin Scripts-UI, CDM-helper **als** live DRM nodig blijkt
|
||||||
|
|
||||||
|
### Wat F1TV moet worden
|
||||||
|
|
||||||
|
Een **first-party event-provider** (niet alleen feed-merge):
|
||||||
|
|
||||||
|
1. Auth + tokens
|
||||||
|
2. Periodieke discovery (kalender → meetings → sessies)
|
||||||
|
3. On-demand play resolve bij `/:id/play`
|
||||||
|
4. Client: F1-hub UI + bestaande player
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Doelen & non-goals
|
||||||
|
|
||||||
|
### Doelen (MVP+)
|
||||||
|
|
||||||
|
1. Provider `f1tv` in Events-pipeline (admin/viewer toggles)
|
||||||
|
2. Upcoming + live F1-sessies in Events (bucket `racing`) én in F1-hub
|
||||||
|
3. F1-hub: agenda weekend + terugkijken (efficient, Events-achtige shelves)
|
||||||
|
4. Play via server-side `CONTENT/PLAY` → HLS naar clients
|
||||||
|
5. Multi-account optioneel later (1 Premium account eerst)
|
||||||
|
|
||||||
|
### Non-goals (later)
|
||||||
|
|
||||||
|
- Volledige F1TV VOD-catalogus (documentaires, shows, F2/F3 alles)
|
||||||
|
- Onboard camera picker UI (MVP: default feed / F1 LIVE channel)
|
||||||
|
- Imperva-bypass reverse-engineeren in productie zonder browser-sessie
|
||||||
|
- Live timing / telemetry overlay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. F1TV API-model (uit HARs)
|
||||||
|
|
||||||
|
### Auth-tokens
|
||||||
|
|
||||||
|
| Token | Rol |
|
||||||
|
|-------|-----|
|
||||||
|
| `ascendontoken` / subscriptionToken | Login-resultaat; header voor entitlement |
|
||||||
|
| `entitlementtoken` | Catalogus + PLAY; vernieuwd via `USER/ENTITLEMENT` of in PLAY-response |
|
||||||
|
| `sessionid` | WEB-uuid; meenemen op AGL-calls |
|
||||||
|
| Imperva / reese84 | Blokkeert server-side password-login |
|
||||||
|
|
||||||
|
**Aanbeveling auth-MVP:** admin plakt / vernieuwt tokens (of browser-assisted login later). Password + Imperva is fase 2.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
```
|
||||||
|
PAGE/12343 (seizoen 2026)
|
||||||
|
→ BUNDLE/MEETING { MeetingKey, pageUri → PAGE/{meetingId} }
|
||||||
|
|
||||||
|
PAGE/{meetingId}
|
||||||
|
→ VIDEO/LIVE_EVENT | VIDEO/REPLAY
|
||||||
|
→ contentId, title, sessionStartDate/EndDate, state (Pre-Live | Live | …), Series
|
||||||
|
```
|
||||||
|
|
||||||
|
### Play
|
||||||
|
|
||||||
|
```
|
||||||
|
CONTENT/VIDEO/{contentId} → metadata + additionalStreams (channelId, playbackUrl)
|
||||||
|
CONTENT/PLAY?contentId=&channelId=&player=player_tm
|
||||||
|
headers: ascendontoken, entitlementtoken, sessionid
|
||||||
|
→ resultObj.tme.feeds[].url (HLS)
|
||||||
|
→ streamType SDR_HD_CMAF
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-cam: ~26 feeds (channelId 1033 = F1 LIVE, plus OBC). MVP speelt default feed (F1 LIVE / eerste feed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Backend-architectuur
|
||||||
|
|
||||||
|
### Nieuwe module
|
||||||
|
|
||||||
|
`apps/master-api/src/f1tv/`
|
||||||
|
|
||||||
|
| Bestand | Verantwoordelijkheid |
|
||||||
|
|---------|----------------------|
|
||||||
|
| `settings.ts` | `IntegrationSetting` id=`f1tv`: tokens, locale (`NLD`), device headers, tweaks |
|
||||||
|
| `client.ts` | HTTP: PAGE, VIDEO, PLAY, ENTITLEMENT |
|
||||||
|
| `auth.ts` | Token load/refresh/expiry; health |
|
||||||
|
| `sync.ts` | Kalender → meetings → sessies → interne event-store |
|
||||||
|
| `play.ts` | Resolve HLS (+ optioneel DRM later) |
|
||||||
|
| `map.ts` | F1TV item → Events-shape + hub-shape |
|
||||||
|
|
||||||
|
### Data-model (intern)
|
||||||
|
|
||||||
|
Geen permanente DB-tabel verplicht voor MVP; wel cache + snapshot in `IntegrationSetting` of korte memory/redis-achtige cache zoals schedule-events.
|
||||||
|
|
||||||
|
**Interne event-record:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: `f1tv:${contentId}`, // stabiel
|
||||||
|
contentId: number,
|
||||||
|
provider: "f1tv",
|
||||||
|
kind: "live_event" | "replay",
|
||||||
|
phase: "upcoming" | "live" | "ended",
|
||||||
|
name, meetingName, meetingKey, series,
|
||||||
|
start, stop, // ISO uit sessionStart/End
|
||||||
|
state, // F1TV raw
|
||||||
|
imageLandscape?, imageBackdrop?,
|
||||||
|
defaultChannelId?: number, // 1033
|
||||||
|
playable: boolean, // live óf replay (hub); Events-list alleen live
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integratie met `schedule-events.ts`
|
||||||
|
|
||||||
|
Twee lagen:
|
||||||
|
|
||||||
|
1. **Merge in `/client/events`**
|
||||||
|
- Alleen `kind=live_event` met `phase` upcoming|live
|
||||||
|
- `sportBucket: "racing"`, `provider: "f1tv"`
|
||||||
|
- `playable` alleen als `phase === "live"` (huidige Events-regel)
|
||||||
|
|
||||||
|
2. **Nieuwe hub-API** (apart van FAAB-lijst)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/client/f1/home
|
||||||
|
GET /api/v1/client/f1/meetings
|
||||||
|
GET /api/v1/client/f1/meetings/:meetingKey
|
||||||
|
GET /api/v1/client/f1/content/:contentId
|
||||||
|
GET /api/v1/client/f1/content/:contentId/play
|
||||||
|
```
|
||||||
|
|
||||||
|
Of één compacte home-payload:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
nextMeeting: { ... },
|
||||||
|
agenda: Session[], // komende 7–14 dagen
|
||||||
|
live: Session[],
|
||||||
|
recentReplays: Session[], // laatste meeting(s)
|
||||||
|
meetings: MeetingSummary[] // seizoen, compact
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Play voor hub mag **replay én live**; bestaande Events-play blijft live-only.
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
- Scripts-pagina sectie **F1TV** (naast Odido): tokens, locale, sync-now, health, laatste sync-samenvatting
|
||||||
|
- Settings IPTV: provider `f1tv` in disabled-providers / viewer allowlist (zelfde mechanisme)
|
||||||
|
|
||||||
|
### Sync-strategie
|
||||||
|
|
||||||
|
| Job | Interval | Actie |
|
||||||
|
|-----|----------|-------|
|
||||||
|
| Seizoen-kalender | 6–24u | PAGE/12343 → meeting map |
|
||||||
|
| Actieve + aankomende meetings | 5–15 min | PAGE/{id} sessies |
|
||||||
|
| Live window | 1–2 min | state refresh rond start−1h … stop+2h |
|
||||||
|
| Play | on-demand | geen pre-fetch van HLS (TTL ~4u in URL) |
|
||||||
|
|
||||||
|
HLS-URLs **niet** lang cachen; alleen metadata cachen. Play altijd vers `CONTENT/PLAY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DRM / playback-pad
|
||||||
|
|
||||||
|
### Bewezen (replay HLS)
|
||||||
|
|
||||||
|
Client krijgt:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
streamUrl: "<hls master>",
|
||||||
|
format: "hls",
|
||||||
|
drm: null, // of weglaten
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
iOS/Android moeten `drm: null` HLS al aankunnen (Events speelt nu ClearKey; check of plain HLS-pad bestaat — zo niet: kleine player-uitbreiding).
|
||||||
|
|
||||||
|
### Onzeker (live)
|
||||||
|
|
||||||
|
Live kan DASH + Widevine zijn. Plan:
|
||||||
|
|
||||||
|
1. MVP: aanname “zelfde HLS als replay”
|
||||||
|
2. Raceweekend: live-HAR valideren
|
||||||
|
3. Zo DRM: Odido-achtig Widevine→ClearKey **of** client Widevine (TV) — aparte beslissing
|
||||||
|
|
||||||
|
Geen CDM-werk in MVP tenzij live-HAR het afdwingt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Client: F1-hub UX
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
|
||||||
|
In **Events** (list):
|
||||||
|
|
||||||
|
- Compacte **F1-tile / chip / hero-rail** bovenaan of in de chip-rij
|
||||||
|
- Label: “F1” of “Formule 1”
|
||||||
|
- Sub: “Agenda & terugkijken”
|
||||||
|
- Tap → `F1HubScreen`
|
||||||
|
|
||||||
|
Niet alleen “racing-shelf”: racing kan MotoGP/etc. blijven; F1-hub is dedicated.
|
||||||
|
|
||||||
|
### Navigatie
|
||||||
|
|
||||||
|
```
|
||||||
|
EventsScreen
|
||||||
|
└─ F1HubScreen
|
||||||
|
├─ MeetingDetail (weekend)
|
||||||
|
│ ├─ Session cards (upcoming / live / replay)
|
||||||
|
│ └─ Play → bestaande PlayerScreen
|
||||||
|
└─ ReplayDetail → Play
|
||||||
|
```
|
||||||
|
|
||||||
|
Android: `f1_hub` / `f1_meeting/{key}` / `f1_play/{contentId}` in `McNav`.
|
||||||
|
iOS: `NavigationStack` path of sheet binnen Events-stack.
|
||||||
|
|
||||||
|
### Hub-layout (Events-DNA, F1-skin)
|
||||||
|
|
||||||
|
Spiegel `EventsLook`, maar F1-palette:
|
||||||
|
|
||||||
|
- Rood accent (`#E10600` F1-achtig), diep zwart, wit typografie
|
||||||
|
- Geen dashboard-rommel: weinig chips, lange horizontale rails
|
||||||
|
|
||||||
|
**Eerste viewport (efficient):**
|
||||||
|
|
||||||
|
1. Header “F1” + LIVE-pill als iets live is
|
||||||
|
2. **Nu / Volgende** — 1 grote featured card (live of next session)
|
||||||
|
3. **Dit weekend** — horizontale rail sessies (FP/Quali/Race)
|
||||||
|
4. **Terugkijken** — rail laatste races (compacte kaarten)
|
||||||
|
5. **Kalender** — verticale of horizontale meeting-rail (vlag/circuit-naam)
|
||||||
|
|
||||||
|
Focus-TV: D-pad FocusRestore zoals Events (filter → first rail).
|
||||||
|
|
||||||
|
### Wat wél / niet in algemene Events
|
||||||
|
|
||||||
|
| | Events-tab | F1-hub |
|
||||||
|
|--|------------|--------|
|
||||||
|
| Live F1-sessie | Ja (`racing`, playable) | Ja (featured) |
|
||||||
|
| Upcoming F1 | Ja (upcoming phase) | Ja (agenda) |
|
||||||
|
| Replay | Nee | Ja |
|
||||||
|
| Multi-cam | Nee (MVP) | Later |
|
||||||
|
|
||||||
|
Zo blijft Events schoon; hub is de rijke F1-ervaring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementatiefases
|
||||||
|
|
||||||
|
### Fase 0 — Design locked (nu)
|
||||||
|
|
||||||
|
Dit document + akkoord scope hub.
|
||||||
|
|
||||||
|
### Fase 1 — Backend skeleton
|
||||||
|
|
||||||
|
- `IntegrationSetting` f1tv + Scripts UI (tokens handmatig)
|
||||||
|
- Client: PAGE calendar + meeting parse + sync snapshot
|
||||||
|
- Unit/probe script met tokens uit HAR (geen Imperva-login nog)
|
||||||
|
|
||||||
|
### Fase 2 — Play path
|
||||||
|
|
||||||
|
- `play.ts` → CONTENT/PLAY → HLS
|
||||||
|
- Wire `GET .../f1/.../play` én Events `/:id/play` voor `f1tv:*` ids
|
||||||
|
- Client player: plain HLS zonder ClearKey
|
||||||
|
|
||||||
|
### Fase 3 — Events merge
|
||||||
|
|
||||||
|
- Inject upcoming/live in `/client/events`
|
||||||
|
- Provider toggle admin/viewer
|
||||||
|
- Smoke: F1 verschijnt in racing-shelf
|
||||||
|
|
||||||
|
### Fase 4 — F1 Hub UI
|
||||||
|
|
||||||
|
- iOS + Android TV hub screens
|
||||||
|
- Entry tile in Events
|
||||||
|
- Agenda + terugkijken rails
|
||||||
|
- Meeting detail
|
||||||
|
|
||||||
|
### Fase 5 — Hardening
|
||||||
|
|
||||||
|
- Token refresh flow / expiry health
|
||||||
|
- Live-weekend validatie (DRM?)
|
||||||
|
- Optioneel: multi-cam picker
|
||||||
|
- Optioneel: Imperva-assisted login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risico’s & open punten
|
||||||
|
|
||||||
|
| Risico | Mitigatie |
|
||||||
|
|--------|-----------|
|
||||||
|
| Imperva blokkeert password-login vanaf server | Tokens handmatig / browser-assisted; geen fake login in MVP |
|
||||||
|
| Live ≠ clear HLS | Live-HAR vóór productie-claim; CDM-fallback klaarzetten |
|
||||||
|
| Token TTL / concurrent streams | Health + rate-limit play; 1 account eerst |
|
||||||
|
| FAAB + F1TV dubbele F1-events | Dedup op naam/tijd of disable FAAB F1 als f1tv aan staat |
|
||||||
|
| HLS auth cookies/headers op CDN | PLAY-URL bevat signed `pa_` path — test of clients zonder extra headers kunnen (HAR suggereert ja) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Success criteria
|
||||||
|
|
||||||
|
1. Upcoming Azerbaijan-sessies zichtbaar in Events (upcoming) en F1-hub
|
||||||
|
2. Tijdens live: playable in Events + hub
|
||||||
|
3. Replay uit hub speelt beeld (zelfde pad als `spelend.har`)
|
||||||
|
4. Viewer zonder `f1tv` in allowlist ziet geen F1TV-items
|
||||||
|
5. Geen regressie Odido / bestaande Events-providers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Samenvatting
|
||||||
|
|
||||||
|
- **Terugkijken:** ja, zelfde F1TV PLAY-pad — thuis in de **F1-hub**, niet in de standaard Events-lijst.
|
||||||
|
- **Live/upcoming:** F1TV sync → Events (`racing`) + hub.
|
||||||
|
- **UI:** F1-knoop onder Events → dedicated hub (agenda + replays), Events-layout-principes, F1-skin.
|
||||||
|
- **Auth:** tokens eerst; Imperva-login later.
|
||||||
|
- **DRM:** replay clear HLS; live valideren tijdens raceweekend.
|
||||||
|
|
||||||
|
Volgende stap na akkoord: **Fase 1** (backend skeleton + token Settings), of eerst alleen hub wireframes als je de UI-richting wilt vastzetten vóór API-werk.
|
||||||
Loading…
Reference in a new issue