diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index 52c0f0c88..d8c501e25 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -70,6 +70,8 @@ record: As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted. +Additionally, you can configure a maximum storage size limit for recordings using the `max_size` option. When the total size of recordings exceeds this limit, Frigate will automatically clean up the oldest recordings to stay within the specified size. + ## Configuring Recording Retention Frigate supports both continuous and tracked object based recordings with separate retention modes and retention periods. @@ -93,6 +95,20 @@ record: Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean) +### Storage Size Limit + +You can configure a maximum storage size limit for recordings. When the total size of recordings exceeds this limit, Frigate will automatically clean up the oldest recordings first. + +```yaml +record: + enabled: True + retain: + days: 7 + max_size: 5000 # <- maximum storage size in MB (5GB in this example) +``` + +The `max_size` parameter specifies the maximum total storage size in megabytes (MB) that recordings should consume. This works in addition to the time-based retention settings - recordings will be deleted when either the time limit OR the size limit is exceeded. + ### Object Recording The number of days to record review items can be specified for review items classified as alerts as well as tracked objects. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 2de72417c..5848446c0 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -452,6 +452,9 @@ record: # active_objects - save all recording segments with active/moving objects # NOTE: this mode only applies when the days setting above is greater than 0 mode: all + # Optional: Maximum storage size in MB for recordings (default: no limit) + # When total recording storage exceeds this limit, oldest recordings will be deleted + max_size: 5000 # 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 52d11e2a5..babf05619 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -31,6 +31,7 @@ class RetainModeEnum(str, Enum): class RecordRetainConfig(FrigateBaseModel): days: float = Field(default=0, title="Default retention period.") mode: RetainModeEnum = Field(default=RetainModeEnum.all, title="Retain mode.") + max_size: Optional[int] = Field(default=None, title="Maximum storage size in MB.") class ReviewRetainConfig(FrigateBaseModel): diff --git a/frigate/storage.py b/frigate/storage.py index 1c4650271..58f4892a5 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -94,6 +94,22 @@ class StorageMaintainer(threading.Thread): [b["bandwidth"] for b in self.camera_storage_stats.values()] ) remaining_storage = round(shutil.disk_usage(RECORD_DIR).free / pow(2, 20), 1) + + # Check if max_size is configured and total usage exceeds it + max_size = self.config.record.retain.max_size + if max_size is not None: + total_usage = ( + Recordings.select(fn.SUM(Recordings.segment_size)) + .where(Recordings.segment_size != 0) + .scalar() or 0 + ) / pow(2, 20) # Convert to MB + + if total_usage > max_size: + logger.debug( + f"Storage cleanup check: total usage {total_usage} MB exceeds max_size {max_size} MB." + ) + return True + logger.debug( f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}." ) diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 4bafe7369..df54dc7f5 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -1531,6 +1531,49 @@ class TestConfig(unittest.TestCase): self.assertRaises(ValueError, lambda: FrigateConfig(**config)) + def test_record_retain_max_size(self): + config = { + "mqtt": {"host": "mqtt"}, + "record": {"retain": {"max_size": 1000}}, + "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.retain.max_size == 1000 + + def test_record_retain_max_size_default(self): + config = { + "mqtt": {"host": "mqtt"}, + "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.retain.max_size is None + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/frigate/test/test_storage.py b/frigate/test/test_storage.py index d36960f47..b6934428b 100644 --- a/frigate/test/test_storage.py +++ b/frigate/test/test_storage.py @@ -260,6 +260,78 @@ class TestHttp(unittest.TestCase): assert Recordings.get(Recordings.id == rec_k2_id) assert Recordings.get(Recordings.id == rec_k3_id) + def test_storage_cleanup_with_max_size(self): + """Test that storage cleanup is triggered when max_size is exceeded.""" + config_with_max_size = { + "mqtt": {"host": "mqtt"}, + "record": {"retain": {"max_size": 1}}, # 1 MB max size + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + }, + } + config = FrigateConfig(**config_with_max_size) + storage = StorageMaintainer(config, MagicMock()) + + # Insert recordings that exceed max_size (1 MB = 1048576 bytes) + time_now = datetime.datetime.now().timestamp() + rec_id1 = "test1.recording" + rec_id2 = "test2.recording" + + # Create recordings with size larger than max_size + _insert_mock_recording( + rec_id1, + os.path.join(self.test_dir, f"{rec_id1}.tmp"), + time_now - 3600, + time_now - 3590, + seg_size=524288, # 0.5 MB + ) + _insert_mock_recording( + rec_id2, + os.path.join(self.test_dir, f"{rec_id2}.tmp"), + time_now - 1800, + time_now - 1790, + seg_size=524288, # 0.5 MB (total 1 MB) + ) + + storage.calculate_camera_bandwidth() + + # Should trigger cleanup since total size (1 MB) exceeds max_size (1 MB) + assert storage.check_storage_needs_cleanup() == True + + def test_storage_cleanup_without_max_size(self): + """Test that max_size check is skipped when not configured.""" + config = FrigateConfig(**self.minimal_config) + storage = StorageMaintainer(config, MagicMock()) + + time_now = datetime.datetime.now().timestamp() + rec_id = "test.recording" + + # Create a large recording + _insert_mock_recording( + rec_id, + os.path.join(self.test_dir, f"{rec_id}.tmp"), + time_now - 3600, + time_now - 3590, + seg_size=1048576, # 1 MB + ) + + storage.calculate_camera_bandwidth() + + # Should not trigger cleanup based on max_size since it's not configured + # (may still trigger based on free space, but that's a separate check) + assert storage.config.record.retain.max_size is None + def _insert_mock_event(id: str, start: int, end: int, retain: bool) -> Event: """Inserts a basic event model with a given id."""