diff --git a/docs/docs/configuration/audio_detectors.md b/docs/docs/configuration/audio_detectors.md index 3bf57b1a7..245ce703c 100644 --- a/docs/docs/configuration/audio_detectors.md +++ b/docs/docs/configuration/audio_detectors.md @@ -75,7 +75,13 @@ audio: ### Audio Transcription -Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI’s open-source Whisper models via `faster-whisper`. To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features. +Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI’s open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background. + +Transcription accuracy also depends heavily on the quality of your camera's microphone and recording conditions. Many cameras use inexpensive microphones, and distance to the speaker, low audio bitrate, or background noise can significantly reduce transcription quality. If you need higher accuracy, more robust long-running queues, or large-scale automatic transcription, consider using the HTTP API in combination with an automation platform and a cloud transcription service. + +#### Configuration + +To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features. ```yaml audio_transcription: diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 1c1371f51..2ee43dd29 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -578,8 +578,14 @@ def create_user( return JSONResponse(content={"username": body.username}) -@router.delete("/users/{username}") -def delete_user(username: str): +@router.delete("/users/{username}", dependencies=[Depends(require_role(["admin"]))]) +def delete_user(request: Request, username: str): + # Prevent deletion of the built-in admin user + if username == "admin": + return JSONResponse( + content={"message": "Cannot delete admin user"}, status_code=403 + ) + User.delete_by_id(username) return JSONResponse(content={"success": True}) diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index fadc483c3..60788e504 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -12,6 +12,7 @@ from typing import Any import cv2 from peewee import DoesNotExist +from titlecase import titlecase from frigate.comms.embeddings_updater import EmbeddingsRequestEnum from frigate.comms.inter_process import InterProcessRequestor @@ -455,14 +456,14 @@ def run_analysis( for i, verified_label in enumerate(final_data["data"]["verified_objects"]): object_type = verified_label.replace("-verified", "").replace("_", " ") - name = sub_labels_list[i].replace("_", " ").title() + name = titlecase(sub_labels_list[i].replace("_", " ")) unified_objects.append(f"{name} ({object_type})") for label in objects_list: if "-verified" in label: continue elif label in labelmap_objects: - object_type = label.replace("_", " ").title() + object_type = titlecase(label.replace("_", " ")) if label in attribute_labels: unified_objects.append(f"{object_type} (delivery/service)") diff --git a/frigate/data_processing/real_time/custom_classification.py b/frigate/data_processing/real_time/custom_classification.py index b5e8b1e35..cae3087ff 100644 --- a/frigate/data_processing/real_time/custom_classification.py +++ b/frigate/data_processing/real_time/custom_classification.py @@ -405,9 +405,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): if obj_data.get("end_time") is not None: return - if obj_data.get("stationary"): - return - object_id = obj_data["id"] if ( diff --git a/web/package-lock.json b/web/package-lock.json index 371defaaa..8df3356c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4702,9 +4702,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001651", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", - "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "dev": true, "funding": [ { diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx index a4b80b2cb..4b6c41c7a 100644 --- a/web/src/components/mobile/MobilePage.tsx +++ b/web/src/components/mobile/MobilePage.tsx @@ -13,7 +13,7 @@ import { cn } from "@/lib/utils"; import { isPWA } from "@/utils/isPWA"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; -import { useLocation } from "react-router-dom"; +import { useHistoryBack } from "@/hooks/use-history-back"; const MobilePageContext = createContext<{ open: boolean; @@ -24,15 +24,16 @@ type MobilePageProps = { children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; + enableHistoryBack?: boolean; }; export function MobilePage({ children, open: controlledOpen, onOpenChange, + enableHistoryBack = true, }: MobilePageProps) { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); - const location = useLocation(); const open = controlledOpen ?? uncontrolledOpen; const setOpen = useCallback( @@ -46,33 +47,12 @@ export function MobilePage({ [onOpenChange, setUncontrolledOpen], ); - useEffect(() => { - let isActive = true; - - if (open && isActive) { - window.history.pushState({ isMobilePage: true }, "", location.pathname); - } - - const handlePopState = (event: PopStateEvent) => { - if (open && isActive) { - event.preventDefault(); - setOpen(false); - // Delay replaceState to ensure state updates are processed - setTimeout(() => { - if (isActive) { - window.history.replaceState(null, "", location.pathname); - } - }, 0); - } - }; - - window.addEventListener("popstate", handlePopState); - - return () => { - isActive = false; - window.removeEventListener("popstate", handlePopState); - }; - }, [open, setOpen, location.pathname]); + // Handle browser back button to close mobile page + useHistoryBack({ + enabled: enableHistoryBack, + open, + onClose: () => setOpen(false), + }); return ( diff --git a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx index e0ec6efa9..5782ba149 100644 --- a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx +++ b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx @@ -113,7 +113,12 @@ export function PlatformAwareSheet({ } return ( - + {trigger} diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 65a861012..f65adbff0 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useHistoryBack } from "@/hooks/use-history-back"; // Enhanced Dialog with History Support interface HistoryDialogProps extends DialogPrimitive.DialogProps { @@ -15,51 +16,28 @@ const Dialog = ({ ...props }: HistoryDialogProps) => { const [internalOpen, setInternalOpen] = React.useState(open || false); - const historyStateRef = React.useRef void; - }>(null); + // Sync internal state with controlled open prop React.useEffect(() => { if (open !== undefined) { setInternalOpen(open); } }, [open]); - React.useEffect(() => { - if (enableHistoryBack) { - if (internalOpen) { - window.history.pushState({ dialogOpen: true }, ""); + const handleOpenChange = React.useCallback( + (newOpen: boolean) => { + setInternalOpen(newOpen); + onOpenChange?.(newOpen); + }, + [onOpenChange], + ); - const listener = () => { - setInternalOpen(false); - if (onOpenChange) onOpenChange(false); - }; - - historyStateRef.current = { listener }; - window.addEventListener("popstate", listener); - - return () => { - if (internalOpen) { - window.removeEventListener("popstate", listener); - historyStateRef.current = null; - } - }; - } else if (historyStateRef.current) { - window.removeEventListener( - "popstate", - historyStateRef.current.listener, - ); - historyStateRef.current = null; - } - } - }, [enableHistoryBack, internalOpen, onOpenChange]); - - const handleOpenChange = (open: boolean) => { - setInternalOpen(open); - if (onOpenChange) { - onOpenChange(open); - } - }; + // Handle browser back button to close dialog + useHistoryBack({ + enabled: enableHistoryBack, + open: internalOpen, + onClose: () => handleOpenChange(false), + }); return ( { const [internalOpen, setInternalOpen] = React.useState(open || false); - const historyStateRef = React.useRef void; - }>(null); + // Sync internal state with controlled open prop React.useEffect(() => { if (open !== undefined) { setInternalOpen(open); } }, [open]); - React.useEffect(() => { - if (enableHistoryBack) { - if (internalOpen) { - window.history.pushState({ sheetOpen: true }, ""); + const handleOpenChange = React.useCallback( + (newOpen: boolean) => { + setInternalOpen(newOpen); + onOpenChange?.(newOpen); + }, + [onOpenChange], + ); - const listener = () => { - setInternalOpen(false); - if (onOpenChange) onOpenChange(false); - }; - - historyStateRef.current = { listener }; - window.addEventListener("popstate", listener); - - return () => { - if (internalOpen) { - window.removeEventListener("popstate", listener); - historyStateRef.current = null; - } - }; - } else if (historyStateRef.current) { - window.removeEventListener( - "popstate", - historyStateRef.current.listener, - ); - historyStateRef.current = null; - } - } - }, [enableHistoryBack, internalOpen, onOpenChange]); - - const handleOpenChange = (open: boolean) => { - setInternalOpen(open); - if (onOpenChange) { - onOpenChange(open); - } - }; + // Handle browser back button to close sheet + useHistoryBack({ + enabled: enableHistoryBack, + open: internalOpen, + onClose: () => handleOpenChange(false), + }); return ( void; +} + +/** + * Hook that manages browser history for overlay components (dialogs, sheets, etc.) + * When enabled, pressing the browser back button will close the overlay instead of navigating away. + */ +export function useHistoryBack({ + enabled, + open, + onClose, +}: UseHistoryBackOptions): void { + const historyPushedRef = React.useRef(false); + const closedByBackRef = React.useRef(false); + + // Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes + const onCloseRef = React.useRef(onClose); + React.useLayoutEffect(() => { + onCloseRef.current = onClose; + }); + + React.useEffect(() => { + if (!enabled) return; + + if (open) { + // Only push history state if we haven't already (prevents duplicates in strict mode) + if (!historyPushedRef.current) { + window.history.pushState({ overlayOpen: true }, ""); + historyPushedRef.current = true; + } + + const handlePopState = () => { + closedByBackRef.current = true; + historyPushedRef.current = false; + onCloseRef.current(); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + } else { + // Overlay is closing - clean up history if we pushed and it wasn't via back button + if (historyPushedRef.current && !closedByBackRef.current) { + window.history.back(); + } + historyPushedRef.current = false; + closedByBackRef.current = false; + } + }, [enabled, open]); +} diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index efeaa9be0..833d3887a 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -35,6 +35,7 @@ import { useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { getTranslatedLabel } from "@/utils/i18n"; +import { cn } from "@/lib/utils"; type MasksAndZoneViewProps = { selectedCamera: string; @@ -697,7 +698,10 @@ export default function MasksAndZonesView({
{cameraConfig && diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx index 1cdaa54f1..4bcd9cdc5 100644 --- a/web/src/views/settings/MotionTunerView.tsx +++ b/web/src/views/settings/MotionTunerView.tsx @@ -23,6 +23,8 @@ import { LuExternalLink } from "react-icons/lu"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { Trans, useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import { cn } from "@/lib/utils"; +import { isDesktop } from "react-device-detect"; type MotionTunerViewProps = { selectedCamera: string; @@ -325,7 +327,12 @@ export default function MotionTunerView({
{cameraConfig ? ( -
+
-
+
{!isSemanticSearchEnabled ? (
@@ -651,7 +657,7 @@ export default function TriggerView({
{/* Desktop Table View */} -
+