From 67e3f8eefa2d297f2cdc921e4de305190c98ffb6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:42:08 -0600 Subject: [PATCH] Miscellaneous fixes (0.17 beta) (#21934) * improve chip tooltip display - use formatList to use i18n separators instead of commas - ensure the correct event type is used so sublabels are not run through normalization - remove smart-capitalization classes as translated labels use i18n (which includes capitalization) - give icons an optional key so that the console doesn't complain about duplication when rendering * Add grace period for recording segment checks to prevent spurious ffmpeg restarts * add admin precedence to proxy role_map resolution to prevent downgrade * clean up * formatting * work around radix pointer events issue when dialog is opened from drawer fixes https://github.com/blakeblackshear/frigate/discussions/21940 * prevent console warnings about missing titles and descriptions make these invisible with sr-only * remove duplicate language * Adjust handling for device sizes * Cleanup --------- Co-authored-by: Nicolas Mowen --- docs/docs/configuration/authentication.md | 4 + frigate/api/auth.py | 15 +++- frigate/test/test_proxy_auth.py | 15 ++++ frigate/video.py | 24 +++++- web/public/locales/en/components/dialog.json | 1 + web/src/components/card/AnimatedEventCard.tsx | 39 ++++++--- web/src/components/card/ReviewCard.tsx | 37 ++++---- web/src/components/menu/GeneralSettings.tsx | 50 ++++++++++- .../overlay/dialog/RestartDialog.tsx | 26 +++++- web/src/components/player/LivePlayer.tsx | 29 ++++--- .../player/PreviewThumbnailPlayer.tsx | 53 ++++++------ web/src/lib/const.ts | 1 - web/src/utils/iconUtil.tsx | 85 ++++++++++--------- web/src/views/recording/RecordingView.tsx | 11 ++- 14 files changed, 268 insertions(+), 122 deletions(-) diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 474998263..70f756b68 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -166,6 +166,10 @@ In this example: - If no mapping matches, Frigate falls back to `default_role` if configured. - If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. +**Note on matching semantics:** + +- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups). + #### Port Considerations **Authenticated Port (8971)** diff --git a/frigate/api/auth.py b/frigate/api/auth.py index bfb3b81a1..e0a6ec924 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -439,10 +439,11 @@ def resolve_role( Determine the effective role for a request based on proxy headers and configuration. Order of resolution: - 1. If a role header is defined in proxy_config.header_map.role: - - If a role_map is configured, treat the header as group claims - (split by proxy_config.separator) and map to roles. - - If no role_map is configured, treat the header as role names directly. + 1. If a role header is defined in proxy_config.header_map.role: + - If a role_map is configured, treat the header as group claims + (split by proxy_config.separator) and map to roles. + Admin matches short-circuit to admin. + - If no role_map is configured, treat the header as role names directly. 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. Args: @@ -492,6 +493,12 @@ def resolve_role( } logger.debug("Matched roles from role_map: %s", matched_roles) + # If admin matches, prioritize it to avoid accidental downgrade when + # users belong to both admin and lower-privilege groups. + if "admin" in matched_roles and "admin" in config_roles: + logger.debug("Resolved role (with role_map) to 'admin'.") + return "admin" + if matched_roles: resolved = next( (r for r in config_roles if r in matched_roles), validated_default diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index 61955486a..2ffad957c 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase): role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "admin") + def test_role_map_or_matching(self): + config = self.proxy_config + config.header_map.role_map = { + "admin": ["group_admin", "group_privileged"], + } + + # OR semantics: a single matching group should map to the role + headers = {"x-remote-role": "group_admin"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + + headers = {"x-remote-role": "group_admin|group_privileged"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + def test_direct_role_header_with_separator(self): config = self.proxy_config config.header_map.role_map = None # disable role_map diff --git a/frigate/video.py b/frigate/video.py index 615c61d61..112844543 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread): self.latest_valid_segment_time: float = 0 self.latest_invalid_segment_time: float = 0 self.latest_cache_segment_time: float = 0 + self.record_enable_time: datetime | None = None def _update_enabled_state(self) -> bool: """Fetch the latest config and update enabled state.""" @@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread): def run(self) -> None: if self._update_enabled_state(): self.start_all_ffmpeg() + # If recording is enabled at startup, set the grace period timer + if self.config.record.enabled: + self.record_enable_time = datetime.now().astimezone(timezone.utc) time.sleep(self.sleeptime) while not self.stop_event.wait(self.sleeptime): @@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread): self.logger.debug(f"Enabling camera {self.config.name}") self.start_all_ffmpeg() - # reset all timestamps + # reset all timestamps and record the enable time for grace period self.latest_valid_segment_time = 0 self.latest_invalid_segment_time = 0 self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(timezone.utc) else: self.logger.debug(f"Disabling camera {self.config.name}") self.stop_all_ffmpeg() + self.record_enable_time = None # update camera status self.requestor.send_data( @@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread): if self.config.record.enabled and "record" in p["roles"]: now_utc = datetime.now().astimezone(timezone.utc) + # Check if we're within the grace period after enabling recording + # Grace period: 90 seconds allows time for ffmpeg to start and create first segment + in_grace_period = self.record_enable_time is not None and ( + now_utc - self.record_enable_time + ) < timedelta(seconds=90) + latest_cache_dt = ( datetime.fromtimestamp( self.latest_cache_segment_time, tz=timezone.utc @@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread): ) # ensure segments are still being created and that they have valid video data - cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120)) - valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120)) + # Skip checks during grace period to allow segments to start being created + cache_stale = not in_grace_period and now_utc > ( + latest_cache_dt + timedelta(seconds=120) + ) + valid_stale = not in_grace_period and now_utc > ( + latest_valid_dt + timedelta(seconds=120) + ) invalid_stale_condition = ( self.latest_invalid_segment_time > 0 + and not in_grace_period and now_utc > (latest_invalid_dt + timedelta(seconds=120)) and self.latest_valid_segment_time <= self.latest_invalid_segment_time diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index a56c2b1da..91ff38d82 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -1,6 +1,7 @@ { "restart": { "title": "Are you sure you want to restart Frigate?", + "description": "This will briefly stop Frigate while it restarts.", "button": "Restart", "restarting": { "title": "Frigate is Restarting", diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index c5da99aa2..b6e9ddf7c 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -19,6 +19,8 @@ import { Button } from "../ui/button"; import { FaCircleCheck } from "react-icons/fa6"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -50,26 +52,37 @@ export function AnimatedEventCard({ fetchPreviews: !currentHour, }); + const getEventType = useCallback( + (text: string) => { + if (event.data.sub_labels?.includes(text)) return "manual"; + if (event.data.audio.includes(text)) return "audio"; + return "object"; + }, + [event], + ); + const tooltipText = useMemo(() => { if (event?.data?.metadata?.title) { return event.data.metadata.title; } return ( - `${[ - ...new Set([ - ...(event.data.objects || []), - ...(event.data.sub_labels || []), - ...(event.data.audio || []), - ]), - ] - .filter((item) => item !== undefined && !item.includes("-verified")) - .map((text) => text.charAt(0).toUpperCase() + text.substring(1)) - .sort() - .join(", ") - .replaceAll("-verified", "")} ` + t("detected") + `${formatList( + [ + ...new Set([ + ...(event.data.objects || []).map((text) => + text.replace("-verified", ""), + ), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter((item) => item !== undefined) + .map((text) => getTranslatedLabel(text, getEventType(text))) + .sort(), + )} ` + t("detected") ); - }, [event, t]); + }, [event, getEventType, t]); // visibility diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index b5ba5cfea..89d79dee7 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -33,13 +33,14 @@ import axios from "axios"; import { toast } from "sonner"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { Button, buttonVariants } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { LuCircle } from "react-icons/lu"; import { MdAutoAwesome } from "react-icons/md"; import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type ReviewCardProps = { event: ReviewSegment; @@ -123,6 +124,12 @@ export default function ReviewCard({ } }, [bypassDialogRef, onDelete]); + const getEventType = (text: string) => { + if (event.data.sub_labels?.includes(text)) return "manual"; + if (event.data.audio.includes(text)) return "audio"; + return "object"; + }; + const content = (
- {[ - ...new Set([ - ...(event.data.objects || []), - ...(event.data.sub_labels || []), - ...(event.data.audio || []), - ]), - ] - .filter( - (item) => item !== undefined && !item.includes("-verified"), - ) - .map((text) => capitalizeFirstLetter(text)) - .sort() - .join(", ") - .replaceAll("-verified", "")} + {formatList( + [ + ...new Set([ + ...(event.data.objects || []).map((text) => + text.replace("-verified", ""), + ), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter((item) => item !== undefined) + .map((text) => getTranslatedLabel(text, getEventType(text))) + .sort(), + )} + {!isDesktop && ( + <> + + {t("menu.settings")} + + + {t("menu.settings")} + + + )}
{isMobile && (
@@ -355,6 +373,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { : "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl" } > + {!isDesktop && ( + <> + + {t("menu.languages")} + + + {t("menu.languages")} + + + )} {languages.map(({ code, label }) => ( + {!isDesktop && ( + <> + + {t("menu.darkMode.label")} + + + {t("menu.darkMode.label")} + + + )} + {!isDesktop && ( + <> + + {t("menu.theme.label")} + + + {t("menu.theme.label")} + + + )} {colorSchemes.map((scheme) => ( { + if (typeof document !== "undefined") { + document.body.style.pointerEvents = ""; + } + }; + useEffect(() => { setRestartDialogOpen(isOpen); }, [isOpen]); @@ -74,14 +81,25 @@ export default function RestartDialog({ <> { - setRestartDialogOpen(false); - onClose(); + onOpenChange={(open) => { + if (!open) { + setRestartDialogOpen(false); + onClose(); + clearBodyPointerEvents(); + } }} > - + { + event.preventDefault(); + clearBodyPointerEvents(); + }} + > {t("restart.title")} + + {t("restart.description")} + diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 1dd7a10a6..dbbc289c5 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -371,22 +371,23 @@ export default function LivePlayer({
- + {formatList( [ - ...new Set([ - ...(objects || []).map(({ label, sub_label }) => - label.endsWith("verified") - ? sub_label - : label.replaceAll("_", " "), - ), - ]), - ] - .filter((label) => label?.includes("-verified") == false) - .map((label) => - getTranslatedLabel(label.replace("-verified", "")), - ) - .sort(), + ...new Set( + (objects || []) + .map(({ label, sub_label }) => { + const isManual = label.endsWith("verified"); + const text = isManual ? sub_label : label; + const type = isManual ? "manual" : "object"; + return getTranslatedLabel(text, type); + }) + .filter( + (translated) => + translated && !translated.includes("-verified"), + ), + ), + ].sort(), )} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 42a00b86c..d612a1566 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next"; import { FaExclamationTriangle } from "react-icons/fa"; import { MdOutlinePersonSearch } from "react-icons/md"; import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type PreviewPlayerProps = { review: ReviewSegment; @@ -182,9 +183,8 @@ export default function PreviewThumbnailPlayer({ ); const getEventType = (text: string) => { - if (review.data.objects.includes(text)) return "object"; - if (review.data.audio.includes(text)) return "audio"; if (review.data.sub_labels?.includes(text)) return "manual"; + if (review.data.audio.includes(text)) return "audio"; return "object"; }; @@ -268,13 +268,16 @@ export default function PreviewThumbnailPlayer({ className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`} onClick={() => onClick(review, false, true)} > - {review.data.objects.sort().map((object) => { - return getIconForLabel( - object, - "object", - "size-3 text-white", - ); - })} + {review.data.objects + .sort() + .map((object, idx) => + getIconForLabel( + object, + "object", + "size-3 text-white", + `${object}-${idx}`, + ), + )} {review.data.audio.map((audio) => { return getIconForLabel( audio, @@ -288,23 +291,25 @@ export default function PreviewThumbnailPlayer({
- + {review.data.metadata ? review.data.metadata.title - : [ - ...new Set([ - ...(review.data.objects || []), - ...(review.data.sub_labels || []), - ...(review.data.audio || []), - ]), - ] - .filter( - (item) => - item !== undefined && !item.includes("-verified"), - ) - .map((text) => getTranslatedLabel(text, getEventType(text))) - .sort() - .join(", ")} + : formatList( + [ + ...new Set([ + ...(review.data.objects || []).map((text) => + text.replace("-verified", ""), + ), + ...(review.data.sub_labels || []), + ...(review.data.audio || []), + ]), + ] + .filter((item) => item !== undefined) + .map((text) => + getTranslatedLabel(text, getEventType(text)), + ) + .sort(), + )} {!!( diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index df95cbe1e..55515f2ae 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -26,6 +26,5 @@ export const supportedLanguageKeys = [ "lt", "uk", "cs", - "sk", "hu", ]; diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index 8ddf3ea08..04324aabe 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -62,83 +62,86 @@ export function getIconForLabel( label: string, type: EventType = "object", className?: string, + key?: string, ) { + const iconKey = key || label; + if (label.endsWith("-verified")) { - return getVerifiedIcon(label, className, type); + return getVerifiedIcon(label, className, type, iconKey); } else if (label.endsWith("-plate")) { - return getRecognizedPlateIcon(label, className, type); + return getRecognizedPlateIcon(label, className, type, iconKey); } switch (label) { // objects case "bear": - return ; + return ; case "bicycle": - return ; + return ; case "bird": - return ; + return ; case "boat": - return ; + return ; case "bus": case "school_bus": - return ; + return ; case "car": case "vehicle": - return ; + return ; case "cat": - return ; + return ; case "deer": - return ; + return ; case "animal": case "bark": case "dog": - return ; + return ; case "fox": - return ; + return ; case "goat": - return ; + return ; case "horse": - return ; + return ; case "kangaroo": - return ; + return ; case "license_plate": - return ; + return ; case "motorcycle": - return ; + return ; case "mouse": - return ; + return ; case "package": - return ; + return ; case "person": - return ; + return ; case "rabbit": - return ; + return ; case "raccoon": - return ; + return ; case "robot_lawnmower": - return ; + return ; case "sports_ball": - return ; + return ; case "skunk": - return ; + return ; case "squirrel": - return ; + return ; case "umbrella": - return ; + return ; case "waste_bin": - return ; + return ; // audio case "crying": case "laughter": case "scream": case "speech": case "yell": - return ; + return ; case "fire_alarm": - return ; + return ; // sub labels case "amazon": - return ; + return ; case "an_post": case "canada_post": case "dpd": @@ -148,20 +151,20 @@ export function getIconForLabel( case "postnord": case "purolator": case "royal_mail": - return ; + return ; case "dhl": - return ; + return ; case "fedex": - return ; + return ; case "ups": - return ; + return ; case "usps": - return ; + return ; default: if (type === "audio") { - return ; + return ; } - return ; + return ; } } @@ -169,11 +172,12 @@ function getVerifiedIcon( label: string, className?: string, type: EventType = "object", + key?: string, ) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return ( -
+
{getIconForLabel(simpleLabel, type, className)}
@@ -184,11 +188,12 @@ function getRecognizedPlateIcon( label: string, className?: string, type: EventType = "object", + key?: string, ) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return ( -
+
{getIconForLabel(simpleLabel, type, className)}
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index aee3d09da..75463b1fd 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -33,7 +33,12 @@ import { useRef, useState, } from "react"; -import { isDesktop, isMobile } from "react-device-detect"; +import { + isDesktop, + isMobile, + isMobileOnly, + isTablet, +} from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; @@ -738,7 +743,7 @@ export function RecordingView({ aspectRatio: getCameraAspect(mainCamera), }} > - {isDesktop && ( + {(isDesktop || isTablet) && ( - {isMobile && timelineType == "timeline" && ( + {isMobileOnly && timelineType == "timeline" && (