diff --git a/frigate/storage.py b/frigate/storage.py index 05e4fb42ab..e1875cac90 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -156,11 +156,13 @@ class StorageMaintainer(threading.Thread): "bandwidth", 0 ), # only streams with segments on disk are reported, so a camera - # keeps its sub entry until sub retention expires those segments + # keeps its sub entry until sub retention expires those segments. + # bandwidth is null rather than 0 when the cache holds no sample + # for the stream, since 0 would claim it writes nothing "streams": { stream_type: { "usage": stream_usages[stream_type], - "bandwidth": stream_bandwidths.get(stream_type, 0), + "bandwidth": stream_bandwidths.get(stream_type), } for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB) if stream_usages.get(stream_type) diff --git a/frigate/test/test_storage.py b/frigate/test/test_storage.py index 4f9e55f758..a5c5a84763 100644 --- a/frigate/test/test_storage.py +++ b/frigate/test/test_storage.py @@ -283,6 +283,49 @@ class TestHttp(unittest.TestCase): == MAX_CALCULATED_BANDWIDTH ) + def test_stream_bandwidth_is_none_without_a_cached_sample(self): + """A stream that appears after the bandwidth cache freezes has no estimate. + + Sub stream recording can be toggled on at runtime, so the cache can hold + a main-only sample while sub segments are already landing on disk. + Reporting 0 there would claim the sub stream costs nothing. + """ + config = FrigateConfig(**self.minimal_config) + storage = StorageMaintainer(config, MagicMock()) + + time_keep = datetime.datetime.now().timestamp() + for i in range(60): + _insert_mock_recording( + f"main_{i}.frontdoor", + os.path.join(self.test_dir, f"main_{i}.tmp"), + time_keep + i * 10, + time_keep + i * 10 + 10, + seg_size=20, + seg_dur=10, + ) + + # 50 or more segments flips needs_refresh off, freezing the cache + storage.calculate_camera_bandwidth() + assert storage.camera_storage_stats["front_door"]["needs_refresh"] is False + + for i in range(60): + _insert_mock_recording( + f"sub_{i}.frontdoor", + os.path.join(self.test_dir, f"sub_{i}.tmp"), + time_keep + 5000 + i * 10, + time_keep + 5000 + i * 10 + 10, + seg_size=2, + seg_dur=10, + stream_type=STREAM_TYPE_SUB, + ) + + storage.calculate_camera_bandwidth() + streams = storage.calculate_camera_usages()["front_door"]["streams"] + + assert streams[STREAM_TYPE_SUB]["usage"] == 120 + assert streams[STREAM_TYPE_SUB]["bandwidth"] is None + assert streams[STREAM_TYPE_MAIN]["bandwidth"] == 7200 + def test_camera_usages_with_no_recordings(self): """A camera with no segments reports zero usage and no streams.""" config = FrigateConfig(**self.minimal_config) diff --git a/web/src/components/graph/CombinedStorageGraph.tsx b/web/src/components/graph/CombinedStorageGraph.tsx index c1ce711944..f909164aeb 100644 --- a/web/src/components/graph/CombinedStorageGraph.tsx +++ b/web/src/components/graph/CombinedStorageGraph.tsx @@ -204,9 +204,12 @@ export function CombinedStorageGraph({ const getStreamSplit = useCallback( (row: StorageRow, field: keyof StreamStorage) => { - const sub = row.streams?.sub; + const mainValue = row.streams?.main?.[field]; + const subValue = row.streams?.sub?.[field]; - if (!sub) { + // omit the split entirely when either side is unknown, rather than + // showing a zero that would read as "this stream costs nothing" + if (mainValue == null || subValue == null) { return null; } @@ -216,13 +219,13 @@ export function CombinedStorageGraph({ {t("quality.main", { ns: "components/player" })} {" "} - {getUnitSize(row.streams?.main?.[field] ?? 0)} + {getUnitSize(mainValue)}