retain previews as long as either stream has recordings

This commit is contained in:
Josh Hawkins
2026-08-23 15:38:47 -05:00
parent 95e129ca43
commit 27291ef9c8
3 changed files with 104 additions and 8 deletions
+1 -1
View File
@@ -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
+34 -7
View File
@@ -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.")
+69
View File
@@ -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(