Add OpenF1 live timing in the iOS F1 hub, with credentials stored in admin.

Login lives encrypted in the database (Instellingen → OpenF1) so the phone can use live data without env secrets.
This commit is contained in:
Jos Vooges | STH 2026-10-01 01:22:46 +02:00
parent 97e76c3551
commit 84cc1e99f9
19 changed files with 2050 additions and 15 deletions

View file

@ -302,6 +302,14 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
</p>
)}
<div className="card" style={{ maxWidth: 760, marginBottom: "1rem" }}>
<h2 style={{ marginTop: 0 }}>OpenF1 live timing</h2>
<p className="muted" style={{ marginTop: 0 }}>
Posities, circuitkaart en telemetrie in de F1-hub. Login staat onder{" "}
<a href="/settings?tab=openf1">Instellingen → OpenF1</a> (versleuteld in de database).
</p>
</div>
<div className="card" style={{ maxWidth: 760 }}>
<h2 style={{ marginTop: 0 }}>F1TV</h2>
<p className="muted" style={{ marginTop: 0 }}>

View file

@ -10,6 +10,7 @@ type SettingsTab =
| "download-station"
| "opensubtitles"
| "omdb"
| "openf1"
| "google-play"
| "tv";
@ -19,6 +20,7 @@ function parseTab(v: string | null): SettingsTab {
v === "download-station" ||
v === "opensubtitles" ||
v === "omdb" ||
v === "openf1" ||
v === "shelves" ||
v === "google-play" ||
v === "tv"
@ -70,6 +72,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
{ id: "omdb", label: "OMDb", short: "OMDb" },
{ id: "openf1", label: "OpenF1", short: "F1" },
{ id: "google-play", label: "Google Play", short: "Play" },
];
@ -133,6 +136,16 @@ export default function SettingsPage() {
const [omdbErr, setOmdbErr] = useState<string | null>(null);
const [omdbWarn, setOmdbWarn] = useState<string | null>(null);
const [openF1Username, setOpenF1Username] = useState("");
const [openF1Password, setOpenF1Password] = useState("");
const [openF1Enabled, setOpenF1Enabled] = useState(true);
const [openF1Configured, setOpenF1Configured] = useState(false);
const [openF1HasPassword, setOpenF1HasPassword] = useState(false);
const [openF1Busy, setOpenF1Busy] = useState(false);
const [openF1Msg, setOpenF1Msg] = useState<string | null>(null);
const [openF1Err, setOpenF1Err] = useState<string | null>(null);
const [openF1Warn, setOpenF1Warn] = useState<string | null>(null);
const [adminEmail, setAdminEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@ -242,6 +255,21 @@ export default function SettingsPage() {
.catch(() => undefined);
}, []);
const loadOpenF1 = useCallback(() => {
fetch("/api/v1/admin/settings/openf1", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const s = d.settings;
if (!s) return;
setOpenF1Enabled(s.enabled !== false);
setOpenF1Configured(!!s.configured);
setOpenF1HasPassword(!!s.hasPassword);
setOpenF1Username(typeof s.username === "string" ? s.username : "");
setOpenF1Warn(s.warning || null);
})
.catch(() => undefined);
}, []);
const loadGooglePlay = useCallback(() => {
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
.then((r) => r.json())
@ -311,6 +339,7 @@ export default function SettingsPage() {
loadDs();
loadOpenSubtitles();
loadOmdb();
loadOpenF1();
loadGooglePlay();
loadTvSettings();
loadTokenTray();
@ -326,7 +355,7 @@ export default function SettingsPage() {
}
})
.catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings, loadTokenTray]);
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadOpenF1, loadGooglePlay, loadTvSettings, loadTokenTray]);
async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true);
@ -580,6 +609,36 @@ export default function SettingsPage() {
}
}
async function saveOpenF1() {
setOpenF1Busy(true);
setOpenF1Msg(null);
setOpenF1Err(null);
setOpenF1Warn(null);
try {
const res = await fetch("/api/v1/admin/settings/openf1", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: openF1Username.trim() || undefined,
password: openF1Password || undefined,
enabled: openF1Enabled,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setOpenF1Err(data.error?.message ?? "Opslaan mislukt");
return;
}
setOpenF1Msg(data.settings?.configured ? "Opgeslagen — OpenF1 OK" : "Opgeslagen");
setOpenF1Warn(data.settings?.warning || null);
setOpenF1Password("");
loadOpenF1();
} finally {
setOpenF1Busy(false);
}
}
async function saveGooglePlay() {
setGpBusy(true);
setGpMsg(null);
@ -1482,6 +1541,64 @@ export default function SettingsPage() {
</section>
)}
{tab === "openf1" && (
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">OpenF1</h2>
<p className="muted">
Live timing, circuitkaart en telemetrie voor de F1-hub. Account van{" "}
<a href="https://openf1.org/" target="_blank" rel="noreferrer">
openf1.org
</a>
. E-mail en wachtwoord worden versleuteld in de database bewaard (niet in env/git).
</p>
</div>
<div className="card">
<div className="form-grid">
<label>
E-mail
<input
type="email"
value={openF1Username}
onChange={(e) => setOpenF1Username(e.target.value)}
placeholder="account@openf1.org"
autoComplete="off"
/>
</label>
<label>
Wachtwoord {openF1HasPassword ? "(leeg = behouden)" : ""}
<input
type="password"
value={openF1Password}
onChange={(e) => setOpenF1Password(e.target.value)}
placeholder={openF1HasPassword ? "••••••••" : "OpenF1-wachtwoord"}
autoComplete="new-password"
/>
</label>
<label className="check-label" style={{ alignSelf: "end" }}>
<input
type="checkbox"
checked={openF1Enabled}
onChange={(e) => setOpenF1Enabled(e.target.checked)}
/>
Ingeschakeld
</label>
</div>
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
<button type="button" disabled={openF1Busy} onClick={() => void saveOpenF1()}>
{openF1Busy ? "Bezig…" : "Opslaan + testen"}
</button>
<span className="muted">
Status: {openF1Configured && openF1Enabled ? "aan" : "uit"}
</span>
{openF1Msg && <span className="ok-text">{openF1Msg}</span>}
{openF1Warn && <span className="danger-text">{openF1Warn}</span>}
{openF1Err && <span className="danger-text">{openF1Err}</span>}
</div>
</div>
</section>
)}
{tab === "google-play" && (
<section className="stack-section">
<div className="section-head">

View file

@ -34,6 +34,7 @@
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */; };
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */; };
D7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */; };
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
@ -71,6 +72,7 @@
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = "<group>"; };
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = "<group>"; };
C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = F1LiveScreen.swift; sourceTree = "<group>"; };
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
@ -189,6 +191,7 @@
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */,
A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */,
C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */,
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
);
@ -295,6 +298,7 @@
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
E5F6A7B8C9D0E1F2A3B4C5D6 /* EventsScreen.swift in Sources */,
B7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift in Sources */,
D7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift in Sources */,
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
);
@ -345,7 +349,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 72;
CURRENT_PROJECT_VERSION = 73;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -360,7 +364,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.59;
MARKETING_VERSION = 0.5.60;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -378,7 +382,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 72;
CURRENT_PROJECT_VERSION = 73;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -393,7 +397,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.59;
MARKETING_VERSION = 0.5.60;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -354,6 +354,10 @@ actor ApiClient {
try await get("/api/v1/client/f1/home")
}
func f1Live() async throws -> F1LiveDashboard {
try await get("/api/v1/client/f1/live")
}
func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail {
let enc = Self.encodePathSegment(meetingKey)
return try await get("/api/v1/client/f1/meetings/\(enc)")

View file

@ -768,7 +768,175 @@ struct ViaplayHubPayload: Decodable {
catchup = try c.decodeIfPresent([ScheduleEvent].self, forKey: .catchup) ?? []
sports = try c.decodeIfPresent([ViaplaySport].self, forKey: .sports) ?? []
events = try c.decodeIfPresent([ScheduleEvent].self, forKey: .events) ?? []
rails = try c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails) ?? []
rails = (try? c.decodeIfPresent([ViaplayHubRail].self, forKey: .rails)) ?? []
}
}
// MARK: - F1 Live (OpenF1)
struct F1NewsItem: Decodable, Identifiable, Hashable {
let id: String
let title: String
let url: String
let imageUrl: String?
let publishedAt: String?
let source: String?
}
struct F1LiveDriver: Decodable, Identifiable, Hashable {
var id: Int { driverNumber }
let position: Int
let driverNumber: Int
let acronym: String
let fullName: String
let teamName: String
let teamColor: String
let headshotUrl: String?
let gap: String
let interval: String
let lastLap: String
let bestLap: String
let tyre: String
let tyreAge: Int?
let laps: Int?
enum CodingKeys: String, CodingKey {
case position, driverNumber, acronym, fullName, teamName, teamColor, headshotUrl
case gap, interval, lastLap, bestLap, tyre, tyreAge, laps
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
position = (try? c.decode(Int.self, forKey: .position)) ?? 0
driverNumber = (try? c.decode(Int.self, forKey: .driverNumber)) ?? 0
acronym = (try? c.decode(String.self, forKey: .acronym)) ?? ""
fullName = (try? c.decode(String.self, forKey: .fullName)) ?? ""
teamName = (try? c.decode(String.self, forKey: .teamName)) ?? ""
teamColor = (try? c.decode(String.self, forKey: .teamColor)) ?? "888888"
headshotUrl = try c.decodeIfPresent(String.self, forKey: .headshotUrl)
gap = (try? c.decode(String.self, forKey: .gap)) ?? "—"
interval = (try? c.decode(String.self, forKey: .interval)) ?? "—"
lastLap = (try? c.decode(String.self, forKey: .lastLap)) ?? "—"
bestLap = (try? c.decode(String.self, forKey: .bestLap)) ?? "—"
tyre = (try? c.decode(String.self, forKey: .tyre)) ?? "—"
if let i = try? c.decode(Int.self, forKey: .tyreAge) { tyreAge = i }
else if let d = try? c.decode(Double.self, forKey: .tyreAge) { tyreAge = Int(d) }
else { tyreAge = nil }
if let i = try? c.decode(Int.self, forKey: .laps) { laps = i }
else if let d = try? c.decode(Double.self, forKey: .laps) { laps = Int(d) }
else { laps = nil }
}
}
struct F1LiveCar: Decodable, Hashable {
let driverNumber: Int
let acronym: String
let teamColor: String
let x: Double
let y: Double
}
struct F1LivePoint: Decodable, Hashable {
let x: Double
let y: Double
}
struct F1LiveMap: Decodable {
let name: String?
let path: [F1LivePoint]
let cars: [F1LiveCar]
enum CodingKeys: String, CodingKey { case name, path, cars }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decodeIfPresent(String.self, forKey: .name)
path = (try? c.decode([F1LivePoint].self, forKey: .path)) ?? []
cars = (try? c.decode([F1LiveCar].self, forKey: .cars)) ?? []
}
}
struct F1LiveTelemetry: Decodable {
let driverNumber: Int
let acronym: String
let speed: Double?
let throttle: Double?
let brake: Double?
let rpm: Double?
let gear: Double?
let drs: Bool?
}
struct F1LiveWeather: Decodable {
let airTemperature: Double?
let trackTemperature: Double?
let humidity: Double?
let windSpeed: Double?
let windDirection: Double?
let rainfall: Bool?
}
struct F1LiveRaceControl: Decodable, Identifiable, Hashable {
var id: String { "\(date ?? "")-\(message)" }
let date: String?
let category: String?
let flag: String?
let message: String
let driverNumber: Int?
}
struct F1LiveScheduleItem: Decodable, Identifiable, Hashable {
var id: String { "\(sessionName)-\(dateStart ?? "")" }
let sessionName: String
let dateStart: String?
let dateEnd: String?
let status: String
}
struct F1LiveSession: Decodable {
let sessionKey: Int?
let meetingKey: Int?
let meetingName: String?
let sessionName: String?
let sessionType: String?
let circuitName: String?
let countryName: String?
let countryCode: String?
let location: String?
let dateStart: String?
let dateEnd: String?
let year: Int?
let currentLap: Int?
}
struct F1LiveDashboard: Decodable {
let live: Bool
let source: String?
let session: F1LiveSession
let weather: F1LiveWeather?
let timing: [F1LiveDriver]
let map: F1LiveMap?
let telemetry: F1LiveTelemetry?
let raceControl: [F1LiveRaceControl]
let schedule: [F1LiveScheduleItem]
let news: [F1NewsItem]
enum CodingKeys: String, CodingKey {
case live, source, session, weather, timing, map, telemetry, raceControl, schedule, news
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
live = (try? c.decode(Bool.self, forKey: .live)) ?? false
source = try c.decodeIfPresent(String.self, forKey: .source)
session = try c.decode(F1LiveSession.self, forKey: .session)
weather = try c.decodeIfPresent(F1LiveWeather.self, forKey: .weather)
timing = (try? c.decode([F1LiveDriver].self, forKey: .timing)) ?? []
map = try c.decodeIfPresent(F1LiveMap.self, forKey: .map)
telemetry = try c.decodeIfPresent(F1LiveTelemetry.self, forKey: .telemetry)
raceControl = (try? c.decode([F1LiveRaceControl].self, forKey: .raceControl)) ?? []
schedule = (try? c.decode([F1LiveScheduleItem].self, forKey: .schedule)) ?? []
news = (try? c.decode([F1NewsItem].self, forKey: .news)) ?? []
}
}

View file

@ -4,6 +4,7 @@ import SwiftUI
enum EventHubRoute: Hashable {
case f1
case f1Live
case f1Meeting(String)
case viaplay
@ -22,6 +23,7 @@ struct EventHubDestination: View {
var body: some View {
switch route {
case .f1: F1HubScreen()
case .f1Live: F1LiveScreen()
case .f1Meeting(let key): F1MeetingScreen(meetingKey: key)
case .viaplay: ViaplayHubScreen()
}
@ -169,6 +171,7 @@ struct F1HubScreen: View {
VStack(alignment: .leading, spacing: 32) {
hero(p, layout)
liveTimingEntry(live: !p.live.isEmpty)
if isEmpty {
ContentUnavailableView(
@ -245,20 +248,22 @@ struct F1HubScreen: View {
Label(state == .live ? "Kijk live" : "Terugkijken", systemImage: "play.fill")
}
.buttonStyle(HubPrimaryButtonStyle())
if let meeting {
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
Label("Weekend", systemImage: "calendar")
NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer")
}
.buttonStyle(HubGlassButtonStyle())
}
} else if let meeting {
NavigationLink(value: EventHubRoute.f1Meeting(meeting.meetingKey)) {
Label("Bekijk weekend", systemImage: "calendar")
}
.buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer")
}
.buttonStyle(HubGlassButtonStyle())
} else {
NavigationLink(value: featured.id) {
Label("Details", systemImage: "info.circle")
NavigationLink(value: EventHubRoute.f1Live) {
Label("Live timing", systemImage: "timer")
}
.buttonStyle(HubPrimaryButtonStyle())
}
@ -279,6 +284,10 @@ struct F1HubScreen: View {
Label("Bekijk weekend", systemImage: "calendar")
}
.buttonStyle(HubPrimaryButtonStyle())
NavigationLink(value: EventHubRoute.f1Live) {
Label("Timing", systemImage: "timer")
}
.buttonStyle(HubGlassButtonStyle())
}
} else {
HubHero(
@ -289,8 +298,56 @@ struct F1HubScreen: View {
isLive: false,
title: "Formule 1",
subtitle: "Live sessies en replays"
) { EmptyView() }
) {
NavigationLink(value: EventHubRoute.f1Live) {
Label("Live timing", systemImage: "timer")
}
.buttonStyle(HubPrimaryButtonStyle())
}
}
}
private func liveTimingEntry(live: Bool) -> some View {
NavigationLink(value: EventHubRoute.f1Live) {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(theme.accent)
Image(systemName: "flag.checkered")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 3) {
Text("Live timing")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
Text("Posities, circuitkaart en telemetrie")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.62))
}
Spacer(minLength: 8)
if live {
Text("LIVE")
.font(.system(size: 11, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(HubTheme.liveRed, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.45))
}
.padding(14)
.background(Color.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
)
.padding(.horizontal, 20)
}
.buttonStyle(HubPressStyle())
}
private func seasonSection(_ p: F1HubPayload) -> some View {

View file

@ -0,0 +1,697 @@
import SwiftUI
private enum F1LiveLook {
static let bg = Color(red: 0.043, green: 0.051, blue: 0.071)
static let card = Color(red: 0.086, green: 0.102, blue: 0.137)
static let stroke = Color.white.opacity(0.10)
static let live = Color(red: 1.0, green: 0.231, blue: 0.188)
static let gold = Color(red: 1.0, green: 0.82, blue: 0.32)
}
struct F1LiveScreen: View {
enum Tab: String, CaseIterable, Identifiable {
case overview, timing, map, telemetry, news
var id: String { rawValue }
var label: String {
switch self {
case .overview: return "Overzicht"
case .timing: return "Live Timing"
case .map: return "Track Map"
case .telemetry: return "Telemetrie"
case .news: return "Nieuws"
}
}
}
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var tab: Tab = .overview
@State private var payload: F1LiveDashboard?
@State private var error: String?
@State private var speedTrail: [Double] = []
private let clock = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
var body: some View {
ZStack(alignment: .top) {
F1LiveLook.bg.ignoresSafeArea()
if let payload {
loaded(payload)
} else if let error {
VStack(spacing: 14) {
header(live: false, title: "F1 Live", subtitle: nil)
ContentUnavailableView {
Label("Live timing niet beschikbaar", systemImage: "flag.checkered")
} description: {
Text(error)
} actions: {
Button("Opnieuw") { Task { await load() } }
.buttonStyle(McPrimaryButtonStyle())
}
Spacer()
}
} else {
VStack {
header(live: false, title: "F1 Live", subtitle: nil)
Spacer()
ProgressView().controlSize(.large).tint(.white)
Spacer()
}
}
}
.environment(\.colorScheme, .dark)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.task { await load() }
.onReceive(clock) { _ in Task { await load(silent: true) } }
}
@ViewBuilder
private func loaded(_ p: F1LiveDashboard) -> some View {
VStack(spacing: 0) {
header(
live: p.live,
title: p.session.meetingName ?? "F1 Live",
subtitle: [
p.session.sessionName,
p.session.circuitName,
p.session.currentLap.map { "Ronde \($0)" },
].compactMap { $0 }.joined(separator: " · ")
)
tabBar
ScrollView {
Group {
switch tab {
case .overview: overview(p)
case .timing: timingTable(p.timing)
case .map: mapBlock(p)
case .telemetry: telemetryBlock(p)
case .news: newsList(p.news)
}
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 40)
}
}
}
private func header(live: Bool, title: String, subtitle: String?) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.white.opacity(0.10), in: Circle())
}
VStack(alignment: .leading, spacing: 2) {
Text("F1 Live")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.55))
Text(title)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(.white)
.lineLimit(1)
}
Spacer(minLength: 8)
if live {
Text("LIVE")
.font(.system(size: 11, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(F1LiveLook.live, in: Capsule())
}
}
HStack {
Text("Aangedreven door OpenF1")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
Spacer()
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.45))
.lineLimit(1)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 6)
.padding(.bottom, 10)
}
private var tabBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Tab.allCases) { item in
Button {
tab = item
} label: {
Text(item.label)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(tab == item ? .white : .white.opacity(0.55))
.padding(.horizontal, 14)
.frame(height: 34)
.background(tab == item ? F1LiveLook.live : Color.white.opacity(0.08), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
.padding(.bottom, 4)
}
private func overview(_ p: F1LiveDashboard) -> some View {
VStack(alignment: .leading, spacing: 16) {
if let w = p.weather {
weatherCard(w, circuit: p.session.circuitName)
}
card {
sectionTitle("Live posities")
ForEach(Array(p.timing.prefix(8))) { d in
timingRow(d, compact: true)
}
if p.timing.count > 8 {
Button {
tab = .timing
} label: {
Text("Bekijk alles")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.top, 6)
}
.buttonStyle(.plain)
}
}
HStack(spacing: 10) {
shortcut("Live Timing", "timer") { tab = .timing }
shortcut("Track Map", "map") { tab = .map }
shortcut("Telemetrie", "speedometer") { tab = .telemetry }
}
if let t = p.telemetry {
telemetryCard(t)
}
if !p.news.isEmpty {
sectionTitle("Nieuws")
ForEach(p.news.prefix(4)) { item in
newsCard(item)
}
}
}
}
private func timingTable(_ rows: [F1LiveDriver]) -> some View {
card {
HStack {
Text("POS").frame(width: 28, alignment: .leading)
Text("Coureur").frame(maxWidth: .infinity, alignment: .leading)
Text("Verschil").frame(width: 72, alignment: .trailing)
Text("Lap").frame(width: 64, alignment: .trailing)
Text("").frame(width: 22)
}
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white.opacity(0.4))
ForEach(rows) { d in
timingRow(d, compact: false)
}
}
}
private func timingRow(_ d: F1LiveDriver, compact: Bool) -> some View {
HStack(spacing: 8) {
Text("\(d.position)")
.font(.system(size: 13, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
.frame(width: 22, alignment: .leading)
Circle()
.fill(F1Hex.color(d.teamColor))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 1) {
Text(d.acronym)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
if !compact {
Text(d.teamName)
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.45))
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(d.position == 1 ? "—" : d.gap)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundStyle(d.position == 1 ? F1LiveLook.gold : .white.opacity(0.85))
if !compact {
Text(d.lastLap)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(.white.opacity(0.65))
.frame(width: 64, alignment: .trailing)
}
tyreBadge(d.tyre)
}
.padding(.vertical, 6)
}
private func mapBlock(_ p: F1LiveDashboard) -> some View {
VStack(alignment: .leading, spacing: 14) {
card {
HStack {
Text(p.map?.name ?? p.session.circuitName ?? "Circuit")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
Spacer()
Text("Van boven · live dots")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
}
F1TrackMapCanvas(path: p.map?.path ?? [], cars: p.map?.cars ?? [])
.frame(height: 280)
.padding(.top, 8)
}
if let leader = p.timing.first {
driverSpotlight(leader)
}
if let t = p.telemetry {
telemetryCard(t)
}
}
}
private func telemetryBlock(_ p: F1LiveDashboard) -> some View {
VStack(alignment: .leading, spacing: 14) {
if let t = p.telemetry {
telemetryCard(t)
if speedTrail.count > 1 {
card {
sectionTitle("Snelheid")
F1Sparkline(values: speedTrail)
.stroke(F1LiveLook.live, lineWidth: 2)
.frame(height: 120)
}
}
} else {
Text("Geen telemetrie voor deze sessie.")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
}
if !p.raceControl.isEmpty {
card {
sectionTitle("Race control")
ForEach(p.raceControl.prefix(8)) { item in
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(flagColor(item.flag))
.frame(width: 8, height: 8)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 2) {
Text(item.message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
if let date = item.date {
Text(F1LiveFormat.clock(date))
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.4))
}
}
}
.padding(.vertical, 4)
}
}
}
}
}
private func newsList(_ items: [F1NewsItem]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("RacingNews365")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
ForEach(items) { item in
newsCard(item)
}
}
}
private func weatherCard(_ w: F1LiveWeather, circuit: String?) -> some View {
card {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(circuit ?? "Weer")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
Text(w.rainfall == true ? "Regen" : "Droog")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
weatherStat("Lucht", w.airTemperature.map { "\(Int($0.rounded()))°" } ?? "—")
weatherStat("Baan", w.trackTemperature.map { "\(Int($0.rounded()))°" } ?? "—")
weatherStat("Vocht", w.humidity.map { "\(Int($0.rounded()))%" } ?? "—")
}
}
}
private func weatherStat(_ label: String, _ value: String) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
}
.frame(width: 54)
}
private func telemetryCard(_ t: F1LiveTelemetry) -> some View {
card {
HStack {
sectionTitle("Live telemetrie")
Spacer()
Text(t.acronym)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
HStack(alignment: .bottom, spacing: 16) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(Int(t.speed ?? 0))")
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("km/h")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
}
Spacer()
Text("\(Int(t.gear ?? 0))")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(F1LiveLook.gold)
}
meter("Gas", (t.throttle ?? 0) / 100, .green)
meter("Rem", (t.brake ?? 0) / 100, F1LiveLook.live)
HStack {
Text("RPM \(t.rpm.map { "\(Int($0))" } ?? "—")")
Spacer()
if t.drs == true {
Text("DRS")
.font(.system(size: 11, weight: .heavy))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.25), in: Capsule())
}
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.55))
}
}
private func meter(_ label: String, _ value: Double, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
Spacer()
Text("\(Int((value * 100).clamped(to: 0...100)))%")
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08))
Capsule()
.fill(color)
.frame(width: max(4, geo.size.width * value.clamped(to: 0...1)))
}
}
.frame(height: 8)
}
}
private func driverSpotlight(_ d: F1LiveDriver) -> some View {
card {
HStack(spacing: 12) {
ZStack {
Circle().fill(F1Hex.color(d.teamColor).opacity(0.25))
if let url = d.headshotUrl {
RemoteImage(url: url, contentMode: .fill)
.clipShape(Circle())
}
}
.frame(width: 64, height: 64)
VStack(alignment: .leading, spacing: 3) {
Text(d.fullName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
Text(d.teamName)
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
Text("P\(d.position)")
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
HStack {
Text("Laatste \(d.lastLap)")
Spacer()
Text("Beste \(d.bestLap)")
}
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.white.opacity(0.55))
.padding(.top, 6)
}
}
private func newsCard(_ item: F1NewsItem) -> some View {
Button {
if let url = URL(string: item.url) { openURL(url) }
} label: {
HStack(alignment: .top, spacing: 12) {
RemoteImage(url: item.imageUrl, contentMode: .fill)
.frame(width: 92, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
.lineLimit(3)
Text(F1LiveFormat.newsDate(item.publishedAt) + " · RacingNews365")
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.4))
}
Spacer(minLength: 0)
}
.padding(10)
.background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(F1LiveLook.stroke, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func shortcut(_ title: String, _ icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
Text(title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(F1LiveLook.stroke, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
content()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(F1LiveLook.card, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(F1LiveLook.stroke, lineWidth: 1)
)
}
private func sectionTitle(_ t: String) -> some View {
Text(t)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
}
private func tyreBadge(_ tyre: String) -> some View {
Text(tyre)
.font(.system(size: 10, weight: .heavy))
.foregroundStyle(tyre == "H" ? .black : .white)
.frame(width: 22, height: 22)
.background(tyreColor(tyre), in: Circle())
}
private func tyreColor(_ tyre: String) -> Color {
switch tyre.uppercased() {
case "S", "SS", "US": return .red
case "M": return .yellow
case "H": return .white
case "I": return .green
case "W": return .blue
default: return .gray
}
}
private func flagColor(_ flag: String?) -> Color {
switch (flag ?? "").uppercased() {
case "YELLOW", "DOUBLE YELLOW": return .yellow
case "GREEN": return .green
case "RED": return .red
case "BLUE": return .blue
case "BLACK AND WHITE": return .white
default: return F1LiveLook.live
}
}
private func load(silent: Bool = false) async {
do {
let next = try await app.api.f1Live()
payload = next
error = nil
if let speed = next.telemetry?.speed {
speedTrail.append(speed)
if speedTrail.count > 40 { speedTrail.removeFirst(speedTrail.count - 40) }
}
} catch {
if payload == nil && !silent { self.error = error.localizedDescription }
}
}
}
private struct F1TrackMapCanvas: View {
let path: [F1LivePoint]
let cars: [F1LiveCar]
var body: some View {
GeometryReader { geo in
let inset: CGFloat = 28
let w = max(geo.size.width - inset * 2, 1)
let h = max(geo.size.height - inset * 2, 1)
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.black.opacity(0.35))
if path.count > 1 {
Path { p in
p.move(to: point(path[0], w: w, h: h, inset: inset))
for pt in path.dropFirst() {
p.addLine(to: point(pt, w: w, h: h, inset: inset))
}
}
.stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 5, lineJoin: .round, lineCap: .round))
} else {
Text("Geen circuitkaart")
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.4))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
ForEach(cars, id: \.driverNumber) { car in
let c = point(F1LivePoint(x: car.x, y: car.y), w: w, h: h, inset: inset)
Text(car.acronym)
.font(.system(size: 8, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(F1Hex.color(car.teamColor), in: Capsule())
.position(c)
}
}
}
}
private func point(_ p: F1LivePoint, w: CGFloat, h: CGFloat, inset: CGFloat) -> CGPoint {
CGPoint(x: inset + CGFloat(p.x) * w, y: inset + CGFloat(p.y) * h)
}
}
private struct F1Sparkline: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
guard let minV = values.min(), let maxV = values.max(), values.count > 1 else {
return Path()
}
let span = max(maxV - minV, 1)
var p = Path()
for (i, v) in values.enumerated() {
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
let y = rect.height - CGFloat((v - minV) / span) * rect.height
if i == 0 { p.move(to: CGPoint(x: x, y: y)) }
else { p.addLine(to: CGPoint(x: x, y: y)) }
}
return p
}
}
private enum F1Hex {
static func color(_ hex: String) -> Color {
var h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
if h.count == 3 {
h = h.map { "\($0)\($0)" }.joined()
}
guard h.count == 6, let n = UInt64(h, radix: 16) else { return .gray }
return Color(
.sRGB,
red: Double((n >> 16) & 0xFF) / 255,
green: Double((n >> 8) & 0xFF) / 255,
blue: Double(n & 0xFF) / 255,
opacity: 1
)
}
}
private enum F1LiveFormat {
static func clock(_ iso: String) -> String {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)
guard let d else { return iso }
let out = DateFormatter()
out.locale = Locale(identifier: "nl_NL")
out.dateFormat = "HH:mm:ss"
return out.string(from: d)
}
static func newsDate(_ iso: String?) -> String {
guard let iso, !iso.isEmpty else { return "" }
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)
guard let d else { return iso }
let out = DateFormatter()
out.locale = Locale(identifier: "nl_NL")
out.dateFormat = "d MMM HH:mm"
return out.string(from: d)
}
}
private extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}

View file

@ -29,6 +29,10 @@ HIDE_UNAVAILABLE_TITLES=true
# Voorkeur: URL's beheren in Admin UI (meerdere feeds mogelijk).
IPTV_EPG_XML_URL=https://epg.faabserver.nl/epg/odido.xml
# OpenF1 live timing: liever Admin → Instellingen → OpenF1 (versleuteld in DB).
# OPENF1_USERNAME=
# OPENF1_PASSWORD=
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
# GOOGLE_CLOUD_PROJECT_ID=
# GOOGLE_SERVICE_ACCOUNT_EMAIL=

View file

@ -6,7 +6,7 @@
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js",
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts && tsx src/viewer/event-fallbacks.test.ts",
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts && tsx src/viewer/event-fallbacks.test.ts && tsx src/openf1/circuit.test.ts && tsx src/openf1/news.test.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",

View file

@ -0,0 +1,88 @@
const TOKEN_URL = "https://api.openf1.org/token";
const SKEW_MS = 120_000;
type TokenState = {
accessToken: string;
expiresAt: number;
};
let cached: TokenState | null = null;
let inflight: Promise<string> | null = null;
function sessionSecret(): string {
return process.env.SESSION_SECRET?.trim() || "";
}
export function clearOpenF1TokenCache() {
cached = null;
inflight = null;
}
export async function resolveOpenF1Login(): Promise<{ username: string; password: string } | null> {
const secret = sessionSecret();
if (secret) {
const { resolveOpenF1Credentials } = await import("../settings/openf1");
const fromDb = await resolveOpenF1Credentials(secret);
if (fromDb) return fromDb;
}
return null;
}
export async function isOpenF1Configured(): Promise<boolean> {
return (await resolveOpenF1Login()) != null;
}
export async function testOpenF1Login(username: string, password: string): Promise<void> {
await fetchToken({ username, password }, false);
}
export async function getOpenF1AccessToken(): Promise<string> {
const creds = await resolveOpenF1Login();
if (!creds) {
throw new Error("OpenF1 is niet geconfigureerd (Admin → Instellingen → OpenF1)");
}
if (cached && cached.expiresAt - SKEW_MS > Date.now()) return cached.accessToken;
if (inflight) return inflight;
inflight = fetchToken(creds, true)
.then((token) => token)
.finally(() => {
inflight = null;
});
return inflight;
}
async function fetchToken(
creds: { username: string; password: string },
store: boolean
): Promise<string> {
const body = new URLSearchParams();
body.set("username", creds.username);
body.set("password", creds.password);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 20_000);
try {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body,
signal: ac.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`OpenF1 token HTTP ${res.status}`);
}
const json = JSON.parse(text) as { access_token?: string; expires_in?: string | number };
const accessToken = String(json.access_token || "").trim();
if (!accessToken) throw new Error("OpenF1 token-response zonder access_token");
if (store) {
const expiresIn = Number(json.expires_in || 3600);
cached = {
accessToken,
expiresAt: Date.now() + Math.max(60, expiresIn) * 1000,
};
}
return accessToken;
} finally {
clearTimeout(timer);
}
}

View file

@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { projectCircuit, projectCars } from "./circuit";
const raw = {
rotation: 90,
x: [0, 10, 10, 0, 0],
y: [0, 0, 10, 10, 0],
circuitKey: 1,
circuitName: "Box",
};
const map = projectCircuit(raw);
assert.ok(map.path.length >= 4);
assert.ok(map.path.every((p) => p.x >= -0.01 && p.x <= 1.01 && p.y >= -0.01 && p.y <= 1.01));
const cars = projectCars(
[
{ driverNumber: 1, x: 5, y: 0 },
{ driverNumber: 2, x: 10, y: 5 },
],
raw
);
assert.equal(cars.length, 2);
assert.ok(cars[0].x >= 0 && cars[0].x <= 1);
console.log("openf1/circuit.test.ts ok");

View file

@ -0,0 +1,132 @@
export type CircuitPoint = { x: number; y: number };
export type CircuitMap = {
circuitKey: number | null;
name: string | null;
rotation: number;
path: CircuitPoint[];
corners: Array<{ number: number; letter?: string; x: number; y: number }>;
};
type RawCircuit = {
circuitKey?: number;
circuitName?: string;
rotation?: number;
x?: number[];
y?: number[];
corners?: Array<{ number?: number; letter?: string; x?: number; y?: number }>;
};
function rotate(x: number, y: number, deg: number): CircuitPoint {
const rad = (deg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return { x: x * cos + y * sin, y: -x * sin + y * cos };
}
function bounds(points: CircuitPoint[]): { minX: number; maxX: number; minY: number; maxY: number } {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of points) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return { minX, maxX, minY, maxY };
}
function normalize(p: CircuitPoint, b: { minX: number; maxX: number; minY: number; maxY: number }): CircuitPoint {
const dx = b.maxX - b.minX || 1;
const dy = b.maxY - b.minY || 1;
return {
x: (p.x - b.minX) / dx,
y: 1 - (p.y - b.minY) / dy,
};
}
export function projectCircuit(raw: RawCircuit): CircuitMap {
const rotation = Number(raw.rotation || 0);
const xs = Array.isArray(raw.x) ? raw.x : [];
const ys = Array.isArray(raw.y) ? raw.y : [];
const n = Math.min(xs.length, ys.length);
const rotated: CircuitPoint[] = [];
for (let i = 0; i < n; i++) {
const x = Number(xs[i]);
const y = Number(ys[i]);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
rotated.push(rotate(x, y, rotation));
}
const b = rotated.length ? bounds(rotated) : { minX: 0, maxX: 1, minY: 0, maxY: 1 };
const path = rotated.map((p) => normalize(p, b));
const corners = (raw.corners || [])
.map((c) => {
const x = Number(c.x);
const y = Number(c.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const p = normalize(rotate(x, y, rotation), b);
return { number: Number(c.number || 0), letter: c.letter, x: p.x, y: p.y };
})
.filter((c): c is { number: number; letter?: string; x: number; y: number } => c != null);
return {
circuitKey: raw.circuitKey ?? null,
name: raw.circuitName ?? null,
rotation,
path,
corners,
};
}
export function projectCars(
cars: Array<{ driverNumber: number; x: number; y: number }>,
raw: RawCircuit
): Array<{ driverNumber: number; x: number; y: number }> {
const rotation = Number(raw.rotation || 0);
const xs = Array.isArray(raw.x) ? raw.x : [];
const ys = Array.isArray(raw.y) ? raw.y : [];
const n = Math.min(xs.length, ys.length);
const track: CircuitPoint[] = [];
for (let i = 0; i < n; i++) {
const x = Number(xs[i]);
const y = Number(ys[i]);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
track.push(rotate(x, y, rotation));
}
const b = track.length ? bounds(track) : { minX: 0, maxX: 1, minY: 0, maxY: 1 };
return cars
.filter((c) => Number.isFinite(c.x) && Number.isFinite(c.y))
.map((c) => {
const p = normalize(rotate(c.x, c.y, rotation), b);
return { driverNumber: c.driverNumber, x: p.x, y: p.y };
});
}
const circuitCache = new Map<string, { at: number; map: CircuitMap; raw: RawCircuit }>();
export async function loadCircuitMap(circuitKey: number, year: number): Promise<{ map: CircuitMap; raw: RawCircuit } | null> {
const cacheKey = `${circuitKey}:${year}`;
const hit = circuitCache.get(cacheKey);
if (hit && Date.now() - hit.at < 24 * 3600_000) return hit;
const years = [year, year - 1, 2025, 2024, 2023, 2022];
for (const y of [...new Set(years)]) {
try {
const res = await fetch(`https://api.multiviewer.app/api/v1/circuits/${circuitKey}/${y}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(12_000),
});
if (!res.ok) continue;
const raw = (await res.json()) as RawCircuit;
const map = projectCircuit(raw);
if (map.path.length < 20) continue;
const packed = { at: Date.now(), map, raw };
circuitCache.set(cacheKey, packed);
return packed;
} catch {
/* try older year */
}
}
return null;
}

View file

@ -0,0 +1,99 @@
import { getOpenF1AccessToken } from "./auth";
const BASE = "https://api.openf1.org/v1";
export type OpenF1Params = Record<string, string | number | boolean | undefined | null>;
function buildUrl(path: string, params: OpenF1Params): string {
let url = `${BASE}/${path.replace(/^\//, "")}`;
const parts: string[] = [];
for (const [key, raw] of Object.entries(params)) {
if (raw === undefined || raw === null || raw === "") continue;
const value = encodeURIComponent(String(raw));
if (/[<>]=?$/.test(key) || key.includes(">=") || key.includes("<=")) {
parts.push(`${key}=${value}`);
} else {
parts.push(`${encodeURIComponent(key)}=${value}`);
}
}
if (parts.length) url += `?${parts.join("&")}`;
return url;
}
export async function openF1Get<T = unknown>(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise<T> {
let lastErr: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
const token = await getOpenF1AccessToken();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(buildUrl(path, params), {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
signal: ac.signal,
});
const text = await res.text();
if (res.status === 429 || res.status >= 500) {
lastErr = new Error(`OpenF1 ${path} HTTP ${res.status}`);
await sleep(400 * (attempt + 1));
continue;
}
if (!res.ok) {
throw new Error(`OpenF1 ${path} HTTP ${res.status}`);
}
return JSON.parse(text) as T;
} catch (err) {
lastErr = err instanceof Error ? err : new Error(String(err));
if (attempt < 2) await sleep(400 * (attempt + 1));
} finally {
clearTimeout(timer);
}
}
throw lastErr ?? new Error(`OpenF1 ${path} mislukt`);
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export function asRows<T = Record<string, unknown>>(raw: unknown): T[] {
return Array.isArray(raw) ? (raw as T[]) : raw ? [raw as T] : [];
}
export function latestBy<T>(
rows: T[],
keyFn: (row: T) => string | number,
dateFn: (row: T) => string
): T[] {
const map = new Map<string, T>();
for (const row of rows) {
const key = String(keyFn(row));
const prev = map.get(key);
if (!prev || dateFn(row) > dateFn(prev)) map.set(key, row);
}
return [...map.values()];
}
export function rec(row: unknown): Record<string, unknown> {
return row && typeof row === "object" ? (row as Record<string, unknown>) : {};
}
export function num(v: unknown): number | null {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim()) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
export function str(v: unknown): string | null {
if (typeof v === "string") {
const t = v.trim();
return t || null;
}
if (typeof v === "number" && Number.isFinite(v)) return String(v);
return null;
}

View file

@ -0,0 +1,385 @@
import { AppError } from "../security/errors";
import { isOpenF1Configured } from "./auth";
import { asRows, latestBy, num, openF1Get, rec, str } from "./client";
import { loadCircuitMap, projectCars, type CircuitMap } from "./circuit";
import { getF1News, type F1NewsItem } from "./news";
export type F1LiveDriver = {
position: number;
driverNumber: number;
acronym: string;
fullName: string;
teamName: string;
teamColor: string;
headshotUrl: string | null;
gap: string;
interval: string;
lastLap: string;
bestLap: string;
tyre: string;
tyreAge: number | null;
laps: number | null;
};
export type F1LiveCar = {
driverNumber: number;
acronym: string;
teamColor: string;
x: number;
y: number;
};
export type F1LiveTelemetry = {
driverNumber: number;
acronym: string;
speed: number | null;
throttle: number | null;
brake: number | null;
rpm: number | null;
gear: number | null;
drs: boolean;
};
export type F1LiveDashboard = {
live: boolean;
source: string;
session: {
sessionKey: number | null;
meetingKey: number | null;
meetingName: string | null;
sessionName: string | null;
sessionType: string | null;
circuitName: string | null;
countryName: string | null;
countryCode: string | null;
location: string | null;
dateStart: string | null;
dateEnd: string | null;
year: number | null;
currentLap: number | null;
};
weather: {
airTemperature: number | null;
trackTemperature: number | null;
humidity: number | null;
windSpeed: number | null;
windDirection: number | null;
rainfall: boolean;
} | null;
timing: F1LiveDriver[];
map: {
name: string | null;
path: Array<{ x: number; y: number }>;
corners: CircuitMap["corners"];
cars: F1LiveCar[];
} | null;
telemetry: F1LiveTelemetry | null;
raceControl: Array<{
date: string | null;
category: string | null;
flag: string | null;
message: string;
driverNumber: number | null;
}>;
schedule: Array<{
sessionName: string;
dateStart: string | null;
dateEnd: string | null;
status: "live" | "done" | "upcoming";
}>;
news: F1NewsItem[];
};
const TYRE: Record<string, string> = {
SOFT: "S",
MEDIUM: "M",
HARD: "H",
INTERMEDIATE: "I",
WET: "W",
SUPERSOFT: "SS",
ULTRASOFT: "US",
};
let snapCache: { at: number; data: F1LiveDashboard } | null = null;
function formatLap(v: unknown): string {
const n = num(v);
if (n == null || n <= 0) return "—";
const m = Math.floor(n / 60);
const s = n - m * 60;
return `${m}:${s.toFixed(3).padStart(6, "0")}`;
}
function formatGap(v: unknown, isLeader: boolean): string {
if (isLeader) return "—";
if (typeof v === "string") {
const t = v.trim();
if (!t) return "—";
if (/lap/i.test(t)) return t.replace(/^(\+)?/, "+");
const n = Number(t);
if (Number.isFinite(n)) return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3);
return t;
}
const n = num(v);
if (n == null) return "—";
return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3);
}
function tyreLetter(compound: unknown): string {
const c = str(compound)?.toUpperCase() || "";
return TYRE[c] || (c ? c.slice(0, 1) : "—");
}
function isLiveWindow(start: string | null, end: string | null, now = Date.now()): boolean {
if (!start) return false;
const a = Date.parse(start);
const b = end ? Date.parse(end) : a + 3 * 3600_000;
if (!Number.isFinite(a)) return false;
const pad = 20 * 60_000;
return now >= a - pad && now <= (Number.isFinite(b) ? b + pad : a + 4 * 3600_000);
}
async function settled<T>(p: Promise<T>, fallback: T): Promise<T> {
try {
return await p;
} catch {
return fallback;
}
}
export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
if (!(await isOpenF1Configured())) {
throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503);
}
if (snapCache && Date.now() - snapCache.at < 2500) return snapCache.data;
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }));
const session = rec(sessions[0]);
const sessionKey = num(session.session_key);
const meetingKey = num(session.meeting_key);
if (sessionKey == null) {
throw new AppError("NOT_FOUND", "Geen OpenF1-sessie gevonden", 404);
}
const dateStart = str(session.date_start);
const dateEnd = str(session.date_end);
const live = isLiveWindow(dateStart, dateEnd);
const locEnd = dateEnd ? Date.parse(dateEnd) : Date.now();
const locFrom = new Date((live ? Date.now() : locEnd) - (live ? 12_000 : 90_000)).toISOString();
const locTo = live ? undefined : new Date(locEnd + 2_000).toISOString();
const year = num(session.year) ?? new Date().getUTCFullYear();
const circuitKey = num(session.circuit_key);
const driverRows = asRows(await openF1Get("drivers", { session_key: sessionKey }));
const [
meetingRows,
positionRows,
intervalRows,
lapRows,
stintRows,
weatherRows,
raceControlRows,
locationRows,
scheduleRows,
circuitPack,
news,
] = await Promise.all([
settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }).then(asRows), []),
settled(openF1Get("position", { session_key: sessionKey }).then(asRows), []),
settled(openF1Get("intervals", { session_key: sessionKey }).then(asRows), []),
settled(openF1Get("laps", { session_key: sessionKey }).then(asRows), []),
settled(openF1Get("stints", { session_key: sessionKey }).then(asRows), []),
settled(openF1Get("weather", { session_key: sessionKey }).then(asRows), []),
settled(openF1Get("race_control", { session_key: sessionKey }).then(asRows), []),
settled(
openF1Get("location", {
session_key: sessionKey,
"date>=": locFrom,
...(locTo ? { "date<": locTo } : {}),
}).then(asRows),
[]
),
meetingKey
? settled(openF1Get("sessions", { meeting_key: meetingKey }).then(asRows), [])
: Promise.resolve([]),
circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null),
settled(getF1News(12), []),
]);
const meeting = rec(meetingRows[0]);
const drivers = driverRows.map(rec);
const lastPos = latestBy(positionRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
const lastInt = latestBy(intervalRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
const lastStint = latestBy(stintRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => String(num(r.stint_number) ?? 0));
const lastLoc = latestBy(locationRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
const lastWeather = weatherRows.length ? rec(weatherRows[weatherRows.length - 1]) : null;
const lapsByDriver = new Map<number, Record<string, unknown>[]>();
for (const row of lapRows.map(rec)) {
const dn = num(row.driver_number);
if (dn == null) continue;
const list = lapsByDriver.get(dn) || [];
list.push(row);
lapsByDriver.set(dn, list);
}
const posMap = new Map(lastPos.map((r) => [num(r.driver_number) ?? -1, r]));
const intMap = new Map(lastInt.map((r) => [num(r.driver_number) ?? -1, r]));
const stintMap = new Map(lastStint.map((r) => [num(r.driver_number) ?? -1, r]));
const timing: F1LiveDriver[] = drivers
.map((d) => {
const n = num(d.driver_number) ?? 0;
const pos = num(posMap.get(n)?.position) ?? 99;
const laps = lapsByDriver.get(n) || [];
const last = laps.length ? laps.reduce((a, b) => ((num(a.lap_number) ?? 0) >= (num(b.lap_number) ?? 0) ? a : b)) : null;
const best = laps
.map((l) => num(l.lap_duration))
.filter((v): v is number => v != null && v > 0)
.sort((a, b) => a - b)[0];
const iv = intMap.get(n);
const st = stintMap.get(n);
const isLeader = pos === 1;
return {
position: pos,
driverNumber: n,
acronym: str(d.name_acronym) || `#${n}`,
fullName: [str(d.first_name), str(d.last_name)].filter(Boolean).join(" ") || str(d.full_name) || "",
teamName: str(d.team_name) || "",
teamColor: str(d.team_colour) || "888888",
headshotUrl: str(d.headshot_url),
gap: formatGap(iv?.gap_to_leader, isLeader),
interval: formatGap(iv?.interval, isLeader),
lastLap: formatLap(last?.lap_duration),
bestLap: formatLap(best),
tyre: tyreLetter(st?.compound),
tyreAge: num(st?.tyre_age_at_start) != null && last?.lap_number != null
? (num(st?.tyre_age_at_start) || 0) + Math.max(0, (num(last.lap_number) || 1) - (num(st?.lap_start) || 1))
: num(st?.tyre_age_at_start),
laps: num(last?.lap_number),
};
})
.sort((a, b) => a.position - b.position);
const currentLap = timing.reduce((m, d) => Math.max(m, d.laps ?? 0), 0) || null;
const leader = timing[0] || null;
let telemetry: F1LiveTelemetry | null = null;
if (leader) {
const carRows = await settled(
openF1Get("car_data", {
session_key: sessionKey,
driver_number: leader.driverNumber,
"date>=": locFrom,
...(locTo ? { "date<": locTo } : {}),
}).then(asRows),
[]
);
const lastCar = carRows.length ? rec(carRows[carRows.length - 1]) : null;
if (lastCar) {
const drs = num(lastCar.drs) ?? 0;
telemetry = {
driverNumber: leader.driverNumber,
acronym: leader.acronym,
speed: num(lastCar.speed),
throttle: num(lastCar.throttle),
brake: num(lastCar.brake),
rpm: num(lastCar.rpm),
gear: num(lastCar.n_gear),
drs: drs >= 10,
};
}
}
const locByNum = new Map(lastLoc.map((r) => [num(r.driver_number) ?? -1, r]));
const carsRaw = timing
.map((d) => {
const loc = locByNum.get(d.driverNumber);
const x = num(loc?.x);
const y = num(loc?.y);
if (x == null || y == null) return null;
return { driverNumber: d.driverNumber, x, y };
})
.filter((c): c is { driverNumber: number; x: number; y: number } => c != null);
const projected = circuitPack ? projectCars(carsRaw, circuitPack.raw) : [];
const projMap = new Map(projected.map((c) => [c.driverNumber, c]));
const cars: F1LiveCar[] = timing
.map((d) => {
const p = projMap.get(d.driverNumber);
if (!p) return null;
return { driverNumber: d.driverNumber, acronym: d.acronym, teamColor: d.teamColor, x: p.x, y: p.y };
})
.filter((c): c is F1LiveCar => c != null);
const now = Date.now();
const schedule = scheduleRows
.map(rec)
.sort((a, b) => (str(a.date_start) || "").localeCompare(str(b.date_start) || ""))
.map((s) => {
const name = str(s.session_name) || str(s.session_type) || "Sessie";
const start = str(s.date_start);
const end = str(s.date_end);
const status: "live" | "done" | "upcoming" = isLiveWindow(start, end, now)
? "live"
: start && Date.parse(start) > now
? "upcoming"
: "done";
return { sessionName: name, dateStart: start, dateEnd: end, status };
});
const raceControl = raceControlRows
.map(rec)
.slice(-12)
.reverse()
.map((r) => ({
date: str(r.date),
category: str(r.category),
flag: str(r.flag),
message: str(r.message) || "",
driverNumber: num(r.driver_number),
}))
.filter((r) => r.message);
const data: F1LiveDashboard = {
live,
source: "OpenF1",
session: {
sessionKey,
meetingKey,
meetingName: str(meeting.meeting_name) || str(meeting.meeting_official_name),
sessionName: str(session.session_name),
sessionType: str(session.session_type),
circuitName: str(session.circuit_short_name) || str(meeting.circuit_short_name),
countryName: str(session.country_name) || str(meeting.country_name),
countryCode: str(session.country_code) || str(meeting.country_code),
location: str(session.location) || str(meeting.location),
dateStart,
dateEnd,
year,
currentLap,
},
weather: lastWeather
? {
airTemperature: num(lastWeather.air_temperature),
trackTemperature: num(lastWeather.track_temperature),
humidity: num(lastWeather.humidity),
windSpeed: num(lastWeather.wind_speed),
windDirection: num(lastWeather.wind_direction),
rainfall: (num(lastWeather.rainfall) ?? 0) > 0,
}
: null,
timing,
map: circuitPack
? { name: circuitPack.map.name, path: circuitPack.map.path, corners: circuitPack.map.corners, cars }
: null,
telemetry,
raceControl,
schedule,
news,
};
snapCache = { at: Date.now(), data };
return data;
}

View file

@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { parseRacingNews365 } from "./news";
const html = `
<a href="https://racingnews365.nl/f1-nieuws" class="card card--default">skip</a>
<a
href="https://racingnews365.nl/hadjar-doet-boekje-open-over-polsbreuk-heel-stom"
class="card card--default
"
data-id="14443134"
>
<span class="card__title"><span data-ab-title="14443134">Hadjar doet boekje open over polsbreuk: &quot;Heel stom&quot;</span></span>
<time datetime="2026-09-30T20:30:00+02:00">30 september 20:30</time>
<img srcset="https://cdn.racingnews365.com/2026/Hadjar/XPB.jpg?width=360&amp;height=203 1x">
</a>
<a href="https://racingnews365.nl/coureurs" class="card card--default"><span data-ab-title="1">Coureurs</span></a>
`;
const items = parseRacingNews365(html);
assert.equal(items.length, 1);
assert.equal(items[0].title, 'Hadjar doet boekje open over polsbreuk: "Heel stom"');
assert.equal(items[0].id, "14443134");
assert.ok(items[0].imageUrl?.includes("cdn.racingnews365.com"));
console.log("openf1/news.test.ts ok");

View file

@ -0,0 +1,107 @@
const LIST_URL = "https://racingnews365.nl/f1-nieuws";
const UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
const SKIP = new Set([
"f1-nieuws",
"liveblog-formule-1",
"coureurs",
"formule-1-op-tv",
"f1-podcast",
"uitslag-formule-1",
"indycar",
"abonnementen",
"premium",
"video",
"interview",
"podcast",
"f1",
]);
export type F1NewsItem = {
id: string;
title: string;
url: string;
imageUrl: string | null;
publishedAt: string | null;
source: string;
};
function decodeHtml(s: string): string {
return s
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
function slugOf(url: string): string {
try {
return new URL(url).pathname.replace(/^\/+|\/+$/g, "");
} catch {
return "";
}
}
export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] {
const items: F1NewsItem[] = [];
const seen = new Set<string>();
const openRe = /<a\b([^>]+)>/gi;
let m: RegExpExecArray | null;
while ((m = openRe.exec(html)) && items.length < limit) {
const attrs = m[1] || "";
if (!/\bcard\b/i.test(attrs)) continue;
const href = attrs.match(/href="(https:\/\/racingnews365\.nl\/[^"]+)"/i)?.[1];
if (!href) continue;
const slug = slugOf(href);
if (!slug || SKIP.has(slug) || slug.includes("/") || seen.has(href) || slug.length < 12) continue;
const chunk = html.slice(m.index, m.index + 4500);
const titleMatch =
chunk.match(/data-ab-title="\d+">([^<]+)/i) ||
chunk.match(/class="card__title"[^>]*>\s*<span[^>]*>([^<]+)/i);
if (!titleMatch) continue;
const title = decodeHtml(titleMatch[1].trim());
if (!title) continue;
const timeMatch = chunk.match(/datetime="([^"]+)"/i);
const imgMatch = chunk.match(/https:\/\/cdn\.racingnews365\.com\/[^"'>\s]+/);
let imageUrl = imgMatch ? imgMatch[0].replace(/&amp;/g, "&") : null;
if (imageUrl) {
imageUrl = imageUrl.replace(/width=\d+/i, "width=720").replace(/height=\d+/i, "height=405");
}
const idMatch = attrs.match(/data-id="(\d+)"/) || chunk.match(/data-id="(\d+)"/);
seen.add(href);
items.push({
id: idMatch?.[1] || slug,
title,
url: href,
imageUrl,
publishedAt: timeMatch?.[1] || null,
source: "RacingNews365",
});
}
return items;
}
let newsCache: { at: number; items: F1NewsItem[] } | null = null;
export async function getF1News(limit = 16): Promise<F1NewsItem[]> {
if (newsCache && Date.now() - newsCache.at < 8 * 60_000) {
return newsCache.items.slice(0, limit);
}
const res = await fetch(LIST_URL, {
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": UA,
"Accept-Language": "nl-NL,nl;q=0.9",
},
signal: AbortSignal.timeout(18_000),
});
if (!res.ok) throw new Error(`RacingNews365 HTTP ${res.status}`);
const html = await res.text();
const items = parseRacingNews365(html, 24);
newsCache = { at: Date.now(), items };
return items.slice(0, limit);
}

View file

@ -0,0 +1,87 @@
import { prisma } from "../database/client";
import { decryptSecret, encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors";
import { clearOpenF1TokenCache, testOpenF1Login } from "../openf1/auth";
const OPENF1_ID = "openf1";
export type OpenF1SettingsPublic = {
enabled: boolean;
configured: boolean;
username: string;
hasPassword: boolean;
warning: string | null;
};
export async function getOpenF1SettingsPublic(): Promise<OpenF1SettingsPublic> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OPENF1_ID } });
return {
enabled: row?.enabled ?? true,
configured: !!(row?.enabled !== false && row?.username?.trim() && row?.passwordEnc),
username: row?.username?.trim() || "",
hasPassword: !!row?.passwordEnc,
warning: null,
};
}
export async function resolveOpenF1Credentials(
sessionSecret: string
): Promise<{ username: string; password: string } | null> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OPENF1_ID } });
if (row && row.enabled === false) return null;
const username = row?.username?.trim() || "";
if (username && row?.passwordEnc && sessionSecret) {
try {
const password = decryptSecret(row.passwordEnc, sessionSecret);
if (password) return { username, password };
} catch {
/* fall through */
}
}
return null;
}
export async function upsertOpenF1Settings(
sessionSecret: string,
input: { username?: string; password?: string; enabled?: boolean }
): Promise<OpenF1SettingsPublic> {
const existing = await prisma.integrationSetting.findUnique({ where: { id: OPENF1_ID } });
const username = (input.username ?? existing?.username ?? "").trim();
const passwordTrim = input.password?.trim();
const passwordEnc = passwordTrim
? encryptSecret(passwordTrim, sessionSecret)
: existing?.passwordEnc ?? null;
const enabled = input.enabled ?? existing?.enabled ?? true;
if (enabled && (!username || !passwordEnc)) {
throw new AppError("OPENF1", "E-mail en wachtwoord zijn verplicht", 400);
}
let warning: string | null = null;
if (enabled && username && passwordEnc) {
const password = decryptSecret(passwordEnc, sessionSecret);
try {
await testOpenF1Login(username, password);
} catch (err) {
warning = `Opgeslagen, maar login-test faalde: ${err instanceof Error ? err.message : String(err)}`;
}
}
await prisma.integrationSetting.upsert({
where: { id: OPENF1_ID },
create: {
id: OPENF1_ID,
username: username || null,
passwordEnc,
enabled,
},
update: {
username: username || null,
passwordEnc,
enabled,
},
});
clearOpenF1TokenCache();
const pub = await getOpenF1SettingsPublic();
return { ...pub, warning };
}

View file

@ -65,6 +65,18 @@ export function registerSettingsRoutes(
return { settings };
});
app.get("/api/v1/admin/settings/openf1", { preHandler: requireAdmin }, async () => {
const { getOpenF1SettingsPublic } = await import("./openf1");
return { settings: await getOpenF1SettingsPublic() };
});
app.put("/api/v1/admin/settings/openf1", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { username?: string; password?: string; enabled?: boolean };
const { upsertOpenF1Settings } = await import("./openf1");
const settings = await upsertOpenF1Settings(config.SESSION_SECRET, body);
return { settings };
});
app.get("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async () => {
return { settings: await getGooglePlaySettingsPublic(config) };
});

View file

@ -910,6 +910,23 @@ export function registerViewerRoutes(
return getF1tvHome();
});
app.get("/api/v1/client/f1/live", 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 { getF1LiveDashboard } = await import("../openf1/live");
return getF1LiveDashboard();
});
app.get("/api/v1/client/f1/news", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const { getF1News } = await import("../openf1/news");
return { items: await getF1News(20) };
});
app.get("/api/v1/client/viaplay/home", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");