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/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index 4728f76bf..a0e4458e2 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -190,7 +190,7 @@ There are improved capabilities in newer GPU architectures that TensorRT can ben #### Compatibility References: -[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt/archives/tensorrt-841/support-matrix/index.html) +[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt-rtx/latest/getting-started/support-matrix.html) [NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html) 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/api/event.py b/frigate/api/event.py index ea5cfb29c..c03cfb431 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -69,6 +69,25 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.events]) +def _build_attribute_filter_clause(attributes: str): + filtered_attributes = [ + attr.strip() for attr in attributes.split(",") if attr.strip() + ] + attribute_clauses = [] + + for attr in filtered_attributes: + attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*') + + escaped_attr = json.dumps(attr, ensure_ascii=True)[1:-1] + if escaped_attr != attr: + attribute_clauses.append(Event.data.cast("text") % f'*:"{escaped_attr}"*') + + if not attribute_clauses: + return None + + return reduce(operator.or_, attribute_clauses) + + @router.get( "/events", response_model=list[EventResponse], @@ -193,14 +212,9 @@ def events( if attributes != "all": # Custom classification results are stored as data[model_name] = result_value - filtered_attributes = attributes.split(",") - attribute_clauses = [] - - for attr in filtered_attributes: - attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*') - - attribute_clause = reduce(operator.or_, attribute_clauses) - clauses.append(attribute_clause) + attribute_clause = _build_attribute_filter_clause(attributes) + if attribute_clause is not None: + clauses.append(attribute_clause) if recognized_license_plate != "all": filtered_recognized_license_plates = recognized_license_plate.split(",") @@ -508,7 +522,7 @@ def events_search( cameras = params.cameras labels = params.labels sub_labels = params.sub_labels - attributes = params.attributes + attributes = unquote(params.attributes) zones = params.zones after = params.after before = params.before @@ -607,13 +621,9 @@ def events_search( if attributes != "all": # Custom classification results are stored as data[model_name] = result_value - filtered_attributes = attributes.split(",") - attribute_clauses = [] - - for attr in filtered_attributes: - attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*') - - event_filters.append(reduce(operator.or_, attribute_clauses)) + attribute_clause = _build_attribute_filter_clause(attributes) + if attribute_clause is not None: + event_filters.append(attribute_clause) if zones != "all": zone_clauses = [] diff --git a/frigate/data_processing/real_time/custom_classification.py b/frigate/data_processing/real_time/custom_classification.py index 5e35c6899..229383d9f 100644 --- a/frigate/data_processing/real_time/custom_classification.py +++ b/frigate/data_processing/real_time/custom_classification.py @@ -658,6 +658,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): def handle_request(self, topic, request_data): if topic == EmbeddingsRequestEnum.reload_classification_model.value: if request_data.get("model_name") == self.model_config.name: + self.__build_detector() logger.info( f"Successfully loaded updated model for {self.model_config.name}" ) diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 67d8cf2e8..f6ab777c1 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -6,6 +6,7 @@ from typing import Dict from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber from frigate.config import FrigateConfig +from frigate.config.classification import ObjectClassificationType from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event from frigate.util.builtin import to_relative_box @@ -247,6 +248,18 @@ class EventProcessor(threading.Thread): "recognized_license_plate" ][1] + # only overwrite attribute-type custom model fields in the database if they're set + for name, model_config in self.config.classification.custom.items(): + if ( + model_config.object_config + and model_config.object_config.classification_type + == ObjectClassificationType.attribute + ): + value = event_data.get(name) + if value is not None: + event[Event.data][name] = value[0] + event[Event.data][f"{name}_score"] = value[1] + ( Event.insert(event) .on_conflict( diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index fc895fabf..bc7f388e1 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -168,6 +168,57 @@ class TestHttpApp(BaseTestHttp): assert events[0]["id"] == id assert events[1]["id"] == id2 + def test_get_event_list_match_multilingual_attribute(self): + event_id = "123456.zh" + attribute = "中文标签" + + with AuthTestClient(self.app) as client: + super().insert_mock_event(event_id, data={"custom_attr": attribute}) + + events = client.get("/events", params={"attributes": attribute}).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + + events = client.get( + "/events", params={"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE"} + ).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + + def test_events_search_match_multilingual_attribute(self): + event_id = "123456.zh.search" + attribute = "中文标签" + mock_embeddings = Mock() + mock_embeddings.search_thumbnail.return_value = [(event_id, 0.05)] + + self.app.frigate_config.semantic_search.enabled = True + self.app.embeddings = mock_embeddings + + with AuthTestClient(self.app) as client: + super().insert_mock_event(event_id, data={"custom_attr": attribute}) + + events = client.get( + "/events/search", + params={ + "search_type": "similarity", + "event_id": event_id, + "attributes": attribute, + }, + ).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + + events = client.get( + "/events/search", + params={ + "search_type": "similarity", + "event_id": event_id, + "attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE", + }, + ).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + def test_get_good_event(self): id = "123456.random" 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/track/object_processing.py b/frigate/track/object_processing.py index e0ee74228..a2638e5a5 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -33,6 +33,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, ) +from frigate.config.classification import ObjectClassificationType from frigate.const import ( FAST_QUEUE_TIMEOUT, UPDATE_CAMERA_ACTIVITY, @@ -759,8 +760,16 @@ class TrackedObjectProcessor(threading.Thread): self.update_mqtt_motion(camera, frame_time, motion_boxes) + attribute_model_names = [ + name + for name, model_config in self.config.classification.custom.items() + if model_config.object_config + and model_config.object_config.classification_type + == ObjectClassificationType.attribute + ] tracked_objects = [ - o.to_dict() for o in camera_state.tracked_objects.values() + o.to_dict(attribute_model_names=attribute_model_names) + for o in camera_state.tracked_objects.values() ] # publish info on this frame diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 453798651..ffa4f51fb 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -376,7 +376,10 @@ class TrackedObject: ) return (thumb_update, significant_change, path_update, autotracker_update) - def to_dict(self) -> dict[str, Any]: + def to_dict( + self, + attribute_model_names: list[str] | None = None, + ) -> dict[str, Any]: event = { "id": self.obj_data["id"], "camera": self.camera_config.name, @@ -411,6 +414,11 @@ class TrackedObject: "path_data": self.path_data.copy(), "recognized_license_plate": self.obj_data.get("recognized_license_plate"), } + if attribute_model_names is not None: + for name in attribute_model_names: + value = self.obj_data.get(name) + if value is not None: + event[name] = value return event 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..63cda9d0b 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,35 @@ 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 || []), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter((item) => item !== undefined && !item.includes("-verified")) + .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..6b8b6bb52 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 = (