/** * Camera ffmpeg streams settings tests -- MEDIUM tier. * * Covers the input-path source toggle: each ffmpeg input can either point at a * go2rtc restream (picked from a dropdown, which writes the rtsp://127.0.0.1:8554 * path plus the preset-rtsp-restream input_args) or use a manually typed path. */ import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { test, expect } from "../../fixtures/frigate-test"; import type { Page } from "@playwright/test"; import { configFactory } from "../../fixtures/mock-data/config"; const __dirname = dirname(fileURLToPath(import.meta.url)); const CONFIG_SCHEMA = JSON.parse( readFileSync( resolve(__dirname, "../../fixtures/mock-data/config-schema.json"), "utf-8", ), ); const GO2RTC_STREAMS = { dome_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"], dome_sub: ["rtsp://user:pass@192.168.0.20:554/Stream2"], }; type CameraInput = { path: string; roles: string[]; input_args?: string; }; async function installRoutes( page: Page, frontDoorInputs: CameraInput[], rawInputs: CameraInput[] = frontDoorInputs, ) { const config = configFactory({ go2rtc: { streams: GO2RTC_STREAMS }, cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs }, }, }, }); let lastSavedConfig: unknown = null; await page.route("**/api/config/schema.json", (route) => route.fulfill({ json: CONFIG_SCHEMA }), ); await page.route("**/api/config", (route) => { if (route.request().method() === "GET") { return route.fulfill({ json: config }); } return route.fulfill({ json: { success: true } }); }); await page.route("**/api/config/raw_paths", (route) => route.fulfill({ json: { cameras: { front_door: { ffmpeg: { inputs: rawInputs } } }, go2rtc: { streams: GO2RTC_STREAMS }, }, }), ); await page.route("**/api/config/set", async (route) => { lastSavedConfig = route.request().postDataJSON(); await route.fulfill({ json: { success: true, require_restart: false } }); }); await page.route("**/api/ffmpeg/presets", (route) => route.fulfill({ json: { hwaccel_args: [], input_args: ["preset-rtsp-restream", "preset-rtsp-generic"], output_args: { record: [], detect: [] }, }, }), ); return { capturedConfig: () => lastSavedConfig }; } const RESTREAM_RADIO = "Restream (go2rtc)"; const MANUAL_RADIO = "Manual input path"; test.describe("camera ffmpeg input source toggle @medium", () => { test("manual input defaults to the manual text field", async ({ frigateApp, }) => { await installRoutes(frigateApp.page, [ { path: "rtsp://10.0.0.1:554/video", roles: ["detect"] }, ]); await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); await expect( frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }), ).toBeChecked(); await expect( frigateApp.page.getByRole("textbox", { name: "Input path" }), ).toHaveValue("rtsp://10.0.0.1:554/video"); }); test("an existing restream path auto-detects into restream mode", async ({ frigateApp, }) => { await installRoutes(frigateApp.page, [ { path: "rtsp://127.0.0.1:8554/dome_main", roles: ["detect"], input_args: "preset-rtsp-restream", }, ]); await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); await expect( frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }), ).toBeChecked(); // The dropdown is preselected to the matching go2rtc stream. await expect( frigateApp.page.getByRole("combobox", { name: /go2rtc stream/i }), ).toContainText("dome_main"); }); test("selecting a restream writes the path and preset", async ({ frigateApp, }) => { const capture = await installRoutes(frigateApp.page, [ { path: "rtsp://10.0.0.1:554/video", roles: ["detect"] }, ]); await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); await frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }).click(); await frigateApp.page .getByRole("combobox", { name: /go2rtc stream/i }) .click(); // The dropdown is searchable: typing narrows the list to matches only, // with no option to enter a custom stream name. await frigateApp.page.getByPlaceholder("Search streams...").fill("sub"); await expect( frigateApp.page.getByRole("option", { name: "dome_main" }), ).toBeHidden(); await frigateApp.page.getByRole("option", { name: "dome_sub" }).click(); await frigateApp.page.getByRole("button", { name: "Save" }).click(); await expect .poll(() => capture.capturedConfig(), { timeout: 5_000 }) .toMatchObject({ config_data: { cameras: { front_door: { ffmpeg: { inputs: [ { path: "rtsp://127.0.0.1:8554/dome_sub", input_args: "preset-rtsp-restream", }, ], }, }, }, }, }); }); test("switching a restream back to manual reverts the preset", async ({ frigateApp, }) => { const capture = await installRoutes(frigateApp.page, [ { path: "rtsp://127.0.0.1:8554/dome_main", roles: ["detect"], input_args: "preset-rtsp-restream", }, ]); await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); await frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }).click(); // The restream path stays editable in the manual text field. await expect( frigateApp.page.getByRole("textbox", { name: "Input path" }), ).toHaveValue("rtsp://127.0.0.1:8554/dome_main"); await frigateApp.page.getByRole("button", { name: "Save" }).click(); await expect .poll(() => capture.capturedConfig(), { timeout: 5_000 }) .not.toBeNull(); const payload = capture.capturedConfig() as { config_data?: { cameras?: { front_door?: { ffmpeg?: { inputs?: Array<{ input_args?: unknown }> }; }; }; }; }; const input = 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); }); test("inputs with the same masked path each reveal their own path by role", async ({ frigateApp, }) => { const record = "rtsp://admin:one@10.0.0.1:554/live"; const detect = "rtsp://viewer:two@10.0.0.1:554/live"; const masked = "rtsp://*:*@10.0.0.1:554/live"; await installRoutes( frigateApp.page, [ { path: masked, roles: ["record"] }, { path: masked, roles: ["detect"] }, ], [ { path: record, roles: ["record"] }, { path: detect, 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 paths.nth(0).focus(); await expect(paths.nth(0)).toHaveValue(record); await paths.nth(1).focus(); await expect(paths.nth(1)).toHaveValue(detect); }); });