From d69107de335f3d0552e7486361dbe31950ef88e4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 17 Sep 2026 10:54:54 -0600 Subject: [PATCH] Handle sub labeled objects to still show up in review filter (#24391) --- frigate/api/review.py | 31 +++++--- frigate/test/http_api/test_http_review.py | 92 +++++++++++++++++++++++ 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/frigate/api/review.py b/frigate/api/review.py index 2194c7c2fb..bb9dd24f34 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -43,6 +43,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], @@ -92,10 +108,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": @@ -236,10 +249,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 @@ -337,9 +347,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 diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index d13a7bd273..e2312e771f 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -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")