From 9f5d93e7239f639d9c933bc06630c62d45156796 Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:17:17 +1100 Subject: [PATCH] Fix recordings root attribution to use DB paths --- frigate/api/record.py | 70 +++++- frigate/storage.py | 84 ++++++- .../http_api/test_http_recordings_storage.py | 229 ++++++++++++++++++ web/__test__/test-setup.ts | 1 + web/public/locales/en/views/system.json | 14 +- .../components/storage/RecordingsRoots.tsx | 83 +++++++ .../__tests__/RecordingsRoots.test.tsx | 62 +++++ web/src/views/system/StorageMetrics.tsx | 39 ++- 8 files changed, 570 insertions(+), 12 deletions(-) create mode 100644 frigate/test/http_api/test_http_recordings_storage.py create mode 100644 web/__test__/test-setup.ts create mode 100644 web/src/components/storage/RecordingsRoots.tsx create mode 100644 web/src/components/storage/__tests__/RecordingsRoots.test.tsx diff --git a/frigate/api/record.py b/frigate/api/record.py index 579a7a1f5..6a9ccd65c 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -37,15 +37,16 @@ router = APIRouter(tags=[Tags.recordings]) def get_recordings_storage_usage(request: Request): storage_stats = request.app.stats_emitter.get_latest_stats()["service"]["storage"] - recording_paths = request.app.frigate_config.get_recordings_paths() - recording_stats = [storage_stats.get(path, {}) for path in recording_paths] + configured_recording_paths = request.app.frigate_config.get_recordings_paths() + recording_stats = [storage_stats.get(path, {}) for path in configured_recording_paths] total_mb = sum(stat.get("total", 0) for stat in recording_stats) if total_mb == 0: return JSONResponse({}) - camera_usages: dict[str, dict] = ( - request.app.storage_maintainer.calculate_camera_usages() + camera_usages: dict[str, dict] = request.app.storage_maintainer.calculate_camera_usages() + root_camera_usages: dict[str, dict] = ( + request.app.storage_maintainer.calculate_camera_usages_by_root() ) for camera_name in camera_usages.keys(): @@ -54,7 +55,66 @@ def get_recordings_storage_usage(request: Request): camera_usages.get(camera_name, {}).get("usage", 0) / total_mb ) * 100 - return JSONResponse(content=camera_usages) + recording_roots = [] + all_recording_roots = sorted( + set(configured_recording_paths).union(root_camera_usages.keys()) + ) + + for root_path in all_recording_roots: + root_stats = storage_stats.get(root_path, {}) + total = root_stats.get("total", 0) + used = root_stats.get("used", 0) + free = root_stats.get("free", 0) + root_usage_percent = (used / total) * 100 if total else 0 + + root_usage = root_camera_usages.get( + root_path, + { + "path": root_path, + "is_default": False, + "recordings_size": 0, + "cameras": [], + "configured_cameras": [], + "camera_usages": {}, + }, + ) + + camera_usages_in_root = root_usage.get("camera_usages", {}) + for camera in camera_usages_in_root.values(): + camera["usage_percent"] = ( + (camera.get("usage", 0) / root_usage["recordings_size"]) * 100 + if root_usage["recordings_size"] + else 0 + ) + + recording_roots.append( + { + "path": root_path, + "total": total, + "used": used, + "free": free, + "usage_percent": root_usage_percent, + "recordings_size": root_usage["recordings_size"], + "is_default": root_usage["is_default"], + "cameras": root_usage["cameras"], + "configured_cameras": root_usage["configured_cameras"], + "camera_usages": camera_usages_in_root, + } + ) + + # New response shape for structured consumers. + response = { + "cameras": camera_usages, + "recording_roots": recording_roots, + } + + # Backward compatibility for legacy consumers expecting top-level camera keys. + response.update(camera_usages) + + # Backward compatibility for PR#3 clients that already consume this transitional key. + response["__recording_roots"] = recording_roots + + return JSONResponse(content=response) @router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())]) diff --git a/frigate/storage.py b/frigate/storage.py index 35f2f8811..ba2a60e56 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -8,7 +8,7 @@ from pathlib import Path from peewee import SQL, fn from frigate.config import FrigateConfig -from frigate.const import REPLAY_CAMERA_PREFIX +from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX from frigate.models import Event, Recordings from frigate.util.builtin import clear_and_unlink @@ -103,6 +103,88 @@ class StorageMaintainer(threading.Thread): return usages + def calculate_camera_usages_by_root(self) -> dict[str, dict]: + """Calculate camera storage usage grouped by recordings root.""" + root_usages: dict[str, dict] = {} + + root_camera_stats = ( + Recordings.select( + Recordings.camera, + Recordings.path, + fn.SUM(Recordings.segment_size).alias("usage"), + ) + .where(Recordings.segment_size != 0) + .group_by(Recordings.camera, Recordings.path) + .namedtuples() + .iterator() + ) + + for stat in root_camera_stats: + camera = stat.camera + + # Skip replay cameras + if camera.startswith(REPLAY_CAMERA_PREFIX): + continue + + root_path = self._get_recordings_root_from_path(stat.path, camera) + if not root_path: + continue + + camera_usage = stat.usage or 0 + camera_config = self.config.cameras.get(camera) + camera_friendly_name = ( + getattr(camera_config, "friendly_name", None) if camera_config else None + ) + + if root_path not in root_usages: + root_usages[root_path] = { + "path": root_path, + "is_default": root_path == RECORD_DIR, + "recordings_size": 0, + "cameras": [], + "configured_cameras": [], + "camera_usages": {}, + } + + root_usages[root_path]["recordings_size"] += camera_usage + + if camera not in root_usages[root_path]["camera_usages"]: + root_usages[root_path]["cameras"].append(camera) + root_usages[root_path]["camera_usages"][camera] = { + "usage": 0, + "bandwidth": self.camera_storage_stats.get(camera, {}).get( + "bandwidth", 0 + ), + "friendly_name": camera_friendly_name, + } + + root_usages[root_path]["camera_usages"][camera]["usage"] += camera_usage + + for root_path, root in root_usages.items(): + root["cameras"] = sorted(root["cameras"]) + root["configured_cameras"] = sorted( + [ + camera + for camera in self.config.cameras.keys() + if self.config.get_camera_recordings_path(camera) == root_path + ] + ) + + return root_usages + + def _get_recordings_root_from_path(self, recording_path: str, camera: str) -> str: + camera_segment = f"/{camera}/" + + if camera_segment in recording_path: + return recording_path.split(camera_segment, 1)[0].rstrip("/") or "/" + + # Fallback for unexpected path layouts; expected format is root/camera/date/file + path = Path(recording_path) + if len(path.parents) >= 3: + return str(path.parents[2]).rstrip("/") or "/" + + return str(path.parent).rstrip("/") or "/" + def _get_path_bandwidths(self) -> dict[str, float]: bandwidth_per_path: dict[str, float] = {} diff --git a/frigate/test/http_api/test_http_recordings_storage.py b/frigate/test/http_api/test_http_recordings_storage.py new file mode 100644 index 000000000..cd52bba29 --- /dev/null +++ b/frigate/test/http_api/test_http_recordings_storage.py @@ -0,0 +1,229 @@ +from unittest.mock import Mock + +from frigate.models import Recordings +from frigate.stats.emitter import StatsEmitter +from frigate.storage import StorageMaintainer +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpRecordingsStorage(BaseTestHttp): + def setUp(self): + super().setUp([Recordings]) + + def _build_app(self): + stats = Mock(spec=StatsEmitter) + stats.get_latest_stats.return_value = self.test_stats + + app = super().create_app(stats) + app.storage_maintainer = StorageMaintainer(app.frigate_config, Mock()) + return app + + def test_recordings_storage_default_only(self): + app = self._build_app() + + Recordings.insert( + id="front_default_1", + path="/media/frigate/recordings/front_door/2024-01-01/00.00.mp4", + camera="front_door", + start_time=100, + end_time=110, + duration=10, + motion=1, + segment_size=100, + ).execute() + + with AuthTestClient(app) as client: + response = client.get("/recordings/storage") + assert response.status_code == 200 + payload = response.json() + + # Backward-compatible top-level camera map + assert payload["front_door"]["usage"] == 100 + + # New structured response + assert "cameras" in payload + assert "recording_roots" in payload + assert payload["cameras"]["front_door"]["usage"] == 100 + + roots = {root["path"]: root for root in payload["recording_roots"]} + assert len(roots) == 1 + assert roots["/media/frigate/recordings"]["is_default"] is True + assert roots["/media/frigate/recordings"]["cameras"] == ["front_door"] + assert roots["/media/frigate/recordings"]["configured_cameras"] == [ + "front_door" + ] + assert ( + roots["/media/frigate/recordings"]["camera_usages"]["front_door"]["usage"] + == 100 + ) + + def test_recordings_storage_mixed_default_and_custom_roots(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": "/mnt/slow-recordings", + } + + self.test_stats["service"]["storage"]["/mnt/slow-recordings"] = { + "free": 3000, + "mount_type": "ext4", + "total": 4000, + "used": 1000, + } + + app = self._build_app() + + Recordings.insert_many( + [ + { + "id": "front_default_1", + "path": "/media/frigate/recordings/front_door/2024-01-01/00.00.mp4", + "camera": "front_door", + "start_time": 100, + "end_time": 110, + "duration": 10, + "motion": 1, + "segment_size": 100, + }, + { + "id": "back_custom_1", + "path": "/mnt/slow-recordings/back_yard/2024-01-01/00.00.mp4", + "camera": "back_yard", + "start_time": 200, + "end_time": 210, + "duration": 10, + "motion": 1, + "segment_size": 250, + }, + ] + ).execute() + + with AuthTestClient(app) as client: + payload = client.get("/recordings/storage").json() + + roots = {root["path"]: root for root in payload["recording_roots"]} + assert len(roots) == 2 + assert roots["/media/frigate/recordings"]["recordings_size"] == 100 + assert roots["/mnt/slow-recordings"]["recordings_size"] == 250 + assert roots["/mnt/slow-recordings"]["is_default"] is False + assert roots["/mnt/slow-recordings"]["cameras"] == ["back_yard"] + + def test_recordings_storage_multiple_cameras_share_custom_root(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": "/mnt/shared-recordings", + } + self.minimal_config["cameras"]["garage"] = { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "path": "/mnt/shared-recordings", + } + + self.test_stats["service"]["storage"]["/mnt/shared-recordings"] = { + "free": 800, + "mount_type": "ext4", + "total": 2000, + "used": 1200, + } + + app = self._build_app() + + Recordings.insert_many( + [ + { + "id": "back_1", + "path": "/mnt/shared-recordings/back_yard/2024-01-01/00.00.mp4", + "camera": "back_yard", + "start_time": 100, + "end_time": 110, + "duration": 10, + "motion": 1, + "segment_size": 300, + }, + { + "id": "garage_1", + "path": "/mnt/shared-recordings/garage/2024-01-01/00.00.mp4", + "camera": "garage", + "start_time": 200, + "end_time": 210, + "duration": 10, + "motion": 1, + "segment_size": 500, + }, + ] + ).execute() + + with AuthTestClient(app) as client: + payload = client.get("/recordings/storage").json() + + shared_root = next( + root + for root in payload["recording_roots"] + if root["path"] == "/mnt/shared-recordings" + ) + + assert shared_root["recordings_size"] == 800 + assert shared_root["cameras"] == ["back_yard", "garage"] + assert set(shared_root["camera_usages"].keys()) == {"back_yard", "garage"} + + def test_recordings_storage_historical_path_migration_splits_usage_by_db_path(self): + self.minimal_config["cameras"]["front_door"]["path"] = "/mnt/new-root" + + self.test_stats["service"]["storage"]["/mnt/new-root"] = { + "free": 700, + "mount_type": "ext4", + "total": 1000, + "used": 300, + } + + app = self._build_app() + + Recordings.insert_many( + [ + { + "id": "front_old_root", + "path": "/media/frigate/recordings/front_door/2024-01-01/00.00.mp4", + "camera": "front_door", + "start_time": 100, + "end_time": 110, + "duration": 10, + "motion": 1, + "segment_size": 120, + }, + { + "id": "front_new_root", + "path": "/mnt/new-root/front_door/2024-02-01/00.00.mp4", + "camera": "front_door", + "start_time": 200, + "end_time": 210, + "duration": 10, + "motion": 1, + "segment_size": 80, + }, + ] + ).execute() + + with AuthTestClient(app) as client: + payload = client.get("/recordings/storage").json() + + roots = {root["path"]: root for root in payload["recording_roots"]} + + # Usage attribution is derived from Recordings.path, not current camera config. + assert roots["/media/frigate/recordings"]["recordings_size"] == 120 + assert roots["/mnt/new-root"]["recordings_size"] == 80 + + assert roots["/media/frigate/recordings"]["camera_usages"]["front_door"][ + "usage" + ] == 120 + assert roots["/mnt/new-root"]["camera_usages"]["front_door"]["usage"] == 80 + + # Config metadata still reflects current root assignment. + assert roots["/media/frigate/recordings"]["configured_cameras"] == [] + assert roots["/mnt/new-root"]["configured_cameras"] == ["front_door"] diff --git a/web/__test__/test-setup.ts b/web/__test__/test-setup.ts new file mode 100644 index 000000000..d0de870dc --- /dev/null +++ b/web/__test__/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index faaff31c9..70939e8be 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -128,11 +128,15 @@ "storage": { "title": "Storage", "overview": "Overview", - "recordings": { - "title": "Recordings", - "tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.", - "earliestRecording": "Earliest recording available:" - }, + "recordings": { + "title": "Recordings", + "tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.", + "earliestRecording": "Earliest recording available:", + "roots": "Recording Roots", + "nonDefault": "Non-default root", + "rootSummary": "Disk used: {{used}} MiB • Free: {{free}} MiB • Usage: {{usage_percent}}%", + "rootCameras": "Cameras: {{cameras}}" + }, "shm": { "title": "SHM (shared memory) allocation", "warning": "The current SHM size of {{total}}MB is too small. Increase it to at least {{min_shm}}MB." diff --git a/web/src/components/storage/RecordingsRoots.tsx b/web/src/components/storage/RecordingsRoots.tsx new file mode 100644 index 000000000..721f30810 --- /dev/null +++ b/web/src/components/storage/RecordingsRoots.tsx @@ -0,0 +1,83 @@ +import { StorageGraph } from "@/components/graph/StorageGraph"; +import { getUnitSize } from "@/utils/storageUtil"; +import { useTranslation } from "react-i18next"; + +export type RootCameraStorage = { + [key: string]: { + bandwidth: number; + usage: number; + usage_percent: number; + }; +}; + +export type RecordingRootStorage = { + path: string; + total: number; + used: number; + free: number; + usage_percent: number; + recordings_size: number; + cameras: string[]; + camera_usages: RootCameraStorage; + is_default: boolean; +}; + +export function RecordingsRoots({ roots }: { roots: RecordingRootStorage[] }) { + const { t } = useTranslation(["views/system"]); + + return ( +
+ {roots.map((root) => ( +
+
+
{root.path}
+ {!root.is_default && ( +
+ {t("storage.recordings.nonDefault")} +
+ )} +
+ +
+ {t("storage.recordings.rootSummary", { + used: root.used, + free: root.free, + usage_percent: root.usage_percent.toFixed(2), + })} +
+
+ {t("storage.recordings.rootCameras", { + cameras: + root.cameras.join(", ").replaceAll("_", " ") || + t("none", { ns: "common" }), + })} +
+
+ {Object.entries(root.camera_usages).map(([camera, usage]) => ( +
+ + {camera.replaceAll("_", " ")} + + + {getUnitSize(usage.usage)} ({usage.usage_percent.toFixed(2)}%) + +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/web/src/components/storage/__tests__/RecordingsRoots.test.tsx b/web/src/components/storage/__tests__/RecordingsRoots.test.tsx new file mode 100644 index 000000000..ffa722933 --- /dev/null +++ b/web/src/components/storage/__tests__/RecordingsRoots.test.tsx @@ -0,0 +1,62 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { RecordingsRoots, type RecordingRootStorage } from "../RecordingsRoots"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === "storage.recordings.nonDefault") return "Non-default root"; + if (key === "storage.recordings.rootSummary") { + return `Disk used: ${opts?.used} MiB • Free: ${opts?.free} MiB • Usage: ${opts?.usage_percent}%`; + } + if (key === "storage.recordings.rootCameras") { + return `Cameras: ${opts?.cameras}`; + } + if (key === "none") return "None"; + return key; + }, + }), +})); + +describe("RecordingsRoots", () => { + it("renders multiple roots and per-camera usage", () => { + const roots: RecordingRootStorage[] = [ + { + path: "/media/frigate/recordings", + total: 1000, + used: 700, + free: 300, + usage_percent: 70, + recordings_size: 600, + cameras: ["front_door"], + is_default: true, + camera_usages: { + front_door: { bandwidth: 5, usage: 600, usage_percent: 100 }, + }, + }, + { + path: "/mnt/custom-recordings", + total: 2000, + used: 1400, + free: 600, + usage_percent: 70, + recordings_size: 800, + cameras: ["back_yard", "garage"], + is_default: false, + camera_usages: { + back_yard: { bandwidth: 4, usage: 300, usage_percent: 37.5 }, + garage: { bandwidth: 6, usage: 500, usage_percent: 62.5 }, + }, + }, + ]; + + const html = renderToStaticMarkup(); + + expect(html).toContain("/media/frigate/recordings"); + expect(html).toContain("/mnt/custom-recordings"); + expect(html).toContain("Non-default root"); + expect(html).toContain("Cameras: back yard, garage"); + expect(html).toContain("garage"); + }); +}); diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index 2c222d6c3..7f12f46e0 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -1,5 +1,9 @@ import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph"; import { StorageGraph } from "@/components/graph/StorageGraph"; +import { + RecordingRootStorage, + RecordingsRoots, +} from "@/components/storage/RecordingsRoots"; import { FrigateStats } from "@/types/stats"; import { useMemo } from "react"; import { @@ -27,13 +31,21 @@ type CameraStorage = { }; }; +type RecordingsStorageResponse = CameraStorage & { + cameras?: CameraStorage; + recording_roots?: RecordingRootStorage[]; + __recording_roots?: RecordingRootStorage[]; +}; + type StorageMetricsProps = { setLastUpdated: (last: number) => void; }; export default function StorageMetrics({ setLastUpdated, }: StorageMetricsProps) { - const { data: cameraStorage } = useSWR("recordings/storage"); + const { data: recordingsStorage } = useSWR( + "recordings/storage", + ); const { data: stats } = useSWR("stats"); const { data: config } = useSWR("config", { revalidateOnFocus: false, @@ -42,6 +54,27 @@ export default function StorageMetrics({ const timezone = useTimezone(config); const { getLocaleDocUrl } = useDocDomain(); + const cameraStorage = useMemo(() => { + if (!recordingsStorage) { + return undefined; + } + + if (recordingsStorage.cameras) { + return recordingsStorage.cameras; + } + + return Object.fromEntries( + Object.entries(recordingsStorage).filter( + ([key]) => key !== "__recording_roots" && key !== "recording_roots", + ), + ) as CameraStorage; + }, [recordingsStorage]); + + const recordingRoots = useMemo( + () => recordingsStorage?.recording_roots ?? recordingsStorage?.__recording_roots ?? [], + [recordingsStorage], + ); + const totalStorage = useMemo(() => { if (!cameraStorage || !stats) { return undefined; @@ -203,6 +236,10 @@ export default function StorageMetrics({ totalStorage={totalStorage} /> +
+ {t("storage.recordings.roots")} +
+ ); }