Compare commits

..
Author SHA1 Message Date
Josh Hawkins d149de038a cover events/search in sub label filter test 2026-11-12 07:41:00 -06:00
Josh Hawkins a3e1966013 fix sub label filter for non-ascii names
The sub_labels filter lowercased the requested name in Python but the stored value with SQLite's LOWER(), which only folds ASCII. Cyrillic and other non-ASCII names never matched, so filtering by a face name returned no events. Both sides are now lowered in SQLite, which keeps ASCII matching case-insensitive and non-ASCII matching exact.
2026-11-12 07:34:25 -06:00
5 changed files with 112 additions and 103 deletions
+10 -11
View File
@@ -413,18 +413,17 @@ Avoid removing audio from only one of the two streams (for example with `-an`).
As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream.
| Feature | Stream used |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
| Feature | Stream used |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
| Exports | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected in the export dialog |
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
| Debug replay | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
| Motion search | Main only |
| Review timeline motion data | Main only |
| Storage usage statistics | Both streams counted, and listed separately per camera |
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
| Motion search | Main only |
| Review timeline motion data | Main only |
| Storage usage statistics | Both streams counted, and listed separately per camera |
This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them.
+14 -22
View File
@@ -199,18 +199,14 @@ def events(
sub_label_clauses.append(Event.sub_label.is_null())
for label in filtered_sub_labels:
lowered = label.lower()
sub_label_clauses.append(
fn.LOWER(Event.sub_label.cast("text")) == lowered
) # include exact matches (case-insensitive)
# SQLite LOWER() only folds ASCII, so lower both sides in SQL to
# keep non-ASCII names matching exactly
stored = fn.LOWER(Event.sub_label.cast("text"))
sub_label_clauses.append(stored == fn.LOWER(label))
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
sub_label_clauses.append(
fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*"
)
sub_label_clauses.append(
fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*"
)
# include this label when part of a list
sub_label_clauses.append(stored % fn.LOWER(f"*{label},*"))
sub_label_clauses.append(stored % fn.LOWER(f"*, {label}*"))
sub_label_clause = reduce(operator.or_, sub_label_clauses)
clauses.append(sub_label_clause)
@@ -633,18 +629,14 @@ def events_search(
sub_label_clauses.append(Event.sub_label.is_null())
for label in filtered_sub_labels:
lowered = label.lower()
sub_label_clauses.append(
fn.LOWER(Event.sub_label.cast("text")) == lowered
) # include exact matches (case-insensitive)
# SQLite LOWER() only folds ASCII, so lower both sides in SQL to
# keep non-ASCII names matching exactly
stored = fn.LOWER(Event.sub_label.cast("text"))
sub_label_clauses.append(stored == fn.LOWER(label))
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
sub_label_clauses.append(
fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*"
)
sub_label_clauses.append(
fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*"
)
# include this label when part of a list
sub_label_clauses.append(stored % fn.LOWER(f"*{label},*"))
sub_label_clauses.append(stored % fn.LOWER(f"*, {label}*"))
event_filters.append(reduce(operator.or_, sub_label_clauses))
+29 -29
View File
@@ -16,21 +16,18 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional, cast
from peewee import ModelSelect
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import (
REPLAY_CAMERA_PREFIX,
REPLAY_DIR,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
)
from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
from frigate.jobs.export import JobStatePublisher
from frigate.jobs.job import Job
from frigate.jobs.manager import job_is_running, set_current_job
from frigate.models import Export
from frigate.models import Export, Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
from frigate.util.recording_coverage import resolve_coverage
from frigate.util.recording_coverage import recording_overlap
if TYPE_CHECKING:
from frigate.debug_replay import DebugReplayManager
@@ -97,6 +94,24 @@ class DebugReplayJob(Job):
}
def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
"""Return the Recordings query for the time range.
Module-level so tests can patch it without instantiating a runner.
"""
query = (
Recordings.select(
Recordings.path,
Recordings.start_time,
Recordings.end_time,
)
.where(recording_overlap(start_ts, end_ts))
.where(Recordings.camera == source_camera)
.order_by(Recordings.start_time.asc())
)
return cast(ModelSelect, query)
class NoRecordingsError(ValueError):
"""Raised when no recordings exist in the requested time range."""
@@ -141,8 +156,7 @@ class RecordingDebugReplaySource(DebugReplaySource):
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
discontinuities. The playlist is pinned to one stream because the
stream-copied clip cannot change codec or resolution mid-file.
discontinuities.
"""
def __init__(
@@ -156,7 +170,6 @@ class RecordingDebugReplaySource(DebugReplaySource):
self._start_ts = start_ts
self._end_ts = end_ts
self._internal_port = internal_port
self._stream_type = STREAM_TYPE_MAIN
@property
def source_camera(self) -> str:
@@ -174,28 +187,15 @@ class RecordingDebugReplaySource(DebugReplaySource):
if self._end_ts <= self._start_ts:
raise ValueError("End time must be after start time")
# the VOD resolver decides what the pinned playlist serves, so it
# also decides the stream; a row merely touching the range has no
# interval and must not win
intervals = resolve_coverage(self._camera, self._start_ts, self._end_ts)
if any(interval.main for interval in intervals):
self._stream_type = STREAM_TYPE_MAIN
return
if any(interval.sub for interval in intervals):
self._stream_type = STREAM_TYPE_SUB
return
raise NoRecordingsError(
f"No recordings found for camera '{self._camera}' in the specified time range"
)
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
raise NoRecordingsError(
f"No recordings found for camera '{self._camera}' in the specified time range"
)
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
playlist_url = (
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
f"/{self._stream_type}/start/{self._start_ts}/end/{self._end_ts}"
"/index.m3u8"
f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
)
return [
"-protocol_whitelist",
+34
View File
@@ -485,6 +485,40 @@ class TestHttpApp(BaseTestHttp):
assert sub_labels
assert sub_labels == [sub_label]
def test_get_event_list_filter_sub_labels(self):
sub_labels = {
"1.cyrillic": "Кузнецова Ира",
"2.joined": "Ира, Bob",
"3.ascii": "Alice",
"4.none": None,
}
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [(id, 0.1) for id in sub_labels]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for id, sub_label in sub_labels.items():
super().insert_mock_event(id)
Event.update(sub_label=sub_label).where(Event.id == id).execute()
search_params = {"search_type": "similarity", "event_id": "1.cyrillic"}
for path, params in (("/events", {}), ("/events/search", search_params)):
def ids(query: str) -> set[str]:
events = client.get(
path, params={**params, "sub_labels": query}
).json()
return {event["id"] for event in events}
assert ids("Кузнецова Ира") == {"1.cyrillic"}
assert ids("Ира") == {"2.joined"}
assert ids("bob") == {"2.joined"}
assert ids("ALICE") == {"3.ascii"}
assert ids("None") == {"4.none"}
####################################################################################################################
################################### GET /metrics Endpoint #########################################################
####################################################################################################################
+25 -41
View File
@@ -18,7 +18,6 @@ from frigate.jobs.debug_replay import (
from frigate.jobs.export import JobStatePublisher
from frigate.jobs.manager import _completed_jobs, _current_jobs
from frigate.types import JobStatusTypesEnum
from frigate.util.recording_coverage import CoverageInterval
def _reset_job_manager() -> None:
@@ -78,31 +77,6 @@ class TestDebugReplayJob(unittest.TestCase):
self.assertEqual(results["end_ts"], 200.0)
class TestRecordingDebugReplaySource(unittest.TestCase):
def _source_url(self, intervals: list[CoverageInterval]) -> str:
source = RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0, internal_port=5000
)
with patch(
"frigate.jobs.debug_replay.resolve_coverage", return_value=intervals
):
source.validate()
return source.ffmpeg_input_args("/tmp")[-1]
def test_pins_main_when_main_has_recordings(self) -> None:
url = self._source_url(
[
CoverageInterval(100.0, 150.0, None, MagicMock()),
CoverageInterval(150.0, 200.0, MagicMock(), MagicMock()),
]
)
self.assertIn("/vod/front/main/start/100.0/end/200.0/", url)
def test_falls_back_to_sub_without_main_recordings(self) -> None:
url = self._source_url([CoverageInterval(100.0, 200.0, None, MagicMock())])
self.assertIn("/vod/front/sub/start/100.0/end/200.0/", url)
class TestStartDebugReplayJob(unittest.TestCase):
def setUp(self) -> None:
_reset_job_manager()
@@ -113,7 +87,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
self.publisher = MagicMock()
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
self.recordings_qs = MagicMock()
self.recordings_qs.count.return_value = 1
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
def tearDown(self) -> None:
runner = get_active_runner()
@@ -151,7 +127,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
)
def test_rejects_when_no_recordings(self) -> None:
with patch("frigate.jobs.debug_replay.resolve_coverage", return_value=[]):
empty_qs = MagicMock()
empty_qs.count.return_value = 0
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
with self.assertRaises(NoRecordingsError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
@@ -174,8 +152,8 @@ class TestStartDebugReplayJob(unittest.TestCase):
with (
patch(
"frigate.jobs.debug_replay.resolve_coverage",
return_value=self.coverage,
"frigate.jobs.debug_replay.query_recordings",
return_value=self.recordings_qs,
),
patch(
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
@@ -214,8 +192,8 @@ class TestStartDebugReplayJob(unittest.TestCase):
with (
patch(
"frigate.jobs.debug_replay.resolve_coverage",
return_value=self.coverage,
"frigate.jobs.debug_replay.query_recordings",
return_value=self.recordings_qs,
),
patch(
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
@@ -264,7 +242,9 @@ class TestRunnerHappyPath(unittest.TestCase):
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
self.publisher = MagicMock()
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
self.recordings_qs = MagicMock()
self.recordings_qs.count.return_value = 1
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
def tearDown(self) -> None:
runner = get_active_runner()
@@ -292,8 +272,8 @@ class TestRunnerHappyPath(unittest.TestCase):
with (
patch(
"frigate.jobs.debug_replay.resolve_coverage",
return_value=self.coverage,
"frigate.jobs.debug_replay.query_recordings",
return_value=self.recordings_qs,
),
patch(
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
@@ -345,7 +325,9 @@ class TestRunnerFailurePath(unittest.TestCase):
self.frigate_config.cameras = {"front": MagicMock()}
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
self.publisher = MagicMock()
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
self.recordings_qs = MagicMock()
self.recordings_qs.count.return_value = 1
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
def tearDown(self) -> None:
runner = get_active_runner()
@@ -368,8 +350,8 @@ class TestRunnerFailurePath(unittest.TestCase):
with (
patch(
"frigate.jobs.debug_replay.resolve_coverage",
return_value=self.coverage,
"frigate.jobs.debug_replay.query_recordings",
return_value=self.recordings_qs,
),
patch(
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
@@ -417,7 +399,9 @@ class TestRunnerCancellation(unittest.TestCase):
self.frigate_config.cameras = {"front": MagicMock()}
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
self.publisher = MagicMock()
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
self.recordings_qs = MagicMock()
self.recordings_qs.count.return_value = 1
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
def tearDown(self) -> None:
runner = get_active_runner()
@@ -447,8 +431,8 @@ class TestRunnerCancellation(unittest.TestCase):
with (
patch(
"frigate.jobs.debug_replay.resolve_coverage",
return_value=self.coverage,
"frigate.jobs.debug_replay.query_recordings",
return_value=self.recordings_qs,
),
patch(
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",