Camera popups: live MJPEG preview via /api/cameras/{id}/stream passthrough, snapshot fallback, taller thumb
All checks were successful
build-and-deploy / build (push) Successful in 1m52s

This commit is contained in:
Sirius DevOps 2026-08-24 21:38:46 -04:00
parent 1f0b831835
commit 250dbbb5df
2 changed files with 67 additions and 7 deletions

View file

@ -769,6 +769,63 @@ async def camera_snapshot(camera_id: UUID):
return Response(content=data, media_type="image/jpeg")
@app.get("/api/cameras/{camera_id}/stream")
async def camera_stream(camera_id: UUID):
"""Live MJPEG passthrough for one camera.
Browsers render multipart/x-mixed-replace responses natively inside an
<img> tag, so proxying the camera's own MJPEG stream through here gives a
true live preview in the map popup (no player, no JS). Single-frame JPEG
endpoints also work they render as a static image.
"""
from camera_models import cameras as cam_table
from fastapi.responses import StreamingResponse
import httpx
async with async_session() as session:
url = (await session.execute(
select(cam_table.c.snapshot_url).where(cam_table.c.id == camera_id)
)).scalar_one_or_none()
if not url:
raise HTTPException(404, "Camera or snapshot not found")
if not str(url).lower().startswith(("http://", "https://")):
raise HTTPException(400, "URL is not streamable over HTTP")
# connect timeout short so dead cams fail fast; read timeout None because
# an MJPEG stream legitimately idles between frames.
client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0, read=None), follow_redirects=True,
headers={"User-Agent": "osint-dashboard-camera-view/1.0"},
)
try:
req = client.build_request("GET", url)
resp = await client.send(req, stream=True)
if resp.status_code != 200 or len(resp.headers.get("content-type", "")) == 0:
await resp.aclose()
await client.aclose()
raise HTTPException(502, "Stream unavailable")
except HTTPException:
raise
except Exception:
await client.aclose()
raise HTTPException(502, "Stream unavailable")
async def gen():
try:
async for chunk in resp.aiter_bytes():
yield chunk
finally:
await resp.aclose()
await client.aclose()
ctype = resp.headers.get("content-type", "")
media = ctype if "multipart" in ctype.lower() else (
ctype if "image/" in ctype.lower()
else "multipart/x-mixed-replace; boundary=frame"
)
return StreamingResponse(gen(), media_type=media)
# ── News pipeline (scraper + summarizer) ──────────────────────────────────
# Backing data for the frontend news panel. Written by the vendored
# news-scraper (hourly Scrapy crawl) and news-summarizer (hourly Gemini

View file

@ -132,7 +132,7 @@
.lp-error { color: var(--red); font-size: 0.68rem; }
/* ── Camera popup thumbnail ───────────────────────────────── */
.cam-pop { min-width: 210px; max-width: 260px; }
.cam-pop .thumb { width: 100%; height: 120px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(56,189,248,0.25); background: #0b1220; }
.cam-pop .thumb { width: 100%; height: 160px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(56,189,248,0.25); background: #0b1220; }
.cam-pop .thumb.placeholder { display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.7rem; height: 80px; }
.cam-pop table { width: 100%; font-size: 0.72rem; border-collapse: collapse; }
.cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; }
@ -1026,12 +1026,15 @@ function esc(s) {
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function camThumb(c) {
// Live snapshot proxied through the backend TTL cache. Only render the
// <img> when the camera actually has a snapshot URL (else the backend
// 404s); onerror swaps to a placeholder so a dead cam never breaks layout.
if (!c.snapshot_url) return '<div class="thumb placeholder">no snapshot available</div>';
const onerr = "this.classList.add('placeholder'); this.onerror=null; this.alt='snapshot unavailable'; this.removeAttribute('src');";
return `<img class="thumb" src="/api/cameras/${esc(c.id)}/snapshot" alt="live snapshot" loading="lazy" onerror="${onerr}">`;
// Live preview: stream endpoint proxies the camera's own MJPEG feed, which
// browsers render natively in an <img>. Fallback chain on error:
// live stream → TTL-cached snapshot → placeholder.
if (!c.snapshot_url || !c.id) return '<div class="thumb placeholder">no preview available</div>';
const onerr = (
"if (!this.dataset.f) { this.dataset.f='1'; this.src='/api/cameras/" + esc(c.id) + "/snapshot'; } " +
"else { this.classList.add('placeholder'); this.onerror=null; this.alt='preview unavailable'; this.removeAttribute('src'); }"
);
return `<img class="thumb" src="/api/cameras/${esc(c.id)}/stream" alt="live camera preview" loading="lazy" onerror="${onerr}">`;
}
async function loadCams() {
if (!map) return;