osint-dashboard/app/camera_preview.py

188 lines
5.9 KiB
Python
Raw Normal View History

"""Resolve a browser-renderable preview for a camera.
HTTP/MJPEG cameras already expose a snapshot_url the existing proxy can
stream. Some scraper sources store `rtsp://` URLs with no snapshot_url, so
the map popup used to skip the <img> entirely and the leftover source link
handed the browser an rtsp:// URL (which opens VLC).
This module:
1. Tries a short list of unauthenticated HTTP snapshot paths (fast).
2. Falls back to grabbing one JPEG frame from RTSP via ffmpeg (no auth).
3. Remembers the first URL that worked on the camera row so the next
popup is a cache hit.
No credentials are ever tried.
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from urllib.parse import urlparse
import httpx
from camera_config import USER_AGENT
from camera_models import cameras
from camera_scraper import fetch_snapshot
from database import async_session
logger = logging.getLogger("osint.camera_preview")
# Most common unauthenticated still-image endpoints on consumer NVRs/IP cams.
# Keep this list SHORT — it runs on popup click.
_HTTP_PATHS = (
"/snapshot.jpg",
"/cgi-bin/snapshot.cgi",
"/jpg/image.jpg",
"/image.jpg",
"/onvif/snapshot",
"/axis-cgi/jpg/image.cgi",
"/tmpfs/auto.jpg",
)
_FFMPEG = shutil.which("ffmpeg")
def _host_from_url(url: str) -> str | None:
try:
return urlparse(url).hostname
except Exception: # noqa: BLE001
return None
def _looks_like_jpeg(data: bytes) -> bool:
return bool(data) and len(data) >= 64 and data[:2] == b"\xff\xd8"
async def _http_get_image(url: str, timeout: float = 2.5) -> bytes | None:
try:
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True,
headers={"User-Agent": USER_AGENT},
) as c:
r = await c.get(url)
if r.status_code != 200:
return None
ctype = (r.headers.get("content-type") or "").lower()
if "html" in ctype or "text/" in ctype:
return None
if not _looks_like_jpeg(r.content) and "image/" not in ctype:
return None
if len(r.content) < 64:
return None
return r.content
except Exception: # noqa: BLE001
return None
async def ffmpeg_snapshot(url: str, timeout: float = 8.0) -> bytes | None:
"""Grab a single JPEG frame from an RTSP URL. None if ffmpeg missing/fails.
The subprocess is scheduled via asyncio.create_task (shared per URL) so
concurrent popup clicks do not stack ffmpeg processes on the request path.
"""
from bg_jobs import cached_ffmpeg_jpeg, schedule_ffmpeg_snapshot
hit = cached_ffmpeg_jpeg(url)
if hit:
return hit
return await schedule_ffmpeg_snapshot(url, timeout)
async def ffmpeg_mjpeg_stream(url: str):
"""Yield an MJPEG multipart body transcoded from RTSP. Caller streams it."""
if not _FFMPEG:
return
cmd = [
_FFMPEG, "-hide_banner", "-loglevel", "error", "-nostdin",
]
if url.lower().startswith("rtsp://"):
cmd += ["-rtsp_transport", "tcp", "-timeout", "4000000"]
cmd += [
"-i", url,
"-an", "-c:v", "mjpeg", "-q:v", "8",
"-f", "mpjpeg", "pipe:1",
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
assert proc.stdout is not None
while True:
chunk = await proc.stdout.read(64 * 1024)
if not chunk:
break
yield chunk
finally:
if proc.returncode is None:
proc.kill()
try:
await proc.wait()
except Exception: # noqa: BLE001
pass
async def _remember(camera_id, url: str) -> None:
try:
async with async_session() as session:
await session.execute(
cameras.update()
.where(cameras.c.id == camera_id)
.values(snapshot_url=url)
)
await session.commit()
except Exception: # noqa: BLE001
logger.warning("failed to persist snapshot_url for %s", camera_id,
exc_info=True)
async def resolve_preview(row) -> tuple[bytes | None, str | None]:
"""Return (jpeg_bytes, working_url) for a cameras-table row.
Tries, in order:
* existing HTTP snapshot_url (via the TTL cache)
* common HTTP still-image paths on the host
* ffmpeg frame grab from the stored RTSP URL, then common RTSP paths
"""
snap = row.get("snapshot_url") or ""
source = row.get("source_url") or ""
host = _host_from_url(snap) or _host_from_url(source)
if not host:
return None, None
# 1. Known HTTP snapshot — go through the existing TTL cache.
if snap.lower().startswith(("http://", "https://")):
data = await fetch_snapshot(snap)
if data:
return data, snap
# 2. Probe unauthenticated HTTP stills in parallel (fast fail) BEFORE
# any ffmpeg — most open cams that preview at all do it over HTTP.
http_urls = [f"http://{host}{p}" for p in _HTTP_PATHS]
http_urls.append(f"http://{host}:8080/shot.jpg")
results = await asyncio.gather(
*(_http_get_image(u) for u in http_urls),
return_exceptions=True,
)
for url, data in zip(http_urls, results):
if isinstance(data, (bytes, bytearray)) and data:
await _remember(row["id"], url)
return bytes(data), url
# 3. One ffmpeg grab of the stored RTSP URL. Path-walking is too slow
# for a popup click; unauthenticated RTSP often needs a vendor path
# and/or credentials we will not try.
rtsp_url = snap if snap.lower().startswith("rtsp://") else source
if rtsp_url.lower().startswith("rtsp://"):
data = await ffmpeg_snapshot(rtsp_url, timeout=5.0)
if data:
if not snap:
await _remember(row["id"], rtsp_url)
return data, rtsp_url
return None, None