Fix recordings root attribution to use DB paths

This commit is contained in:
ibs0d 2026-03-07 12:17:17 +11:00
parent ba045ab3cf
commit 9f5d93e723
8 changed files with 570 additions and 12 deletions

View File

@ -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())])

View File

@ -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] = {}

View File

@ -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"]

View File

@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@ -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."

View File

@ -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 (
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{roots.map((root) => (
<div
key={root.path}
className={`rounded-lg bg-background_alt p-2.5 md:rounded-2xl ${
root.is_default ? "" : "border border-primary/30"
}`}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="break-all text-sm font-medium">{root.path}</div>
{!root.is_default && (
<div className="rounded-md bg-primary/15 px-2 py-1 text-xs text-primary">
{t("storage.recordings.nonDefault")}
</div>
)}
</div>
<StorageGraph
graphId={`recordings-root-${root.path}`}
used={root.recordings_size}
total={root.total}
/>
<div className="mt-2 text-xs text-primary-variant">
{t("storage.recordings.rootSummary", {
used: root.used,
free: root.free,
usage_percent: root.usage_percent.toFixed(2),
})}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{t("storage.recordings.rootCameras", {
cameras:
root.cameras.join(", ").replaceAll("_", " ") ||
t("none", { ns: "common" }),
})}
</div>
<div className="mt-2 space-y-1">
{Object.entries(root.camera_usages).map(([camera, usage]) => (
<div
key={`${root.path}-${camera}`}
className="flex items-center justify-between text-xs"
>
<span className="smart-capitalize">
{camera.replaceAll("_", " ")}
</span>
<span>
{getUnitSize(usage.usage)} ({usage.usage_percent.toFixed(2)}%)
</span>
</div>
))}
</div>
</div>
))}
</div>
);
}

View File

@ -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<string, string>) => {
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(<RecordingsRoots roots={roots} />);
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");
});
});

View File

@ -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<CameraStorage>("recordings/storage");
const { data: recordingsStorage } = useSWR<RecordingsStorageResponse>(
"recordings/storage",
);
const { data: stats } = useSWR<FrigateStats>("stats");
const { data: config } = useSWR<FrigateConfig>("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}
/>
</div>
<div className="mt-4 text-sm font-medium text-muted-foreground">
{t("storage.recordings.roots")}
</div>
<RecordingsRoots roots={recordingRoots} />
</div>
);
}