* fix classification wizard finding no sample images on large databases

The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found.

* add notice and system ui message when a camera isn't using go2rtc

* add docs note about privacy masks

* match index-less device strings to hardware units in the detection models picker

A config with `edgetpu:usb` was reported as hardware that wasn't found, because the picker compared device strings exactly and the probe reports the unit as `edgetpu:usb:0`. A device without an index now resolves to the first unit of that kind, so the hardware dropdown, the unit checkboxes, and the model summary all recognize it.

* don't treat unknown audio as an audio-bearing stream

A recording row with NULL has_audio (ffprobe failed and the cv2 fallback can't report audio) marked the whole stream as audio-bearing, so every confirmed video-only row on it was dropped as a glitch. A stream now counts as audio-bearing only when a row is known to carry audio.

* stage main+sub exports on disk instead of /tmp/cache

A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export.

* address review feedback

- stage main+sub export runs in a staging subfolder of the exports directory, so media sync can't delete them mid-export and startup cleanup can't remove a finished export with a matching name or fail on an unremovable file
- run object classification example collection off the event loop
- prefer an exact hardware unit match over an index-less one, so an AMD GPU's "onnx" no longer selects the NVIDIA entry
- add tests for the classification fallback and index-less hardware matching

* serialize example collection and clean up exports when staging dir creation fails

- overlapping object example requests could delete each other's images in the shared temp and train directories, so collection now runs under a lock
- a failed makedirs for the staging directory raised past the failed-export cleanup and left the export spinning until restart, so it now fails the run through the normal cleanup path

* revert
This commit is contained in:
Josh Hawkins
2026-10-11 07:01:39 -06:00
committed by GitHub
parent 2cb03f442c
commit acc84d7658
17 changed files with 292 additions and 22 deletions
+8
View File
@@ -53,6 +53,14 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution. Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy ### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards. This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+8 -1
View File
@@ -1,5 +1,6 @@
"""Object classification APIs.""" """Object classification APIs."""
import asyncio
import datetime import datetime
import logging import logging
import os import os
@@ -48,6 +49,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.classification]) router = APIRouter(tags=[Tags.classification])
# concurrent collections for one model share its temp and train directories
example_collection_lock = asyncio.Lock()
def invalid_name_response(value: str) -> JSONResponse: def invalid_name_response(value: str) -> JSONResponse:
"""Response for a name that cannot be used as a path component.""" """Response for a name that cannot be used as a path component."""
@@ -1290,7 +1294,10 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
if model_name is None: if model_name is None:
return invalid_name_response(body.model_name) return invalid_name_response(body.model_name)
collect_object_classification_examples(model_name, body.label) async with example_collection_lock:
await asyncio.to_thread(
collect_object_classification_examples, model_name, body.label
)
return JSONResponse( return JSONResponse(
content={"success": True, "message": "Example generation completed"}, content={"success": True, "message": "Example generation completed"},
+1
View File
@@ -8,6 +8,7 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports" EXPORT_DIR = f"{BASE_DIR}/exports"
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
FACE_DIR = f"{CLIPS_DIR}/faces" FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs" THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"
+5 -1
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
import shutil
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -15,7 +16,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum from frigate.config.camera.record import ChaptersEnum
from frigate.const import UPDATE_JOB_STATE from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
from frigate.jobs.job import Job from frigate.jobs.job import Job
from frigate.models import Export from frigate.models import Export
from frigate.record.export import ( from frigate.record.export import (
@@ -415,6 +416,9 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return. of the sweep, and a failure in the top-level query will log and return.
""" """
# staged stream runs live on disk, so a killed export leaves them behind
shutil.rmtree(EXPORT_STAGING_DIR, ignore_errors=True)
try: try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception: except Exception:
+13 -1
View File
@@ -26,6 +26,7 @@ from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
EXPORT_DIR, EXPORT_DIR,
EXPORT_STAGING_DIR,
MAX_PLAYLIST_SECONDS, MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE, PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN, STREAM_TYPE_MAIN,
@@ -559,7 +560,7 @@ class RecordingExporter(threading.Thread):
) )
def _staged_run_path(self, index: int) -> str: def _staged_run_path(self, index: int) -> str:
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4") return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None: def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution. """Probe one recording from a run for its resolution.
@@ -714,6 +715,17 @@ class RecordingExporter(threading.Thread):
for index, run in enumerate(runs): for index, run in enumerate(runs):
dest = self._staged_run_path(index) dest = self._staged_run_path(index)
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
except OSError:
logger.exception(
"Failed to create staging directory for export %s",
self.export_id,
)
self._cleanup_staged_runs()
return False
weight = 100.0 * run.duration / total_duration weight = 100.0 * run.duration / total_duration
base = 100.0 * completed / total_duration base = 100.0 * completed / total_duration
+17
View File
@@ -453,6 +453,23 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export" assert unchanged.name == "front door export"
assert unchanged.video_path == video assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staging_dir = os.path.join(tmpdir, "staging")
os.makedirs(staging_dir)
staged = os.path.join(staging_dir, "front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "export_stage_saved_abc.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_STAGING_DIR", staging_dir):
reap_stale_exports()
assert not os.path.exists(staging_dir)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self): def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4") stale_video = os.path.join(tmpdir, "stale.mp4")
@@ -0,0 +1,33 @@
"""Tests for object classification example extraction."""
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from frigate.util.classification import _extract_event_thumbnails
class TestExtractEventThumbnails(unittest.TestCase):
def test_falls_back_past_missing_images_and_stops_at_target(self):
events = [SimpleNamespace(id=f"event_{i}") for i in range(10)]
missing = {"event_0", "event_1", "event_2"}
image = np.zeros((32, 32, 3), dtype=np.uint8)
with (
tempfile.TemporaryDirectory() as tmpdir,
patch(
"frigate.util.classification._load_event_classification_crop",
side_effect=lambda event: None if event.id in missing else image,
) as load,
):
paths = _extract_event_thumbnails(events, tmpdir, target_count=4)
self.assertEqual(len(paths), 4)
self.assertEqual(load.call_count, 7)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -653,6 +653,17 @@ class TestStagedFileCleanup(unittest.TestCase):
self.assertEqual(os.listdir(tmpdir), []) self.assertEqual(os.listdir(tmpdir), [])
def test_unwritable_staging_directory_fails_the_run(self) -> None:
"""A raise here would skip the failed-export cleanup."""
with tempfile.TemporaryDirectory() as tmpdir:
exporter = self._exporter(tmpdir)
runs = [StreamRun("main", 1_000, 1_020, "/m1.mp4")]
with patch(
"frigate.record.export.os.makedirs", side_effect=OSError("no space")
):
self.assertFalse(exporter._stage_stream_runs(runs, {"h264"}, False))
class TestStagingFailure(unittest.TestCase): class TestStagingFailure(unittest.TestCase):
def test_failed_staging_aborts_rather_than_falling_back(self) -> None: def test_failed_staging_aborts_rather_than_falling_back(self) -> None:
+17
View File
@@ -13,6 +13,7 @@ from frigate.models import Recordings
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
_rows_query, _rows_query,
coverage_spans, coverage_spans,
null_audio_glitches,
plan_clip, plan_clip,
realized_timeline, realized_timeline,
resolve_coverage, resolve_coverage,
@@ -166,6 +167,22 @@ class TestRecordingCoverage(CoverageDbTestCase):
expected = int(5 * 1024 * 1024 * 8 / 10) expected = int(5 * 1024 * 1024 * 8 / 10)
assert summary["main"]["bitrate"] == expected assert summary["main"]["bitrate"] == expected
def test_unknown_audio_row_keeps_video_only_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual(
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
)
def test_video_only_glitch_dropped_on_audio_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
def test_other_camera_rows_excluded(self): def test_other_camera_rows_excluded(self):
self._insert("m1", 1000.0, 1010.0, "main") self._insert("m1", 1000.0, 1010.0, "main")
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard") self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
+20 -4
View File
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100) selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events") logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events # Step 3: Extract thumbnails from events, falling back to the remaining
thumbnails = _extract_event_thumbnails(selected_events, temp_dir) # events when the selected ones have no image on disk
selected_ids = {e.id for e in selected_events}
remaining_events = [e for e in events if e.id not in selected_ids]
random.shuffle(remaining_events)
thumbnails = _extract_event_thumbnails(
selected_events + remaining_events, temp_dir, target_count=100
)
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails") logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails # Step 4: Select 24 most visually distinct thumbnails
@@ -833,10 +839,16 @@ def _select_balanced_events(
else: else:
selected.extend(remaining) selected.extend(remaining)
# groups are ordered oldest first, so truncating unshuffled keeps only the
# oldest events, which are the least likely to still have images on disk
random.shuffle(selected)
return selected[:target_count] return selected[:target_count]
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]: def _extract_event_thumbnails(
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
""" """
Extract a training image for each event. Extract a training image for each event.
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
using a step ladder sized from the box/region area ratio. using a step ladder sized from the box/region area ratio.
Args: Args:
events: List of Event objects events: List of Event objects, in order of preference
output_dir: Directory to save crops output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns: Returns:
List of paths to successfully extracted images List of paths to successfully extracted images
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
image_paths = [] image_paths = []
for idx, event in enumerate(events): for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try: try:
img = _load_event_classification_crop(event) img = _load_event_classification_crop(event)
if img is None: if img is None:
+4 -3
View File
@@ -204,11 +204,12 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool: def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
"""Whether a stream is audio-bearing over a coverage window. """Whether a stream is audio-bearing over a coverage window.
A stream counts as audio-bearing unless EVERY one of its rows reports A stream counts as audio-bearing only when one of its rows is known
has_audio False; NULL (legacy or undetermined) counts as audio. to carry audio. A NULL row (legacy, or a segment ffprobe could not
read) proves nothing either way.
""" """
return any( return any(
row is not None and row.has_audio is not False row is not None and row.has_audio is True
for row in ((interval.main if main else interval.sub) for interval in intervals) for row in ((interval.main if main else interval.sub) for interval in intervals)
) )
@@ -66,6 +66,27 @@ const HAILO_PLUS_MODELS = [
}, },
]; ];
// a migrated `type: onnx` detector is the bare "onnx", and a Ryzen iGPU is
// always probed alongside an NVIDIA card
const ONNX_GPU_HARDWARE = [
{
key: "onnx:nvidia",
detector: "onnx",
name: "NVIDIA GeForce RTX 3060",
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
count: 1,
unlimited: true,
},
{
key: "onnx:amd",
detector: "onnx",
name: "AMD GPU",
units: [{ device: "onnx", label: "renderD128" }],
count: 1,
unlimited: true,
},
];
const HAILO_HARDWARE = [ const HAILO_HARDWARE = [
{ {
key: "hailo", key: "hailo",
@@ -84,7 +105,7 @@ async function installRoutes(
models: Model[], models: Model[],
plusEnabled = false, plusEnabled = false,
plusModels: unknown[] = [PLUS_MODEL], plusModels: unknown[] = [PLUS_MODEL],
hailoHardware = false, hardware?: unknown[],
) { ) {
const config = configFactory({ const config = configFactory({
models, models,
@@ -107,9 +128,9 @@ async function installRoutes(
route.fulfill({ json: plusModels }), route.fulfill({ json: plusModels }),
); );
if (hailoHardware) { if (hardware) {
await page.route("**/api/hardware/probe**", (route) => await page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: HAILO_HARDWARE }), route.fulfill({ json: hardware }),
); );
} }
await page.route("**/api/config/set", async (route) => { await page.route("**/api/config/set", async (route) => {
@@ -202,6 +223,39 @@ test.describe("Detection models settings @high", () => {
).not.toBeChecked(); ).not.toBeChecked();
}); });
test("a device without an index selects the first unit", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["edgetpu:pci"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:0"),
).toBeChecked();
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1"),
).not.toBeChecked();
});
test("a migrated onnx device resolves to the NVIDIA GPU", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
[{ scene: "default", devices: ["onnx"] }],
false,
[PLUS_MODEL],
ONNX_GPU_HARDWARE,
);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"NVIDIA GeForce RTX 3060 \u2022 3 cameras",
);
});
test("a unit claimed by another model cannot be picked", async ({ test("a unit claimed by another model cannot be picked", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -510,7 +564,7 @@ test.describe("Detection models settings @high", () => {
], ],
true, true,
HAILO_PLUS_MODELS, HAILO_PLUS_MODELS,
true, HAILO_HARDWARE,
); );
await openPage(frigateApp); await openPage(frigateApp);
+46 -2
View File
@@ -14,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold // the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } }; const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
// the fixture has no go2rtc streams, which gives every camera a live view hint
const RESTREAMED = {
go2rtc: {
streams: {
front_door: ["rtsp://x"],
backyard: ["rtsp://x"],
garage: ["rtsp://x"],
},
},
};
const ERROR_NOTICE = { const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx", id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed", kind: "model_download_failed",
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -84,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) { for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => { test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
} }
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => { test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: { stats: {
service: { retention_unmet: true }, service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } }, cameras: { front_door: { camera_fps: 0 } },
@@ -829,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
}); });
test("a camera without a go2rtc stream gets a live view hint", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("lower frame rate and no audio");
await expect(
frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
),
).toHaveCount(0);
});
test("a global config problem is not repeated per camera", async ({ test("a global config problem is not repeated per camera", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({ test("empty state when stats, config, and registry are clean", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -1130,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -1997,6 +1997,9 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.", "genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images." "genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
}, },
"live": {
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
},
"audio": { "audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
}, },
@@ -1,8 +1,24 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = { const live: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/live", sectionDocs: "/configuration/live",
messages: [
{
key: "no-go2rtc-stream",
health: true,
messageKey: "configMessages.live.noGo2rtcStream",
severity: "info",
docLink: "/configuration/live",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !Object.values(ctx.fullCameraConfig.live.streams).some(
(name) => isRestreamedStream(ctx.fullConfig, name),
);
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"], fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {}, fieldGroups: {},
@@ -15,6 +15,7 @@ import {
hardwareForDevices, hardwareForDevices,
MAX_DETECTORS, MAX_DETECTORS,
recommendedDetectorCount, recommendedDetectorCount,
resolveUnitDevice,
} from "@/utils/detectionHardware"; } from "@/utils/detectionHardware";
type HardwarePickerProps = { type HardwarePickerProps = {
@@ -60,9 +61,13 @@ export function HardwarePicker({
return []; return [];
} }
const assigned = devices.map((device) =>
resolveUnitDevice(selected, device),
);
return selected.units return selected.units
.map((unit) => unit.device) .map((unit) => unit.device)
.filter((device) => devices.includes(device)); .filter((device) => assigned.includes(device));
}, [selected, devices]); }, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */ /** Spread `count` detectors round robin over the selected units. */
@@ -200,7 +205,7 @@ export function HardwarePicker({
<Checkbox <Checkbox
id={`${idPrefix}-${unit.device}`} id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white" className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={devices.includes(unit.device)} checked={selectedUnits.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)} disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true) handleUnitToggle(unit.device, checked === true)
+25 -4
View File
@@ -21,10 +21,31 @@ export function hardwareForDevices(
return undefined; return undefined;
} }
return hardware.find((entry) => { return hardware.find((entry) =>
const known = new Set(entry.units.map((unit) => unit.device)); devices.every((device) => resolveUnitDevice(entry, device)),
return devices.every((device) => known.has(device)); );
}); }
/**
* The unit device string a configured device refers to.
*
* A config may leave the index off, such as "edgetpu:usb", which the detector
* resolves to the first unit of that kind.
*/
export function resolveUnitDevice(
entry: DetectionHardware,
device: string,
): string | undefined {
const unitDevices = entry.units.map((unit) => unit.device);
return (
unitDevices.find((candidate) => candidate === device) ??
unitDevices.find(
(candidate) =>
candidate.startsWith(`${device}:`) ||
candidate.startsWith(`${device}.`),
)
);
} }
/** /**