stremio/apps/master-api/src/openf1/live.ts

421 lines
14 KiB
TypeScript

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;
let inflight: Promise<F1LiveDashboard> | null = null;
const OF1_MS = 8_000;
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);
}
const now = Date.now();
const ttl = snapCache?.data.live ? 2_000 : 10_000;
if (snapCache && now - snapCache.at < ttl) return snapCache.data;
if (snapCache && now - snapCache.at < 45_000) {
if (!inflight) {
inflight = buildDashboard().finally(() => {
inflight = null;
});
}
return snapCache.data;
}
if (inflight) return inflight;
inflight = buildDashboard().finally(() => {
inflight = null;
});
return inflight;
}
async function buildDashboard(): Promise<F1LiveDashboard> {
try {
return await buildDashboardInner();
} catch (err) {
if (snapCache) return snapCache.data;
throw err;
}
}
async function buildDashboardInner(): Promise<F1LiveDashboard> {
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }, OF1_MS));
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,
meetingRows,
positionRows,
intervalRows,
lapRows,
stintRows,
weatherRows,
raceControlRows,
locationRows,
scheduleRows,
circuitPack,
] = await Promise.all([
settled(openF1Get("drivers", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }, OF1_MS).then(asRows), []),
settled(openF1Get("position", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("intervals", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("laps", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("stints", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("weather", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(openF1Get("race_control", { session_key: sessionKey }, OF1_MS).then(asRows), []),
settled(
openF1Get(
"location",
{
session_key: sessionKey,
"date>=": locFrom,
...(locTo ? { "date<": locTo } : {}),
},
OF1_MS
).then(asRows),
[]
),
meetingKey
? settled(openF1Get("sessions", { meeting_key: meetingKey }, OF1_MS).then(asRows), [])
: Promise.resolve([]),
circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null),
]);
const news = snapCache?.data.news ?? [];
void getF1News(12)
.then((items) => {
if (snapCache) snapCache.data = { ...snapCache.data, news: items };
})
.catch(() => undefined);
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: "live",
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;
}