"""Event API endpoints for frigate-notify v2.""" import base64 import logging from typing import Optional from fastapi import APIRouter, HTTPException, Query from fastapi.responses import Response from database import get_connection from models import EventResponse, TagEventRequest logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/events", tags=["events"]) @router.get("", response_model=list[EventResponse]) async def list_events( camera: Optional[str] = Query(default=None), label: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), limit: int = Query(default=50, ge=1, le=500), offset: int = Query(default=0, ge=0), ): """List events with optional filters and pagination. Returns events without snapshot/embedding BLOBs but with has_snapshot bool. Joins to known_objects for matched_object_name. """ conn = get_connection() query = """ SELECT e.id, e.frigate_event_id, e.camera, e.label, e.score, CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot, e.match_status, e.matched_object_id, ko.name AS matched_object_name, e.match_confidence, e.ai_description, e.notified, e.created_at FROM events e LEFT JOIN known_objects ko ON e.matched_object_id = ko.id """ conditions = [] params: list = [] if camera: conditions.append("e.camera = ?") params.append(camera) if label: conditions.append("e.label = ?") params.append(label) if status: conditions.append("e.match_status = ?") params.append(status) if conditions: query += " WHERE " + " AND ".join(conditions) query += " ORDER BY e.created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) rows = cursor.fetchall() return [ EventResponse( id=row["id"], frigate_event_id=row["frigate_event_id"], camera=row["camera"], label=row["label"], score=row["score"], has_snapshot=bool(row["has_snapshot"]), match_status=row["match_status"], matched_object_id=row["matched_object_id"], matched_object_name=row["matched_object_name"], match_confidence=row["match_confidence"], ai_description=row["ai_description"], notified=bool(row["notified"]), created_at=row["created_at"], ) for row in rows ] @router.get("/{event_id}", response_model=EventResponse) async def get_event(event_id: int): """Get a single event detail. Includes base64-encoded snapshot if exists.""" conn = get_connection() cursor = conn.execute( """ SELECT e.id, e.frigate_event_id, e.camera, e.label, e.score, e.snapshot, CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot, e.match_status, e.matched_object_id, ko.name AS matched_object_name, e.match_confidence, e.ai_description, e.notified, e.created_at FROM events e LEFT JOIN known_objects ko ON e.matched_object_id = ko.id WHERE e.id = ? """, (event_id,), ) row = cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Event not found") snapshot_b64 = None if row["snapshot"] is not None: snapshot_b64 = base64.b64encode(row["snapshot"]).decode("ascii") return EventResponse( id=row["id"], frigate_event_id=row["frigate_event_id"], camera=row["camera"], label=row["label"], score=row["score"], has_snapshot=bool(row["has_snapshot"]), snapshot_base64=snapshot_b64, match_status=row["match_status"], matched_object_id=row["matched_object_id"], matched_object_name=row["matched_object_name"], match_confidence=row["match_confidence"], ai_description=row["ai_description"], notified=bool(row["notified"]), created_at=row["created_at"], ) @router.get("/{event_id}/snapshot") async def get_event_snapshot(event_id: int): """Return raw JPEG snapshot for an event. 404 if no snapshot.""" conn = get_connection() cursor = conn.execute( "SELECT snapshot FROM events WHERE id = ?", (event_id,), ) row = cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Event not found") if row["snapshot"] is None: raise HTTPException(status_code=404, detail="No snapshot available") return Response(content=row["snapshot"], media_type="image/jpeg") @router.post("/{event_id}/tag") async def tag_event(event_id: int, body: TagEventRequest): """Tag an event as a known object. Either provide known_object_id to link to an existing object, or provide name + type to create a new known object. If the event has an embedding, store it in the embeddings table. """ conn = get_connection() # Verify event exists cursor = conn.execute( "SELECT id, embedding FROM events WHERE id = ?", (event_id,), ) event_row = cursor.fetchone() if not event_row: raise HTTPException(status_code=404, detail="Event not found") known_object_id = body.known_object_id if known_object_id is not None: # Verify the known object exists cursor = conn.execute( "SELECT id FROM known_objects WHERE id = ?", (known_object_id,), ) if not cursor.fetchone(): raise HTTPException(status_code=404, detail="Known object not found") elif body.name and body.type: # Create a new known object cursor = conn.execute( "INSERT INTO known_objects (name, type) VALUES (?, ?)", (body.name, body.type), ) known_object_id = cursor.lastrowid conn.commit() else: raise HTTPException( status_code=400, detail="Must provide either known_object_id or both name and type", ) # If event has an embedding, store it in the embeddings table if event_row["embedding"] is not None: conn.execute( "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", (known_object_id, event_row["embedding"]), ) # Update event conn.execute( """UPDATE events SET match_status = 'known', matched_object_id = ?, match_confidence = 1.0 WHERE id = ?""", (known_object_id, event_id), ) conn.commit() return {"status": "ok", "matched_object_id": known_object_id} @router.post("/{event_id}/dismiss") async def dismiss_event(event_id: int): """Set event match_status to 'unknown'.""" conn = get_connection() cursor = conn.execute("SELECT id FROM events WHERE id = ?", (event_id,)) if not cursor.fetchone(): raise HTTPException(status_code=404, detail="Event not found") conn.execute( "UPDATE events SET match_status = 'unknown' WHERE id = ?", (event_id,), ) conn.commit() return {"status": "ok"}