Merge pull request 'fix(aircraft): show planespotters photo in overlay popup' (#18) from fix/planespotters-popup-photo into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 2m55s

Reviewed-on: #18
This commit is contained in:
sirius 2026-08-28 23:27:23 -04:00
commit 30a9c9e9b1
4 changed files with 63 additions and 23 deletions

View file

@ -751,6 +751,18 @@ def _headers() -> dict[str, str]:
return {"User-Agent": OSINT_USER_AGENT, "Accept": "application/json"}
def _planespotters_headers() -> dict[str, str]:
"""Planespotters 403s server clients whose UA has no contact URL/email."""
ua = (OSINT_USER_AGENT or "").strip()
if ua and ("@" in ua or "http://" in ua.lower() or "https://" in ua.lower()):
return {"User-Agent": ua, "Accept": "application/json"}
base = ua or "osint-dashboard/1.0"
return {
"User-Agent": f"{base} (lancewalters94@gmail.com)",
"Accept": "application/json",
}
async def _lock_for(key: str) -> asyncio.Lock:
async with _key_locks_guard:
lock = _key_locks.get(key)
@ -794,15 +806,17 @@ async def close_http() -> None:
_http = None
async def _get_json(url: str, params: dict | None = None) -> Any:
async def _get_json(
url: str, params: dict | None = None, headers: dict | None = None,
) -> Any:
if _http is None:
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(),
) as client:
resp = await client.get(url, params=params)
resp = await client.get(url, params=params, headers=headers)
resp.raise_for_status()
return resp.json()
resp = await _http.get(url, params=params)
resp = await _http.get(url, params=params, headers=headers)
resp.raise_for_status()
return resp.json()
@ -851,7 +865,7 @@ async def fetch_planespotters_photo(
return None
async def _load() -> dict | None:
data = await _get_json(url)
data = await _get_json(url, headers=_planespotters_headers())
photos = data.get("photos") or []
return _normalize_planespotter_photo(photos[0]) if photos else None

View file

@ -356,9 +356,13 @@
.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 { margin-top: 0.45rem; }
.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-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; box-shadow: 0 0 10px rgba(53,224,255,0.2);
}
.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; }
@ -1872,7 +1876,7 @@ function applyLiveMarker(kind, p) {
if (acOn && p.aircraft_lat != null) {
acGroup = upsertLivePoint(acGroup, {
id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon,
label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true },
label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true, hex: p.aircraft_hex, src: 'adsb.lol' },
}, q => acColor(q), 'ac');
}
}
@ -1990,7 +1994,7 @@ async function initMap() {
}
startHlsFrom(root);
});
map.on('popupopen', (e) => { loadPlanePhoto(e.popup.getElement()); });
map.on('popupopen', (e) => { loadPlanePhoto(e.popup); });
map.on('popupclose', () => {
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
});
@ -2656,18 +2660,19 @@ function pointPopup(p) {
.forEach(k => add(k, extra[k]));
}
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>`;
const hex = extra.hex || p.id || '';
if (src === 'adsb.lol' && (hex || extra.reg)) {
photoBlock = `<div class="ps-photo" data-hex="${esc(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) {
Lazy-loaded into the ADS-B popup. Server-side /api/aircraft/photo looks up
JSON (their ToS requires a contact User-Agent on server clients). The
thumbnail binary is loaded by the browser from their CDN — never re-hosted. */
function loadPlanePhoto(popup) {
const root = popup && (popup.getElement ? popup.getElement() : popup);
if (!root) return;
root.querySelectorAll('.ps-photo').forEach(box => {
if (box.dataset.loaded === '1' || box.dataset.loading === '1') return;
@ -2676,6 +2681,13 @@ function loadPlanePhoto(root) {
if (!hex && !reg) { box.remove(); return; }
box.dataset.loading = '1';
box.innerHTML = `<div class="ps-loading">photo…</div>`;
const bump = () => {
if (!popup || !popup._map) return;
// popup.update() re-runs bindPopup()'s factory and wipes the <img>.
if (popup._updateLayout) popup._updateLayout();
if (popup._updatePosition) popup._updatePosition();
if (popup._adjustPan) popup._adjustPan();
};
const q = hex ? `hex=${encodeURIComponent(hex)}` : `reg=${encodeURIComponent(reg)}`;
fetch(`${API}/api/aircraft/photo?${q}`, { headers: { 'Accept': 'application/json' } })
.then(r => {
@ -2684,7 +2696,7 @@ function loadPlanePhoto(root) {
return r.json();
})
.then(ph => {
if (!ph || !ph.src) { box.remove(); return; }
if (!ph || !ph.src) { box.remove(); bump(); return; }
const link = esc(ph.link || '');
const alt = `Photo of ${esc(reg || hex)}`;
const credit = ph.photographer ? `© ${esc(ph.photographer)}` : '';
@ -2695,8 +2707,11 @@ function loadPlanePhoto(root) {
(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>` : '');
const img = box.querySelector('img');
if (img && !img.complete) img.addEventListener('load', bump, { once: true });
else bump();
})
.catch(() => { box.remove(); });
.catch(() => { box.remove(); bump(); });
});
}
@ -2988,6 +3003,7 @@ async function loadAircraft() {
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac');
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>');
} catch (e) {
if (isAbort(e)) return;
console.error('Aircraft load failed', e);

View file

@ -93,7 +93,7 @@ services:
FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT}
FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75}
FIRMS_INTERVAL: ${FIRMS_INTERVAL:-900}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted)}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)}
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_INGEST: ${AISSTREAM_IN_INGEST:-0}
@ -127,7 +127,7 @@ services:
FIRMS_DATASET: ${FIRMS_DATASET:-VIIRS_NOAA20_NRT}
FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT}
FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted)}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)}
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}

View file

@ -610,8 +610,8 @@ def test_fetch_planespotters_photo_hex_builds_url_and_normalizes(monkeypatch):
seen = []
async def fake_get(url, params=None):
seen.append(url)
async def fake_get(url, params=None, headers=None):
seen.append((url, (headers or {}).get("User-Agent", "")))
return {"photos": [{
"id": "1", "thumbnail": {"src": "https://t.plnspttrs.net/a_t.jpg"},
"thumbnail_large": {"src": "https://t.plnspttrs.net/a_280.jpg"},
@ -622,7 +622,8 @@ def test_fetch_planespotters_photo_hex_builds_url_and_normalizes(monkeypatch):
_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"]
assert seen[0][0] == "https://api.planespotters.net/pub/photos/hex/e8027e"
assert "@" in seen[0][1] or "http" in seen[0][1]
def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch):
@ -632,7 +633,7 @@ def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch):
seen = []
async def fake_get(url, params=None):
async def fake_get(url, params=None, headers=None):
seen.append(url)
return {"photos": []}
@ -642,3 +643,12 @@ def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch):
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
def test_planespotters_headers_add_contact_when_ua_is_generic(monkeypatch):
import live_layers
monkeypatch.setattr(live_layers, "OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted)")
ua = live_layers._planespotters_headers()["User-Agent"]
assert "osint-dashboard" in ua
assert "@" in ua