From 665aaec22fa17f805ab003a5b8e8492bd7fdd4a1 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Fri, 28 Aug 2026 22:58:04 -0400 Subject: [PATCH] feat(aircraft): planespotters.net photo in aircraft popup Add planespotters.net latest-photo lookup for ADS-B aircraft. - app/live_layers.py: fetch_planespotters_photo() (hex preferred, reg fallback) + _normalize_planespotter_photo(); 24h TTL cache (their ToS cap). - app/main.py: GET /api/aircraft/photo?hex=...|reg=... (422/404/502). - app/static/index.html: .ps-photo block in ADS-B popup; lazy load on popupopen; thumbnail links to photo page + photographer credit. Server-side proxy, not browser fetch(): planespotters 403s any request carrying an Origin header (every browser fetch() sends one). The thumbnail binary is loaded by the browser straight from their CDN, never re-hosted. Tests: 4 unit + 4 API contract (38 pass in the two files). --- app/live_layers.py | 55 +++++++++++++++++++++++++++ app/main.py | 24 ++++++++++-- app/static/index.html | 51 ++++++++++++++++++++++++- tests/test_api_live_layers.py | 32 ++++++++++++++++ tests/test_live_layers.py | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 4 deletions(-) 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 `
${esc(p.label || p.id)}${badge}${rows.join('')}
`; + let photoBlock = ''; + if (src === 'adsb.lol' && (extra.hex || extra.reg)) { + photoBlock = `
`; + } + return `
${esc(p.label || p.id)}${badge}${rows.join('')}
${photoBlock}
`; +} + +/* ── Planespotters.net aircraft photos ──────────────────────────────────── + Lazy-loaded into the ADS-B popup. Server-side endpoint (/api/aircraft/photo) + proxies the JSON (planespotters 403s any Origin-bearing request, which every + browser fetch() sends), but the thumbnail binary is always loaded by the + browser straight from the planespotters CDN — never re-hosted. */ +function loadPlanePhoto(root) { + if (!root) return; + root.querySelectorAll('.ps-photo').forEach(box => { + if (box.dataset.loaded === '1' || box.dataset.loading === '1') return; + const hex = (box.dataset.hex || '').trim(); + const reg = (box.dataset.reg || '').trim(); + if (!hex && !reg) { box.remove(); return; } + box.dataset.loading = '1'; + box.innerHTML = `
photo…
`; + const q = hex ? `hex=${encodeURIComponent(hex)}` : `reg=${encodeURIComponent(reg)}`; + fetch(`${API}/api/aircraft/photo?${q}`, { headers: { 'Accept': 'application/json' } }) + .then(r => { + if (r.status === 404) throw new Error('none'); + if (!r.ok) throw new Error('http ' + r.status); + return r.json(); + }) + .then(ph => { + if (!ph || !ph.src) { box.remove(); return; } + const link = esc(ph.link || ''); + const alt = `Photo of ${esc(reg || hex)}`; + const credit = ph.photographer ? `© ${esc(ph.photographer)}` : ''; + box.dataset.loaded = '1'; + box.innerHTML = + `` + + `${alt}` + + (credit ? `
${credit}
` : ''); + }) + .catch(() => { box.remove(); }); + }); } /* ── Heading-aware live-feed glyph markers ─────────────────────────────── diff --git a/tests/test_api_live_layers.py b/tests/test_api_live_layers.py index 83f200c..7349fd8 100644 --- a/tests/test_api_live_layers.py +++ b/tests/test_api_live_layers.py @@ -75,3 +75,35 @@ def test_vessels_subscribe_rejects_bad_bbox(): assert resp.status_code == 422, bad # Explicit null bbox is the "reset to env default" path (still 200). assert asyncio.run(_post("/api/vessels/subscribe", {"bbox": None})).status_code == 200 + + +def test_aircraft_photo_requires_hex_or_reg(): + assert asyncio.run(_get("/api/aircraft/photo")).status_code == 422 + + +def test_aircraft_photo_rejects_bad_hex(): + # hex must be exactly 6 hex chars + resp = asyncio.run(_get("/api/aircraft/photo?hex=xyz1234")) + assert resp.status_code == 422 + + +def test_aircraft_photo_returns_photo(monkeypatch): + async def fake(hex_code=None, reg=None): + return {"id": "1", "src": "https://t.plnspttrs.net/a_280.jpg", + "link": "https://www.planespotters.net/photo/1/x", "photographer": "A"} + + monkeypatch.setattr("main.fetch_planespotters_photo", fake) + resp = asyncio.run(_get("/api/aircraft/photo?hex=e8027e")) + assert resp.status_code == 200 + body = resp.json() + assert body["src"].startswith("https://t.plnspttrs.net/") + assert "max-age" in (resp.headers.get("cache-control") or "").lower() + + +def test_aircraft_photo_404_when_no_photo(monkeypatch): + async def fake(hex_code=None, reg=None): + return None + + monkeypatch.setattr("main.fetch_planespotters_photo", fake) + resp = asyncio.run(_get("/api/aircraft/photo?reg=D-ABCD")) + assert resp.status_code == 404 diff --git a/tests/test_live_layers.py b/tests/test_live_layers.py index 4ff214d..6982c87 100644 --- a/tests/test_live_layers.py +++ b/tests/test_live_layers.py @@ -572,3 +572,73 @@ def test_persist_aircraft_snapshot_writes_tracks(monkeypatch): }] asyncio.run(persist_aircraft_snapshot(rows)) assert recorded == [("aircraft", "abc")] + + +# ── Planespotters.net photo lookup ──────────────────────────────────────── + +def test_normalize_planespotter_photo_prefers_large_thumbnail(): + from live_layers import _normalize_planespotter_photo + + out = _normalize_planespotter_photo({ + "id": "1053982", + "thumbnail": {"src": "https://t.plnspttrs.net/x_t.jpg", "size": {"width": 200, "height": 141}}, + "thumbnail_large": {"src": "https://t.plnspttrs.net/x_280.jpg", "size": {"width": 395, "height": 280}}, + "link": "https://www.planespotters.net/photo/1053982/foo", + "photographer": "Günther Feniuk", + }) + assert out["id"] == "1053982" + assert out["src"] == "https://t.plnspttrs.net/x_280.jpg" + assert out["width"] == 395 + assert out["height"] == 280 + assert out["photographer"] == "Günther Feniuk" + assert "planespotters.net" in out["link"] + + +def test_normalize_planespotter_photo_empty_or_malformed_returns_none(): + from live_layers import _normalize_planespotter_photo + + assert _normalize_planespotter_photo({}) is None + assert _normalize_planespotter_photo({"thumbnail": {}}) is None + assert _normalize_planespotter_photo(None) is None + assert _normalize_planespotter_photo("not-a-dict") is None + + +def test_fetch_planespotters_photo_hex_builds_url_and_normalizes(monkeypatch): + import asyncio + + from live_layers import fetch_planespotters_photo, _cache + + seen = [] + + async def fake_get(url, params=None): + seen.append(url) + return {"photos": [{ + "id": "1", "thumbnail": {"src": "https://t.plnspttrs.net/a_t.jpg"}, + "thumbnail_large": {"src": "https://t.plnspttrs.net/a_280.jpg"}, + "link": "https://www.planespotters.net/photo/1/x", "photographer": "A", + }]} + + monkeypatch.setattr("live_layers._get_json", fake_get) + _cache.clear() + out = asyncio.run(fetch_planespotters_photo(hex_code="e8027e")) + assert out["src"] == "https://t.plnspttrs.net/a_280.jpg" + assert seen == ["https://api.planespotters.net/pub/photos/hex/e8027e"] + + +def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch): + import asyncio + + from live_layers import fetch_planespotters_photo, _cache + + seen = [] + + async def fake_get(url, params=None): + seen.append(url) + return {"photos": []} + + monkeypatch.setattr("live_layers._get_json", fake_get) + _cache.clear() + assert asyncio.run(fetch_planespotters_photo(reg="D-ABCD")) is None + assert seen == ["https://api.planespotters.net/pub/photos/reg/D-ABCD"] + # no hex and no reg → no upstream call at all + assert asyncio.run(fetch_planespotters_photo()) is None