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
All checks were successful
build-and-deploy / build (push) Successful in 1m52s
This commit is contained in:
parent
1f0b831835
commit
250dbbb5df
2 changed files with 67 additions and 7 deletions
57
app/main.py
57
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
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// <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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue