Merge remote-tracking branch 'origin/master' into dev

This commit is contained in:
Blake Blackshear
2026-10-02 06:39:23 -05:00
57 changed files with 1242 additions and 333 deletions
+12 -17
View File
@@ -130,23 +130,18 @@ def require_admin_by_default():
if path.startswith(EXEMPT_PREFIXES):
return
# Dynamic camera path exemption:
# Any path whose first segment matches a configured camera name should
# bypass the global admin requirement. These endpoints enforce access
# via route-level dependencies (e.g. require_camera_access) to ensure
# per-camera authorization. This allows non-admin authenticated users
# (e.g. viewer role) to access camera-specific resources without
# needing admin privileges.
try:
if path.startswith("/"):
first_segment = path.split("/", 2)[1]
if (
first_segment
and first_segment in request.app.frigate_config.cameras
):
return
except Exception:
pass
# Camera routes enforce per-camera access via route-level dependencies
# (e.g. require_camera_access). Match on the route template, not the raw
# path, so a camera named like another namespace (e.g. "faces") can't
# waive the admin check for that namespace's routes.
route = request.scope.get("route")
if (
route is not None
and route.path.startswith("/{camera_name}")
and request.path_params.get("camera_name")
in request.app.frigate_config.cameras
):
return
# For all other paths, require admin role
# Internal port requests have admin role set automatically
@@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel):
zone: str | None = "all"
zones: str | None = "all"
limit: int | None = 100
offset: int | None = Field(0, ge=0)
after: float | None = None
before: float | None = None
time_range: str | None = DEFAULT_TIME_RANGE
@@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
deprecated=True,
)
limit: int | None = 50
offset: int | None = Field(0, ge=0)
cameras: str | None = "all"
labels: str | None = "all"
sub_labels: str | None = "all"
+11 -2
View File
@@ -129,6 +129,7 @@ def events(
zones = zone
limit = params.limit
offset = params.offset
after = params.after
before = params.before
time_range = params.time_range
@@ -361,11 +362,15 @@ def events(
else:
order_by = Event.start_time.desc()
# offset paging needs a stable order when scores or speeds tie
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
events = (
Event.select(*selected_columns)
.where(reduce(operator.and_, clauses))
.order_by(order_by)
.order_by(order_by, *tiebreaker)
.limit(limit)
.offset(offset)
.dicts()
.iterator()
)
@@ -534,6 +539,7 @@ def events_search(
search_type = params.search_type
include_thumbnails = params.include_thumbnails
limit = params.limit
offset = params.offset
sort = params.sort
# Filters
@@ -840,6 +846,9 @@ def events_search(
if search_results:
events_query = events_query.where(Event.id << list(search_results.keys()))
# sorts below are stable, so this orders ties for offset paging
events_query = events_query.order_by(Event.id)
# Fetch events and process them in a single pass
processed_events = []
for event in events_query.dicts():
@@ -897,7 +906,7 @@ def events_search(
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned
processed_events = processed_events[:limit]
processed_events = processed_events[offset:][:limit]
return JSONResponse(content=processed_events)
+20 -11
View File
@@ -44,6 +44,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.review])
def get_label_clause(label: str, include_audio: bool = True):
"""Build a clause matching a label within a review segment's data.
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
so that variant is matched as well.
"""
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
)
if include_audio:
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
return clause
@router.get(
"/review",
response_model=list[ReviewSegmentResponse],
@@ -93,10 +109,7 @@ async def review(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
label_clauses.append(get_label_clause(label))
clauses.append(reduce(operator.or_, label_clauses))
if zones != "all":
@@ -239,10 +252,7 @@ async def review_summary(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
label_clauses.append(get_label_clause(label))
clauses.append(reduce(operator.or_, label_clauses))
if zones != "all":
# use matching so segments with multiple zones
@@ -340,9 +350,8 @@ async def review_summary(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
)
label_clauses.append(get_label_clause(label, include_audio=False))
clauses.append(reduce(operator.or_, label_clauses))
# Find the time range of available data
+6 -4
View File
@@ -104,12 +104,13 @@ class CameraActivityManager:
all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys():
if camera not in self.config.cameras:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue
# handle cameras that were added dynamically
if camera not in self.camera_all_object_counts:
self.__init_camera(self.config.cameras[camera])
self.__init_camera(camera_config)
new_objects = new_activity[camera].get("objects", [])
all_objects.extend(new_objects)
@@ -234,12 +235,13 @@ class AudioActivityManager:
now = datetime.datetime.now().timestamp()
for camera in new_activity.keys():
if camera not in self.config.cameras:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue
# handle cameras that were added dynamically
if camera not in self.current_audio_detections:
self.__init_camera(self.config.cameras[camera])
self.__init_camera(camera_config)
new_detections = new_activity[camera].get("detections", [])
if self.compare_audio_activity(camera, new_detections, now):
+76 -15
View File
@@ -1,8 +1,10 @@
import logging
import sqlite3
import threading
from typing import Any
import regex
from peewee import DatabaseError
from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
@@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
self.load_vec_extension: bool = load_vec_extension
# no extension necessary, sqlite will load correctly for each platform
self.sqlite_vec_path = "/usr/local/lib/vec0"
self.upsert_lock = threading.Lock()
super().__init__(*args, **kwargs)
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
@@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp)
def execute_write(self, sql: str, params: Any = None) -> None:
"""Run a write and wait for it, so that failures are raised here.
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
stores any exception on the cursor it returns, so callers that ignore
that cursor never learn the write failed.
"""
self.execute_sql(sql, params).fetchall()
def _table_exists(self, table: str) -> bool:
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
return cursor.fetchone() is not None
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists.
@@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
return
# the embeddings tables are only created once semantic search has run
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
if not self._table_exists(table):
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
# callers treat cleanup as best effort, so log rather than propagate
try:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
except DatabaseError:
logger.exception("Failed to delete embeddings from %s", table)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids)
@@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def delete_embeddings_description(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_descriptions", event_ids)
def _restore_vec_info_table(self, table: str) -> None:
"""Recreate the _info shadow table a legacy vec0 table is missing.
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
table is destroyed, so tables written by Frigate 0.17 and earlier fail
to drop. An empty stub is enough, and leaving it unseeded keeps the
table reading as pre-0.1.10 if the drop does not follow.
"""
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
return
logger.debug("Restoring the %s_info shadow table before dropping", table)
self.execute_write(
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
)
def drop_embeddings_tables(self) -> None:
self.execute_sql("""
DROP TABLE vec_descriptions;
""")
self.execute_sql("""
DROP TABLE vec_thumbnails;
""")
for table in ("vec_descriptions", "vec_thumbnails"):
self._restore_vec_info_table(table)
self.execute_write(f"DROP TABLE IF EXISTS {table}")
def create_embeddings_tables(self) -> None:
"""Create vec0 virtual table for embeddings"""
self.execute_sql("""
self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768] distance_metric=cosine
);
""")
self.execute_sql("""
self.execute_write("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
id TEXT PRIMARY KEY,
description_embedding FLOAT[768] distance_metric=cosine
);
""")
def upsert_embeddings(
self, table: str, column: str, embeddings: dict[str, bytes]
) -> None:
"""Write embeddings for the given event ids, replacing any that exist.
vec0 implements neither REPLACE nor UPSERT, so rows that are already
there have to be deleted first.
"""
if not embeddings:
return
event_ids = list(embeddings.keys())
ids = ",".join(["?" for _ in event_ids])
params: list[Any] = []
for event_id in event_ids:
params.extend((event_id, embeddings[event_id]))
values = ", ".join(["(?, ?)"] * len(event_ids))
# reindexing and live embedding run on separate threads, and each write
# is queued separately, so the delete and the insert have to be held
# together or an interleaved pair fails on the vec0 primary key
with self.upsert_lock:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
self.execute_write(
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
)
+40 -40
View File
@@ -6,9 +6,10 @@ import logging
import os
import threading
import time
from typing import Any
import numpy as np
from peewee import DoesNotExist, IntegrityError
from peewee import DatabaseError, DoesNotExist, IntegrityError
from PIL import Image
from playhouse.shortcuts import model_to_dict
@@ -207,12 +208,10 @@ class Embeddings:
embedding = self.vision_embedding([thumbnail])[0]
if upsert:
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
self.db.upsert_embeddings(
"vec_thumbnails",
"thumbnail_embedding",
{event_id: serialize(embedding)},
)
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -251,19 +250,12 @@ class Embeddings:
embeddings = self.vision_embedding(valid_thumbs)
if upsert:
items = []
items = {}
for i in range(len(valid_ids)):
items.append(valid_ids[i])
items.append(serialize(embeddings[i]))
items[valid_ids[i]] = serialize(embeddings[i])
self.image_eps.update()
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
items,
)
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
duration = datetime.datetime.now().timestamp() - start
self.image_inference_speed.update(duration / len(valid_ids))
@@ -277,12 +269,10 @@ class Embeddings:
embedding = self.text_embedding([description])[0]
if upsert:
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
self.db.upsert_embeddings(
"vec_descriptions",
"description_embedding",
{event_id: serialize(embedding)},
)
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -302,19 +292,14 @@ class Embeddings:
if upsert:
ids = list(event_descriptions.keys())
items = []
items = {}
for i in range(len(ids)):
items.append(ids[i])
items.append(serialize(embeddings[i]))
items[ids[i]] = serialize(embeddings[i])
self.text_eps.update()
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", items
)
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -322,6 +307,17 @@ class Embeddings:
return embeddings
def reindex(self) -> None:
"""Rebuild every tracked object embedding from scratch."""
totals: dict[str, Any] = {"status": "indexing"}
try:
self._reindex(totals)
except DatabaseError:
logger.exception("Unable to reindex tracked object embeddings")
totals["status"] = "failed"
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
def _reindex(self, totals: dict[str, Any]) -> None:
logger.info("Indexing tracked object embeddings...")
self.db.drop_embeddings_tables()
@@ -346,14 +342,18 @@ class Embeddings:
batch_size = 32
current_page = 1
totals = {
"thumbnails": 0,
"descriptions": 0,
"processed_objects": total_events - 1 if total_events < batch_size else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
}
totals.update(
{
"thumbnails": 0,
"descriptions": 0,
"processed_objects": total_events - 1
if total_events < batch_size
else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
}
)
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
+1
View File
@@ -365,6 +365,7 @@ class EventCleanup(threading.Thread):
chunk = ids_to_delete[i : i + CHUNK_SIZE]
logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute()
Timeline.delete().where(Timeline.source_id << chunk).execute()
# embeddings are always cleaned up, even when semantic search
# is disabled, so that they don't outlive their events
+2 -2
View File
@@ -126,8 +126,8 @@ PRESETS_HW_ACCEL_SCALE = {
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
"preset-jetson-h264": "-r {0}", # scaled in decoder
"preset-jetson-h265": "-r {0}", # scaled in decoder
+35 -38
View File
@@ -691,6 +691,22 @@ class RecordingMaintainer(threading.Thread):
if start_resolved is not None:
start_resolved.set()
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
# ensure delayed segment info does not lead to lost segments, every
# retention decision below depends on complete stats for the segment
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.UTC)
< end_time
):
return None
record_config = self.config.cameras[camera].record
# sub's alerts/detections carry the retain mode directly, unlike
@@ -718,43 +734,28 @@ class RecordingMaintainer(threading.Thread):
# we should first just check if this segment matches that
# and avoid any DB calls
if highest is not None:
# assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
record_mode = (
RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
)
segment_stats = self.segment_stats(camera, start_time, end_time)
# ensure delayed segment info does not lead to lost segments
if (
datetime.datetime.fromtimestamp(
most_recently_processed_frame_time
).astimezone(datetime.UTC)
>= end_time
):
record_mode = (
RetainModeEnum.all
if highest == "continuous"
else RetainModeEnum.motion
# Here we only check if we should move the segment based on non-object recording retention
# we will always want to check for overlapping review items below before dropping the segment
if not segment_stats.should_discard_segment(record_mode):
return await self.move_segment(
camera,
stream_type,
start_time,
end_time,
duration,
cache_path,
segment_stats,
has_audio,
audio_rate,
audio_codec,
video_codec,
keyframes,
)
segment_stats = self.segment_stats(camera, start_time, end_time)
# Here we only check if we should move the segment based on non-object recording retention
# we will always want to check for overlapping review items below before dropping the segment
if not segment_stats.should_discard_segment(record_mode):
return await self.move_segment(
camera,
stream_type,
start_time,
end_time,
duration,
cache_path,
segment_stats,
has_audio,
audio_rate,
audio_codec,
video_codec,
keyframes,
)
# we fell through the continuous / motion check, so we need to check the review items
# if the cached segment overlaps with the review items:
@@ -816,10 +817,6 @@ class RecordingMaintainer(threading.Thread):
# continuous/motion retention (either disabled or segment_stats said
# discard), so waiting longer just fills the cache.
else:
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
retain_cutoff = datetime.datetime.fromtimestamp(
most_recently_processed_frame_time - record_config.event_pre_capture
).astimezone(datetime.UTC)
+5 -4
View File
@@ -248,8 +248,9 @@ def stats_snapshot(
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
stats["cameras"] = {}
for name, camera_stats in camera_metrics.items():
if name not in config.cameras:
for name, camera_stats in list(camera_metrics.items()):
camera_config = config.cameras.get(name)
if camera_config is None:
continue
total_camera_fps += camera_stats.camera_fps.value
@@ -266,7 +267,7 @@ def stats_snapshot(
# Calculate connection quality based on current state
# This is computed at stats-collection time so offline cameras
# correctly show as unusable rather than excellent
expected_fps = config.cameras[name].detect.fps
expected_fps = camera_config.detect.fps
current_fps = camera_stats.camera_fps.value
reconnects = camera_stats.reconnects_last_hour.value
stalls = camera_stats.stalls_last_hour.value
@@ -299,7 +300,7 @@ def stats_snapshot(
config.cameras[name].enabled,
),
"detection_fps": round(camera_stats.detection_fps.value, 2),
"detection_enabled": config.cameras[name].detect.enabled,
"detection_enabled": camera_config.detect.enabled,
"pid": pid,
"capture_pid": capture_pid,
"ffmpeg_pid": ffmpeg_pid,
+6 -2
View File
@@ -145,7 +145,11 @@ class BaseTestHttp(unittest.TestCase):
pass
def create_app(
self, stats=None, event_metadata_publisher=None, notice_registry=None
self,
stats=None,
event_metadata_publisher=None,
notice_registry=None,
enforce_default_admin=False,
):
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -160,7 +164,7 @@ class BaseTestHttp(unittest.TestCase):
event_metadata_publisher,
None,
DebugReplayManager(),
enforce_default_admin=False,
enforce_default_admin=enforce_default_admin,
notice_registry=notice_registry,
)
+19
View File
@@ -49,6 +49,25 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert response.json()["front_door"]["usage_percent"] == 25.0
def test_camera_name_collision_keeps_admin_default(self):
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app(enforce_default_admin=True)
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with AuthTestClient(app) as client:
assert client.get("/faces", headers=viewer).status_code == 403
assert client.get("/faces").status_code == 200
assert (
client.post("/faces/train/person/classify", headers=viewer).status_code
== 403
)
# Camera routes for the same name stay reachable by viewers
response = client.get("/faces/recordings/summary", headers=viewer)
assert response.status_code == 200
def test_config_set_in_memory_replaces_objects_track_list(self):
self.minimal_config["cameras"]["front_door"]["objects"] = {
"track": ["person", "car"],
+102
View File
@@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id
assert events[1]["id"] == id2
def test_get_event_list_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
f"event-{i}", start_time=now + i, data={"score": score}
)
params = {"sort": "score_desc"}
full = [e["id"] for e in client.get("/events", params=params).json()]
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events", params={**params, "limit": 2, "offset": offset}
).json()
]
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
assert paged == full
def test_get_event_list_match_multilingual_attribute(self):
event_id = "123456.zh"
attribute = "中文标签"
@@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp):
assert len(events) == 1
assert events[0]["id"] == event_id
def test_events_search_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
ids = [f"event-{i}" for i in range(len(scores))]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
ids[i], start_time=now + i, data={"score": score}
)
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": "score_desc",
}
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events/search",
params={**params, "limit": 2, "offset": offset},
).json()
]
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
def test_events_search_offset_pages_orders_ties_by_id(self):
now = datetime.now().timestamp()
ids = ["event-c", "event-a", "event-b"]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1) for event_id in ids
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, event_id in enumerate(ids):
super().insert_mock_event(
event_id, start_time=now + i, data={"score": 0.8}
)
for sort in ("score_desc", "relevance"):
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": sort,
}
paged = [
e["id"]
for offset in (0, 1, 2)
for e in client.get(
"/events/search",
params={**params, "limit": 1, "offset": offset},
).json()
]
assert paged == ["event-a", "event-b", "event-c"]
def test_event_list_rejects_negative_offset(self):
with AuthTestClient(self.app) as client:
response = client.get("/events", params={"offset": -5})
assert response.status_code == 422
response = client.get(
"/events/search",
params={"query": "car", "offset": -5},
)
assert response.status_code == 422
def test_similarity_search_hides_unauthorized_anchor_event(self):
mock_embeddings = Mock()
self.app.frigate_config.semantic_search.enabled = True
+92
View File
@@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp):
assert len(response_json) == 1
assert response_json[0]["id"] == id_reviewed
def test_get_review_with_label_filter_matches_verified(self):
"""Test that a label filter also matches the `-verified` variant."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.person", now, now + 2, data={"objects": ["person"]}
)
super().insert_mock_review_segment(
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", now, now + 2, data={"objects": ["car"]}
)
params = {
"labels": "person",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert {r["id"] for r in response_json} == {
"123456.person",
"123456.verified",
}
def test_get_review_with_label_filter_does_not_match_prefix(self):
"""Test that a label filter does not match labels that only share a prefix."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
)
params = {
"labels": "car",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
assert len(response.json()) == 0
def test_get_review_with_audio_label_filter(self):
"""Test that a label filter still matches audio labels."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.audio", now, now + 2, data={"audio": ["speech"]}
)
params = {
"labels": "speech",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == "123456.audio"
####################################################################################################################
################################### GET /review/summary Endpoint #################################################
####################################################################################################################
def test_get_review_summary_label_filter_matches_verified(self):
"""Test that the summary label filter also matches the `-verified` variant."""
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.verified", data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
)
params = {
"cameras": "front_door",
"labels": "person",
"zones": "all",
"timezone": "utc",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
response_json = response.json()
assert response_json["last24Hours"]["total_alert"] == 1
assert response_json["last24Hours"]["total_detection"] == 0
today_formatted = datetime.today().strftime("%Y-%m-%d")
assert response_json[today_formatted]["total_alert"] == 1
assert response_json[today_formatted]["total_detection"] == 0
def test_get_review_summary_all_filters(self):
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
+75 -1
View File
@@ -16,7 +16,7 @@ for name in _MOCKED_MODULES:
sys.modules[name] = MagicMock()
# Now import the class under test
from frigate.config import FrigateConfig # noqa: E402
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
# Restore original modules (or remove mock if there was no original)
@@ -124,6 +124,80 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_defers_review_overlap_segment_until_metadata_catches_up(self):
# Regression: a segment overlapping an active_objects review must not
# be dropped while detection metadata lags behind the segment end,
# the missing frames may hold the active objects (or continuous
# retention would keep it anyway).
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 7
camera_config.record.motion.days = 0
camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects
camera_config.record.get_review_pre_capture.return_value = 5
camera_config.record.get_review_post_capture.return_value = 5
config.cameras = {"test_cam": camera_config}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now(datetime.UTC)
start_time = now - datetime.timedelta(seconds=20)
end_time = now - datetime.timedelta(seconds=10)
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
maintainer.end_time_cache = {
cache_path: (end_time, 10.0, False, None, None, None, [])
}
# Metadata has only reached partway into the segment.
maintainer.object_recordings_info["test_cam"] = [
(end_time.timestamp() - 8, [], [], [])
]
maintainer.audio_recordings_info["test_cam"] = []
maintainer.drop_segment = MagicMock()
maintainer.move_segment = AsyncMock(return_value=None)
maintainer.recordings_publisher = MagicMock()
review = MagicMock()
review.severity = "alert"
review.start_time = start_time.timestamp() - 30
review.end_time = None
result = await maintainer.validate_and_move_segment(
"test_cam",
reviews=[review],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
self.assertIsNone(result)
maintainer.drop_segment.assert_not_called()
maintainer.move_segment.assert_not_awaited()
# Once metadata passes the segment end, continuous retention keeps it.
maintainer.object_recordings_info["test_cam"].append(
(now.timestamp(), [], [], [])
)
await maintainer.validate_and_move_segment(
"test_cam",
reviews=[review],
recording={
"start_time": start_time,
"cache_path": cache_path,
"stream_type": "main",
},
)
maintainer.drop_segment.assert_not_called()
maintainer.move_segment.assert_awaited_once()
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
+125 -1
View File
@@ -1,16 +1,24 @@
"""Tests for embedding cleanup on the main Frigate database.
"""Tests for embedding storage and cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable.
The write paths need the real extension, since the behavior under test belongs
to vec0 itself, so those tests are skipped when it is not installed.
"""
import os
import struct
import tempfile
import unittest
from peewee import OperationalError
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None:
@@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase):
self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_failure_is_logged_not_raised(self) -> None:
self._create_thumbnails_table()
self.db.execute_sql(
"""
CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails
BEGIN SELECT RAISE(ABORT, 'delete blocked'); END
"""
).fetchall()
with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs:
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self.assertIn("Failed to delete embeddings", logs.output[0])
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table()
self.db.load_vec_extension = False
@@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase):
# the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def _vector(value: float) -> bytes:
return struct.pack("768f", *([value] * 768))
@unittest.skipUnless(
os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed"
)
class TestEmbeddingsTableWrites(unittest.TestCase):
"""Covers the vec0 writes behind semantic search reindexing."""
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(
os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True
)
self.db.start()
self.db.create_embeddings_tables()
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _vec_tables(self) -> list[str]:
return [
row[0]
for row in self.db.execute_sql(
"SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name"
)
]
def _make_legacy(self, table: str) -> None:
# sqlite-vec added the _info shadow table in 0.1.6, so tables written by
# Frigate 0.17 and earlier do not have one
self.db.execute_sql(f"DROP TABLE {table}_info").fetchall()
def _stored(self, table: str, column: str, event_id: str) -> str | None:
row = self.db.execute_sql(
f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,)
).fetchone()
return row[0] if row else None
def test_write_error_is_raised(self) -> None:
# queued writes hide their exception in the returned cursor
with self.assertRaises(OperationalError):
self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')")
def test_drop_tables_removes_legacy_tables(self) -> None:
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.assertEqual(self._vec_tables(), [])
def test_drop_tables_without_any_tables_does_not_raise(self) -> None:
self.db.drop_embeddings_tables()
self.db.drop_embeddings_tables()
def test_upsert_replaces_existing_embedding(self) -> None:
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
def test_upsert_keeps_one_row_per_event(self) -> None:
for _ in range(3):
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", {"evt1": _vector(0.5)}
)
count = self.db.execute_sql(
"SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'"
).fetchone()[0]
self.assertEqual(count, 1)
def test_reindex_cycle_rewrites_legacy_tables(self) -> None:
"""The 0.18 upgrade path: old vectors in, new vectors out."""
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.db.create_embeddings_tables()
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
+66
View File
@@ -0,0 +1,66 @@
import unittest
from unittest.mock import MagicMock, patch
from frigate.events.types import EventStateEnum
from frigate.models import Timeline
from frigate.timeline import TimelineProcessor
def make_event(has_clip: bool, has_snapshot: bool) -> dict:
return {
"id": "event-1",
"frame_time": 1000.0,
"box": [0, 0, 10, 10],
"region": [0, 0, 100, 100],
"label": "car",
"sub_label": None,
"score": 0.8,
"has_clip": has_clip,
"has_snapshot": has_snapshot,
"current_zones": [],
"stationary": False,
"attributes": {},
"current_attributes": [],
}
class TestTimelineProcessor(unittest.TestCase):
def setUp(self):
camera_config = MagicMock()
camera_config.detect.width = 1280
camera_config.detect.height = 720
config = MagicMock()
config.cameras.get.return_value = camera_config
self.processor = TimelineProcessor(config, MagicMock(), MagicMock())
@patch.object(Timeline, "insert")
def test_unsaved_event_writes_no_timeline_rows(self, insert):
event = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, event
)
self.processor.handle_object_detection(
"front", EventStateEnum.end, event, event
)
insert.assert_not_called()
self.assertEqual(self.processor.pre_event_cache, {})
@patch.object(Timeline, "insert")
def test_cached_entries_flush_when_event_is_saved(self, insert):
start = make_event(has_clip=False, has_snapshot=False)
self.processor.handle_object_detection(
"front", EventStateEnum.start, None, start
)
insert.assert_not_called()
end = make_event(has_clip=True, has_snapshot=False)
self.processor.handle_object_detection("front", EventStateEnum.end, start, end)
class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list]
self.assertEqual(class_types, ["visible", "gone"])
self.assertEqual(self.processor.pre_event_cache, {})
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread):
timeline_entry[Timeline.class_type] = "gone"
self.insert_or_save(timeline_entry, prev_event_data, event_data)
# drop entries for events that ended without being saved
self.pre_event_cache.pop(event_id, None)
def handle_api_entry(
self,
camera: str,
+30 -15
View File
@@ -24,6 +24,7 @@ from frigate.comms.event_metadata_updater import (
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import (
CameraConfig,
CameraMqttConfig,
FrigateConfig,
RecordConfig,
@@ -129,8 +130,10 @@ class TrackedObjectProcessor(threading.Thread):
)
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj)
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
after = obj.to_dict()
message = {
"before": obj.previous,
@@ -154,8 +157,10 @@ class TrackedObjectProcessor(threading.Thread):
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
# populate has_snapshot
obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj)
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
# write thumbnail to disk if it will be saved as an event
if obj.has_snapshot or obj.has_clip:
@@ -185,8 +190,8 @@ class TrackedObjectProcessor(threading.Thread):
)
def snapshot(camera: str, obj: TrackedObject) -> bool:
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
jpg_bytes, _ = obj.get_img_bytes(
ext="jpg",
timestamp=mqtt_config.timestamp,
@@ -241,11 +246,13 @@ class TrackedObjectProcessor(threading.Thread):
with self.camera_states_lock:
self.camera_states[camera] = camera_state
def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool:
def should_save_snapshot(
self, camera_config: CameraConfig, obj: TrackedObject
) -> bool:
if obj.false_positive:
return False
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
snapshot_config: SnapshotsConfig = camera_config.snapshots
if not snapshot_config.enabled:
return False
@@ -264,11 +271,13 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool:
def should_retain_recording(
self, camera_config: CameraConfig, obj: TrackedObject
) -> bool:
if obj.false_positive:
return False
record_config: RecordConfig = self.config.cameras[camera].record
record_config: RecordConfig = camera_config.record
# Recording is disabled
if not record_config.enabled:
@@ -284,13 +293,15 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool:
def should_mqtt_snapshot(
self, mqtt_config: CameraMqttConfig, obj: TrackedObject
) -> bool:
# object never changed position
if obj.is_stationary():
return False
# if there are required zones and there is no overlap
required_zones = self.config.cameras[camera].mqtt.required_zones
required_zones = mqtt_config.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
logger.debug(
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
@@ -300,7 +311,11 @@ class TrackedObjectProcessor(threading.Thread):
return True
def update_mqtt_motion(
self, camera: str, frame_time: float, motion_boxes: list
self,
camera: str,
camera_config: CameraConfig,
frame_time: float,
motion_boxes: list,
) -> None:
# publish if motion is currently being detected
if motion_boxes:
@@ -315,7 +330,7 @@ class TrackedObjectProcessor(threading.Thread):
# always updated latest motion
self.last_motion_detected[camera] = frame_time
elif self.last_motion_detected.get(camera, 0) > 0:
mqtt_delay = self.config.cameras[camera].motion.mqtt_off_delay
mqtt_delay = camera_config.motion.mqtt_off_delay
# If no motion, make sure the off_delay has passed
if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay:
@@ -818,7 +833,7 @@ class TrackedObjectProcessor(threading.Thread):
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
)
self.update_mqtt_motion(camera, frame_time, motion_boxes)
self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes)
tracked_objects = [
o.to_dict() for o in camera_state.tracked_objects.values()
+27 -15
View File
@@ -18,6 +18,7 @@ from frigate.comms.recordings_updater import (
RecordingsDataTypeEnum,
)
from frigate.config import CameraConfig, LoggerConfig
from frigate.config.camera.ffmpeg import CameraRoleEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
@@ -187,29 +188,45 @@ class CameraWatchdog(threading.Thread):
# Status caching to reduce message volume
self._last_detect_status: str | None = None
self._last_record_status: dict[str, str] = {}
self._last_status_update_time: float = 0.0
self._last_detect_status_update_time: float = 0.0
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
status != self._last_detect_status
or (now - self._last_status_update_time) >= self.sleeptime
or (now - self._last_detect_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/detect", status)
self._last_detect_status = status
self._last_status_update_time = now
self._last_detect_status_update_time = now
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
"""Send a record stream's status only if changed or retry_interval has elapsed."""
if (
status != self._last_record_status.get(stream_type)
or (now - self._last_status_update_time) >= self.sleeptime
or (now - self._last_record_status_update_time[stream_type])
>= self.sleeptime
):
self.requestor.send_data(
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
)
self._last_record_status[stream_type] = status
self._last_status_update_time = now
self._last_record_status_update_time[stream_type] = now
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
"""Send offline status for each role of a restarted ffmpeg process."""
for role in roles:
# record roles go through the status cache so the recovery to
# online is published once the stream is healthy again
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
if stream_type is not None:
self._send_record_status(stream_type, "offline", now)
else:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
def _reset_segment_times(self) -> None:
self.latest_valid_segment_time.clear()
@@ -514,18 +531,16 @@ class CameraWatchdog(threading.Thread):
ffmpeg_process=p["process"],
)
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
self._send_roles_offline(p["roles"], now)
self._grant_restart_grace(recorded_streams, now_utc)
last_restart_time = now
continue
elif stale_stream is None:
for stream_type in recorded_streams:
self._send_record_status(stream_type, "online", now)
if poll is None:
for stream_type in recorded_streams:
self._send_record_status(stream_type, "online", now)
p["latest_segment_time"] = max(
self.latest_cache_segment_time[stream_type]
@@ -535,10 +550,7 @@ class CameraWatchdog(threading.Thread):
if poll is None:
continue
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
self._send_roles_offline(p["roles"], now)
p["process"] = start_or_restart_ffmpeg(
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]