feat: frigate-notify v2 (modular app, CLIP recognition) + clip-service
Splits the single main.py into config/database/models/routes/vision/recognition/ ollama_client/cleanup modules, adds a web UI, and introduces clip-service for CLIP (ViT-L-14) embeddings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user