From 8685870358a01b85a7303b34f13560b30ab82b73 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 22 Sep 2026 03:43:56 +0200 Subject: [PATCH] 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. --- apps/admin-ui/src/app/scripts/F1tvPanel.tsx | 601 +++++++++++++++ apps/admin-ui/src/app/scripts/OdidoPanel.tsx | 677 +++++++++++++++++ .../src/app/scripts/ScriptsPageInner.tsx | 175 +++++ apps/admin-ui/src/app/scripts/page.tsx | 683 +----------------- apps/master-api/src/admin/routes.ts | 98 +++ apps/master-api/src/f1tv/accounts.ts | 198 +++++ apps/master-api/src/f1tv/client.ts | 206 ++++++ apps/master-api/src/f1tv/events.ts | 166 +++++ apps/master-api/src/f1tv/play.ts | 79 ++ apps/master-api/src/f1tv/settings.ts | 360 +++++++++ apps/master-api/src/f1tv/sync.ts | 276 +++++++ apps/master-api/src/f1tv/tweaks.ts | 131 ++++ apps/master-api/src/viewer/routes.ts | 86 +++ apps/master-api/src/viewer/schedule-events.ts | 178 +++-- docs/f1tv-events-design.md | 359 +++++++++ 15 files changed, 3557 insertions(+), 716 deletions(-) create mode 100644 apps/admin-ui/src/app/scripts/F1tvPanel.tsx create mode 100644 apps/admin-ui/src/app/scripts/OdidoPanel.tsx create mode 100644 apps/admin-ui/src/app/scripts/ScriptsPageInner.tsx create mode 100644 apps/master-api/src/f1tv/accounts.ts create mode 100644 apps/master-api/src/f1tv/client.ts create mode 100644 apps/master-api/src/f1tv/events.ts create mode 100644 apps/master-api/src/f1tv/play.ts create mode 100644 apps/master-api/src/f1tv/settings.ts create mode 100644 apps/master-api/src/f1tv/sync.ts create mode 100644 apps/master-api/src/f1tv/tweaks.ts create mode 100644 docs/f1tv-events-design.md diff --git a/apps/admin-ui/src/app/scripts/F1tvPanel.tsx b/apps/admin-ui/src/app/scripts/F1tvPanel.tsx new file mode 100644 index 0000000..8604124 --- /dev/null +++ b/apps/admin-ui/src/app/scripts/F1tvPanel.tsx @@ -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; + +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 | 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(null); + const [enabled, setEnabled] = useState(true); + const [tweaks, setTweaks] = useState({}); + const [showTweaks, setShowTweaks] = useState(false); + const [showTokens, setShowTokens] = useState(false); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(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 ( + + uit + + ); + } + if (a.healthStatus === "ok") { + return ( + + actief + + ); + } + if (a.healthStatus === "dead") { + return ( + + dood + + ); + } + return ( + + onbekend + + ); + } + + function resetTweaks() { + if (settings?.tweakDefaults) setTweaks({ ...settings.tweakDefaults }); + } + + const accounts = settings?.accounts ?? []; + const meta = settings?.tweakMeta ?? []; + const summary = settings?.lastSyncSummary; + + return ( + <> + {msg && ( +

+ {msg} +

+ )} + {err && ( +

+ {err} +

+ )} + +
+

F1TV

+

+ Events + F1-hub. Imperva blokkeert server-login: plak{" "} + ascendontoken + entitlementtoken uit DevTools (na browser-login). + Meerdere accounts: sessies worden als unie samengevoegd; play round-robint over owners. +

+ +
+ + +
+ +

Accounts

+ {accounts.length === 0 ? ( +

Nog geen accounts — voeg e-mail + tokens toe.

+ ) : ( +
    + {accounts.map((a) => ( +
  • +
    + {a.label || a.email} + {healthBadge(a)} + + {a.email} + {" · "} + {a.hasAscendonToken && a.hasEntitlementToken ? "tokens ok" : "tokens missen"} + {a.sessionCount ? ` · ${a.sessionCount} sessies` : ""} + +
    +
    + + + +
    +
  • + ))} +
+ )} + +
+
+ + + +
+ + + + {showTokens && ( +
+