diff --git a/app/main.py b/app/main.py index dd765bf..5ee8612 100755 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,9 @@ from fastapi.staticfiles import StaticFiles from config import config from database import close_connection, get_connection, init_db, set_db_path from matrix_client import MatrixClient +from routes.events import router as events_router +from routes.known_objects import router as known_objects_router +from routes.settings import router as settings_router from vision import analyze_snapshot # --- Logging --- @@ -253,6 +256,11 @@ app = FastAPI( lifespan=lifespan, ) +# Include API routers +app.include_router(events_router) +app.include_router(known_objects_router) +app.include_router(settings_router) + # Mount static files directory static_dir = Path(__file__).parent / "static" static_dir.mkdir(exist_ok=True) diff --git a/app/models.py b/app/models.py index 450c9a5..5233db1 100644 --- a/app/models.py +++ b/app/models.py @@ -40,10 +40,15 @@ class KnownObjectResponse(BaseModel): class TagEventRequest(BaseModel): - """Request model for tagging an event with a known object.""" + """Request model for tagging an event with a known object. - known_object_id: int - match_status: str = Field(default="known", pattern="^(known|unknown|ambiguous)$") + Either provide known_object_id to link to an existing object, + or provide name + type to create a new known object. + """ + + known_object_id: Optional[int] = None + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + type: Optional[str] = Field(default=None, pattern="^(person|vehicle|pet)$") class EventResponse(BaseModel): @@ -54,8 +59,11 @@ class EventResponse(BaseModel): camera: str label: str score: float + has_snapshot: bool = False + snapshot_base64: Optional[str] = None match_status: str matched_object_id: Optional[int] = None + matched_object_name: Optional[str] = None match_confidence: Optional[float] = None ai_description: Optional[str] = None notified: bool @@ -79,8 +87,9 @@ class SettingsResponse(BaseModel): class StatsResponse(BaseModel): """Response model for application statistics.""" - events_processed: int - mqtt_connected: bool - last_event: Optional[str] = None - total_events_stored: int = 0 + total_events: int = 0 + known_matches: int = 0 + unknown_events: int = 0 + unreviewed: int = 0 known_objects_count: int = 0 + events_today: int = 0 diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/events.py b/app/routes/events.py new file mode 100644 index 0000000..25dad77 --- /dev/null +++ b/app/routes/events.py @@ -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"} diff --git a/app/routes/known_objects.py b/app/routes/known_objects.py new file mode 100644 index 0000000..c6fced4 --- /dev/null +++ b/app/routes/known_objects.py @@ -0,0 +1,177 @@ +"""Known objects API endpoints for frigate-notify v2.""" + +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 KnownObjectResponse, KnownObjectUpdate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/known-objects", tags=["known-objects"]) + + +@router.get("") +async def list_known_objects( + type: Optional[str] = Query(default=None, pattern="^(person|vehicle|pet)$"), +): + """List all known objects with embedding_count. Optional type filter.""" + conn = get_connection() + + query = """ + SELECT + ko.id, ko.name, ko.type, ko.action, + ko.created_at, ko.updated_at, + COUNT(e.id) AS embedding_count + FROM known_objects ko + LEFT JOIN embeddings e ON ko.id = e.known_object_id + """ + params: list = [] + + if type: + query += " WHERE ko.type = ?" + params.append(type) + + query += " GROUP BY ko.id ORDER BY ko.name" + + cursor = conn.execute(query, params) + rows = cursor.fetchall() + + return [ + { + "id": row["id"], + "name": row["name"], + "type": row["type"], + "action": row["action"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "embedding_count": row["embedding_count"], + } + for row in rows + ] + + +@router.get("/{object_id}") +async def get_known_object(object_id: int): + """Get a single known object with list of its embeddings (id, created_at only).""" + conn = get_connection() + + cursor = conn.execute( + "SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?", + (object_id,), + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Known object not found") + + # Get embeddings list + emb_cursor = conn.execute( + "SELECT id, created_at FROM embeddings WHERE known_object_id = ? ORDER BY created_at", + (object_id,), + ) + embeddings = [ + {"id": e["id"], "created_at": e["created_at"]} for e in emb_cursor.fetchall() + ] + + return { + "id": row["id"], + "name": row["name"], + "type": row["type"], + "action": row["action"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "embeddings": embeddings, + } + + +@router.get("/{object_id}/thumbnails/{embedding_id}") +async def get_embedding_thumbnail(object_id: int, embedding_id: int): + """Return raw JPEG thumbnail from embeddings table.""" + conn = get_connection() + + cursor = conn.execute( + "SELECT thumbnail FROM embeddings WHERE id = ? AND known_object_id = ?", + (embedding_id, object_id), + ) + row = cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Embedding not found") + if row["thumbnail"] is None: + raise HTTPException(status_code=404, detail="No thumbnail available") + + return Response(content=row["thumbnail"], media_type="image/jpeg") + + +@router.put("/{object_id}") +async def update_known_object(object_id: int, body: KnownObjectUpdate): + """Update name/type/action for a known object. Sets updated_at.""" + conn = get_connection() + + cursor = conn.execute( + "SELECT id FROM known_objects WHERE id = ?", + (object_id,), + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Known object not found") + + updates = [] + params: list = [] + + if body.name is not None: + updates.append("name = ?") + params.append(body.name) + if body.type is not None: + updates.append("type = ?") + params.append(body.type) + if body.action is not None: + updates.append("action = ?") + params.append(body.action) + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(object_id) + + conn.execute( + f"UPDATE known_objects SET {', '.join(updates)} WHERE id = ?", + params, + ) + conn.commit() + + # Return the updated object + cursor = conn.execute( + "SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?", + (object_id,), + ) + row = cursor.fetchone() + + return KnownObjectResponse( + id=row["id"], + name=row["name"], + type=row["type"], + action=row["action"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +@router.delete("/{object_id}") +async def delete_known_object(object_id: int): + """Delete a known object. Embeddings cascade delete automatically.""" + conn = get_connection() + + cursor = conn.execute( + "SELECT id FROM known_objects WHERE id = ?", + (object_id,), + ) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Known object not found") + + conn.execute("DELETE FROM known_objects WHERE id = ?", (object_id,)) + conn.commit() + + return {"status": "ok"} diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..7a0841c --- /dev/null +++ b/app/routes/settings.py @@ -0,0 +1,78 @@ +"""Settings and stats API endpoints for frigate-notify v2.""" + +import logging +from datetime import date + +from fastapi import APIRouter + +from config import config +from database import get_connection +from models import StatsResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["settings"]) + + +@router.get("/settings") +async def get_settings(): + """Return all settings from DB plus runtime config values.""" + conn = get_connection() + cursor = conn.execute("SELECT key, value FROM settings") + db_settings = {row["key"]: row["value"] for row in cursor.fetchall()} + + # Merge with runtime config values + return { + **db_settings, + "recognition_threshold_high": config.RECOGNITION_THRESHOLD_HIGH, + "recognition_threshold_low": config.RECOGNITION_THRESHOLD_LOW, + "rate_limit_seconds": config.RATE_LIMIT_SECONDS, + "allowed_labels": list(config.ALLOWED_LABELS), + "min_score": config.MIN_SCORE, + } + + +@router.put("/settings") +async def update_settings(body: dict): + """Accept dict body, upsert each key/value into settings table.""" + conn = get_connection() + for key, value in body.items(): + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, str(value)), + ) + conn.commit() + return {"status": "ok"} + + +@router.get("/stats", response_model=StatsResponse) +async def get_stats(): + """Return event and object counts.""" + conn = get_connection() + + total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + known = conn.execute( + "SELECT COUNT(*) FROM events WHERE match_status = 'known'" + ).fetchone()[0] + unknown = conn.execute( + "SELECT COUNT(*) FROM events WHERE match_status = 'unknown'" + ).fetchone()[0] + unreviewed = conn.execute( + "SELECT COUNT(*) FROM events WHERE match_status = 'unreviewed'" + ).fetchone()[0] + objects_count = conn.execute("SELECT COUNT(*) FROM known_objects").fetchone()[0] + today = date.today().isoformat() + events_today = conn.execute( + "SELECT COUNT(*) FROM events WHERE DATE(created_at) = ?", + (today,), + ).fetchone()[0] + + return StatsResponse( + total_events=total, + known_matches=known, + unknown_events=unknown, + unreviewed=unreviewed, + known_objects_count=objects_count, + events_today=events_today, + ) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..364c29b --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,619 @@ +"""API endpoint tests for frigate-notify v2.""" + +import sqlite3 +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from database import close_connection, get_connection, init_db, set_db_path + + +@pytest.fixture +def client(db_path): + """Create a TestClient with a temporary database. + + Patches config.DB_PATH and mocks heavy dependencies (MQTT, anthropic, + matrix_client, vision) so that main.py can be imported without them. + """ + # Set DB path before anything imports the app + set_db_path(db_path) + init_db() + + # Mock modules that main.py imports at top level but aren't needed for API tests + mock_modules = {} + for mod_name in ("paho", "paho.mqtt", "paho.mqtt.client", "anthropic", "aiohttp"): + if mod_name not in sys.modules: + mock_modules[mod_name] = MagicMock() + sys.modules[mod_name] = mock_modules[mod_name] + + # Ensure matrix_client and vision modules are available as mocks + if "matrix_client" not in sys.modules: + mock_modules["matrix_client"] = MagicMock() + sys.modules["matrix_client"] = mock_modules["matrix_client"] + if "vision" not in sys.modules: + mock_modules["vision"] = MagicMock() + sys.modules["vision"] = mock_modules["vision"] + + # Patch config DB_PATH + from config import config + + original_db_path = config.DB_PATH + config.DB_PATH = db_path + + # Now import the app (this will use the patched DB_PATH) + # We need to patch the lifespan to avoid starting MQTT + from fastapi.testclient import TestClient + + from main import app + + # Override the lifespan to just init DB without MQTT + from contextlib import asynccontextmanager + + @asynccontextmanager + async def test_lifespan(app): + set_db_path(db_path) + init_db() + yield + close_connection() + + app.router.lifespan_context = test_lifespan + + with TestClient(app) as tc: + yield tc + + # Cleanup + close_connection() + config.DB_PATH = original_db_path + + # Remove mocked modules + for mod_name in mock_modules: + if mod_name in sys.modules and sys.modules[mod_name] is mock_modules[mod_name]: + del sys.modules[mod_name] + + +# --- Helper to insert test data --- + + +def _insert_event( + frigate_event_id="evt-001", + camera="front_door", + label="person", + score=0.95, + snapshot=None, + embedding=None, + match_status="unreviewed", + matched_object_id=None, + ai_description=None, +): + """Insert a test event directly into the database. Returns the event id.""" + conn = get_connection() + cursor = conn.execute( + """INSERT INTO events + (frigate_event_id, camera, label, score, snapshot, embedding, + match_status, matched_object_id, ai_description, notified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""", + ( + frigate_event_id, + camera, + label, + score, + snapshot, + embedding, + match_status, + matched_object_id, + ai_description, + ), + ) + conn.commit() + return cursor.lastrowid + + +def _insert_known_object(name="John", type_="person", action="quiet"): + """Insert a test known object directly into the database. Returns the object id.""" + conn = get_connection() + cursor = conn.execute( + "INSERT INTO known_objects (name, type, action) VALUES (?, ?, ?)", + (name, type_, action), + ) + conn.commit() + return cursor.lastrowid + + +# --- Health endpoint --- + + +class TestHealth: + def test_health_returns_200(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["version"] == "2.0.0" + + +# --- Events endpoints --- + + +class TestEventsAPI: + def test_list_events_empty(self, client): + resp = client.get("/api/events") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_events_returns_events(self, client): + _insert_event(frigate_event_id="evt-list-1", camera="cam1", label="person") + _insert_event(frigate_event_id="evt-list-2", camera="cam2", label="car") + + resp = client.get("/api/events") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + def test_list_events_filter_by_camera(self, client): + _insert_event(frigate_event_id="evt-cam1", camera="front_door") + _insert_event(frigate_event_id="evt-cam2", camera="back_yard") + + resp = client.get("/api/events?camera=front_door") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["camera"] == "front_door" + + def test_list_events_filter_by_status(self, client): + _insert_event(frigate_event_id="evt-s1", match_status="unreviewed") + _insert_event(frigate_event_id="evt-s2", match_status="known") + + resp = client.get("/api/events?status=known") + data = resp.json() + assert len(data) == 1 + assert data[0]["match_status"] == "known" + + def test_list_events_has_snapshot_field(self, client): + _insert_event( + frigate_event_id="evt-snap", snapshot=b"\xff\xd8\xff\xe0fake-jpeg" + ) + _insert_event(frigate_event_id="evt-nosnap", snapshot=None) + + resp = client.get("/api/events") + data = resp.json() + snap_event = next(e for e in data if e["frigate_event_id"] == "evt-snap") + nosnap_event = next(e for e in data if e["frigate_event_id"] == "evt-nosnap") + assert snap_event["has_snapshot"] is True + assert nosnap_event["has_snapshot"] is False + + def test_list_events_with_matched_object_name(self, client): + obj_id = _insert_known_object(name="Alice") + _insert_event( + frigate_event_id="evt-matched", + match_status="known", + matched_object_id=obj_id, + ) + + resp = client.get("/api/events") + data = resp.json() + assert data[0]["matched_object_name"] == "Alice" + + def test_get_event_not_found(self, client): + resp = client.get("/api/events/99999") + assert resp.status_code == 404 + + def test_get_event_detail(self, client): + event_id = _insert_event(frigate_event_id="evt-detail", camera="pool") + + resp = client.get(f"/api/events/{event_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["frigate_event_id"] == "evt-detail" + assert data["camera"] == "pool" + + def test_get_event_detail_includes_snapshot_base64(self, client): + jpeg_bytes = b"\xff\xd8\xff\xe0fake-jpeg-detail" + event_id = _insert_event( + frigate_event_id="evt-detail-snap", camera="pool", snapshot=jpeg_bytes + ) + + resp = client.get(f"/api/events/{event_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["has_snapshot"] is True + assert data["snapshot_base64"] is not None + + import base64 + + decoded = base64.b64decode(data["snapshot_base64"]) + assert decoded == jpeg_bytes + + def test_get_event_detail_no_snapshot_base64_when_missing(self, client): + event_id = _insert_event( + frigate_event_id="evt-detail-nosnap", camera="pool", snapshot=None + ) + + resp = client.get(f"/api/events/{event_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["has_snapshot"] is False + assert data["snapshot_base64"] is None + + def test_get_event_snapshot(self, client): + jpeg_bytes = b"\xff\xd8\xff\xe0fake-jpeg-data" + event_id = _insert_event( + frigate_event_id="evt-snap-get", snapshot=jpeg_bytes + ) + + resp = client.get(f"/api/events/{event_id}/snapshot") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/jpeg" + assert resp.content == jpeg_bytes + + def test_get_event_snapshot_not_found(self, client): + resp = client.get("/api/events/99999/snapshot") + assert resp.status_code == 404 + + def test_get_event_snapshot_no_snapshot(self, client): + event_id = _insert_event(frigate_event_id="evt-nosnap2", snapshot=None) + + resp = client.get(f"/api/events/{event_id}/snapshot") + assert resp.status_code == 404 + + def test_dismiss_event(self, client): + event_id = _insert_event(frigate_event_id="evt-dismiss") + + resp = client.post(f"/api/events/{event_id}/dismiss") + assert resp.status_code == 200 + + # Verify status changed + resp2 = client.get(f"/api/events/{event_id}") + assert resp2.json()["match_status"] == "unknown" + + def test_dismiss_event_not_found(self, client): + resp = client.post("/api/events/99999/dismiss") + assert resp.status_code == 404 + + def test_tag_event_with_existing_object(self, client): + obj_id = _insert_known_object(name="Bob") + event_id = _insert_event(frigate_event_id="evt-tag1") + + resp = client.post( + f"/api/events/{event_id}/tag", + json={"known_object_id": obj_id}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["matched_object_id"] == obj_id + + # Verify event updated + resp2 = client.get(f"/api/events/{event_id}") + event_data = resp2.json() + assert event_data["match_status"] == "known" + assert event_data["matched_object_id"] == obj_id + assert event_data["match_confidence"] == 1.0 + + def test_tag_event_creates_new_object(self, client): + event_id = _insert_event(frigate_event_id="evt-tag2") + + resp = client.post( + f"/api/events/{event_id}/tag", + json={"name": "NewPerson", "type": "person"}, + ) + assert resp.status_code == 200 + new_obj_id = resp.json()["matched_object_id"] + + # Verify the known object was created + conn = get_connection() + cursor = conn.execute( + "SELECT name, type FROM known_objects WHERE id = ?", (new_obj_id,) + ) + row = cursor.fetchone() + assert row["name"] == "NewPerson" + assert row["type"] == "person" + + def test_tag_event_stores_embedding(self, client): + fake_embedding = b"\x00\x01\x02\x03\x04" + obj_id = _insert_known_object(name="EmbedTest") + event_id = _insert_event( + frigate_event_id="evt-embed", embedding=fake_embedding + ) + + client.post( + f"/api/events/{event_id}/tag", + json={"known_object_id": obj_id}, + ) + + # Verify embedding was stored + conn = get_connection() + cursor = conn.execute( + "SELECT embedding FROM embeddings WHERE known_object_id = ?", (obj_id,) + ) + row = cursor.fetchone() + assert row is not None + assert bytes(row["embedding"]) == fake_embedding + + def test_tag_event_bad_request(self, client): + event_id = _insert_event(frigate_event_id="evt-bad-tag") + + resp = client.post( + f"/api/events/{event_id}/tag", + json={}, + ) + assert resp.status_code == 400 + + def test_tag_event_not_found(self, client): + resp = client.post( + "/api/events/99999/tag", + json={"name": "Nobody", "type": "person"}, + ) + assert resp.status_code == 404 + + def test_tag_event_known_object_not_found(self, client): + event_id = _insert_event(frigate_event_id="evt-bad-obj") + + resp = client.post( + f"/api/events/{event_id}/tag", + json={"known_object_id": 99999}, + ) + assert resp.status_code == 404 + + def test_list_events_pagination(self, client): + for i in range(5): + _insert_event(frigate_event_id=f"evt-page-{i}", camera="cam1") + + resp = client.get("/api/events?limit=2&offset=0") + assert len(resp.json()) == 2 + + resp2 = client.get("/api/events?limit=2&offset=2") + assert len(resp2.json()) == 2 + + resp3 = client.get("/api/events?limit=2&offset=4") + assert len(resp3.json()) == 1 + + +# --- Known Objects endpoints --- + + +class TestKnownObjectsAPI: + def test_list_known_objects_empty(self, client): + resp = client.get("/api/known-objects") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_known_objects(self, client): + _insert_known_object(name="Alice", type_="person") + _insert_known_object(name="Rex", type_="pet") + + resp = client.get("/api/known-objects") + data = resp.json() + assert len(data) == 2 + # Should include embedding_count + assert all("embedding_count" in obj for obj in data) + + def test_list_known_objects_filter_by_type(self, client): + _insert_known_object(name="Alice", type_="person") + _insert_known_object(name="Rex", type_="pet") + + resp = client.get("/api/known-objects?type=person") + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "Alice" + + def test_get_known_object(self, client): + obj_id = _insert_known_object(name="Charlie") + + resp = client.get(f"/api/known-objects/{obj_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Charlie" + assert "embeddings" in data + assert data["embeddings"] == [] + + def test_get_known_object_not_found(self, client): + resp = client.get("/api/known-objects/99999") + assert resp.status_code == 404 + + def test_update_known_object(self, client): + obj_id = _insert_known_object(name="OldName", type_="person", action="quiet") + + resp = client.put( + f"/api/known-objects/{obj_id}", + json={"name": "NewName", "action": "alert"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "NewName" + assert data["action"] == "alert" + assert data["type"] == "person" # unchanged + + def test_update_known_object_not_found(self, client): + resp = client.put( + "/api/known-objects/99999", + json={"name": "Nobody"}, + ) + assert resp.status_code == 404 + + def test_update_known_object_no_fields(self, client): + obj_id = _insert_known_object(name="NoChange") + + resp = client.put( + f"/api/known-objects/{obj_id}", + json={}, + ) + assert resp.status_code == 400 + + def test_delete_known_object(self, client): + obj_id = _insert_known_object(name="DeleteMe") + + resp = client.delete(f"/api/known-objects/{obj_id}") + assert resp.status_code == 200 + + # Verify deleted + resp2 = client.get(f"/api/known-objects/{obj_id}") + assert resp2.status_code == 404 + + def test_delete_known_object_not_found(self, client): + resp = client.delete("/api/known-objects/99999") + assert resp.status_code == 404 + + def test_get_embedding_thumbnail(self, client): + obj_id = _insert_known_object(name="ThumbTest") + thumbnail_data = b"\xff\xd8\xff\xe0thumb-jpeg" + + conn = get_connection() + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding, thumbnail) VALUES (?, ?, ?)", + (obj_id, b"\x00\x01", thumbnail_data), + ) + conn.commit() + emb_id = conn.execute( + "SELECT id FROM embeddings WHERE known_object_id = ?", (obj_id,) + ).fetchone()[0] + + resp = client.get(f"/api/known-objects/{obj_id}/thumbnails/{emb_id}") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/jpeg" + assert resp.content == thumbnail_data + + def test_get_embedding_thumbnail_not_found(self, client): + resp = client.get("/api/known-objects/99999/thumbnails/99999") + assert resp.status_code == 404 + + def test_known_objects_embedding_count(self, client): + obj_id = _insert_known_object(name="CountTest") + + conn = get_connection() + for i in range(3): + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", + (obj_id, b"\x00"), + ) + conn.commit() + + resp = client.get("/api/known-objects") + data = resp.json() + obj = next(o for o in data if o["name"] == "CountTest") + assert obj["embedding_count"] == 3 + + +# --- Settings endpoints --- + + +class TestSettingsAPI: + def test_get_settings_defaults(self, client): + resp = client.get("/api/settings") + assert resp.status_code == 200 + data = resp.json() + # DB defaults + assert data["vision_backend"] == "haiku" + assert data["recognition_enabled"] == "false" + assert data["event_retention_days"] == "90" + # Runtime config values + assert "recognition_threshold_high" in data + assert "recognition_threshold_low" in data + assert "rate_limit_seconds" in data + assert "allowed_labels" in data + assert "min_score" in data + + def test_update_settings(self, client): + resp = client.put( + "/api/settings", + json={"vision_backend": "ollama", "event_retention_days": "30"}, + ) + assert resp.status_code == 200 + + # Verify changes + resp2 = client.get("/api/settings") + data = resp2.json() + assert data["vision_backend"] == "ollama" + assert data["event_retention_days"] == "30" + + def test_update_settings_upsert_new_key(self, client): + resp = client.put( + "/api/settings", + json={"custom_key": "custom_value"}, + ) + assert resp.status_code == 200 + + resp2 = client.get("/api/settings") + data = resp2.json() + assert data["custom_key"] == "custom_value" + + +# --- Stats endpoint --- + + +class TestStatsAPI: + def test_stats_returns_zeros(self, client): + resp = client.get("/api/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total_events"] == 0 + assert data["known_matches"] == 0 + assert data["unknown_events"] == 0 + assert data["unreviewed"] == 0 + assert data["known_objects_count"] == 0 + assert data["events_today"] == 0 + + def test_stats_counts_events(self, client): + _insert_event(frigate_event_id="evt-stat-1", match_status="unreviewed") + _insert_event(frigate_event_id="evt-stat-2", match_status="known") + _insert_event(frigate_event_id="evt-stat-3", match_status="unknown") + _insert_known_object(name="StatObj") + + resp = client.get("/api/stats") + data = resp.json() + assert data["total_events"] == 3 + assert data["known_matches"] == 1 + assert data["unknown_events"] == 1 + assert data["unreviewed"] == 1 + assert data["known_objects_count"] == 1 + assert data["events_today"] == 3 # All inserted today + + +# --- Full tag event flow --- + + +class TestTagEventFlow: + def test_full_tag_flow(self, client): + """Create event in DB, tag it with a new object, verify everything.""" + # 1. Create an event with an embedding + fake_embedding = b"\x00\x01\x02\x03" + event_id = _insert_event( + frigate_event_id="evt-flow-1", + camera="driveway", + label="person", + score=0.92, + embedding=fake_embedding, + ) + + # 2. Verify it shows as unreviewed + resp = client.get(f"/api/events/{event_id}") + assert resp.json()["match_status"] == "unreviewed" + + # 3. Tag the event with a new person + resp = client.post( + f"/api/events/{event_id}/tag", + json={"name": "FlowPerson", "type": "person"}, + ) + assert resp.status_code == 200 + new_obj_id = resp.json()["matched_object_id"] + + # 4. Verify event is now 'known' + resp = client.get(f"/api/events/{event_id}") + event_data = resp.json() + assert event_data["match_status"] == "known" + assert event_data["matched_object_id"] == new_obj_id + assert event_data["matched_object_name"] == "FlowPerson" + assert event_data["match_confidence"] == 1.0 + + # 5. Verify known object was created with embedding + resp = client.get(f"/api/known-objects/{new_obj_id}") + obj_data = resp.json() + assert obj_data["name"] == "FlowPerson" + assert obj_data["type"] == "person" + assert len(obj_data["embeddings"]) == 1 + + # 6. Verify stats updated + resp = client.get("/api/stats") + stats = resp.json() + assert stats["total_events"] == 1 + assert stats["known_matches"] == 1 + assert stats["known_objects_count"] == 1