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:
Jos Vooges | STH 2026-09-22 03:43:56 +02:00
parent 852a91cbed
commit 8685870358
15 changed files with 3557 additions and 716 deletions

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View file

@ -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>
</>
); );
} }

View file

@ -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");

View 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);
}

View 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];
}

View 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 };
}

View 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;
}

View 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) },
});
}

View 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
);
}
}

View 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()}`;
}

View file

@ -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.

View file

@ -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
View 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.