Spaces:
Running on Zero
Running on Zero
| """Thin client for the official FlightRadar24 API (fr24api.flightradar24.com). | |
| Docs: https://fr24api.flightradar24.com/docs/getting-started | |
| Auth is via a Bearer token supplied in the FR24_API_TOKEN env var. | |
| """ | |
| from __future__ import annotations | |
| import datetime as dt | |
| import os | |
| import time | |
| from functools import lru_cache | |
| import requests | |
| BASE = "https://fr24api.flightradar24.com" | |
| class FR24Error(RuntimeError): | |
| pass | |
| def _headers() -> dict: | |
| token = os.environ.get("FR24_API_TOKEN", "").strip() | |
| if not token: | |
| raise FR24Error( | |
| "FR24_API_TOKEN is not set. Put your FlightRadar24 API token in the " | |
| "environment (see .env.example)." | |
| ) | |
| return { | |
| "Accept": "application/json", | |
| "Accept-Version": os.environ.get("FR24_API_VERSION", "v1"), | |
| "Authorization": f"Bearer {token}", | |
| } | |
| def _clamp_bounds(north, south, west, east): | |
| """FR24 caps the queryable area; keep the box sane.""" | |
| north = max(-90.0, min(90.0, float(north))) | |
| south = max(-90.0, min(90.0, float(south))) | |
| west = max(-180.0, min(180.0, float(west))) | |
| east = max(-180.0, min(180.0, float(east))) | |
| if north < south: | |
| north, south = south, north | |
| return north, south, west, east | |
| FLIGHT_POSITIONS_URL = f"{BASE}/api/live/flight-positions/full" | |
| def query_positions(params: dict, *, timeout=25): | |
| """Low-level call to live/flight-positions/full with arbitrary FR24 filters. | |
| Returns (data_list, request_url). At least one filter param is required by | |
| FR24 (bounds, routes, airports, callsigns, ...). | |
| """ | |
| resp = requests.get(FLIGHT_POSITIONS_URL, headers=_headers(), | |
| params=params, timeout=timeout) | |
| if resp.status_code == 400: | |
| raise FR24Error(f"FR24 bad request (400): {resp.text[:300]}") | |
| if resp.status_code == 401: | |
| raise FR24Error("FR24 rejected the token (401). Check FR24_API_TOKEN.") | |
| if resp.status_code == 402: | |
| raise FR24Error("FR24 says payment/credits required (402).") | |
| if resp.status_code == 429: | |
| raise FR24Error("FR24 rate limit hit (429). Slow down the refresh.") | |
| if resp.status_code >= 400: | |
| raise FR24Error(f"FR24 error {resp.status_code}: {resp.text[:300]}") | |
| payload = resp.json() | |
| data = payload.get("data", payload if isinstance(payload, list) else []) | |
| return data, resp.url | |
| def live_positions(north, south, west, east, *, extra_params=None, timeout=25): | |
| """Return a list of live flight dicts inside the bounding box.""" | |
| north, south, west, east = _clamp_bounds(north, south, west, east) | |
| params = {"bounds": f"{north},{south},{west},{east}"} | |
| if extra_params: | |
| params.update(extra_params) | |
| data, _ = query_positions(params, timeout=timeout) | |
| return data | |
| def search_route(origin: str, destination: str, *, limit=200): | |
| """Live flights on a route, e.g. origin='LHR', destination='JFK'.""" | |
| params = {"routes": f"{origin.strip().upper()}-{destination.strip().upper()}", | |
| "limit": limit} | |
| return query_positions(params) | |
| def search_airport(airport: str, direction: str = "both", *, limit=200): | |
| """Live flights to/from an airport. direction in {inbound,outbound,both}.""" | |
| direction = (direction or "both").strip().lower() | |
| if direction not in {"inbound", "outbound", "both"}: | |
| direction = "both" | |
| params = {"airports": f"{direction}:{airport.strip().upper()}", "limit": limit} | |
| return query_positions(params) | |
| def airport_coords(code: str): | |
| """lat/lon for an IATA/ICAO code via the bundled OpenFlights table. | |
| (FR24's airport endpoints don't return coordinates on this plan, so this is | |
| resolved locally — fast and reliable.) | |
| """ | |
| import airports | |
| return airports.coords(code) | |
| def parse_eta(eta_value): | |
| """FR24 'eta' is an ISO timestamp. Return (iso_string, seconds_remaining).""" | |
| if not eta_value: | |
| return None, None | |
| try: | |
| if isinstance(eta_value, (int, float)): | |
| target = dt.datetime.fromtimestamp(eta_value, tz=dt.timezone.utc) | |
| else: | |
| s = str(eta_value).replace("Z", "+00:00") | |
| target = dt.datetime.fromisoformat(s) | |
| if target.tzinfo is None: | |
| target = target.replace(tzinfo=dt.timezone.utc) | |
| now = dt.datetime.now(tz=dt.timezone.utc) | |
| remaining = (target - now).total_seconds() | |
| return target.isoformat(), remaining | |
| except Exception: | |
| return str(eta_value), None | |
| def human_duration(seconds): | |
| if seconds is None: | |
| return "—" | |
| seconds = int(max(0, seconds)) | |
| h, rem = divmod(seconds, 3600) | |
| m, _ = divmod(rem, 60) | |
| if h: | |
| return f"{h}h {m:02d}m" | |
| return f"{m}m" | |