Compare commits

..
Author SHA1 Message Date
Josh Hawkins 4f82c68f64 keep audio skip consistent with playback
Clips were skipped even when the range would be served video-only, and seeking into a skipped span landed on later footage. The skip now only applies when every row in the range carries audio, and seeks inside a zero-duration span land on the next clip.
2026-10-25 15:58:01 -05:00
Josh Hawkins 0ed5561553 skip vod clips that start after their audio ends
When a camera's audio dropped out, a recording segment could end its audio early. If a playback range started past that point in the segment, nginx-vod saw no audio track in the first clip, failed the whole range with a track count mismatch, and left the player spinning for up to an hour of footage. Recording now stores each segment's audio length alongside its keyframe index, from the same ffprobe pass, and plan_clip skips a clip whose window starts at or after its audio ends when the range has other clips. A range made of only that clip still plays video-only, and legacy rows without the new column are unchanged.
2026-10-25 15:37:28 -05:00
17 changed files with 69 additions and 314 deletions
+3 -6
View File
@@ -2,11 +2,10 @@
import logging import logging
import queue import queue
import random
import threading import threading
import time import time
from collections import deque from collections import deque
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime
from multiprocessing import Queue from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
@@ -256,9 +255,7 @@ def process_frames(
region_grid: list[list[dict[str, Any]]], region_grid: list[list[dict[str, Any]]],
exit_on_empty: bool = False, exit_on_empty: bool = False,
): ):
# cameras share one dispatcher, so stagger the daily grid rebuilds next_region_update = get_tomorrow_at_time(2)
region_update_offset = timedelta(seconds=random.randrange(3600))
next_region_update = get_tomorrow_at_time(2) + region_update_offset
config_subscriber = CameraConfigUpdateSubscriber( config_subscriber = CameraConfigUpdateSubscriber(
None, None,
{camera_config.name: camera_config}, {camera_config.name: camera_config},
@@ -347,7 +344,7 @@ def process_frames(
if updated_grid: if updated_grid:
region_grid = updated_grid region_grid = updated_grid
next_region_update = get_tomorrow_at_time(2) + region_update_offset next_region_update = get_tomorrow_at_time(2)
try: try:
if exit_on_empty: if exit_on_empty:
+2 -2
View File
@@ -474,10 +474,10 @@ class CameraWatchdog(threading.Thread):
if not self.capture_thread.is_alive(): if not self.capture_thread.is_alive():
self.detect_status.send("offline", now) self.detect_status.send("offline", now)
self.camera_fps.value = 0 self.camera_fps.value = 0
if can_restart:
self.logger.error( self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}." f"Ffmpeg process crashed unexpectedly for {self.config.name}."
) )
if can_restart:
self.reset_capture_thread(terminate=False) self.reset_capture_thread(terminate=False)
last_restart_time = now last_restart_time = now
elif self.camera_fps.value >= (self.config.detect.fps + 10): elif self.camera_fps.value >= (self.config.detect.fps + 10):
@@ -498,10 +498,10 @@ class CameraWatchdog(threading.Thread):
elif now - self.capture_thread.current_frame.value > 20: elif now - self.capture_thread.current_frame.value > 20:
self.detect_status.send("offline", now) self.detect_status.send("offline", now)
self.camera_fps.value = 0 self.camera_fps.value = 0
if can_restart:
self.logger.info( self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..." f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
) )
if can_restart:
self.reset_capture_thread() self.reset_capture_thread()
last_restart_time = now last_restart_time = now
else: else:
-92
View File
@@ -1,5 +1,4 @@
import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import { viewerProfile } from "../fixtures/mock-data/profile";
import { import {
expectBodyInteractive, expectBodyInteractive,
waitForBodyInteractive, waitForBodyInteractive,
@@ -989,97 +988,6 @@ test.describe("Multi-Camera Export from History @high", () => {
"Front gate follow-up", "Front gate follow-up",
); );
}); });
test("a blank case name shows an error and disables export", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
const caseName = form.getByPlaceholder(/new case name/i);
await expect(caseName).toHaveValue(/^Multi-camera export - /, {
timeout: 5_000,
});
await expect(form.getByText("Case name is required")).toBeHidden();
await caseName.fill("");
await expect(form.getByText("Case name is required")).toBeVisible();
await expect(
form.getByRole("button", { name: /^export \d+ cameras?$/i }),
).toBeDisabled();
});
test("clearing a time field keeps the previous valid range", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
let capturedPayload: { items: { end_time: number }[] } | null = null;
await frigateApp.page.route("**/api/exports/batch", async (route) => {
capturedPayload = route.request().postDataJSON();
await route.fulfill({
status: 202,
json: { export_case_id: null, export_ids: [], results: [] },
});
});
const form = await openMultiCameraTab(frigateApp);
const firstCamera = form.locator("button[aria-pressed]").first();
await expect(firstCamera).toBeVisible({ timeout: 5_000 });
if ((await firstCamera.getAttribute("aria-pressed")) === "false") {
await firstCamera.click();
}
// the seeded end can equal the picker's fallback, so move it off that
// value before clearing
await form.getByRole("button", { name: /^end time$/i }).click();
const endInput = frigateApp.page.locator("#endTime");
const [hour, minute, second] = (await endInput.inputValue()).split(":");
const chosen = `${hour}:${minute}:${String((Number(second) + 30) % 60).padStart(2, "0")}`;
await endInput.fill(chosen);
await endInput.fill("");
await expect(endInput).toHaveValue(chosen);
await frigateApp.page.keyboard.press("Escape");
await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click();
await expect.poll(() => capturedPayload).not.toBeNull();
const submittedClock = await frigateApp.page.evaluate(
(endTime) => new Date(endTime * 1000).toTimeString().slice(0, 8),
capturedPayload!.items[0].end_time,
);
expect(submittedClock).toBe(chosen);
});
test("viewers can export without a case", async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
let capturedPayload: Record<string, unknown> | null = null;
await frigateApp.page.route("**/api/exports/batch", async (route) => {
capturedPayload = route.request().postDataJSON();
await route.fulfill({
status: 202,
json: { export_case_id: null, export_ids: [], results: [] },
});
});
const form = await openMultiCameraTab(frigateApp);
const firstCamera = form.locator("button[aria-pressed]").first();
await expect(firstCamera).toBeVisible({ timeout: 5_000 });
if ((await firstCamera.getAttribute("aria-pressed")) === "false") {
await firstCamera.click();
}
await expect(form.getByPlaceholder(/new case name/i)).toBeHidden();
await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click();
await expect.poll(() => capturedPayload).not.toBeNull();
expect(capturedPayload).not.toHaveProperty("new_case_name");
expect(capturedPayload).not.toHaveProperty("export_case_id");
});
}); });
test.describe("Export Page - Active Job Progress @medium", () => { test.describe("Export Page - Active Job Progress @medium", () => {
@@ -1,77 +0,0 @@
/**
* Add-camera wizard Step 1 validation -- MEDIUM tier.
*
* Continue validates every field, so an invalid ONVIF port or a non-RTSP
* custom URL must show its translated message and keep the wizard on Step 1.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const PORT_ERROR = "ONVIF port must be a whole number from 1 to 65535";
async function openWizard(page: Page) {
await page.getByRole("button", { name: /Add New Camera/i }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByPlaceholder(/front_door/i).fill("step1_camera");
return dialog;
}
test.describe("Camera wizard step 1 validation @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
// not in the default mock; unmocked it 500s and trips the error collector
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
await frigateApp.page.route("**/api/onvif/probe**", (route) =>
route.fulfill({ json: { success: false } }),
);
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("an invalid ONVIF port blocks Continue with a translated message", async ({
frigateApp,
}) => {
const dialog = await openWizard(frigateApp.page);
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
const port = dialog.getByPlaceholder("80", { exact: true });
const next = dialog.getByRole("button", { name: /^Continue$/i });
for (const value of ["abc", "0", "70000", "80.5"]) {
await port.fill(value);
await next.click();
await expect(dialog.getByText(PORT_ERROR)).toBeVisible();
await expect(port).toBeVisible();
}
await port.fill("8080");
await next.click();
await expect(port).toBeHidden();
await expect(dialog.getByText(PORT_ERROR)).toBeHidden();
});
test("a custom URL must use RTSP", async ({ frigateApp }) => {
const dialog = await openWizard(frigateApp.page);
await dialog.getByText("Manual selection", { exact: true }).click();
await dialog.getByRole("combobox").click();
await frigateApp.page.getByRole("option", { name: "Other" }).click();
const url = dialog.getByPlaceholder(
"rtsp://username:password@host:port/path",
);
const next = dialog.getByRole("button", { name: /^Continue$/i });
await url.fill("http://192.168.1.100/stream");
await next.click();
await expect(
dialog.getByText(/Custom URLs must begin with "rtsp:\/\/"/),
).toBeVisible();
await url.fill("rtsp://192.168.1.100/stream");
await next.click();
await expect(url).toBeHidden();
});
});
@@ -121,14 +121,9 @@ async function confirmImport(page: Page) {
// route change, so #pageRoot is already present and waiting for it // route change, so #pageRoot is already present and waiting for it
// alone can race the navigation. Wait for the "load" event first so // alone can race the navigation. Wait for the "load" event first so
// the evaluate() calls below run against the post-reload document. // the evaluate() calls below run against the post-reload document.
// scoped to the dialog: until it opens, a page-wide "Import" match is the
// Import Settings button, which only reopens the file chooser
await Promise.all([ await Promise.all([
page.waitForEvent("load"), page.waitForEvent("load"),
page page.getByRole("button", { name: "Import" }).click(),
.getByRole("dialog")
.getByRole("button", { name: "Import", exact: true })
.click(),
]); ]);
await page.waitForSelector("#pageRoot"); await page.waitForSelector("#pageRoot");
} }
@@ -275,9 +270,6 @@ test.describe("UI settings import/export @medium", () => {
// naturalAspectLayout preference must not override that. // naturalAspectLayout preference must not override that.
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true }); await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
// the view reads the setting once on mount, so load it again after the
// write
await frigateApp.goto("/settings?page=uiSettings");
await chooseImportFile( await chooseImportFile(
frigateApp.page, frigateApp.page,
-33
View File
@@ -248,37 +248,4 @@ test.describe("zone rename and delete @medium @mobile", () => {
}, },
}); });
}); });
test("inertia below 1 blocks saving and a valid value saves as a number", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
const inertia = frigateApp.page.getByRole("textbox", {
name: "Inertia",
exact: true,
});
const save = frigateApp.page.getByRole("button", { name: "Save" });
await inertia.fill("0");
await expect(
frigateApp.page.getByText("Inertia must be above 0."),
).toBeVisible();
await expect(save).toBeDisabled();
await inertia.fill("5");
await expect(
frigateApp.page.getByText("Inertia must be above 0."),
).toBeHidden();
await save.click();
await expect.poll(() => requests.length).toBe(1);
expect(requests[0].body).toMatchObject({
config_data: {
cameras: { front_door: { zones: { driveway: { inertia: 5 } } } },
},
});
});
}); });
+32 -4
View File
@@ -83,7 +83,7 @@
"vaul": "^1.1.2", "vaul": "^1.1.2",
"virtua": "^0.53.6", "virtua": "^0.53.6",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^4.6.7" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -3131,6 +3131,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3148,6 +3151,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3165,6 +3171,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3182,6 +3191,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3199,6 +3211,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3216,6 +3231,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5097,6 +5115,16 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
} }
}, },
"node_modules/eslint-plugin-react-hooks/node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
@@ -10627,9 +10655,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.6.7", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+1 -1
View File
@@ -95,7 +95,7 @@
"vaul": "^1.1.2", "vaul": "^1.1.2",
"virtua": "^0.53.6", "virtua": "^0.53.6",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^4.6.7" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
+2 -4
View File
@@ -66,8 +66,8 @@
"newCaseNamePlaceholder": "New case name", "newCaseNamePlaceholder": "New case name",
"newCaseDescriptionPlaceholder": "Case description", "newCaseDescriptionPlaceholder": "Case description",
"label": "Case", "label": "Case",
"placeholder": "Select a case", "nonAdminHelp": "A new case will be created for these exports.",
"nameRequired": "Case name is required" "placeholder": "Select a case"
}, },
"select": "Select", "select": "Select",
"export": "Export", "export": "Export",
@@ -91,11 +91,9 @@
"selectedCount": "{{selected}} / {{total}} selected", "selectedCount": "{{selected}} / {{total}} selected",
"checkingActivity": "Checking camera activity...", "checkingActivity": "Checking camera activity...",
"noCameras": "No cameras available", "noCameras": "No cameras available",
"noCamerasSelected": "Select at least one camera",
"detectionCount_one": "1 tracked object", "detectionCount_one": "1 tracked object",
"detectionCount_other": "{{count}} tracked objects", "detectionCount_other": "{{count}} tracked objects",
"nameLabel": "Export name", "nameLabel": "Export name",
"caseNameDefault": "Multi-camera export - {{date}}",
"namePlaceholder": "Optional base name for these exports", "namePlaceholder": "Optional base name for these exports",
"queueingButton": "Queueing Exports...", "queueingButton": "Queueing Exports...",
"exportButton_one": "Export 1 Camera", "exportButton_one": "Export 1 Camera",
+1 -2
View File
@@ -367,8 +367,7 @@
"nameLength": "Camera name must be 64 characters or less", "nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters", "invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists", "nameExists": "Camera name already exists",
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams.", "customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams."
"onvifPortInvalid": "ONVIF port must be a whole number from 1 to 65535"
} }
}, },
"step2": { "step2": {
@@ -114,7 +114,7 @@ export default function ClassificationModelEditDialog({
const sharedFields = { const sharedFields = {
enabled: z.boolean(), enabled: z.boolean(),
saveAttempts: z.coerce saveAttempts: z.coerce
.number<number>({ message: t("edit.errors.saveAttemptsInvalid") }) .number({ message: t("edit.errors.saveAttemptsInvalid") })
.int(t("edit.errors.saveAttemptsInvalid")) .int(t("edit.errors.saveAttemptsInvalid"))
.min(0, t("edit.errors.saveAttemptsInvalid")), .min(0, t("edit.errors.saveAttemptsInvalid")),
}; };
@@ -136,7 +136,7 @@ export default function ClassificationModelEditDialog({
(val) => (val) =>
val === "" || val === null || val === undefined ? undefined : val, val === "" || val === null || val === undefined ? undefined : val,
z.coerce z.coerce
.number<number>({ message: t("edit.errors.intervalInvalid") }) .number({ message: t("edit.errors.intervalInvalid") })
.int(t("edit.errors.intervalInvalid")) .int(t("edit.errors.intervalInvalid"))
.positive(t("edit.errors.intervalInvalid")) .positive(t("edit.errors.intervalInvalid"))
.optional(), .optional(),
@@ -171,6 +171,7 @@ export default function ClassificationModelEditDialog({
resolver: zodResolver( resolver: zodResolver(
formSchema as z.ZodType< formSchema as z.ZodType<
ObjectFormData | StateFormData, ObjectFormData | StateFormData,
z.ZodTypeDef,
ObjectFormData | StateFormData ObjectFormData | StateFormData
>, >,
), ),
@@ -151,11 +151,6 @@ export function CustomTimeSelector({
step={isIOS ? "60" : "1"} step={isIOS ? "60" : "1"}
onChange={(e) => { onChange={(e) => {
const clock = e.target.value; const clock = e.target.value;
if (!clock) {
return;
}
const [hour, minute, second] = isIOS const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"] ? [...clock.split(":"), "00"]
: clock.split(":"); : clock.split(":");
@@ -222,11 +217,6 @@ export function CustomTimeSelector({
step={isIOS ? "60" : "1"} step={isIOS ? "60" : "1"}
onChange={(e) => { onChange={(e) => {
const clock = e.target.value; const clock = e.target.value;
if (!clock) {
return;
}
const [hour, minute, second] = isIOS const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"] ? [...clock.split(":"), "00"]
: clock.split(":"); : clock.split(":");
+4 -46
View File
@@ -65,8 +65,6 @@ import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useDateLocale } from "@/hooks/use-date-locale";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { isReplayCamera } from "@/utils/cameraUtil"; import { isReplayCamera } from "@/utils/cameraUtil";
import { isValidIconName } from "@/utils/iconUtil"; import { isValidIconName } from "@/utils/iconUtil";
@@ -443,7 +441,6 @@ export function ExportContent({
}: ExportContentProps) { }: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const locale = useDateLocale();
const [selectedOption, setSelectedOption] = useState<ExportOption>("1"); const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null); const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@@ -666,25 +663,12 @@ export function ExportContent({
]); ]);
const selectedCameraCount = selectedCameraIds.length; const selectedCameraCount = selectedCameraIds.length;
const batchRangeError = !range
? t("export.toast.error.noValidTimeSelected")
: range.before <= range.after
? t("export.toast.error.endTimeMustAfterStartTime")
: undefined;
const batchCameraError =
!isEventsLoading && selectedCameraCount === 0
? t("export.multiCamera.noCamerasSelected")
: undefined;
const batchCaseNameError =
isAdmin && batchCaseSelection === "new" && newCaseName.trim().length === 0
? t("export.case.nameRequired")
: undefined;
const canStartBatchExport = const canStartBatchExport =
!batchRangeError && Boolean(range && range.before > range.after) &&
!batchCaseNameError &&
selectedCameraCount > 0 && selectedCameraCount > 0 &&
!isStartingBatchExport; !isStartingBatchExport &&
(batchCaseSelection !== "new" || newCaseName.trim().length > 0) &&
batchCaseSelection.length > 0;
const onSelectTime = useCallback( const onSelectTime = useCallback(
(option: ExportOption) => { (option: ExportOption) => {
@@ -981,17 +965,6 @@ export function ExportContent({
if (tab === "multi") { if (tab === "multi") {
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS)); setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
setBatchCaseSelection(selectedCaseId ?? "new"); setBatchCaseSelection(selectedCaseId ?? "new");
if (newCaseName.trim().length === 0) {
setNewCaseName(
t("export.multiCamera.caseNameDefault", {
date: formatUnixTimestampToDateTime(Date.now() / 1000, {
date_style: "medium",
time_style: "short",
locale,
}),
}),
);
}
} else { } else {
onSelectTime(selectedOption); onSelectTime(selectedOption);
} }
@@ -1159,11 +1132,6 @@ export function ExportContent({
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
{batchRangeError && (
<p className="text-sm font-medium text-destructive">
{batchRangeError}
</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -1347,11 +1315,6 @@ export function ExportContent({
); );
})} })}
</div> </div>
{batchCameraError && (
<p className="text-sm font-medium text-destructive">
{batchCameraError}
</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -1404,11 +1367,6 @@ export function ExportContent({
value={newCaseName} value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)} onChange={(event) => setNewCaseName(event.target.value)}
/> />
{batchCaseNameError && (
<p className="text-sm font-medium text-destructive">
{batchCaseNameError}
</p>
)}
<Textarea <Textarea
placeholder={t("export.case.newCaseDescriptionPlaceholder")} placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription} value={newCaseDescription}
@@ -68,7 +68,9 @@ export default function MultiExportDialog({
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// Only admins can assign exports to a case, so non-admins skip the case list // Only admins can attach exports to an existing case (enforced server-side
// by POST /exports/batch). Skip fetching the case list entirely for
// non-admins — they can only ever use the "Create new case" branch.
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null); const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -282,6 +284,9 @@ export default function MultiExportDialog({
t, t,
]); ]);
// New-case inputs: rendered below the Select when caseSelection === "new",
// or rendered standalone for non-admins (who never see the Select since
// they cannot attach to an existing case).
const newCaseInputs = ( const newCaseInputs = (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<Input <Input
+2 -6
View File
@@ -332,9 +332,7 @@ function PreviewVideoPlayer({
{t("noPreviewFoundFor", { cameraName: cameraName })} {t("noPreviewFoundFor", { cameraName: cameraName })}
</div> </div>
)} )}
{firstLoad && ( {firstLoad && <Skeleton className="absolute aspect-video size-full" />}
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
)}
</div> </div>
); );
} }
@@ -557,9 +555,7 @@ function PreviewFramesPlayer({
{t("noPreviewFoundFor", { cameraName: cameraName })} {t("noPreviewFoundFor", { cameraName: cameraName })}
</div> </div>
)} )}
{firstLoad && ( {firstLoad && <Skeleton className="absolute aspect-video size-full" />}
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
)}
</div> </div>
); );
} }
+7 -7
View File
@@ -200,13 +200,13 @@ export default function ZoneEditPane({
), ),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
inertia: z.coerce inertia: z.coerce
.number<number>() .number()
.min(1, { .min(1, {
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"), message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
}) })
.or(z.literal("")), .or(z.literal("")),
loitering_time: z.coerce loitering_time: z.coerce
.number<number>() .number()
.min(0, { .min(0, {
message: t( message: t(
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero", "masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
@@ -222,35 +222,35 @@ export default function ZoneEditPane({
review_detections: z.boolean().default(false).optional(), review_detections: z.boolean().default(false).optional(),
speedEstimation: z.boolean().default(false), speedEstimation: z.boolean().default(false),
lineA: z.coerce lineA: z.coerce
.number<number>() .number()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
lineB: z.coerce lineB: z.coerce
.number<number>() .number()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
lineC: z.coerce lineC: z.coerce
.number<number>() .number()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
lineD: z.coerce lineD: z.coerce
.number<number>() .number()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.distance.error.text"), message: t("masksAndZones.form.distance.error.text"),
}) })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
speed_threshold: z.coerce speed_threshold: z.coerce
.number<number>() .number()
.min(0.1, { .min(0.1, {
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"), message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
}) })
@@ -81,14 +81,7 @@ export default function Step1NameCamera({
username: z.string().optional(), username: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
onvifPort: z.coerce onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
.number<number>({
message: t("cameraWizard.step1.errors.onvifPortInvalid"),
})
.int(t("cameraWizard.step1.errors.onvifPortInvalid"))
.min(1, t("cameraWizard.step1.errors.onvifPortInvalid"))
.max(65535, t("cameraWizard.step1.errors.onvifPortInvalid"))
.optional(),
useDigestAuth: z.boolean().optional(), useDigestAuth: z.boolean().optional(),
customUrl: z customUrl: z
.string() .string()