diff --git a/app/main.py b/app/main.py
index d87ec63..e74cca1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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
+ 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
diff --git a/app/static/index.html b/app/static/index.html
index 442fe14..0dc38e2 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function camThumb(c) {
- // Live snapshot proxied through the backend TTL cache. Only render the
- //
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 '