diff --git a/frigate/api/record.py b/frigate/api/record.py index 6a9ccd65c..cf739f761 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -37,8 +37,40 @@ router = APIRouter(tags=[Tags.recordings]) def get_recordings_storage_usage(request: Request): storage_stats = request.app.stats_emitter.get_latest_stats()["service"]["storage"] - configured_recording_paths = request.app.frigate_config.get_recordings_paths() - recording_stats = [storage_stats.get(path, {}) for path in configured_recording_paths] + def normalize_storage_path(path: str) -> str: + return str(Path(path)).rstrip("/") or "/" + + def resolve_root_storage_stats(root_path: str) -> dict: + normalized_root = normalize_storage_path(root_path) + + direct_stats = storage_stats.get(normalized_root, {}) + if direct_stats: + return direct_stats + + # Some stats payloads include date/hour pseudo-paths under a configured + # recordings root. In that case, use a descendant stat for the owning root + # without exposing the pseudo-path as a separate root entry. + descendant_candidates = [ + (normalize_storage_path(path), stats) + for path, stats in storage_stats.items() + if normalize_storage_path(path).startswith(f"{normalized_root}/") and stats + ] + + if not descendant_candidates: + return {} + + descendant_candidates.sort(key=lambda item: len(item[0])) + return descendant_candidates[0][1] + + configured_recording_paths = sorted( + { + normalize_storage_path(path) + for path in request.app.frigate_config.get_recordings_paths() + } + ) + recording_stats = [ + resolve_root_storage_stats(path) for path in configured_recording_paths + ] total_mb = sum(stat.get("total", 0) for stat in recording_stats) if total_mb == 0: @@ -57,11 +89,13 @@ def get_recordings_storage_usage(request: Request): recording_roots = [] all_recording_roots = sorted( - set(configured_recording_paths).union(root_camera_usages.keys()) + set(configured_recording_paths).union( + normalize_storage_path(path) for path in root_camera_usages.keys() + ) ) for root_path in all_recording_roots: - root_stats = storage_stats.get(root_path, {}) + root_stats = resolve_root_storage_stats(root_path) total = root_stats.get("total", 0) used = root_stats.get("used", 0) free = root_stats.get("free", 0) diff --git a/frigate/test/http_api/test_http_recordings_storage.py b/frigate/test/http_api/test_http_recordings_storage.py index a46ef8b16..4409764d7 100644 --- a/frigate/test/http_api/test_http_recordings_storage.py +++ b/frigate/test/http_api/test_http_recordings_storage.py @@ -307,3 +307,39 @@ class TestHttpRecordingsStorage(BaseTestHttp): assert "/video1/2026-03-07/08" not in roots assert "/video1/2026-03-07/09" not in roots + def test_recordings_storage_ignores_pseudo_root_storage_stat_entries(self): + 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, + } + self.test_stats["service"]["storage"]["/media/frigate/recordings/2026-03-07/10"] = { + "free": 700, + "mount_type": "ext4", + "total": 1000, + "used": 300, + } + self.test_stats["service"]["storage"]["/video1/2026-03-07/10"] = { + "free": 500, + "mount_type": "ext4", + "total": 1000, + "used": 500, + } + + app = self._build_app() + + with AuthTestClient(app) as client: + payload = client.get("/recordings/storage").json() + + root_paths = {root["path"] for root in payload["recording_roots"]} + + assert root_paths == {"/media/frigate/recordings", "/video1"}