From 167aac8799e6ef2f087c51c73500349d18ddf51e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:06:18 -0500 Subject: [PATCH] keep post capture segments after a review item ends The recording maintainer only loaded review items whose end_time was after the oldest cached segment, so once an item closed in the database the segments in its post_capture window no longer matched it. With continuous recording off and no motion after the item, everything past roughly the review cutoff_time was deleted from the cache instead of being kept for the full post_capture. The query bound now subtracts the camera's largest post_capture, and the per-item check still decides the exact cutoff. --- frigate/config/camera/record.py | 7 +++++ frigate/record/maintainer.py | 13 +++++++-- frigate/test/test_maintainer.py | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index ecaf779350..8d6980942c 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -224,6 +224,13 @@ class RecordConfig(FrigateBaseModel): self.detections.pre_capture, ) + @property + def event_post_capture(self) -> int: + return max( + self.alerts.post_capture, + self.detections.post_capture, + ) + def get_review_pre_capture(self, severity: SeverityEnum) -> int: if severity == SeverityEnum.alert: return self.alerts.pre_capture diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index e024907343..8a5a3cc236 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -471,9 +471,13 @@ class RecordingMaintainer(threading.Thread): for key, recordings in grouped_recordings.items(): camera, stream_type = key - # get all reviews with the end time after the start of the oldest - # cache file or with end_time None; shared across stream types + # get all reviews whose post capture reaches the oldest cache file + # or with end_time None; shared across stream types if camera not in reviews_by_camera: + camera_config = self.config.cameras.get(camera) + post_capture = ( + camera_config.record.event_post_capture if camera_config else 0 + ) reviews_by_camera[camera] = ( ReviewSegment.select( ReviewSegment.start_time, @@ -484,7 +488,10 @@ class RecordingMaintainer(threading.Thread): .where( ReviewSegment.camera == camera, (ReviewSegment.end_time == None) - | (ReviewSegment.end_time >= min_start_per_camera[camera]), + | ( + ReviewSegment.end_time + >= min_start_per_camera[camera] - post_capture + ), ) .order_by(ReviewSegment.start_time) ) diff --git a/frigate/test/test_maintainer.py b/frigate/test/test_maintainer.py index 63e1ddbb34..f58bd17b6f 100644 --- a/frigate/test/test_maintainer.py +++ b/frigate/test/test_maintainer.py @@ -6,6 +6,8 @@ import unittest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +from peewee import SqliteDatabase + # Mock complex imports before importing maintainer, saving originals so we can # restore them after import and avoid polluting sys.modules for other tests. _MOCKED_MODULES = [ @@ -20,6 +22,7 @@ for name in _MOCKED_MODULES: # Now import the class under test from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402 +from frigate.models import ReviewSegment # noqa: E402 from frigate.record.maintainer import RecordingMaintainer, SegmentInfo # noqa: E402 # Restore original modules (or remove mock if there was no original) @@ -81,6 +84,53 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase): f"Expected a single warning for unexpected files, got {len(matching)}", ) + async def test_ended_review_stays_eligible_through_post_capture(self): + config = MagicMock(spec=FrigateConfig) + camera_config = MagicMock() + camera_config.record.sub.enabled = False + camera_config.record.event_post_capture = 60 + config.cameras = {"test_cam": camera_config} + + maintainer = RecordingMaintainer(config, MagicMock()) + maintainer.requestor = MagicMock() + maintainer.recordings_publisher = MagicMock() + maintainer.validate_and_move_segment = AsyncMock(return_value=None) + + db = SqliteDatabase(":memory:") + with db.bind_ctx([ReviewSegment]): + db.create_tables([ReviewSegment]) + + # oldest cached segment starts 40s after the review ended + segment_start = datetime.datetime( + 2026, 10, 19, 13, 1, 40, tzinfo=datetime.UTC + ) + ReviewSegment.create( + id="review", + camera="test_cam", + start_time=segment_start.timestamp() - 50, + end_time=segment_start.timestamp() - 40, + severity="alert", + thumb_path="", + data={}, + ) + + with tempfile.TemporaryDirectory() as cache_dir: + name = segment_start.strftime("%Y%m%d%H%M%S%z") + Path(cache_dir, f"test_cam@{name}.mp4").touch() + + with ( + patch("frigate.record.maintainer.CACHE_DIR", cache_dir), + patch( + "frigate.record.maintainer.psutil.process_iter", return_value=[] + ), + ): + await maintainer.move_files() + + reviews = maintainer.validate_and_move_segment.call_args.args[1] + self.assertEqual( + [segment_start.timestamp() - 40], [r.end_time for r in reviews] + ) + async def test_drops_quiet_segment_when_only_motion_retention(self): # Regression: when motion retention is enabled but a segment has no # motion and no review overlaps it, the segment must still be dropped.