Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/docs/configuration/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,12 @@ record:
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
expire_interval: 60
# Optional: Maximum size of recordings in MB or string format (e.g. 10GB). (default: shown below)
# This serves as a hard limit for the size of the recordings for this camera.
# If the total size of recordings exceeds this limit, the oldest recordings will be deleted
# until the total size is below the limit, regardless of retention settings.
# 0 means no limit.
max_size: 0
# Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below).
sync_recordings: False
# Optional: Continuous retention settings
Expand Down
19 changes: 17 additions & 2 deletions frigate/config/camera/record.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from enum import Enum
from typing import Optional
from typing import Optional, Union

from pydantic import Field
from pydantic import Field, field_validator

from frigate.const import MAX_PRE_CAPTURE
from frigate.review.types import SeverityEnum
from frigate.util.size import parse_size_to_mb

from ..base import FrigateBaseModel

Expand Down Expand Up @@ -81,6 +82,10 @@ class RecordConfig(FrigateBaseModel):
default=60,
title="Number of minutes to wait between cleanup runs.",
)
max_size: Union[float, str] = Field(
default=0,
title="Maximum size of recordings in MB or string format (e.g. 10GB).",
)
continuous: RecordRetainConfig = Field(
default_factory=RecordRetainConfig,
title="Continuous recording retention settings.",
Expand All @@ -104,6 +109,16 @@ class RecordConfig(FrigateBaseModel):
default=None, title="Keep track of original state of recording."
)

@field_validator("max_size", mode="before")
@classmethod
def parse_max_size(cls, v: Union[float, str], info: object) -> float:
if isinstance(v, str):
try:
return parse_size_to_mb(v)
except ValueError:
raise ValueError(f"Invalid size string: {v}")
return v

@property
def event_pre_capture(self) -> int:
return max(
Expand Down
62 changes: 62 additions & 0 deletions frigate/record/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@
logger = logging.getLogger(__name__)


def get_directory_size(directory: str) -> float:
"""Get the size of a directory in MB."""
total_size = 0
for dirpath, dirnames, filenames in os.walk(directory):
for f in filenames:
fp = os.path.join(dirpath, f)
if not os.path.islink(fp):
total_size += os.path.getsize(fp)
return total_size / 1000000


class RecordingCleanup(threading.Thread):
"""Cleanup existing recordings based on retention config."""

Expand Down Expand Up @@ -120,6 +131,7 @@ def expire_existing_camera_recordings(
Recordings.objects,
Recordings.motion,
Recordings.dBFS,
Recordings.segment_size,
)
.where(
(Recordings.camera == config.name)
Expand Down Expand Up @@ -206,6 +218,10 @@ def expire_existing_camera_recordings(
Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute()

# Check if we need to enforce max_size
if config.record.max_size > 0:
self.enforce_max_size(config, deleted_recordings)

previews: list[Previews] = (
Previews.select(
Previews.id,
Expand Down Expand Up @@ -266,6 +282,52 @@ def expire_existing_camera_recordings(
Previews.id << deleted_previews_list[i : i + max_deletes]
).execute()

def enforce_max_size(
self, config: CameraConfig, deleted_recordings: set[str]
) -> None:
"""Ensure that the camera recordings do not exceed the max size."""
# Get all recordings for this camera
recordings: Recordings = (
Recordings.select(
Recordings.id,
Recordings.path,
Recordings.segment_size,
)
.where(
(Recordings.camera == config.name)
& (Recordings.id.not_in(list(deleted_recordings)))
)
.order_by(Recordings.start_time)
.namedtuples()
.iterator()
)

total_size = 0
recordings_list = []
for recording in recordings:
recordings_list.append(recording)
total_size += recording.segment_size

# If the total size is less than the max size, we are good
if total_size <= config.record.max_size:
return

# Delete recordings until we are under the max size
recordings_to_delete = []
for recording in recordings_list:
total_size -= recording.segment_size
recordings_to_delete.append(recording.id)
Path(recording.path).unlink(missing_ok=True)
if total_size <= config.record.max_size:
break

# Delete from database
max_deletes = 100000
for i in range(0, len(recordings_to_delete), max_deletes):
Recordings.delete().where(
Recordings.id << recordings_to_delete[i : i + max_deletes]
).execute()

def expire_recordings(self) -> None:
"""Delete recordings based on retention config."""
logger.debug("Start expire recordings.")
Expand Down
23 changes: 23 additions & 0 deletions frigate/test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,29 @@ def test_default_input_args(self):
frigate_config = FrigateConfig(**config)
assert "-rtsp_transport" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]

def test_record_max_size_validation(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {"max_size": "10GB"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}

frigate_config = FrigateConfig(**config)
assert frigate_config.record.max_size == 10000

def test_ffmpeg_params_global(self):
config = {
"ffmpeg": {"input_args": "-re"},
Expand Down
12 changes: 12 additions & 0 deletions frigate/test/test_record_retention.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@ def test_should_keep_audio_in_motion_mode(self):
)
assert not segment_info.should_discard_segment(RetainModeEnum.motion)
assert segment_info.should_discard_segment(RetainModeEnum.active_objects)

def test_size_utility(self):
from frigate.util.size import parse_size_to_mb

assert parse_size_to_mb("10GB") == 10240
assert parse_size_to_mb("10MB") == 10
assert parse_size_to_mb("1024KB") == 1
assert parse_size_to_mb("1048576B") == 1
assert parse_size_to_mb("10") == 10

with self.assertRaises(ValueError):
parse_size_to_mb("invalid")
21 changes: 21 additions & 0 deletions frigate/util/size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Utility for parsing size strings."""


def parse_size_to_mb(size_str: str) -> float:
"""Parse a size string to megabytes."""
size_str = size_str.strip().upper()
if size_str.endswith("TB"):
return float(size_str[:-2]) * 1024 * 1024
elif size_str.endswith("GB"):
return float(size_str[:-2]) * 1024
elif size_str.endswith("MB"):
return float(size_str[:-2])
elif size_str.endswith("KB"):
return float(size_str[:-2]) / 1024
elif size_str.endswith("B"):
return float(size_str[:-1]) / (1024 * 1024)
else:
try:
return float(size_str)
except ValueError:
raise ValueError(f"Invalid size string: {size_str}")
Loading