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).
This commit is contained in:
Sirius DevOps 2026-08-28 22:58:04 -04:00
parent e568d013ca
commit 665aaec22f
5 changed files with 228 additions and 4 deletions

View file

@ -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]:

View file

@ -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"),

View file

@ -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 `<div class="cam-pop"><b>${esc(p.label || p.id)}</b>${badge}<table>${rows.join('')}</table></div>`;
let photoBlock = '';
if (src === 'adsb.lol' && (extra.hex || extra.reg)) {
photoBlock = `<div class="ps-photo" data-hex="${esc(extra.hex || '')}" data-reg="${esc(extra.reg || '')}"></div>`;
}
return `<div class="cam-pop"><b>${esc(p.label || p.id)}</b>${badge}<table>${rows.join('')}</table>${photoBlock}</div>`;
}
/* ── 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 = `<div class="ps-loading">photo…</div>`;
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 =
`<a class="ps-link" href="${link}" target="_blank" rel="noopener">` +
`<img class="ps-thumb" src="${esc(ph.src)}" alt="${alt}" loading="lazy"` +
(ph.width ? ` width="${esc(ph.width)}"` : '') +
(ph.height ? ` height="${esc(ph.height)}"` : '') + `></a>` +
(credit ? `<div class="ps-credit"><a href="${link}" target="_blank" rel="noopener">${credit}</a></div>` : '');
})
.catch(() => { box.remove(); });
});
}
/* ── Heading-aware live-feed glyph markers ───────────────────────────────

View file

@ -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

View file

@ -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