diff --git a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts index 38b0f5c4a6..d343a3b3ee 100644 --- a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts +++ b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts @@ -32,7 +32,11 @@ type CameraInput = { input_args?: string; }; -async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { +async function installRoutes( + page: Page, + frontDoorInputs: CameraInput[], + rawInputs: CameraInput[] = frontDoorInputs, +) { const config = configFactory({ go2rtc: { streams: GO2RTC_STREAMS }, cameras: { @@ -56,7 +60,7 @@ async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { await page.route("**/api/config/raw_paths", (route) => route.fulfill({ json: { - cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } }, + cameras: { front_door: { ffmpeg: { inputs: rawInputs } } }, go2rtc: { streams: GO2RTC_STREAMS }, }, }), @@ -200,4 +204,35 @@ test.describe("camera ffmpeg input source toggle @medium", () => { payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0]; expect(input?.input_args).not.toBe("preset-rtsp-restream"); }); + + test("a masked path reveals its own saved path after an earlier input is deleted", async ({ + frigateApp, + }) => { + const main = "rtsp://admin:mainpw@10.0.0.1:554/main"; + const sub = "rtsp://admin:subpw@10.0.0.1:554/sub"; + await installRoutes( + frigateApp.page, + [ + { path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] }, + { path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] }, + ], + [ + { path: main, roles: ["record"] }, + { path: sub, roles: ["detect"] }, + ], + ); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + const paths = frigateApp.page.getByRole("textbox", { name: "Input path" }); + await expect(paths).toHaveCount(2); + + await frigateApp.page + .getByRole("button", { name: "Delete" }) + .first() + .click(); + await expect(paths).toHaveCount(1); + + await paths.focus(); + await expect(paths).toHaveValue(sub); + }); }); diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index 03f6abb9d9..4e446e8595 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -66,17 +66,26 @@ export function CameraPathWidget(props: WidgetProps) { shouldFetchRawPaths ? "config/raw_paths" : null, ); + const rawValue = typeof value === "string" ? value : ""; + + // Match by masked value, as the backend restores on save, so deleting or + // reordering inputs never reveals another input's path const rawPath = useMemo(() => { - if (!cameraName || inputIndex === undefined) { + if (!cameraName || !isMaskedPath(rawValue)) { return undefined; } - const path = - rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path; - return typeof path === "string" ? path : undefined; - }, [cameraName, inputIndex, rawPaths]); + const matches = new Set( + (rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []) + .map((input) => input.path) + .filter( + (path): path is string => + typeof path === "string" && maskCredentials(path) === rawValue, + ), + ); + return matches.size === 1 ? [...matches][0] : undefined; + }, [cameraName, rawPaths, rawValue]); - const rawValue = typeof value === "string" ? value : ""; const resolvedValue = isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? ""); const canReveal =