From c9b208a2551a8a551a0a7a181006334fdfa2e44b Mon Sep 17 00:00:00 2001 From: jon Date: Sun, 1 Mar 2026 12:19:33 -0600 Subject: [PATCH] Skip time-based recording expiry in continuous_rollover mode In rollover mode, RecordingCleanup no longer deletes recordings based on continuous.days / motion.days. Instead, StorageMaintainer handles overflow by deleting oldest recordings when disk fills up. Deleted cameras have their recordings removed immediately rather than waiting for time-based expiry. Review segment expiry still runs normally. --- frigate/record/cleanup.py | 117 ++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 49 deletions(-) diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 15a0ba7e8..0d59f66ae 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -10,7 +10,7 @@ from pathlib import Path from playhouse.sqlite_ext import SqliteExtDatabase -from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum +from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum, RetainPolicyEnum from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.util.builtin import clear_and_unlink @@ -281,27 +281,45 @@ class RecordingCleanup(threading.Thread): def expire_recordings(self) -> set[Path]: """Delete recordings based on retention config.""" logger.debug("Start expire recordings.") - logger.debug("Start deleted cameras.") + + is_rollover = ( + self.config.record.retain_policy == RetainPolicyEnum.continuous_rollover + ) # Handle deleted cameras - expire_days = max( - self.config.record.continuous.days, self.config.record.motion.days - ) - expire_before = ( - datetime.datetime.now() - datetime.timedelta(days=expire_days) - ).timestamp() - no_camera_recordings: Recordings = ( - Recordings.select( - Recordings.id, - Recordings.path, + logger.debug("Start deleted cameras.") + if is_rollover: + # In rollover mode, delete recordings from removed cameras immediately + no_camera_recordings: Recordings = ( + Recordings.select( + Recordings.id, + Recordings.path, + ) + .where( + Recordings.camera.not_in(list(self.config.cameras.keys())), + ) + .namedtuples() + .iterator() ) - .where( - Recordings.camera.not_in(list(self.config.cameras.keys())), - Recordings.end_time < expire_before, + else: + expire_days = max( + self.config.record.continuous.days, self.config.record.motion.days + ) + expire_before = ( + datetime.datetime.now() - datetime.timedelta(days=expire_days) + ).timestamp() + no_camera_recordings: Recordings = ( + Recordings.select( + Recordings.id, + Recordings.path, + ) + .where( + Recordings.camera.not_in(list(self.config.cameras.keys())), + Recordings.end_time < expire_before, + ) + .namedtuples() + .iterator() ) - .namedtuples() - .iterator() - ) maybe_empty_dirs = set() @@ -313,7 +331,6 @@ class RecordingCleanup(threading.Thread): maybe_empty_dirs.add(recording_path.parent) logger.debug(f"Expiring {len(deleted_recordings)} recordings") - # delete up to 100,000 at a time max_deletes = 100000 deleted_recordings_list = list(deleted_recordings) for i in range(0, len(deleted_recordings_list), max_deletes): @@ -327,39 +344,41 @@ class RecordingCleanup(threading.Thread): logger.debug(f"Start camera: {camera}.") now = datetime.datetime.now() + # Always expire review segments (alerts/detections) regardless of policy maybe_empty_dirs |= self.expire_review_segments(config, now) - continuous_expire_date = ( - now - datetime.timedelta(days=config.record.continuous.days) - ).timestamp() - motion_expire_date = ( - now - - datetime.timedelta( - days=max( - config.record.motion.days, config.record.continuous.days - ) # can't keep motion for less than continuous - ) - ).timestamp() - # Get all the reviews to check against - reviews: ReviewSegment = ( - ReviewSegment.select( - ReviewSegment.start_time, - ReviewSegment.end_time, - ReviewSegment.severity, - ) - .where( - ReviewSegment.camera == camera, - # need to ensure segments for all reviews starting - # before the expire date are included - ReviewSegment.start_time < motion_expire_date, - ) - .order_by(ReviewSegment.start_time) - .namedtuples() - ) + # Skip continuous/motion time-based expiry in rollover mode + if not is_rollover: + continuous_expire_date = ( + now - datetime.timedelta(days=config.record.continuous.days) + ).timestamp() + motion_expire_date = ( + now + - datetime.timedelta( + days=max( + config.record.motion.days, config.record.continuous.days + ) + ) + ).timestamp() + + reviews: ReviewSegment = ( + ReviewSegment.select( + ReviewSegment.start_time, + ReviewSegment.end_time, + ReviewSegment.severity, + ) + .where( + ReviewSegment.camera == camera, + ReviewSegment.start_time < motion_expire_date, + ) + .order_by(ReviewSegment.start_time) + .namedtuples() + ) + + maybe_empty_dirs |= self.expire_existing_camera_recordings( + continuous_expire_date, motion_expire_date, config, reviews + ) - maybe_empty_dirs |= self.expire_existing_camera_recordings( - continuous_expire_date, motion_expire_date, config, reviews - ) logger.debug(f"End camera: {camera}.") logger.debug("End all cameras.")