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.
This commit is contained in:
kllnspr3 2026-01-02 18:40:12 +00:00
parent d1f28eb8e1
commit fa87b8830a
5 changed files with 313 additions and 27 deletions

View File

@ -95,6 +95,29 @@ record:
Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean) 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 ### Object Recording
The number of days to record review items can be specified for review items classified as alerts as well as tracked objects. 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? ## 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? ## How do I export recordings?

View File

@ -517,13 +517,31 @@ record:
# Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below) # 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 # 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. # if you only want to retain recordings of alerts and detections.
# Set to null for unlimited retention.
days: 0 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 # Optional: Motion retention settings
motion: motion:
# Optional: Number of days to retain recordings regardless of tracked objects (default: shown below) # 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 # 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. # if you only want to retain recordings of alerts and detections.
# Set to null for unlimited retention.
days: 0 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 # Optional: Recording Export Settings
export: export:
# Optional: Timelapse Output Args (default: shown below). # Optional: Timelapse Output Args (default: shown below).

View File

@ -1,7 +1,8 @@
import re
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field, field_validator
from frigate.const import MAX_PRE_CAPTURE from frigate.const import MAX_PRE_CAPTURE
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
@ -21,9 +22,117 @@ __all__ = [
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" 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): 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): class RetainModeEnum(str, Enum):

View File

@ -7,6 +7,7 @@ import os
import threading import threading
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path from pathlib import Path
from typing import Optional
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
@ -104,13 +105,28 @@ class RecordingCleanup(threading.Thread):
self, self,
continuous_expire_date: float, continuous_expire_date: float,
motion_expire_date: float, motion_expire_date: float,
window_expire_date: Optional[float],
config: CameraConfig, config: CameraConfig,
reviews: ReviewSegment, reviews: ReviewSegment,
) -> None: ) -> None:
"""Delete recordings for existing camera based on retention config.""" """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: Recordings = (
Recordings.select( Recordings.select(
Recordings.id, Recordings.id,
@ -121,17 +137,7 @@ class RecordingCleanup(threading.Thread):
Recordings.motion, Recordings.motion,
Recordings.dBFS, Recordings.dBFS,
) )
.where( .where(expire_condition)
(Recordings.camera == config.name)
& (
(
(Recordings.end_time < continuous_expire_date)
& (Recordings.motion == 0)
& (Recordings.dBFS == 0)
)
| (Recordings.end_time < motion_expire_date)
)
)
.order_by(Recordings.start_time) .order_by(Recordings.start_time)
.namedtuples() .namedtuples()
.iterator() .iterator()
@ -180,16 +186,35 @@ class RecordingCleanup(threading.Thread):
if review.end_time + post_capture < recording.start_time: if review.end_time + post_capture < recording.start_time:
review_start = idx 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 # Delete recordings outside of the retention window or based on the retention mode
if ( if (
not keep (not keep and not within_window)
or ( or (
mode == RetainModeEnum.motion keep
and mode == RetainModeEnum.motion
and recording.motion == 0 and recording.motion == 0
and recording.objects == 0 and recording.objects == 0
and recording.dBFS == 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) Path(recording.path).unlink(missing_ok=True)
deleted_recordings.add(recording.id) deleted_recordings.add(recording.id)
@ -271,10 +296,13 @@ class RecordingCleanup(threading.Thread):
logger.debug("Start expire recordings.") logger.debug("Start expire recordings.")
logger.debug("Start deleted cameras.") logger.debug("Start deleted cameras.")
# Handle deleted cameras # Handle deleted cameras - use configured days or skip if unlimited
expire_days = max( continuous_days = self.config.record.continuous.days
self.config.record.continuous.days, self.config.record.motion.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 = ( expire_before = (
datetime.datetime.now() - datetime.timedelta(days=expire_days) datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp() ).timestamp()
@ -312,18 +340,34 @@ class RecordingCleanup(threading.Thread):
now = datetime.datetime.now() now = datetime.datetime.now()
self.expire_review_segments(config, 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 = ( 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() ).timestamp()
motion_expire_date = ( motion_expire_date = (
now now
- datetime.timedelta( - datetime.timedelta(
days=max( days=max(
config.record.motion.days, config.record.continuous.days motion_days if motion_days is not None else 36500,
) # can't keep motion for less than continuous continuous_days if continuous_days is not None else 0,
)
) )
).timestamp() ).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 # Get all the reviews to check against
reviews: ReviewSegment = ( reviews: ReviewSegment = (
ReviewSegment.select( ReviewSegment.select(
@ -342,7 +386,11 @@ class RecordingCleanup(threading.Thread):
) )
self.expire_existing_camera_recordings( 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}.") logger.debug(f"End camera: {camera}.")

View File

@ -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)