From fa87b8830a68b94bfdc9e67ac99a0a0c3b34d496 Mon Sep 17 00:00:00 2001 From: kllnspr3 <104352093+kllnspr3@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:40:12 +0000 Subject: [PATCH] Add time-window based recording retention Adds ability to filter recordings by time windows after a configurable retention period. Useful for reducing storage while keeping recordings during relevant times (e.g., business hours). New config options under record.motion (and record.continuous): - days: maximum retention in days (null for unlimited, 0 for none) - hours: list of time windows like "mon-fri 07:00-18:00" - always_retain: hours to keep all recordings before filtering (default 24) Recordings associated with alerts/detections are always retained regardless of time windows. --- docs/docs/configuration/record.md | 27 ++++- docs/docs/configuration/reference.md | 18 ++++ frigate/config/camera/record.py | 113 ++++++++++++++++++++- frigate/record/cleanup.py | 96 ++++++++++++----- frigate/test/test_time_window_retention.py | 86 ++++++++++++++++ 5 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 frigate/test/test_time_window_retention.py diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 4dfd8b77c..fb4aee218 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -95,6 +95,29 @@ record: Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean) +### Time-Window Based Retention + +Recordings can be filtered to only retain footage from specific time windows after an initial period. + +```yaml +record: + enabled: True + motion: + days: 14 + always_retain: 48 + hours: + - "mon-fri 07:00-23:00" + - "sat-sun 10:00-18:00" +``` + +Supported formats for `hours`: +- `"07:00-23:00"` - all days +- `"mon-fri 07:00-18:00"` - day range +- `"sat 10:00-16:00"` - single day +- `"22:00-06:00"` - overnight + +Alerts and detections are always retained regardless of time windows. + ### Object Recording The number of days to record review items can be specified for review items classified as alerts as well as tracked objects. @@ -116,7 +139,9 @@ This configuration will retain recording segments that overlap with alerts and d ## Can I have "continuous" recordings, but only at certain times? -Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times. +Yes! You can use time-window based retention to keep recordings only from specific hours. See [Time-Window Based Retention](#time-window-based-retention) above. + +Alternatively, using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times. ## How do I export recordings? diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index cccaf3eaa..9a9a94a70 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -517,13 +517,31 @@ record: # Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below) # NOTE: This should be set to 0 and retention should be defined in alerts and detections section below # if you only want to retain recordings of alerts and detections. + # Set to null for unlimited retention. days: 0 + # Optional: Time windows to retain recordings (default: not set, keep all times) + # After always_retain period, only recordings within these windows are kept. + # Format: "HH:MM-HH:MM" or "mon-fri HH:MM-HH:MM" or "sat HH:MM-HH:MM" + hours: + - "mon-fri 07:00-23:00" + - "sat-sun 10:00-18:00" + # Optional: Hours to retain all recordings before applying time window filter (default: shown below) + always_retain: 24 # Optional: Motion retention settings motion: # Optional: Number of days to retain recordings regardless of tracked objects (default: shown below) # NOTE: This should be set to 0 and retention should be defined in alerts and detections section below # if you only want to retain recordings of alerts and detections. + # Set to null for unlimited retention. days: 0 + # Optional: Time windows to retain recordings (default: not set, keep all times) + # After always_retain period, only recordings within these windows are kept. + # Format: "HH:MM-HH:MM" or "mon-fri HH:MM-HH:MM" or "sat HH:MM-HH:MM" + hours: + - "mon-fri 07:00-23:00" + - "sat-sun 10:00-18:00" + # Optional: Hours to retain all recordings before applying time window filter (default: shown below) + always_retain: 24 # Optional: Recording Export Settings export: # Optional: Timelapse Output Args (default: shown below). diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 09a7a84d5..e605a5513 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -1,7 +1,8 @@ +import re from enum import Enum from typing import Optional -from pydantic import Field +from pydantic import Field, field_validator from frigate.const import MAX_PRE_CAPTURE from frigate.review.types import SeverityEnum @@ -21,9 +22,117 @@ __all__ = [ DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" +WEEKDAY_MAP = { + "mon": 0, + "monday": 0, + "tue": 1, + "tuesday": 1, + "wed": 2, + "wednesday": 2, + "thu": 3, + "thursday": 3, + "fri": 4, + "friday": 4, + "sat": 5, + "saturday": 5, + "sun": 6, + "sunday": 6, +} + +TIME_WINDOW_PATTERN = re.compile( + r"^(?:([a-z]+)(?:-([a-z]+))?\s+)?(\d{2}):(\d{2})-(\d{2}):(\d{2})$", re.IGNORECASE +) + + +def _parse_time_window(entry: str) -> dict: + """Parse time window string into components.""" + match = TIME_WINDOW_PATTERN.match(entry.strip()) + if not match: + raise ValueError( + f"Invalid time window format: '{entry}'. " + "Use 'HH:MM-HH:MM' or 'mon-fri HH:MM-HH:MM'" + ) + + day_start, day_end, start_h, start_m, end_h, end_m = match.groups() + + weekdays = None + if day_start: + start_day = day_start.lower() + if start_day not in WEEKDAY_MAP: + raise ValueError(f"Invalid weekday: '{day_start}'") + start_num = WEEKDAY_MAP[start_day] + + if day_end: + end_day = day_end.lower() + if end_day not in WEEKDAY_MAP: + raise ValueError(f"Invalid weekday: '{day_end}'") + end_num = WEEKDAY_MAP[end_day] + if start_num <= end_num: + weekdays = list(range(start_num, end_num + 1)) + else: + weekdays = list(range(start_num, 7)) + list(range(0, end_num + 1)) + else: + weekdays = [start_num] + + return { + "weekdays": weekdays, + "start": int(start_h) * 60 + int(start_m), + "end": int(end_h) * 60 + int(end_m), + } + + +def _matches_time_window( + windows: list[dict], weekday: int, hour: int, minute: int +) -> bool: + """Check if given time falls within any time window.""" + time_val = hour * 60 + minute + for window in windows: + if window["weekdays"] is not None and weekday not in window["weekdays"]: + continue + start, end = window["start"], window["end"] + if start <= end: + if start <= time_val <= end: + return True + else: + # overnight range like 22:00-06:00 + if time_val >= start or time_val <= end: + return True + return False + class RecordRetainConfig(FrigateBaseModel): - days: float = Field(default=0, ge=0, title="Default retention period.") + days: Optional[float] = Field( + default=None, + ge=0, + title="Maximum retention period in days. None for unlimited, 0 for no retention.", + ) + hours: Optional[list[str]] = Field( + default=None, + title="Time windows to retain recordings. Outside these windows, recordings are deleted after always_retain period.", + ) + always_retain: float = Field( + default=24, + ge=0, + title="Hours to keep all recordings before applying time window filter.", + ) + + @field_validator("hours", mode="before") + @classmethod + def validate_hours(cls, v): + if v is None: + return None + if not isinstance(v, list): + raise ValueError("hours must be a list") + for entry in v: + _parse_time_window(entry) + return v + + def is_in_retention_window(self, weekday: int, hour: int, minute: int = 0) -> bool: + """Check if recordings at this time should be retained.""" + if not self.hours: + return True + windows = [_parse_time_window(s) for s in self.hours] + return _matches_time_window(windows, weekday, hour, minute) class RetainModeEnum(str, Enum): diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 94dd43eba..1cf64c4df 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -7,6 +7,7 @@ import os import threading from multiprocessing.synchronize import Event as MpEvent from pathlib import Path +from typing import Optional from playhouse.sqlite_ext import SqliteExtDatabase @@ -104,13 +105,28 @@ class RecordingCleanup(threading.Thread): self, continuous_expire_date: float, motion_expire_date: float, + window_expire_date: Optional[float], config: CameraConfig, reviews: ReviewSegment, ) -> None: """Delete recordings for existing camera based on retention config.""" - # Get the timestamp for cutoff of retained days + # Build query condition for recordings to check + expire_condition = (Recordings.camera == config.name) & ( + ( + (Recordings.end_time < continuous_expire_date) + & (Recordings.motion == 0) + & (Recordings.dBFS == 0) + ) + | (Recordings.end_time < motion_expire_date) + ) + + # Include recordings past time-window cutoff for filtering + if window_expire_date is not None: + expire_condition = expire_condition | ( + (Recordings.camera == config.name) + & (Recordings.end_time < window_expire_date) + ) - # Get recordings to check for expiration recordings: Recordings = ( Recordings.select( Recordings.id, @@ -121,17 +137,7 @@ class RecordingCleanup(threading.Thread): Recordings.motion, Recordings.dBFS, ) - .where( - (Recordings.camera == config.name) - & ( - ( - (Recordings.end_time < continuous_expire_date) - & (Recordings.motion == 0) - & (Recordings.dBFS == 0) - ) - | (Recordings.end_time < motion_expire_date) - ) - ) + .where(expire_condition) .order_by(Recordings.start_time) .namedtuples() .iterator() @@ -180,16 +186,35 @@ class RecordingCleanup(threading.Thread): if review.end_time + post_capture < recording.start_time: review_start = idx + # Check time-window retention for recordings without review overlap + within_window = False + motion_config = config.record.motion + if motion_config.hours and not keep: + recording_dt = datetime.datetime.fromtimestamp(recording.start_time) + now = datetime.datetime.now() + age_hours = (now - recording_dt).total_seconds() / 3600 + if age_hours <= motion_config.always_retain: + within_window = True + elif motion_config.is_in_retention_window( + recording_dt.weekday(), recording_dt.hour, recording_dt.minute + ): + within_window = True + # Delete recordings outside of the retention window or based on the retention mode if ( - not keep + (not keep and not within_window) or ( - mode == RetainModeEnum.motion + keep + and mode == RetainModeEnum.motion and recording.motion == 0 and recording.objects == 0 and recording.dBFS == 0 ) - or (mode == RetainModeEnum.active_objects and recording.objects == 0) + or ( + keep + and mode == RetainModeEnum.active_objects + and recording.objects == 0 + ) ): Path(recording.path).unlink(missing_ok=True) deleted_recordings.add(recording.id) @@ -271,10 +296,13 @@ class RecordingCleanup(threading.Thread): logger.debug("Start expire recordings.") logger.debug("Start deleted cameras.") - # Handle deleted cameras - expire_days = max( - self.config.record.continuous.days, self.config.record.motion.days - ) + # Handle deleted cameras - use configured days or skip if unlimited + continuous_days = self.config.record.continuous.days + motion_days = self.config.record.motion.days + if continuous_days is None and motion_days is None: + expire_days = 0 # no day-based expiration configured + else: + expire_days = max(continuous_days or 0, motion_days or 0) expire_before = ( datetime.datetime.now() - datetime.timedelta(days=expire_days) ).timestamp() @@ -312,18 +340,34 @@ class RecordingCleanup(threading.Thread): now = datetime.datetime.now() self.expire_review_segments(config, now) + + # Calculate expire dates - None means unlimited (use far future) + continuous_days = config.record.continuous.days + motion_days = config.record.motion.days continuous_expire_date = ( - now - datetime.timedelta(days=config.record.continuous.days) + now + - datetime.timedelta( + days=continuous_days if continuous_days is not None else 36500 + ) ).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 + motion_days if motion_days is not None else 36500, + continuous_days if continuous_days is not None else 0, + ) ) ).timestamp() + # Calculate time-window cutoff if configured + motion_config = config.record.motion + window_expire_date = None + if motion_config.hours: + window_expire_date = ( + now - datetime.timedelta(hours=motion_config.always_retain) + ).timestamp() + # Get all the reviews to check against reviews: ReviewSegment = ( ReviewSegment.select( @@ -342,7 +386,11 @@ class RecordingCleanup(threading.Thread): ) self.expire_existing_camera_recordings( - continuous_expire_date, motion_expire_date, config, reviews + continuous_expire_date, + motion_expire_date, + window_expire_date, + config, + reviews, ) logger.debug(f"End camera: {camera}.") diff --git a/frigate/test/test_time_window_retention.py b/frigate/test/test_time_window_retention.py new file mode 100644 index 000000000..c009e0d21 --- /dev/null +++ b/frigate/test/test_time_window_retention.py @@ -0,0 +1,86 @@ +import unittest + +from frigate.config.camera.record import ( + RecordRetainConfig, + _matches_time_window, + _parse_time_window, +) + + +class TestTimeWindowParsing(unittest.TestCase): + def test_parse_time_only(self): + result = _parse_time_window("07:00-18:00") + self.assertIsNone(result["weekdays"]) + self.assertEqual(result["start"], 7 * 60) + self.assertEqual(result["end"], 18 * 60) + + def test_parse_single_day(self): + result = _parse_time_window("mon 09:00-17:00") + self.assertEqual(result["weekdays"], [0]) + self.assertEqual(result["start"], 9 * 60) + self.assertEqual(result["end"], 17 * 60) + + def test_parse_day_range(self): + result = _parse_time_window("mon-fri 07:00-23:00") + self.assertEqual(result["weekdays"], [0, 1, 2, 3, 4]) + + def test_parse_day_range_wrap(self): + result = _parse_time_window("fri-mon 10:00-16:00") + self.assertEqual(result["weekdays"], [4, 5, 6, 0]) + + def test_parse_full_day_names(self): + result = _parse_time_window("monday-friday 08:00-18:00") + self.assertEqual(result["weekdays"], [0, 1, 2, 3, 4]) + + def test_parse_invalid_format(self): + with self.assertRaises(ValueError): + _parse_time_window("invalid") + + def test_parse_invalid_weekday(self): + with self.assertRaises(ValueError): + _parse_time_window("xyz 10:00-12:00") + + +class TestTimeWindowMatching(unittest.TestCase): + def test_match_all_days(self): + windows = [_parse_time_window("07:00-23:00")] + self.assertTrue(_matches_time_window(windows, 0, 12, 0)) + self.assertTrue(_matches_time_window(windows, 6, 12, 0)) + self.assertFalse(_matches_time_window(windows, 0, 3, 0)) + + def test_match_weekday_range(self): + windows = [_parse_time_window("mon-fri 09:00-17:00")] + self.assertTrue(_matches_time_window(windows, 2, 12, 0)) + self.assertFalse(_matches_time_window(windows, 5, 12, 0)) + self.assertFalse(_matches_time_window(windows, 2, 8, 0)) + + def test_match_overnight(self): + windows = [_parse_time_window("22:00-06:00")] + self.assertTrue(_matches_time_window(windows, 0, 23, 0)) + self.assertTrue(_matches_time_window(windows, 0, 3, 0)) + self.assertFalse(_matches_time_window(windows, 0, 12, 0)) + + def test_match_multiple_windows(self): + windows = [ + _parse_time_window("mon-fri 07:00-18:00"), + _parse_time_window("sat-sun 10:00-16:00"), + ] + self.assertTrue(_matches_time_window(windows, 0, 12, 0)) + self.assertTrue(_matches_time_window(windows, 5, 12, 0)) + self.assertFalse(_matches_time_window(windows, 5, 8, 0)) + + +class TestRecordRetainConfig(unittest.TestCase): + def test_no_hours_always_retained(self): + config = RecordRetainConfig() + self.assertTrue(config.is_in_retention_window(0, 3, 0)) + + def test_hours_filters_correctly(self): + config = RecordRetainConfig(hours=["mon-fri 09:00-17:00"]) + self.assertTrue(config.is_in_retention_window(0, 12, 0)) + self.assertFalse(config.is_in_retention_window(0, 20, 0)) + + def test_defaults(self): + config = RecordRetainConfig() + self.assertIsNone(config.days) + self.assertEqual(config.always_retain, 24)