mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
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:
parent
d1f28eb8e1
commit
fa87b8830a
@ -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?
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,16 +340,32 @@ 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
|
||||
@ -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}.")
|
||||
|
||||
|
||||
86
frigate/test/test_time_window_retention.py
Normal file
86
frigate/test/test_time_window_retention.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user