● Zion Boggan
repos/Sightline/backend/app/api/routes/cameras.py
zionboggan.com ↗
120 lines · python
History for this file →
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}