From 84d8c82a8c1555a13b64dd9f33135690281b5290 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:22:15 -0500 Subject: [PATCH] fixes --- frigate/test/test_live_streams.py | 13 ++++++++ frigate/util/live_streams.py | 10 +++++- .../settings/camera-live-transcode.spec.ts | 31 +++++++++++++++++++ .../theme/fields/LiveStreamsField.tsx | 10 +++++- web/src/pages/Settings.tsx | 3 ++ web/src/utils/configUtil.ts | 12 ++++++- 6 files changed, 76 insertions(+), 3 deletions(-) diff --git a/frigate/test/test_live_streams.py b/frigate/test/test_live_streams.py index 504a8804d8..b72fa5d322 100644 --- a/frigate/test/test_live_streams.py +++ b/frigate/test/test_live_streams.py @@ -202,6 +202,19 @@ class TestLiveTranscodeConfig(unittest.TestCase): self.assertEqual(config.cameras["front"].live.transcode.source, "front_sub") + def test_startup_and_validation_agree_on_inherited_source(self): + config = camera_config({"transcode": {"enabled": True}}) + config["live"] = {"streams": {"Sub": "front_sub"}} + + self.assertEqual( + raw_transcode_streams(config), + generated_transcode_streams(FrigateConfig(**config)), + ) + self.assertEqual( + raw_transcode_streams(config)["front_transcode_360p"], + transcode_stream_source("front_sub", 360, 250), + ) + def test_placed_entries_keep_position_and_label(self): streams = self.streams( { diff --git a/frigate/util/live_streams.py b/frigate/util/live_streams.py index 82545c48e3..65e765500f 100644 --- a/frigate/util/live_streams.py +++ b/frigate/util/live_streams.py @@ -81,6 +81,7 @@ def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]: resolves defaults the same way the validator does. """ streams: dict[str, str] = {} + global_streams = (config.get("live") or {}).get("streams") or {} for camera, camera_config in (config.get("cameras") or {}).items(): live = (camera_config or {}).get("live") or {} @@ -89,8 +90,15 @@ def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]: if not transcode.get("enabled"): continue + # cameras inherit global live streams after their own, like deep_merge + camera_streams = live.get("streams") or {} + live_streams = camera_streams | { + label: name + for label, name in global_streams.items() + if label not in camera_streams + } source = transcode.get("source") or default_transcode_source( - camera, live.get("streams") or {camera: camera} + camera, live_streams or {camera: camera} ) if source is None: diff --git a/web/e2e/specs/settings/camera-live-transcode.spec.ts b/web/e2e/specs/settings/camera-live-transcode.spec.ts index ce39e84782..74920b65a0 100644 --- a/web/e2e/specs/settings/camera-live-transcode.spec.ts +++ b/web/e2e/specs/settings/camera-live-transcode.spec.ts @@ -150,6 +150,37 @@ test.describe("live stream order and transcoded streams @medium", () => { ).toEqual(["Main", "Sub"]); }); + test("save all keeps a reorder made on another page", async ({ + frigateApp, + }) => { + const capture = await installRoutes(frigateApp.page); + await frigateApp.goto(SETTINGS_URL); + + await expect + .poll(() => streamNames(frigateApp.page)) + .toEqual(["Sub", "Main"]); + + await dragRow(frigateApp.page, 1, 0); + await expect + .poll(() => streamNames(frigateApp.page)) + .toEqual(["Main", "Sub"]); + + await frigateApp.page.getByText("Snapshots", { exact: true }).click(); + await frigateApp.page.getByRole("button", { name: "Save All" }).click(); + + await expect.poll(() => capture.saved()).not.toBeNull(); + const saved = capture.saved() as { + replace_paths: string[]; + config_data: { + cameras: { front_door: { live: { streams: Record } } }; + }; + }; + expect(saved.replace_paths).toEqual(["cameras.front_door.live.streams"]); + expect( + Object.keys(saved.config_data.cameras.front_door.live.streams), + ).toEqual(["Main", "Sub"]); + }); + test("enabling transcoding saves the shown source", async ({ frigateApp, }) => { diff --git a/web/src/components/config-form/theme/fields/LiveStreamsField.tsx b/web/src/components/config-form/theme/fields/LiveStreamsField.tsx index 8eada75224..6ddf96bf32 100644 --- a/web/src/components/config-form/theme/fields/LiveStreamsField.tsx +++ b/web/src/components/config-form/theme/fields/LiveStreamsField.tsx @@ -484,10 +484,13 @@ export function LiveStreamsField(props: FieldProps) { ); // Auto order commits after measuring, so edits made meanwhile must survive + // and a camera switched to meanwhile must not receive the result const dataRef = useRef(data); + const cameraRef = useRef(camera); useEffect(() => { dataRef.current = data; - }, [data]); + cameraRef.current = camera; + }, [data, camera]); const [measurement, setMeasurement] = useState<{ data: LiveStreamsData; @@ -527,6 +530,11 @@ export function LiveStreamsField(props: FieldProps) { }), ); + if (cameraRef.current !== camera) { + setMeasuring(false); + return; + } + const next = Object.fromEntries(measured); const latest = dataRef.current; setMeasurement({ data: latest, transcode, rates: next }); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index a037a2135b..bbe71915d9 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -875,6 +875,9 @@ export default function Settings() { requires_restart: payload.needsRestart ? 1 : 0, update_topic: payload.updateTopic, config_data: configData, + ...(payload.replacePaths?.length + ? { replace_paths: payload.replacePaths } + : {}), }); if (payload.needsRestart) { diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 78b12eaa03..9a1256495d 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -556,6 +556,7 @@ export interface SectionSavePayload { updateTopic: string | undefined; needsRestart: boolean; pendingDataKey: string; + replacePaths?: string[]; } // --------------------------------------------------------------------------- @@ -765,7 +766,13 @@ export function prepareSectionSavePayload(opts: { ); // Build overrides - const overrides = buildOverrides(pendingData, rawData, effectiveDefaults); + const orderedMaps = sectionConfig.orderedMaps ?? []; + const overrides = applyOrderedMaps( + buildOverrides(pendingData, rawData, effectiveDefaults), + pendingData, + rawData, + orderedMaps, + ); const sanitizedOverrides = sanitizeOverridesForSection( schemaSection, level, @@ -815,6 +822,9 @@ export function prepareSectionSavePayload(opts: { updateTopic, needsRestart, pendingDataKey, + replacePaths: changedOrderedMapPaths(pendingData, rawData, orderedMaps).map( + (path) => `${basePath}.${path}`, + ), }; }