diff --git a/frigate/storage.py b/frigate/storage.py index ba2a60e56..bd028ea8c 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -1,6 +1,7 @@ """Handle storage retention and usage.""" import logging +import re import shutil import threading from pathlib import Path @@ -178,6 +179,22 @@ class StorageMaintainer(threading.Thread): if camera_segment in recording_path: return recording_path.split(camera_segment, 1)[0].rstrip("/") or "/" + # Prefer configured recording roots when available. + for configured_root in sorted( + self.config.get_recordings_paths(), key=len, reverse=True + ): + if recording_path == configured_root or recording_path.startswith( + f"{configured_root}/" + ): + return configured_root.rstrip("/") or "/" + + # Support layouts like /root/YYYY-MM-DD/HH/... and normalize to /root. + date_hour_match = re.match( + r"^(?P.+?)/\d{4}-\d{2}-\d{2}/\d{2}(?:/|$)", recording_path + ) + if date_hour_match: + return date_hour_match.group("root").rstrip("/") or "/" + # Fallback for unexpected path layouts; expected format is root/camera/date/file path = Path(recording_path) if len(path.parents) >= 3: diff --git a/frigate/test/http_api/test_http_recordings_storage.py b/frigate/test/http_api/test_http_recordings_storage.py index cd52bba29..a46ef8b16 100644 --- a/frigate/test/http_api/test_http_recordings_storage.py +++ b/frigate/test/http_api/test_http_recordings_storage.py @@ -227,3 +227,83 @@ class TestHttpRecordingsStorage(BaseTestHttp): # Config metadata still reflects current root assignment. assert roots["/media/frigate/recordings"]["configured_cameras"] == [] assert roots["/mnt/new-root"]["configured_cameras"] == ["front_door"] + + def test_recordings_storage_normalizes_date_hour_paths_to_configured_roots(self): + self.minimal_config["cameras"]["front_door"]["path"] = "/media/frigate/recordings" + self.minimal_config["cameras"]["back_yard"] = { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "path": "/video1", + } + + self.test_stats["service"]["storage"]["/video1"] = { + "free": 600, + "mount_type": "ext4", + "total": 1000, + "used": 400, + } + + app = self._build_app() + + Recordings.insert_many( + [ + { + "id": "front_08", + "path": "/media/frigate/recordings/2026-03-07/08/seg1.mp4", + "camera": "front_door", + "start_time": 100, + "end_time": 110, + "duration": 10, + "motion": 1, + "segment_size": 100, + }, + { + "id": "front_09", + "path": "/media/frigate/recordings/2026-03-07/09/seg1.mp4", + "camera": "front_door", + "start_time": 120, + "end_time": 130, + "duration": 10, + "motion": 1, + "segment_size": 150, + }, + { + "id": "video1_08", + "path": "/video1/2026-03-07/08/seg1.mp4", + "camera": "back_yard", + "start_time": 140, + "end_time": 150, + "duration": 10, + "motion": 1, + "segment_size": 200, + }, + { + "id": "video1_09", + "path": "/video1/2026-03-07/09/seg1.mp4", + "camera": "back_yard", + "start_time": 160, + "end_time": 170, + "duration": 10, + "motion": 1, + "segment_size": 50, + }, + ] + ).execute() + + with AuthTestClient(app) as client: + payload = client.get("/recordings/storage").json() + + roots = {root["path"]: root for root in payload["recording_roots"]} + + assert set(roots.keys()) == {"/media/frigate/recordings", "/video1"} + assert roots["/media/frigate/recordings"]["recordings_size"] == 250 + assert roots["/video1"]["recordings_size"] == 250 + + # Ensure date/hour pseudo-roots are not emitted. + assert "/media/frigate/recordings/2026-03-07/08" not in roots + assert "/media/frigate/recordings/2026-03-07/09" not in roots + assert "/video1/2026-03-07/08" not in roots + assert "/video1/2026-03-07/09" not in roots +