From 27291ef9c800e5123cf8a10460d9e68f2003c66f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:38:47 -0500 Subject: [PATCH] retain previews as long as either stream has recordings --- docs/docs/configuration/record.md | 2 +- frigate/record/cleanup.py | 41 ++++++++++++--- frigate/test/test_record_cleanup_sub.py | 69 +++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 7845f5f77d..833b2d13b4 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -280,7 +280,7 @@ This configuration will retain recording segments that overlap with alerts and d In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes: - **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details. -- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. +- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. Timeline previews are kept for as long as either stream still has recordings, so scrubbing works across the whole retained history. ### Configuring sub stream recording diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 03e0ceb2f7..c71b366d36 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -149,8 +149,12 @@ class RecordingCleanup(threading.Thread): detections_retain_mode: RetainModeEnum, config: CameraConfig, reviews: list[Any], - ) -> set[Path]: - """Delete recordings for existing camera based on retention config.""" + ) -> tuple[set[Path], list[tuple[float, float]]]: + """Delete recordings for one stream of an existing camera based on retention config. + + Returns the directories to check for emptiness and the segments that + were kept, which the caller feeds to expire_camera_previews. + """ # Get the timestamp for cutoff of retained days # Get recordings to check for expiration @@ -257,9 +261,23 @@ class RecordingCleanup(threading.Thread): Recordings.id << deleted_recordings_list[i : i + max_deletes] ).execute() - # previews follow main retention, so only the main pass expires them - if stream_type != STREAM_TYPE_MAIN: - return maybe_empty_dirs + return maybe_empty_dirs, kept_recordings + + def expire_camera_previews( + self, + config: CameraConfig, + continuous_expire_date: float, + motion_expire_date: float, + kept_recordings: list[tuple[float, float]], + ) -> set[Path]: + """Delete previews that no longer have recordings on any stream. + + Previews aren't recorded per stream, so the cutoffs must be the oldest + of the per stream values and kept_recordings must cover every stream, + sorted by start time. Otherwise a short main retention expires previews + the sub recordings still need. + """ + maybe_empty_dirs: set[Path] = set() previews = ( Previews.select( @@ -438,7 +456,7 @@ class RecordingCleanup(threading.Thread): .namedtuples() ) - maybe_empty_dirs |= self.expire_existing_camera_recordings( + main_dirs, main_kept = self.expire_existing_camera_recordings( STREAM_TYPE_MAIN, continuous_expire_date, motion_expire_date, @@ -452,10 +470,11 @@ class RecordingCleanup(threading.Thread): config.record.detections.retain.days, ), ) + maybe_empty_dirs |= main_dirs # runs even when sub recording is disabled so old rows still # expire - maybe_empty_dirs |= self.expire_existing_camera_recordings( + sub_dirs, sub_kept = self.expire_existing_camera_recordings( STREAM_TYPE_SUB, sub_continuous_expire_date, sub_motion_expire_date, @@ -469,6 +488,14 @@ class RecordingCleanup(threading.Thread): config.record.sub.detections.days, ), ) + maybe_empty_dirs |= sub_dirs + + maybe_empty_dirs |= self.expire_camera_previews( + config, + min(continuous_expire_date, sub_continuous_expire_date), + min(motion_expire_date, sub_motion_expire_date), + sorted(main_kept + sub_kept), + ) logger.debug(f"End camera: {camera}.") logger.debug("End all cameras.") diff --git a/frigate/test/test_record_cleanup_sub.py b/frigate/test/test_record_cleanup_sub.py index edbae0f8e3..b5425e8cc2 100644 --- a/frigate/test/test_record_cleanup_sub.py +++ b/frigate/test/test_record_cleanup_sub.py @@ -73,6 +73,21 @@ class TestRecordingCleanupSubRetention(unittest.TestCase): stream_type=stream_type, ) + def _insert_preview( + self, id: str, age_days: float, camera: str = "front_door" + ) -> None: + end_time = ( + datetime.datetime.now() - datetime.timedelta(days=age_days) + ).timestamp() + Previews.create( + id=id, + camera=camera, + path=f"/media/frigate/previews/{id}.mp4", + start_time=end_time - 10, + end_time=end_time, + duration=10, + ) + def test_sub_recordings_expire_independently(self): # main retention 7 days, sub retention 30 days; rows 10 days old # -> main row deleted, sub row kept @@ -91,6 +106,60 @@ class TestRecordingCleanupSubRetention(unittest.TestCase): assert Recordings.get_or_none(Recordings.id == "m1") is None assert Recordings.get_or_none(Recordings.id == "s1") is not None + def test_previews_survive_while_sub_recordings_remain(self): + # main retention 7 days, sub retention 30 days; only the sub row + # survives at 10 days, and the preview covering it must survive too + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": True, "continuous": {"days": 30}}, + } + ) + self._insert_recording("m1", "main", 10) + self._insert_recording("s1", "sub", 10) + self._insert_preview("p1", 10) + + cleanup.expire_recordings() + + assert Recordings.get_or_none(Recordings.id == "m1") is None + assert Previews.get_or_none(Previews.id == "p1") is not None + + def test_previews_expire_once_every_stream_has(self): + # both streams expired at 40 days -> the preview goes with them + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": True, "continuous": {"days": 30}}, + } + ) + self._insert_recording("m1", "main", 40) + self._insert_recording("s1", "sub", 40) + self._insert_preview("p1", 40) + + cleanup.expire_recordings() + + assert Recordings.get_or_none(Recordings.id == "s1") is None + assert Previews.get_or_none(Previews.id == "p1") is None + + def test_preview_retention_unchanged_when_sub_disabled(self): + cleanup = self._build_cleanup( + { + "enabled": True, + "continuous": {"days": 7}, + "sub": {"enabled": False}, + } + ) + self._insert_recording("m1", "main", 10) + self._insert_preview("p_old", 10) + self._insert_preview("p_new", 1) + + cleanup.expire_recordings() + + assert Previews.get_or_none(Previews.id == "p_old") is None + assert Previews.get_or_none(Previews.id == "p_new") is not None + def test_sub_recordings_expire_after_sub_retention(self): # sub retention 30 days; sub row 40 days old -> deleted cleanup = self._build_cleanup(