diff --git a/apps/admin-ui/src/app/scripts/F1tvPanel.tsx b/apps/admin-ui/src/app/scripts/F1tvPanel.tsx index 916fd5d..18591d5 100644 --- a/apps/admin-ui/src/app/scripts/F1tvPanel.tsx +++ b/apps/admin-ui/src/app/scripts/F1tvPanel.tsx @@ -302,6 +302,14 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void

)} +
+

OpenF1 live timing

+

+ Posities, circuitkaart en telemetrie in de F1-hub. Login staat onder{" "} + Instellingen → OpenF1 (versleuteld in de database). +

+
+

F1TV

diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index 30cc379..a1e369b 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -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(null); const [omdbWarn, setOmdbWarn] = useState(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(null); + const [openF1Err, setOpenF1Err] = useState(null); + const [openF1Warn, setOpenF1Warn] = useState(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() { )} + {tab === "openf1" && ( +

+
+

OpenF1

+

+ Live timing, circuitkaart en telemetrie voor de F1-hub. Account van{" "} + + openf1.org + + . E-mail en wachtwoord worden versleuteld in de database bewaard (niet in env/git). +

+
+
+
+ + + +
+
+ + + Status: {openF1Configured && openF1Enabled ? "aan" : "uit"} + + {openF1Msg && {openF1Msg}} + {openF1Warn && {openF1Warn}} + {openF1Err && {openF1Err}} +
+
+
+ )} + {tab === "google-play" && (
diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index dca88d3..08e1ea5 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -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 = ""; }; F1A2B3C4D5E6F7A8B9C0D1E2 /* EventsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsScreen.swift; sourceTree = ""; }; A7C3E9F1B2D4068E5A1C3E71 /* EventHubsScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHubsScreens.swift; sourceTree = ""; }; + C7C3E9F1B2D4068E5A1C3E80 /* F1LiveScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = F1LiveScreen.swift; sourceTree = ""; }; 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = ""; }; D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; }; @@ -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)"; diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index e956e89..db6051f 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -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)") diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index d6f5f61..67ddf18 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -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)) ?? [] } } diff --git a/apps/ios/VMC/UI/EventHubsScreens.swift b/apps/ios/VMC/UI/EventHubsScreens.swift index 96a9bbc..c1db9f0 100644 --- a/apps/ios/VMC/UI/EventHubsScreens.swift +++ b/apps/ios/VMC/UI/EventHubsScreens.swift @@ -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") - } - .buttonStyle(HubGlassButtonStyle()) + 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,10 +298,58 @@ 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 { let nowMs = now.timeIntervalSince1970 * 1000 let nextKey = p.nextMeeting?.meetingKey diff --git a/apps/ios/VMC/UI/F1LiveScreen.swift b/apps/ios/VMC/UI/F1LiveScreen.swift new file mode 100644 index 0000000..6dc76dc --- /dev/null +++ b/apps/ios/VMC/UI/F1LiveScreen.swift @@ -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(@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 { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/apps/master-api/.env.example b/apps/master-api/.env.example index d818d7d..11d8b7a 100644 --- a/apps/master-api/.env.example +++ b/apps/master-api/.env.example @@ -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= diff --git a/apps/master-api/package.json b/apps/master-api/package.json index da1d11d..ce9fc19 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -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", diff --git a/apps/master-api/src/openf1/auth.ts b/apps/master-api/src/openf1/auth.ts new file mode 100644 index 0000000..de3d95e --- /dev/null +++ b/apps/master-api/src/openf1/auth.ts @@ -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 | 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 { + return (await resolveOpenF1Login()) != null; +} + +export async function testOpenF1Login(username: string, password: string): Promise { + await fetchToken({ username, password }, false); +} + +export async function getOpenF1AccessToken(): Promise { + 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 { + 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); + } +} diff --git a/apps/master-api/src/openf1/circuit.test.ts b/apps/master-api/src/openf1/circuit.test.ts new file mode 100644 index 0000000..c0e0f46 --- /dev/null +++ b/apps/master-api/src/openf1/circuit.test.ts @@ -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"); diff --git a/apps/master-api/src/openf1/circuit.ts b/apps/master-api/src/openf1/circuit.ts new file mode 100644 index 0000000..a140dca --- /dev/null +++ b/apps/master-api/src/openf1/circuit.ts @@ -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(); + +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; +} diff --git a/apps/master-api/src/openf1/client.ts b/apps/master-api/src/openf1/client.ts new file mode 100644 index 0000000..b041c32 --- /dev/null +++ b/apps/master-api/src/openf1/client.ts @@ -0,0 +1,99 @@ +import { getOpenF1AccessToken } from "./auth"; + +const BASE = "https://api.openf1.org/v1"; + +export type OpenF1Params = Record; + +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(path: string, params: OpenF1Params = {}, timeoutMs = 18_000): Promise { + 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>(raw: unknown): T[] { + return Array.isArray(raw) ? (raw as T[]) : raw ? [raw as T] : []; +} + +export function latestBy( + rows: T[], + keyFn: (row: T) => string | number, + dateFn: (row: T) => string +): T[] { + const map = new Map(); + 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 { + return row && typeof row === "object" ? (row as Record) : {}; +} + +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; +} diff --git a/apps/master-api/src/openf1/live.ts b/apps/master-api/src/openf1/live.ts new file mode 100644 index 0000000..3bc4f79 --- /dev/null +++ b/apps/master-api/src/openf1/live.ts @@ -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 = { + 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(p: Promise, fallback: T): Promise { + try { + return await p; + } catch { + return fallback; + } +} + +export async function getF1LiveDashboard(): Promise { + 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[]>(); + 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; +} diff --git a/apps/master-api/src/openf1/news.test.ts b/apps/master-api/src/openf1/news.test.ts new file mode 100644 index 0000000..1f85f3b --- /dev/null +++ b/apps/master-api/src/openf1/news.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { parseRacingNews365 } from "./news"; + +const html = ` +skip + + Hadjar doet boekje open over polsbreuk: "Heel stom" + + + +Coureurs +`; + +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"); diff --git a/apps/master-api/src/openf1/news.ts b/apps/master-api/src/openf1/news.ts new file mode 100644 index 0000000..af9a809 --- /dev/null +++ b/apps/master-api/src/openf1/news.ts @@ -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(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/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(); + const openRe = /]+)>/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*]*>([^<]+)/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(/&/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 { + 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); +} diff --git a/apps/master-api/src/settings/openf1.ts b/apps/master-api/src/settings/openf1.ts new file mode 100644 index 0000000..6790147 --- /dev/null +++ b/apps/master-api/src/settings/openf1.ts @@ -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 { + 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 { + 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 }; +} diff --git a/apps/master-api/src/settings/routes.ts b/apps/master-api/src/settings/routes.ts index a57bfa5..4427c2c 100644 --- a/apps/master-api/src/settings/routes.ts +++ b/apps/master-api/src/settings/routes.ts @@ -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) }; }); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 8e11bdf..7cd042a 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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");