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:
parent
e568d013ca
commit
665aaec22f
5 changed files with 228 additions and 4 deletions
|
|
@ -19,6 +19,7 @@ from datetime import datetime, timezone
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from config import OSINT_USER_AGENT
|
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")
|
MARKER_FIELDS = ("id", "lat", "lon", "heading", "speed", "label", "extra")
|
||||||
|
|
||||||
ADSB_LOL_BASE = "https://api.adsb.lol"
|
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"
|
AMTRAKER_TRAINS = "https://api.amtraker.com/v3/trains"
|
||||||
RAINVIEWER_MAPS = "https://api.rainviewer.com/public/weather-maps.json"
|
RAINVIEWER_MAPS = "https://api.rainviewer.com/public/weather-maps.json"
|
||||||
NWS_ALERTS = "https://api.weather.gov/alerts/active"
|
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()
|
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(
|
async def fetch_aircraft(
|
||||||
bbox: str, limit: int = DEFAULT_LIMIT, persist: bool = False,
|
bbox: str, limit: int = DEFAULT_LIMIT, persist: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
|
|
|
||||||
24
app/main.py
24
app/main.py
|
|
@ -18,6 +18,7 @@ from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import structlog
|
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 settings_store import SettingsError, get_app_settings, list_models, set_summary_model
|
||||||
from live_layers import (
|
from live_layers import (
|
||||||
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
||||||
fetch_radar_meta, fetch_storms, fetch_trains, fetch_vessels,
|
fetch_planespotters_photo, fetch_radar_meta, fetch_storms, fetch_trains,
|
||||||
fetch_weather_alerts, overlay_catalog, parse_bbox,
|
fetch_vessels, fetch_weather_alerts, overlay_catalog, parse_bbox,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -1390,7 +1391,7 @@ async def map_layers():
|
||||||
return {"layers": MAP_LAYERS, "overlays": overlay_catalog()}
|
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))
|
logger.warning("live_layer_upstream_failed", layer=name, error=str(exc))
|
||||||
raise HTTPException(502, f"{name} upstream unavailable: {exc}") from exc
|
raise HTTPException(502, f"{name} upstream unavailable: {exc}") from exc
|
||||||
|
|
||||||
|
|
@ -1424,6 +1425,23 @@ async def list_aircraft(
|
||||||
_upstream_or_502(exc, "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")
|
@app.get("/api/trains")
|
||||||
async def list_trains(
|
async def list_trains(
|
||||||
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,12 @@
|
||||||
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
|
.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 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 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 {
|
.role-badge {
|
||||||
display: inline-block; font-family: 'Share Tech Mono', monospace;
|
display: inline-block; font-family: 'Share Tech Mono', monospace;
|
||||||
font-size: 0.58rem; font-weight: 700; letter-spacing: 0.12em;
|
font-size: 0.58rem; font-weight: 700; letter-spacing: 0.12em;
|
||||||
|
|
@ -1984,6 +1990,7 @@ async function initMap() {
|
||||||
}
|
}
|
||||||
startHlsFrom(root);
|
startHlsFrom(root);
|
||||||
});
|
});
|
||||||
|
map.on('popupopen', (e) => { loadPlanePhoto(e.popup.getElement()); });
|
||||||
map.on('popupclose', () => {
|
map.on('popupclose', () => {
|
||||||
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
|
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
|
||||||
});
|
});
|
||||||
|
|
@ -2648,7 +2655,49 @@ function pointPopup(p) {
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.forEach(k => add(k, extra[k]));
|
.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 ───────────────────────────────
|
/* ── Heading-aware live-feed glyph markers ───────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,35 @@ def test_vessels_subscribe_rejects_bad_bbox():
|
||||||
assert resp.status_code == 422, bad
|
assert resp.status_code == 422, bad
|
||||||
# Explicit null bbox is the "reset to env default" path (still 200).
|
# Explicit null bbox is the "reset to env default" path (still 200).
|
||||||
assert asyncio.run(_post("/api/vessels/subscribe", {"bbox": None})).status_code == 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
|
||||||
|
|
|
||||||
|
|
@ -572,3 +572,73 @@ def test_persist_aircraft_snapshot_writes_tracks(monkeypatch):
|
||||||
}]
|
}]
|
||||||
asyncio.run(persist_aircraft_snapshot(rows))
|
asyncio.run(persist_aircraft_snapshot(rows))
|
||||||
assert recorded == [("aircraft", "abc")]
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue