diff --git a/app/live_layers.py b/app/live_layers.py index db1c0f7..ace617e 100644 --- a/app/live_layers.py +++ b/app/live_layers.py @@ -19,6 +19,7 @@ from datetime import datetime, timezone from typing import Any, Awaitable, Callable import httpx +from urllib.parse import quote from config import OSINT_USER_AGENT @@ -27,6 +28,9 @@ logger = logging.getLogger("osint.live_layers") MARKER_FIELDS = ("id", "lat", "lon", "heading", "speed", "label", "extra") ADSB_LOL_BASE = "https://api.adsb.lol" +PLANESPOTTERS_PHOTO = "https://api.planespotters.net/pub/photos" +# Planespotters ToS cap server-side JSON caching at 24 hours. +PLANESPOTTERS_CACHE_TTL = 24 * 3600 AMTRAKER_TRAINS = "https://api.amtraker.com/v3/trains" RAINVIEWER_MAPS = "https://api.rainviewer.com/public/weather-maps.json" NWS_ALERTS = "https://api.weather.gov/alerts/active" @@ -803,6 +807,57 @@ async def _get_json(url: str, params: dict | None = None) -> Any: return resp.json() +def _normalize_planespotter_photo(photo: dict) -> dict | None: + """Slim a planespotters.net photo object for the aircraft popup. + + Image binaries are never proxied/re-hosted: we return the CDN ``src`` + and the browser loads it directly (per ToS). + """ + if not isinstance(photo, dict): + return None + large = photo.get("thumbnail_large") or {} + small = photo.get("thumbnail") or {} + src = large.get("src") or small.get("src") + if not src: + return None + size = large.get("size") or small.get("size") or {} + return { + "id": str(photo.get("id") or ""), + "src": src, + "width": size.get("width"), + "height": size.get("height"), + "link": photo.get("link") or "", + "photographer": photo.get("photographer") or "", + } + + +async def fetch_planespotters_photo( + hex_code: str | None = None, reg: str | None = None, +) -> dict | None: + """Latest photo for an aircraft from planespotters.net (hex preferred). + + Server-side only: planespotters 403s any request carrying an ``Origin`` + header (browser fetch() always sends one), so the browser can never reach + it directly. We fetch here with the identifying UA, cache the JSON ≤24h, + and hand the CDN image URL back for the browser to load. + """ + if hex_code: + key = f"psp-hex:{hex_code.strip().lower()}" + url = f"{PLANESPOTTERS_PHOTO}/hex/{quote(hex_code.strip())}" + elif reg: + key = f"psp-reg:{reg.strip().lower()}" + url = f"{PLANESPOTTERS_PHOTO}/reg/{quote(reg.strip())}" + else: + return None + + async def _load() -> dict | None: + data = await _get_json(url) + photos = data.get("photos") or [] + return _normalize_planespotter_photo(photos[0]) if photos else None + + return await _ttl_get(key, PLANESPOTTERS_CACHE_TTL, _load) + + async def fetch_aircraft( bbox: str, limit: int = DEFAULT_LIMIT, persist: bool = False, ) -> list[dict]: diff --git a/app/main.py b/app/main.py index 5b31141..80c5133 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from decimal import Decimal from pathlib import Path +from typing import NoReturn from uuid import UUID import structlog @@ -52,8 +53,8 @@ from keystore import KeyFormatError, delete_key, list_keys, set_key from settings_store import SettingsError, get_app_settings, list_models, set_summary_model from live_layers import ( fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters, - fetch_radar_meta, fetch_storms, fetch_trains, fetch_vessels, - fetch_weather_alerts, overlay_catalog, parse_bbox, + fetch_planespotters_photo, fetch_radar_meta, fetch_storms, fetch_trains, + fetch_vessels, fetch_weather_alerts, overlay_catalog, parse_bbox, ) logging.basicConfig(level=logging.INFO) @@ -1390,7 +1391,7 @@ async def map_layers(): return {"layers": MAP_LAYERS, "overlays": overlay_catalog()} -def _upstream_or_502(exc: Exception, name: str): +def _upstream_or_502(exc: Exception, name: str) -> NoReturn: logger.warning("live_layer_upstream_failed", layer=name, error=str(exc)) raise HTTPException(502, f"{name} upstream unavailable: {exc}") from exc @@ -1424,6 +1425,23 @@ async def list_aircraft( _upstream_or_502(exc, "aircraft") +@app.get("/api/aircraft/photo") +async def aircraft_photo( + hex_code: str | None = Query(None, alias="hex", pattern="^[0-9a-fA-F]{6}$"), + reg: str | None = Query(None, min_length=1, max_length=12), +): + """Latest planespotters.net photo for an aircraft (hex preferred, reg fallback).""" + if not hex_code and not reg: + raise HTTPException(422, "hex or reg required") + try: + photo = await fetch_planespotters_photo(hex_code=hex_code, reg=reg) + except Exception as exc: + _upstream_or_502(exc, "planespotters") + if photo is None: + raise HTTPException(404, "no photo") + return overlay_json(photo, 86400) + + @app.get("/api/trains") async def list_trains( bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"), diff --git a/app/static/index.html b/app/static/index.html index efd9e61..4d2622e 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -356,6 +356,12 @@ .cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; } .cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.6rem; letter-spacing: 0.05em; white-space: nowrap; width: 34%; font-family: 'Share Tech Mono', monospace; } .cam-pop a { color: var(--cyan); word-break: break-all; } + .cam-pop .ps-photo { margin-top: 0.35rem; } + .cam-pop .ps-photo a.ps-link { display: block; } + .cam-pop .ps-thumb { width: 100%; height: auto; max-height: 220px; object-fit: contain; border-radius: 4px; border: 1px solid var(--line); background: var(--bg-0); display: block; } + .cam-pop .ps-credit { font-size: 0.68rem; color: var(--muted); margin-top: 0.2rem; } + .cam-pop .ps-credit a { color: var(--cyan); } + .cam-pop .ps-loading { font-size: 0.68rem; color: var(--muted); margin-top: 0.35rem; } .role-badge { display: inline-block; font-family: 'Share Tech Mono', monospace; font-size: 0.58rem; font-weight: 700; letter-spacing: 0.12em; @@ -1984,6 +1990,7 @@ async function initMap() { } startHlsFrom(root); }); + map.on('popupopen', (e) => { loadPlanePhoto(e.popup.getElement()); }); map.on('popupclose', () => { if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; } }); @@ -2648,7 +2655,49 @@ function pointPopup(p) { .slice(0, 8) .forEach(k => add(k, extra[k])); } - return `