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 '
no snapshot available
'; - const onerr = "this.classList.add('placeholder'); this.onerror=null; this.alt='snapshot unavailable'; this.removeAttribute('src');"; - return `live snapshot`; + // Live preview: stream endpoint proxies the camera's own MJPEG feed, which + // browsers render natively in an . Fallback chain on error: + // live stream → TTL-cached snapshot → placeholder. + if (!c.snapshot_url || !c.id) return '
no preview available
'; + 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 `live camera preview`; } async function loadCams() { if (!map) return;