| 1 | from __future__ import annotations |
| 2 | |
| 3 | import httpx |
| 4 | from fastapi import APIRouter, Depends, HTTPException, Query, Response |
| 5 | from sqlalchemy import func, select |
| 6 | from sqlalchemy.orm import Session |
| 7 | |
| 8 | from app.api.deps import Perm, get_db, require |
| 9 | from app.models import Camera, Location, PlateRead, User |
| 10 | |
| 11 | router = APIRouter(tags=["cameras"]) |
| 12 | |
| 13 | |
| 14 | @router.get("/cameras/live") |
| 15 | def live_cameras(state: str | None = None, q: str | None = None, limit: int = 120, offset: int = 0, |
| 16 | db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 17 | """Cameras that expose a real live snapshot feed (e.g. NYC DOT). Each returns a same-origin |
| 18 | snapshot_url (proxied) the UI can render and refresh.""" |
| 19 | base = select(Camera, Location).join(Location, Camera.location_id == Location.id, isouter=True)\ |
| 20 | .where(Camera.has_live_feed.is_(True)) |
| 21 | if state: |
| 22 | base = base.where(Camera.state == state.upper()) |
| 23 | if q: |
| 24 | base = base.where(Camera.name.ilike(f"%{q}%")) |
| 25 | total = db.scalar(select(func.count()).select_from( |
| 26 | select(Camera).where(Camera.has_live_feed.is_(True)).subquery())) |
| 27 | rows = db.execute(base.order_by(Camera.name).limit(min(limit, 300)).offset(offset)).all() |
| 28 | return {"total": total, "limit": limit, "offset": offset, |
| 29 | "cameras": [{"id": c.id, "name": c.name, "operator": c.operator, "area": c.area, |
| 30 | "state": c.state, "lat": loc.lat if loc else None, "lon": loc.lon if loc else None, |
| 31 | "online": (c.meta or {}).get("online", True), |
| 32 | "snapshot_url": f"/api/v1/cameras/{c.id}/snapshot", |
| 33 | "live_source": c.live_source} for c, loc in rows]} |
| 34 | |
| 35 | |
| 36 | _SNAP_CACHE: dict[str, tuple] = {} |
| 37 | _SNAP_TTL = 18.0 |
| 38 | |
| 39 | |
| 40 | @router.get("/cameras/{camera_id}/snapshot") |
| 41 | def camera_snapshot(camera_id: str, db: Session = Depends(get_db)): |
| 42 | """Proxy + cache the camera's live snapshot (public DOT imagery). Public + cacheable so the UI |
| 43 | can refresh it in an <img> without auth headers. Upstream is hit at most once per TTL.""" |
| 44 | import time |
| 45 | now = time.time() |
| 46 | hit = _SNAP_CACHE.get(camera_id) |
| 47 | if hit and now - hit[0] < _SNAP_TTL: |
| 48 | return Response(content=hit[1], media_type=hit[2], |
| 49 | headers={"Cache-Control": f"public, max-age={int(_SNAP_TTL)}"}) |
| 50 | |
| 51 | cam = db.get(Camera, camera_id) |
| 52 | if not cam or not cam.has_live_feed or not cam.image_url: |
| 53 | raise HTTPException(404, "no live feed for this camera") |
| 54 | from urllib.parse import urlparse |
| 55 | p = urlparse(cam.image_url) |
| 56 | try: |
| 57 | r = httpx.get(cam.image_url, timeout=12, follow_redirects=True, |
| 58 | headers={"User-Agent": "Mozilla/5.0 Sightline/1.0", |
| 59 | "Referer": f"{p.scheme}://{p.netloc}/"}) |
| 60 | r.raise_for_status() |
| 61 | ctype = r.headers.get("content-type", "image/jpeg") |
| 62 | if not ctype.startswith("image"): |
| 63 | raise ValueError("not an image") |
| 64 | except Exception: |
| 65 | if hit: |
| 66 | return Response(content=hit[1], media_type=hit[2], |
| 67 | headers={"Cache-Control": "public, max-age=10"}) |
| 68 | raise HTTPException(502, "snapshot unavailable") |
| 69 | _SNAP_CACHE[camera_id] = (now, r.content, ctype) |
| 70 | if len(_SNAP_CACHE) > 4000: |
| 71 | for k in list(_SNAP_CACHE)[:1000]: |
| 72 | _SNAP_CACHE.pop(k, None) |
| 73 | return Response(content=r.content, media_type=ctype, |
| 74 | headers={"Cache-Control": f"public, max-age={int(_SNAP_TTL)}"}) |
| 75 | |
| 76 | |
| 77 | @router.get("/cameras") |
| 78 | def list_cameras(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 79 | rows = db.scalars(select(Camera).limit(1000)).all() |
| 80 | return [_camera_dict(db, c) for c in rows] |
| 81 | |
| 82 | |
| 83 | @router.get("/cameras/map") |
| 84 | def camera_map(bbox: str | None = Query(default=None, description="min_lon,min_lat,max_lon,max_lat"), |
| 85 | state: str | None = None, operator: str | None = None, limit: int = 20000, |
| 86 | db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 87 | """All camera points for the map. Efficient single join (no per-camera counts) so it scales |
| 88 | to the full ~9.5k real-camera network; the frontend clusters them.""" |
| 89 | q = (select(Camera.id, Camera.name, Camera.operator, Camera.state, Camera.zone, |
| 90 | Camera.direction_facing, Location.lat, Location.lon) |
| 91 | .join(Location, Camera.location_id == Location.id) |
| 92 | .where(Location.lat.isnot(None))) |
| 93 | if state: |
| 94 | q = q.where(Camera.state == state.upper()) |
| 95 | if operator: |
| 96 | q = q.where(Camera.operator.ilike(f"%{operator}%")) |
| 97 | if bbox: |
| 98 | b = [float(x) for x in bbox.split(",")] |
| 99 | q = q.where(Location.lon >= b[0], Location.lat >= b[1], Location.lon <= b[2], Location.lat <= b[3]) |
| 100 | rows = db.execute(q.limit(limit)).all() |
| 101 | return {"type": "camera_map", "count": len(rows), |
| 102 | "cameras": [{"camera_id": r[0], "name": r[1], "operator": r[2], "state": r[3], |
| 103 | "zone": r[4], "direction_facing": r[5], "lat": r[6], "lon": r[7]} for r in rows]} |
| 104 | |
| 105 | |
| 106 | @router.get("/cameras/{camera_id}") |
| 107 | def camera_detail(camera_id: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 108 | cam = db.get(Camera, camera_id) |
| 109 | if not cam: |
| 110 | raise HTTPException(404, "camera not found") |
| 111 | count = db.scalar(select(func.count()).where(PlateRead.camera_id == cam.id)) or 0 |
| 112 | return {**_camera_dict(db, cam), "read_count": count} |
| 113 | |
| 114 | |
| 115 | def _camera_dict(db: Session, cam: Camera) -> dict: |
| 116 | loc = db.get(Location, cam.location_id) if cam.location_id else None |
| 117 | return {"id": cam.id, "external_camera_id": cam.external_camera_id, "name": cam.name, |
| 118 | "direction_facing": cam.direction_facing, |
| 119 | "lat": loc.lat if loc else None, "lon": loc.lon if loc else None, |
| 120 | "address": loc.address if loc else None} |