"""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 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