From 40ea80d43b735a6cd119cec3acdf619d9a48dc7d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 29 Sep 2026 16:12:46 -0600 Subject: [PATCH] Handle form validation --- .../specs/settings/detection-models.spec.ts | 36 ++++++++++++ .../config-form/section-validations/models.ts | 58 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/web/e2e/specs/settings/detection-models.spec.ts b/web/e2e/specs/settings/detection-models.spec.ts index 40a3a170db..fafb86c096 100644 --- a/web/e2e/specs/settings/detection-models.spec.ts +++ b/web/e2e/specs/settings/detection-models.spec.ts @@ -286,6 +286,42 @@ test.describe("Detection models settings @high", () => { ).toBeDisabled(); }); + test("two models without a path share the detector's default model", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, [ + { scene: "default", devices: ["cpu"] }, + { scene: "driveway", devices: ["cpu"] }, + ]); + await openPage(frigateApp); + + await expect(frigateApp.page.locator("#pageRoot")).toContainText( + "The Default and driveway models use the same model file", + ); + }); + + test("another spelling of the same path is the same model", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, [ + { + scene: "default", + devices: ["openvino:GPU.0"], + path: "/config/model_cache/yolo.onnx", + }, + { + scene: "driveway", + devices: ["openvino:GPU.1"], + path: "/config//model_cache/./tmp/../yolo.onnx", + }, + ]); + await openPage(frigateApp); + + await expect(frigateApp.page.locator("#pageRoot")).toContainText( + "The Default and driveway models use the same model file", + ); + }); + test("one model file may run on different detectors", async ({ frigateApp, }) => { diff --git a/web/src/components/config-form/section-validations/models.ts b/web/src/components/config-form/section-validations/models.ts index 8675ad0d76..17fd932c7b 100644 --- a/web/src/components/config-form/section-validations/models.ts +++ b/web/src/components/config-form/section-validations/models.ts @@ -3,6 +3,51 @@ import type { TFunction } from "i18next"; import { isJsonObject } from "@/lib/utils"; import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil"; +/** The model the backend loads for a detector when no path is configured. */ +function defaultModelPath(detector: string): string | undefined { + if (detector === "cpu" || detector.endsWith("_tfl")) { + return "/cpu_model.tflite"; + } + + if (detector === "edgetpu") { + return "/edgetpu_model.tflite"; + } + + if (detector === "openvino") { + return "/openvino-model/ssdlite_mobilenet_v2.xml"; + } + + return undefined; +} + +/** + * Collapse `//`, `.`, and `..` so spellings of one file path compare equal, + * as the backend's realpath does. Symlinks and copies can only be seen by the + * backend, which still combines them at startup. + */ +function normalizeModelPath(path: string): string { + // plus:// and other URLs are compared as written + if (path.includes("://")) { + return path; + } + + const segments: string[] = []; + + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") { + continue; + } + + if (segment === ".." && segments.length && segments.at(-1) !== "..") { + segments.pop(); + } else if (segment !== ".." || !path.startsWith("/")) { + segments.push(segment); + } + } + + return `${path.startsWith("/") ? "/" : ""}${segments.join("/")}`; +} + /** * A camera that names no scene runs the model whose scene is `default`. * Without one the backend rejects the config outright once a second model @@ -38,14 +83,23 @@ export function validateModelScenes( const seen = new Map(); formData.forEach((model, index) => { - if (!isJsonObject(model) || typeof model.path !== "string" || !model.path) { + if (!isJsonObject(model)) { return; } const devices = Array.isArray(model.devices) ? model.devices : []; const detector = typeof devices[0] === "string" ? devices[0].split(":")[0] : ""; - const key = `${detector}|${model.path}`; + const path = + typeof model.path === "string" && model.path + ? normalizeModelPath(model.path) + : defaultModelPath(detector); + + if (!path) { + return; + } + + const key = `${detector}|${path}`; const other = seen.get(key); if (other === undefined) {