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.
This commit is contained in:
jon 2026-03-01 12:19:33 -06:00
parent aed3793ce1
commit c9b208a255

View File

@ -10,7 +10,7 @@ from pathlib import Path
from playhouse.sqlite_ext import SqliteExtDatabase 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.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.util.builtin import clear_and_unlink from frigate.util.builtin import clear_and_unlink
@ -281,27 +281,45 @@ class RecordingCleanup(threading.Thread):
def expire_recordings(self) -> set[Path]: def expire_recordings(self) -> set[Path]:
"""Delete recordings based on retention config.""" """Delete recordings based on retention config."""
logger.debug("Start expire recordings.") logger.debug("Start expire recordings.")
logger.debug("Start deleted cameras.")
is_rollover = (
self.config.record.retain_policy == RetainPolicyEnum.continuous_rollover
)
# Handle deleted cameras # Handle deleted cameras
expire_days = max( logger.debug("Start deleted cameras.")
self.config.record.continuous.days, self.config.record.motion.days if is_rollover:
) # In rollover mode, delete recordings from removed cameras immediately
expire_before = ( no_camera_recordings: Recordings = (
datetime.datetime.now() - datetime.timedelta(days=expire_days) Recordings.select(
).timestamp() Recordings.id,
no_camera_recordings: Recordings = ( Recordings.path,
Recordings.select( )
Recordings.id, .where(
Recordings.path, Recordings.camera.not_in(list(self.config.cameras.keys())),
)
.namedtuples()
.iterator()
) )
.where( else:
Recordings.camera.not_in(list(self.config.cameras.keys())), expire_days = max(
Recordings.end_time < expire_before, 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() maybe_empty_dirs = set()
@ -313,7 +331,6 @@ class RecordingCleanup(threading.Thread):
maybe_empty_dirs.add(recording_path.parent) maybe_empty_dirs.add(recording_path.parent)
logger.debug(f"Expiring {len(deleted_recordings)} recordings") logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time
max_deletes = 100000 max_deletes = 100000
deleted_recordings_list = list(deleted_recordings) deleted_recordings_list = list(deleted_recordings)
for i in range(0, len(deleted_recordings_list), max_deletes): 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}.") logger.debug(f"Start camera: {camera}.")
now = datetime.datetime.now() now = datetime.datetime.now()
# Always expire review segments (alerts/detections) regardless of policy
maybe_empty_dirs |= self.expire_review_segments(config, now) 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 # Skip continuous/motion time-based expiry in rollover mode
reviews: ReviewSegment = ( if not is_rollover:
ReviewSegment.select( continuous_expire_date = (
ReviewSegment.start_time, now - datetime.timedelta(days=config.record.continuous.days)
ReviewSegment.end_time, ).timestamp()
ReviewSegment.severity, motion_expire_date = (
) now
.where( - datetime.timedelta(
ReviewSegment.camera == camera, days=max(
# need to ensure segments for all reviews starting config.record.motion.days, config.record.continuous.days
# before the expire date are included )
ReviewSegment.start_time < motion_expire_date, )
) ).timestamp()
.order_by(ReviewSegment.start_time)
.namedtuples() 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(f"End camera: {camera}.")
logger.debug("End all cameras.") logger.debug("End all cameras.")