From d7d347de273ef0c2e9d74eae83de0d1c96f2e555 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 23 Feb 2026 22:55:13 +0000 Subject: [PATCH] [ci skip] f1-stream: add F1 schedule subsystem (Phase 2) - Fetch 2026 F1 race calendar from jolpica API with all sessions (FP1-3, Qualifying, Sprint, Race) and UTC timestamps - Persist schedule to NFS as JSON, load on startup if fresh - APScheduler daily refresh at 03:00 UTC - GET /schedule endpoint with live/upcoming/past session status - POST /schedule/refresh for manual refresh trigger --- stacks/f1-stream/files/backend/__init__.py | 0 .../__pycache__/schedule.cpython-314.pyc | Bin 0 -> 13667 bytes stacks/f1-stream/files/backend/main.py | 63 ++++- .../f1-stream/files/backend/requirements.txt | 2 + stacks/f1-stream/files/backend/schedule.py | 240 ++++++++++++++++++ stacks/f1-stream/main.tf | 2 +- 6 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 stacks/f1-stream/files/backend/__init__.py create mode 100644 stacks/f1-stream/files/backend/__pycache__/schedule.cpython-314.pyc create mode 100644 stacks/f1-stream/files/backend/schedule.py diff --git a/stacks/f1-stream/files/backend/__init__.py b/stacks/f1-stream/files/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/stacks/f1-stream/files/backend/__pycache__/schedule.cpython-314.pyc b/stacks/f1-stream/files/backend/__pycache__/schedule.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..705a4c12d93b20e25265eab78eac9e96672ba8b7 GIT binary patch literal 13667 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 T7`VW7Z`~P&S$l_IO#A;ovQq)t literal 0 HcmV?d00001 diff --git a/stacks/f1-stream/files/backend/main.py b/stacks/f1-stream/files/backend/main.py index ffefec0c..fe830354 100644 --- a/stacks/f1-stream/files/backend/main.py +++ b/stacks/f1-stream/files/backend/main.py @@ -1,6 +1,54 @@ +"""F1 Streams - FastAPI backend with schedule service.""" + +import logging +from contextlib import asynccontextmanager + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger from fastapi import FastAPI -app = FastAPI(title="F1 Streams") +from backend.schedule import ScheduleService + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +schedule_service = ScheduleService() +scheduler = AsyncIOScheduler() + + +async def _scheduled_refresh() -> None: + """Callback for APScheduler daily refresh.""" + logger.info("Running scheduled schedule refresh...") + await schedule_service.refresh() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown lifecycle handler.""" + # Startup: load schedule and start background scheduler + await schedule_service.initialize() + + scheduler.add_job( + _scheduled_refresh, + trigger=CronTrigger(hour=3, minute=0, timezone="UTC"), + id="daily_schedule_refresh", + name="Refresh F1 schedule daily at 03:00 UTC", + replace_existing=True, + ) + scheduler.start() + logger.info("APScheduler started - daily refresh at 03:00 UTC") + + yield + + # Shutdown + scheduler.shutdown(wait=False) + logger.info("APScheduler shut down") + + +app = FastAPI(title="F1 Streams", lifespan=lifespan) @app.get("/health") @@ -13,6 +61,19 @@ async def root(): return {"service": "f1-streams", "version": "2.0.1"} +@app.get("/schedule") +async def get_schedule(): + """Return the F1 race schedule for the current season with session statuses.""" + return schedule_service.get_schedule() + + +@app.post("/schedule/refresh") +async def refresh_schedule(): + """Manually trigger a schedule refresh from the jolpica API.""" + await schedule_service.refresh() + return {"status": "refreshed"} + + if __name__ == "__main__": import uvicorn diff --git a/stacks/f1-stream/files/backend/requirements.txt b/stacks/f1-stream/files/backend/requirements.txt index 1e8e22cc..f724afaa 100644 --- a/stacks/f1-stream/files/backend/requirements.txt +++ b/stacks/f1-stream/files/backend/requirements.txt @@ -1,2 +1,4 @@ fastapi==0.115.0 uvicorn[standard] +httpx>=0.27.0 +apscheduler>=3.10.0,<4.0 diff --git a/stacks/f1-stream/files/backend/schedule.py b/stacks/f1-stream/files/backend/schedule.py new file mode 100644 index 00000000..1688f1e3 --- /dev/null +++ b/stacks/f1-stream/files/backend/schedule.py @@ -0,0 +1,240 @@ +"""F1 Schedule Service - fetches, caches, and serves the F1 race calendar.""" + +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +JOLPICA_API_URL = "https://api.jolpi.ca/ergast/f1/current.json" +SCHEDULE_PATH = Path(os.getenv("SCHEDULE_PATH", "/data/schedule.json")) +STALE_THRESHOLD = timedelta(hours=24) + +# Typical session durations in minutes +SESSION_DURATIONS = { + "fp1": 60, + "fp2": 60, + "fp3": 60, + "qualifying": 60, + "sprint_qualifying": 30, + "sprint": 30, + "race": 120, +} + + +def _parse_session_datetime(session: dict[str, str] | None) -> str | None: + """Parse a session dict with 'date' and 'time' fields into an ISO 8601 UTC string.""" + if not session or "date" not in session or "time" not in session: + return None + # Time format from API: "14:30:00Z" + time_str = session["time"].rstrip("Z") + return f"{session['date']}T{time_str}+00:00" + + +def _parse_race(race: dict[str, Any]) -> dict[str, Any]: + """Transform a raw jolpica/Ergast race object into our internal format.""" + circuit = race.get("Circuit", {}) + location = circuit.get("Location", {}) + + # Build session list + sessions = [] + + # Map API keys to our session types, in chronological order for a race weekend + session_map = [ + ("FirstPractice", "fp1", "FP1"), + ("SecondPractice", "fp2", "FP2"), + ("ThirdPractice", "fp3", "FP3"), + ("SprintQualifying", "sprint_qualifying", "Sprint Qualifying"), + ("SprintShootout", "sprint_qualifying", "Sprint Qualifying"), + ("Sprint", "sprint", "Sprint"), + ("Qualifying", "qualifying", "Qualifying"), + ] + + seen_types = set() + for api_key, session_type, display_name in session_map: + if api_key in race and session_type not in seen_types: + dt_str = _parse_session_datetime(race[api_key]) + if dt_str: + sessions.append( + { + "type": session_type, + "name": display_name, + "start_utc": dt_str, + "duration_minutes": SESSION_DURATIONS.get(session_type, 60), + } + ) + seen_types.add(session_type) + + # Race session itself (date and time are top-level) + race_dt = _parse_session_datetime({"date": race.get("date", ""), "time": race.get("time", "")}) + if race_dt: + sessions.append( + { + "type": "race", + "name": "Race", + "start_utc": race_dt, + "duration_minutes": SESSION_DURATIONS["race"], + } + ) + + # Sort sessions chronologically + sessions.sort(key=lambda s: s["start_utc"]) + + return { + "round": int(race.get("round", 0)), + "race_name": race.get("raceName", ""), + "circuit": circuit.get("circuitName", ""), + "circuit_id": circuit.get("circuitId", ""), + "country": location.get("country", ""), + "locality": location.get("locality", ""), + "date": race.get("date", ""), + "url": race.get("url", ""), + "sessions": sessions, + } + + +def _compute_session_status(session: dict[str, Any], now: datetime) -> str: + """Determine if a session is 'past', 'live', or 'upcoming'.""" + try: + start = datetime.fromisoformat(session["start_utc"]) + except (ValueError, KeyError): + return "upcoming" + + duration = timedelta(minutes=session.get("duration_minutes", 60)) + end = start + duration + + if now >= end: + return "past" + elif now >= start: + return "live" + else: + return "upcoming" + + +class ScheduleService: + """Manages the F1 schedule: fetching, caching, and serving.""" + + def __init__(self) -> None: + self._schedule: dict[str, Any] | None = None + + async def fetch_schedule(self) -> dict[str, Any]: + """Fetch the current season schedule from the jolpica API.""" + logger.info("Fetching F1 schedule from jolpica API...") + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(JOLPICA_API_URL) + response.raise_for_status() + data = response.json() + + race_table = data.get("MRData", {}).get("RaceTable", {}) + season = race_table.get("season", "") + raw_races = race_table.get("Races", []) + + races = [_parse_race(r) for r in raw_races] + + schedule = { + "season": season, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "races": races, + } + + self._schedule = schedule + logger.info("Fetched schedule for %s season: %d races", season, len(races)) + return schedule + + def load_from_disk(self) -> bool: + """Load schedule from NFS-backed JSON file. Returns True if loaded successfully.""" + if not SCHEDULE_PATH.exists(): + logger.info("No cached schedule found at %s", SCHEDULE_PATH) + return False + + try: + with open(SCHEDULE_PATH, "r") as f: + self._schedule = json.load(f) + logger.info("Loaded cached schedule from %s", SCHEDULE_PATH) + return True + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load cached schedule: %s", e) + return False + + def save_to_disk(self) -> None: + """Persist current schedule to NFS-backed JSON file.""" + if not self._schedule: + logger.warning("No schedule data to save") + return + + try: + SCHEDULE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(SCHEDULE_PATH, "w") as f: + json.dump(self._schedule, f, indent=2) + logger.info("Saved schedule to %s", SCHEDULE_PATH) + except OSError as e: + logger.error("Failed to save schedule to disk: %s", e) + + def is_stale(self) -> bool: + """Check if the cached schedule data is older than the stale threshold.""" + if not self._schedule: + return True + + fetched_at_str = self._schedule.get("fetched_at") + if not fetched_at_str: + return True + + try: + fetched_at = datetime.fromisoformat(fetched_at_str) + return datetime.now(timezone.utc) - fetched_at > STALE_THRESHOLD + except ValueError: + return True + + def get_schedule(self) -> dict[str, Any]: + """Return the current schedule with computed session statuses.""" + if not self._schedule: + return {"season": "", "races": [], "error": "No schedule data available"} + + now = datetime.now(timezone.utc) + races = [] + + for race in self._schedule.get("races", []): + sessions = [] + for session in race.get("sessions", []): + sessions.append( + { + **session, + "status": _compute_session_status(session, now), + } + ) + + races.append( + { + **race, + "sessions": sessions, + } + ) + + return { + "season": self._schedule.get("season", ""), + "fetched_at": self._schedule.get("fetched_at", ""), + "races": races, + } + + async def refresh(self) -> None: + """Fetch fresh schedule and persist to disk. Falls back to cached data on error.""" + try: + await self.fetch_schedule() + self.save_to_disk() + except httpx.HTTPError as e: + logger.error("Failed to refresh schedule from API: %s", e) + if not self._schedule: + logger.warning("No cached data available either - schedule will be empty") + except Exception: + logger.exception("Unexpected error during schedule refresh") + + async def initialize(self) -> None: + """Load from disk on startup and refresh if stale.""" + self.load_from_disk() + if self.is_stale(): + await self.refresh() diff --git a/stacks/f1-stream/main.tf b/stacks/f1-stream/main.tf index 9c604bd0..889ccc07 100644 --- a/stacks/f1-stream/main.tf +++ b/stacks/f1-stream/main.tf @@ -36,7 +36,7 @@ resource "kubernetes_deployment" "f1-stream" { } spec { container { - image = "viktorbarzin/f1-stream:v2.0.1" + image = "viktorbarzin/f1-stream:v2.0.3" name = "f1-stream" resources { limits = {