reveal camera paths by masked value instead of position

After deleting or reordering inputs in the camera ffmpeg form, the path widget looked up raw_paths by index and revealed another input's real path, which a save then wrote with the wrong roles. The widget now matches the saved path whose mask equals the field's value, the same rule the backend uses on save, and doesn't reveal when there is no single match.
This commit is contained in:
Josh Hawkins
2026-10-18 14:59:57 -05:00
parent e7496367e9
commit 4d89bd3309
2 changed files with 52 additions and 8 deletions
@@ -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);
});
});
@@ -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 =