Compare commits

..
Author SHA1 Message Date
Josh HawkinsandGitHub fd98977506 Categorize manual events as alerts when their label is an alert label (#23981)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* Categorize manual events as alerts when their label is an alert label

* tweak docs
2026-08-13 11:16:02 -06:00
LarosenandGitHub 6816050a46 fix(audio): correct sodeling typo to yodeling (#23946)
* fix(audio): correct sodeling typo to yodeling

Fixes a typo in audio-labelmap.txt where the yodeling class was
misspelled as "sodeling".

* fix(i18n): remove duplicate sodeling key in en audio.json

The en audio.json already contains a correct "yodeling" key. Remove
the duplicate/misspelled "sodeling" entry to avoid ambiguity.
2026-08-13 07:02:38 -05:00
Josh HawkinsandGitHub c70a0802b8 filter dedicated LPR plates before creating the event (#23977) 2026-08-13 05:44:31 -06:00
Josh HawkinsandGitHub aff9799451 Don't require a restart to enable GenAI descriptions (#23964)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* create GenAI post processors when a camera enables GenAI at runtime

* fix types
2026-08-12 08:55:34 -05:00
Josh HawkinsandGitHub c75611b4df Multi-export UI fixes (#23959)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* multi export fixes

* i18n

* new tests
2026-08-11 10:11:47 -06:00
Josh HawkinsandGitHub 0735a8ac75 Docs updates (#23947)
* misc docs updates

* add warning about proxies to 5000 for notifications
2026-08-10 15:54:41 -06:00
Josh HawkinsandGitHub 2599795ab0 add faq to notifications docs (#23939) 2026-08-08 11:12:13 -06:00
28 changed files with 972 additions and 200 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ yell
sigh
singing
choir
sodeling
yodeling
chant
mantra
child_singing
+25
View File
@@ -50,6 +50,31 @@ Connect each stream to get a live preview, an estimated bandwidth figure, and a
Other features, including [hardware acceleration](hardware_acceleration_video.md), [two way talk](/configuration/live#two-way-talk), and audio transcoding, is configured after the camera has been added. For camera model specific quirks, see the [camera specific](camera_specific.md) docs.
## Deleting a camera
Click **Delete Camera** in <NavPath path="Settings > Global configuration > Camera management" />, choose the camera, and confirm. Deleting a camera requires the `admin` role and cannot be undone.
:::warning
Deleting a camera permanently removes its recordings, tracked objects, and configuration. If you only want to stop processing a camera, set its state to **Off** or **Disabled** in <NavPath path="Settings > Global configuration > Camera management" /> instead. See [camera state](/configuration/live#camera-state).
:::
Deleting a camera removes:
- The camera's section of your config file, along with its entries in any [role](authentication.md#user-roles) camera list. A custom role left with no cameras is removed as well.
- Every database record for the camera: tracked objects, review items, recordings, previews, timeline entries, the saved region grid, and [triggers](semantic_search.md#triggers).
- Every media file for the camera: recordings, snapshots, thumbnails, and preview clips.
[Exports](/usage/exports) are kept by default, so saved footage survives the deletion of the camera it came from. Turn on **Also delete exports for this camera** in the confirmation step to remove those too.
The camera's processes are stopped and the change takes effect immediately, so no restart is required. If the resulting config cannot be parsed, Frigate restores the previous config and reports an error instead of leaving Frigate in a broken state.
Two things are not cleaned up for you:
- **go2rtc streams.** Frigate makes a best effort to stop a running [go2rtc](go2rtc.md) stream named after the camera, but stream entries in your config file remain and are recreated on the next restart. Remove them in <NavPath path="Settings > System > go2rtc streams" /> or in your config file.
- **Camera groups.** A deleted camera stays listed in any [camera group](#setting-up-camera-groups) that referenced it. The group skips the missing camera, so this is harmless, but you can edit the group to drop the stale entry.
## Setting Up Camera Inputs
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
+68 -2
View File
@@ -6,6 +6,7 @@ title: Notifications
import ConfigTabs from "@site/src/components/ConfigTabs";
import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FaqItem from "@site/src/components/FaqItem";
# Notifications
@@ -21,7 +22,7 @@ Push notifications require internet access from the Frigate server to the browse
In order to use notifications the following requirements must be met:
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
- Frigate must be accessed via a secure `https` connection while signed in as a Frigate user ([see the authorization docs](/configuration/authentication)).
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
- In order for notifications to be usable externally, Frigate must be accessible externally.
- For iOS devices, some users have also indicated that the Notifications switch needs to be enabled in iOS Settings --> Apps --> Safari --> Advanced --> Features.
@@ -85,7 +86,13 @@ cameras:
### Registration
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
Once notifications are enabled, press the `Register This Device` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
:::warning
Each registration is attached to the Frigate user account you are signed in as, so you must register over a secure connection to the authenticated port (`8971`). Reverse proxies and tunnels should point at port `8971`.
:::
## Supported Notifications
@@ -104,3 +111,62 @@ Different platforms handle notifications differently, some settings changes may
### Android
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
## Notifications FAQ
<FaqItem id="how-do-i-debug-notifications-issues" question="How do I debug notifications issues?">
Push notifications involve Frigate, your browser, and your browser vendor's push service, so it helps to work from the server outward.
1. Enable debug logs for the push client by adding `frigate.comms.webpush: debug` to your `logger` configuration. Restart Frigate after this change.
```yaml
logger:
default: info
logs:
# highlight-next-line
frigate.comms.webpush: debug
```
These logs show exactly where a notification stopped, including:
- `Email must be provided for push notifications to be sent` means the global `email` field is empty and nothing will ever be sent.
- `Sending test notification` and `Sending push notification for <camera>, review ID <id>` mean Frigate handed the message off to the push service.
- `Skipping notification for <camera> - in global cooldown period` (or `camera-specific cooldown period`) means your [cooldown](#configuration) values suppressed it.
- `Notifications for <camera> are currently suspended` means notifications were suspended from <NavPath path="Settings > Notifications" /> or MQTT.
- `Notification endpoint expired for <user>, received 410` means that device's subscription is no longer valid and it must be re-registered.
- `Failed to send notification to <user> :: <status>` means the push service rejected the message. A `401` or `403` usually points at a VAPID or `email` problem, and a `5xx` is a problem on the push service's end.
- If you see no messages at all when an alert occurs, the notification was never queued. Confirm an actual **alert** was created (notifications are not sent for detections), and that notifications are enabled both globally and for that camera.
2. Verify the basics that most reports come down to:
- Frigate must be reached over `https` with a certificate your device trusts. Browsers silently refuse to register a service worker otherwise, and a self-signed certificate that is not installed as trusted on the device will fail.
- On iOS, notifications only work when Frigate has been installed to the Home Screen via **Share > Add to Home Screen** and opened from that icon. Safari and Chrome tabs cannot receive web push on iOS.
- Each device must be registered individually, and Frigate must be restarted after registering before anything can be sent, including test notifications.
- The Frigate server needs outbound internet access to the browser vendor's push service. See [Network Requirements](/frigate/network_requirements#push-notifications).
3. Test from the UI. Use the `Send a test notification` button in <NavPath path="Settings > Notifications" />. If the log shows `Sending test notification` but nothing arrives on the device, the problem is between the push service and your device rather than in Frigate.
4. Check the browser side on the device that is not receiving notifications:
- Confirm the site's notification permission is set to **Allow** in your browser or OS settings, and that a focus/do not disturb mode is not hiding them.
- In desktop browsers, open Developer Tools > Application > Service Workers and confirm `notifications-worker.js` is registered and activated. Unregistering it and registering the device again will rebuild a broken subscription.
- Check the browser console and your reverse proxy logs for failures loading `/notifications-worker.js` or errors on `/api/notifications/register`.
</FaqItem>
<FaqItem id="why-did-notifications-stop-arriving-after-working-for-a-while" question="Why did notifications stop arriving after working for a while?">
Push subscriptions are issued by the browser vendor and can be revoked, most often after a browser update, after clearing site data, or when a device has been offline for an extended period. When this happens the device still appears registered in Frigate, but the push service rejects the message. The debug logs will show `Notification endpoint expired` with a `404` or `410` status.
Unregister and re-register the affected device from <NavPath path="Settings > Notifications" />, then restart Frigate.
</FaqItem>
<FaqItem id="why-am-i-not-getting-notifications-for-one-specific-camera" question="Why am I not getting notifications for one specific camera?">
Work through these in order:
- Notifications are only sent for **alerts**. If the camera is producing detections instead, adjust the camera's `review > alerts > labels` so the objects you care about are classified as alerts.
- Confirm notifications are enabled for that camera in <NavPath path="Settings > Camera configuration > Notifications" />.
- Check the camera's `cooldown` value, and remember that the global cooldown applies across all cameras. A busy camera can consume the global cooldown and suppress a quieter one.
- If [authentication](/configuration/authentication) is enabled with roles, users only receive notifications for the cameras their role grants access to.
</FaqItem>
+25
View File
@@ -121,6 +121,31 @@ cameras:
</TabItem>
</ConfigTabs>
## Categorizing manual events
Events created with the [create manual event API](../integrations/api/create-event-events-camera-name-label-create-post.api.mdx) are categorized with the same label lists, using the label from the request path:
1. If alerts are enabled and the label is listed in `review -> alerts -> labels`, the review item is an alert.
2. Otherwise, if detections are enabled and the label is listed in `review -> detections -> labels`, the review item is a detection.
3. If the label is in neither list, the review item is an alert, or no review item is created if alerts are disabled.
This means manual events are alerts unless you explicitly list their label as a detection label. For example, to have PIR sensors create detections instead of alerts, post to `/api/events/front_door/pir_sensor/create` with the following config:
```yaml {5-7}
cameras:
front_door:
review:
detections:
labels:
- pir_sensor
```
:::note
Required zones do not apply to manual events, since they are created through the API rather than by the object tracker. Setting `review -> alerts -> labels` to an empty list also does not stop manual events from becoming alerts, as a label in neither list still falls back to an alert.
:::
## Restricting review items to specific zones
By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones)
+2
View File
@@ -308,6 +308,8 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
- `offline`: Stream is offline and is being restarted
- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction.
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
### `frigate/<camera_name>/<object_name>`
Publishes the count of objects for the camera for use as a sensor in Home Assistant.
+10 -2
View File
@@ -65,9 +65,17 @@ This is because Frigate does not run in host mode so localhost points to the Fri
### How do I know if my camera is offline
A camera being offline can be detected via MQTT or /api/stats, the camera_fps for any offline camera will be 0.
Frigate publishes a per-role health status to [`frigate/<camera_name>/status/<role>`](/integrations/mqtt#frigatecamera_namestatusrole), where `<role>` is each enabled role on the camera (`detect`, `record`, and `audio`). The published value is one of:
Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline.
- `online`: Frigate's process for that role is running normally
- `offline`: the process is down and Frigate is restarting it
- `disabled`: the camera is turned off, either at runtime or in the configuration file
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
Because the status is per role, a camera whose substream is fine but whose recording stream has dropped will report `online` for `detect` and `offline` for `record`. The status is republished whenever it changes.
You can also detect an offline camera through `/api/stats`, where `camera_fps` will be 0.
### How can I view the Frigate log files without using the Web UI?
+1
View File
@@ -5093,6 +5093,7 @@ paths:
NOTES:
- Creating a manual event does not trigger an update to /events MQTT topic.
- If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end.
- The review item is an alert unless the label is listed in the camera's review -> detections -> labels config.
operationId: create_event_events__camera_name___label__create_post
parameters:
- name: camera_name
+1
View File
@@ -1748,6 +1748,7 @@ async def delete_events(request: Request, body: EventsDeleteBody):
NOTES:
- Creating a manual event does not trigger an update to /events MQTT topic.
- If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end.
- The review item is an alert unless the label is listed in the camera's review -> detections -> labels config.
""",
)
def create_event(
+3 -15
View File
@@ -103,21 +103,9 @@ class FrigateApp:
self.detection_shms: list[mp.shared_memory.SharedMemory] = []
self.log_queue: Queue = mp.Queue()
self.camera_metrics: DictProxy = self.metrics_manager.dict()
self.embeddings_metrics: DataProcessorMetrics | None = (
DataProcessorMetrics(
self.metrics_manager, list(config.classification.custom.keys())
)
if (
config.semantic_search.enabled
or any(
c.objects.genai.enabled or c.review.genai.enabled
for c in config.cameras.values()
)
or config.lpr.enabled
or config.face_recognition.enabled
or len(config.classification.custom) > 0
)
else None
self.embeddings_metrics = DataProcessorMetrics(
self.metrics_manager, list(config.classification.custom.keys())
)
self.ptz_metrics: dict[str, PTZMetrics] = {}
self.processes: dict[str, int] = {}
@@ -1172,6 +1172,28 @@ class LicensePlateProcessingMixin:
return rep["plate"], rep["conf"], rep["char_confidences"], rep["area"]
def _passes_plate_filters(self, camera: str, plate: str) -> bool:
"""Check a plate against the configured length and format filters."""
if len(plate) < self.lpr_config.min_plate_length:
logger.debug(
f"{camera}: Filtered out plate '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})"
)
return False
if self.lpr_config.format:
try:
if not re.fullmatch(self.lpr_config.format, plate):
logger.debug(
f"{camera}: Filtered out plate '{plate}' due to format mismatch"
)
return False
except re.error:
logger.error(
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
)
return True
def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str:
"""Generate a unique ID for a plate event based on camera and text."""
now = datetime.datetime.now().timestamp()
@@ -1511,10 +1533,14 @@ class LicensePlateProcessingMixin:
plate_id = None
for existing_id, data in self.detected_license_plates.items():
# entries from the object pipeline on this camera have no
# last_seen until they pass the filters below
last_seen = data.get("last_seen")
if (
data["camera"] == camera
and data["last_seen"] is not None
and current_time - data["last_seen"]
and last_seen is not None
and current_time - last_seen
<= self.config.cameras[camera].lpr.expire_time
):
similarity = JaroWinkler.similarity(data["plate"], top_plate)
@@ -1525,6 +1551,11 @@ class LicensePlateProcessingMixin:
)
break
if plate_id is None:
# the event id doubles as the cluster key, so a plate rejected
# after this point would leave an entry that never expires
if not self._passes_plate_filters(camera, top_plate):
return
plate_id = self._generate_plate_event(camera, top_plate, avg_confidence)
logger.debug(
f"{camera}: New plate event for dedicated LPR camera {plate_id}: {top_plate}"
@@ -1569,27 +1600,12 @@ class LicensePlateProcessingMixin:
f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})"
)
# Apply length and format filters to the clustered representative
# rather than individual OCR readings, so noisy variants still
# contribute to clustering even when they don't pass on their own.
if len(rep_plate) < self.lpr_config.min_plate_length:
logger.debug(
f"{camera}: Filtered out clustered plate '{rep_plate}' due to length ({len(rep_plate)} < {self.lpr_config.min_plate_length})"
)
# filter the clustered representative rather than individual OCR
# readings, so noisy variants still contribute to clustering even
# when they don't pass on their own
if not self._passes_plate_filters(camera, rep_plate):
return
if self.lpr_config.format:
try:
if not re.fullmatch(self.lpr_config.format, rep_plate):
logger.debug(
f"{camera}: Filtered out clustered plate '{rep_plate}' due to format mismatch"
)
return
except re.error:
logger.error(
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
)
# Update stored rep
self.detected_license_plates[id].update(
{
@@ -63,8 +63,10 @@ class ObjectDescriptionProcessor(PostProcessorApi):
"""Handle an update to a frame for an object."""
camera_config = self.config.cameras[camera]
# no need to save our own thumbnails if genai is not enabled
# or if the object has become stationary
if not camera_config.objects.genai.enabled:
return
# no need to save our own thumbnails if the object has become stationary
if not data["stationary"]:
if data["id"] not in self.tracked_events:
self.tracked_events[data["id"]] = []
+1 -1
View File
@@ -33,7 +33,7 @@ class EmbeddingProcess(FrigateProcess):
def __init__(
self,
config: FrigateConfig,
metrics: DataProcessorMetrics | None,
metrics: DataProcessorMetrics,
stop_event: MpEvent,
) -> None:
super().__init__(
+62 -20
View File
@@ -78,6 +78,16 @@ logger = logging.getLogger(__name__)
MAX_THUMBNAILS = 10
GENAI_UPDATE_TOPICS = frozenset(
{
CameraConfigUpdateEnum.add.name,
CameraConfigUpdateEnum.objects.name,
CameraConfigUpdateEnum.object_genai.name,
CameraConfigUpdateEnum.review.name,
CameraConfigUpdateEnum.review_genai.name,
}
)
class EmbeddingMaintainer(threading.Thread):
"""Handle embedding queue and post event updates."""
@@ -85,7 +95,7 @@ class EmbeddingMaintainer(threading.Thread):
def __init__(
self,
config: FrigateConfig,
metrics: DataProcessorMetrics | None,
metrics: DataProcessorMetrics,
stop_event: MpEvent,
) -> None:
super().__init__(name="embeddings_maintainer")
@@ -220,16 +230,6 @@ class EmbeddingMaintainer(threading.Thread):
# post processors
self.post_processors: list[PostProcessorApi] = []
if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()):
self.post_processors.append(
ReviewDescriptionProcessor(
self.config,
self.requestor,
self.metrics,
self.genai_manager,
)
)
if self.config.lpr.enabled:
self.post_processors.append(
LicensePlatePostProcessor(
@@ -252,9 +252,9 @@ class EmbeddingMaintainer(threading.Thread):
)
)
semantic_trigger_processor: SemanticTriggerProcessor | None = None
self.semantic_trigger_processor: SemanticTriggerProcessor | None = None
if self.config.semantic_search.enabled:
semantic_trigger_processor = SemanticTriggerProcessor(
self.semantic_trigger_processor = SemanticTriggerProcessor(
db,
self.config,
self.requestor,
@@ -262,9 +262,49 @@ class EmbeddingMaintainer(threading.Thread):
metrics,
self.embeddings,
)
self.post_processors.append(semantic_trigger_processor)
self.post_processors.append(self.semantic_trigger_processor)
if any(c.objects.genai.enabled_in_config for c in self.config.cameras.values()):
self._sync_genai_processors()
self.stop_event = stop_event
# recordings data
self.recordings_available_through: dict[str, float] = {}
def _sync_genai_processors(self) -> None:
"""Create GenAI post processors for cameras that have GenAI enabled.
Called at startup and again after camera config updates so enabling
GenAI on the first camera does not require a restart. Processors are
never removed once created.
A profile can turn GenAI on without setting enabled_in_config, so both
flags are checked.
"""
cameras = self.config.cameras.values()
if any(
c.review.genai.enabled or c.review.genai.enabled_in_config for c in cameras
) and not any(
isinstance(p, ReviewDescriptionProcessor) for p in self.post_processors
):
logger.debug("Initializing review description processor")
self.post_processors.append(
ReviewDescriptionProcessor(
self.config,
self.requestor,
self.metrics,
self.genai_manager,
)
)
if any(
c.objects.genai.enabled or c.objects.genai.enabled_in_config
for c in cameras
) and not any(
isinstance(p, ObjectDescriptionProcessor) for p in self.post_processors
):
logger.debug("Initializing object description processor")
self.post_processors.append(
ObjectDescriptionProcessor(
self.config,
@@ -272,19 +312,21 @@ class EmbeddingMaintainer(threading.Thread):
self.requestor,
self.metrics,
self.genai_manager,
semantic_trigger_processor,
self.semantic_trigger_processor,
)
)
self.stop_event = stop_event
def _check_camera_config_updates(self) -> None:
"""Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates()
# recordings data
self.recordings_available_through: dict[str, float] = {}
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors()
def run(self) -> None:
"""Maintain a SQLite-vec database for semantic search."""
while not self.stop_event.is_set():
self.config_updater.check_for_updates()
self._check_camera_config_updates()
self._check_enrichment_config_updates()
self._process_requests()
self._process_updates()
+43 -40
View File
@@ -392,6 +392,32 @@ class ReviewSegmentMaintainer(threading.Thread):
return self._publish_segment_end(segment, prev_data)
return None
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
"""Determine the review severity for a manual event label.
Alert labels take precedence over detection labels, matching how
tracked objects are categorized. Labels in neither list default to
alerts so manual events keep their historical severity.
"""
review_config = self.config.cameras[camera].review
# label contains 'label: sub_label', only the label is categorized
label = label.split(": ")[0]
if review_config.alerts.enabled and label in review_config.alerts.labels:
return SeverityEnum.alert
if (
review_config.detections.enabled
and review_config.detections.labels is not None
and label in review_config.detections.labels
):
return SeverityEnum.detection
if review_config.alerts.enabled:
return SeverityEnum.alert
return None
def update_existing_segment(
self,
segment: PendingReviewSegment,
@@ -734,24 +760,19 @@ class ReviewSegmentMaintainer(threading.Thread):
manual_info["label"]
)
if topic == DetectionTypeEnum.api:
# manual_info["label"] contains 'label: sub_label'
# so split out the label without modifying manual_info
det_labels = self.config.cameras[
camera
].review.detections.labels
if (
self.config.cameras[camera].review.detections.enabled
and det_labels is not None
and manual_info["label"].split(": ")[0] in det_labels
):
current_segment.last_detection_time = manual_info[
"end_time"
]
elif self.config.cameras[camera].review.alerts.enabled:
severity = self.get_manual_event_severity(
camera, manual_info["label"]
)
if severity == SeverityEnum.alert:
current_segment.severity = SeverityEnum.alert
current_segment.last_alert_time = manual_info[
"end_time"
]
elif severity == SeverityEnum.detection:
current_segment.last_detection_time = manual_info[
"end_time"
]
elif (
topic == DetectionTypeEnum.lpr
and self.config.cameras[camera].review.detections.enabled
@@ -765,21 +786,12 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment.detections[manual_info["event_id"]] = (
manual_info["label"]
)
if (
topic == DetectionTypeEnum.api
and self.config.cameras[camera].review.alerts.enabled
):
# manual_info["label"] contains 'label: sub_label'
# so split out the label without modifying manual_info
det_labels = self.config.cameras[
camera
].review.detections.labels
if topic == DetectionTypeEnum.api:
if (
not self.config.cameras[
camera
].review.detections.enabled
or det_labels is None
or manual_info["label"].split(": ")[0] not in det_labels
self.get_manual_event_severity(
camera, manual_info["label"]
)
== SeverityEnum.alert
):
current_segment.severity = SeverityEnum.alert
elif (
@@ -853,18 +865,9 @@ class ReviewSegmentMaintainer(threading.Thread):
detections,
)
elif topic == DetectionTypeEnum.api:
severity = None
# manual_info["label"] contains 'label: sub_label'
# so split out the label without modifying manual_info
det_labels = self.config.cameras[camera].review.detections.labels
if (
self.config.cameras[camera].review.detections.enabled
and det_labels is not None
and manual_info["label"].split(": ")[0] in det_labels
):
severity = SeverityEnum.detection
elif self.config.cameras[camera].review.alerts.enabled:
severity = SeverityEnum.alert
severity = self.get_manual_event_severity(
camera, manual_info["label"]
)
if severity:
api_segment = PendingReviewSegment(
+1 -1
View File
@@ -62,7 +62,7 @@ def get_latest_version(config: FrigateConfig) -> str:
def stats_init(
config: FrigateConfig,
camera_metrics: DictProxy,
embeddings_metrics: DataProcessorMetrics | None,
embeddings_metrics: DataProcessorMetrics,
detectors: dict[str, ObjectDetectProcess],
processes: dict[str, int],
) -> StatsTrackingTypes:
+213
View File
@@ -0,0 +1,213 @@
"""Tests for GenAI enablement gating in the embeddings maintainer.
Covers creating post processors when GenAI is enabled at runtime, and the
per-camera gating those processors apply once they exist.
"""
import sys
import unittest
from unittest.mock import MagicMock, patch
# Mock TFLite before importing the maintainer
_MOCK_MODULES = [
"tflite_runtime",
"tflite_runtime.interpreter",
"ai_edge_litert",
"ai_edge_litert.interpreter",
]
for mod in _MOCK_MODULES:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
# imported from the maintainer to avoid tripping the circular import between
# the maintainer and the processor modules
from frigate.embeddings.maintainer import ( # noqa: E402
EmbeddingMaintainer,
ObjectDescriptionProcessor,
PostProcessDataEnum,
ReviewDescriptionProcessor,
)
class TestGenAIProcessorSync(unittest.TestCase):
"""Enabling GenAI on the first camera must not require a restart."""
def _make_maintainer(
self,
review: bool = False,
objects: bool = False,
review_in_config: bool | None = None,
objects_in_config: bool | None = None,
) -> EmbeddingMaintainer:
# Bypass the heavy __init__; only the attributes touched by
# _sync_genai_processors are needed for these tests.
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.post_processors = []
maintainer.config = MagicMock()
maintainer.config.cameras = {
"front": self._make_camera(
review,
objects,
review if review_in_config is None else review_in_config,
objects if objects_in_config is None else objects_in_config,
)
}
maintainer.config_updater = MagicMock()
maintainer.embeddings = None
maintainer.requestor = MagicMock()
maintainer.metrics = MagicMock()
maintainer.genai_manager = MagicMock()
maintainer.semantic_trigger_processor = None
return maintainer
def _make_camera(
self,
review: bool,
objects: bool,
review_in_config: bool,
objects_in_config: bool,
) -> MagicMock:
camera = MagicMock()
camera.review.genai.enabled = review
camera.review.genai.enabled_in_config = review_in_config
camera.objects.genai.enabled = objects
camera.objects.genai.enabled_in_config = objects_in_config
return camera
def _processor_types(self, maintainer: EmbeddingMaintainer) -> list[type]:
return [type(p) for p in maintainer.post_processors]
def test_no_processors_when_genai_disabled(self):
"""A config with no GenAI cameras registers neither processor."""
maintainer = self._make_maintainer()
maintainer._sync_genai_processors()
self.assertEqual(maintainer.post_processors, [])
def test_review_processor_added_when_enabled_after_startup(self):
"""Enabling review GenAI on the first camera registers the processor."""
maintainer = self._make_maintainer()
maintainer._sync_genai_processors()
camera = maintainer.config.cameras["front"]
camera.review.genai.enabled = True
camera.review.genai.enabled_in_config = True
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer), [ReviewDescriptionProcessor]
)
def test_object_processor_added_when_enabled_after_startup(self):
"""Enabling object GenAI on the first camera registers the processor."""
maintainer = self._make_maintainer()
maintainer._sync_genai_processors()
camera = maintainer.config.cameras["front"]
camera.objects.genai.enabled = True
camera.objects.genai.enabled_in_config = True
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer), [ObjectDescriptionProcessor]
)
def test_processor_added_when_only_enabled_by_profile(self):
"""A profile enables GenAI without setting enabled_in_config."""
maintainer = self._make_maintainer(
review=True, objects=True, review_in_config=False, objects_in_config=False
)
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer),
[ReviewDescriptionProcessor, ObjectDescriptionProcessor],
)
def test_processors_are_not_duplicated(self):
"""Repeated config updates must not register a second processor."""
maintainer = self._make_maintainer(review=True, objects=True)
maintainer._sync_genai_processors()
maintainer._sync_genai_processors()
self.assertEqual(
self._processor_types(maintainer),
[ReviewDescriptionProcessor, ObjectDescriptionProcessor],
)
def test_genai_topic_triggers_sync(self):
"""A camera config update on a GenAI topic registers the processor."""
maintainer = self._make_maintainer(review=True)
maintainer.config_updater.check_for_updates.return_value = {"review": ["front"]}
maintainer._check_camera_config_updates()
self.assertEqual(
self._processor_types(maintainer), [ReviewDescriptionProcessor]
)
def test_unrelated_topic_does_not_sync(self):
"""An unrelated camera config update must not register processors."""
maintainer = self._make_maintainer(review=True)
maintainer.config_updater.check_for_updates.return_value = {"motion": ["front"]}
maintainer._check_camera_config_updates()
self.assertEqual(maintainer.post_processors, [])
class TestObjectDescriptionCameraGating(unittest.TestCase):
"""One camera enabling object descriptions must not enlist the others."""
def _make_processor(self, enabled: bool) -> ObjectDescriptionProcessor:
config = MagicMock()
camera = MagicMock()
camera.objects.genai.enabled = enabled
camera.objects.genai.send_triggers.after_significant_updates = None
config.cameras = {"front": camera}
genai_manager = MagicMock()
genai_manager.description_client = MagicMock()
return ObjectDescriptionProcessor(
config, None, MagicMock(), MagicMock(), genai_manager, None
)
def _update(self, processor: ObjectDescriptionProcessor) -> None:
processor.process_data(
{
"camera": "front",
"data": {
"id": "1234.5-abcdef",
"box": (0, 0, 10, 10),
"stationary": False,
},
"state": "update",
"yuv_frame": MagicMock(),
},
PostProcessDataEnum.tracked_object,
)
@patch("frigate.data_processing.post.object_descriptions.create_thumbnail")
def test_disabled_camera_collects_no_thumbnails(self, mock_create_thumbnail):
"""A camera with object descriptions off does no thumbnail work."""
processor = self._make_processor(enabled=False)
self._update(processor)
mock_create_thumbnail.assert_not_called()
self.assertEqual(processor.tracked_events, {})
@patch("frigate.data_processing.post.object_descriptions.create_thumbnail")
def test_enabled_camera_collects_thumbnails(self, mock_create_thumbnail):
"""A camera with object descriptions on still collects thumbnails."""
mock_create_thumbnail.return_value = b"jpg"
processor = self._make_processor(enabled=True)
self._update(processor)
mock_create_thumbnail.assert_called_once()
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
@@ -0,0 +1,154 @@
"""Tests for manual event severity categorization.
Regression coverage for manual events created via the events API being
categorized as detections when their label appears in both the alerts and
detections label lists. Alert labels must win, matching how tracked objects
are categorized, and labels in neither list must default to alerts so the
historical behavior of the API is preserved.
"""
import unittest
from frigate.config import FrigateConfig
from frigate.review.maintainer import ReviewSegmentMaintainer
from frigate.review.types import SeverityEnum
BASE_CONFIG = """
mqtt:
enabled: False
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://10.0.0.1:554/video
roles:
- detect
detect:
width: 1920
height: 1080
fps: 5
%s
"""
class TestManualEventSeverity(unittest.TestCase):
def _make_maintainer(self, review_config: str = "") -> ReviewSegmentMaintainer:
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
sockets, shared memory, and clip dirs). Only the config is read when
categorizing a manual event label."""
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
return maintainer
def test_defaults_to_alert(self) -> None:
maintainer = self._make_maintainer()
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "person"),
SeverityEnum.alert,
)
def test_unlisted_label_defaults_to_alert(self) -> None:
maintainer = self._make_maintainer(
"""
review:
detections:
labels:
- dog
"""
)
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "pir_sensor"),
SeverityEnum.alert,
)
def test_detection_label_is_detection(self) -> None:
maintainer = self._make_maintainer(
"""
review:
alerts:
labels:
- person
detections:
labels:
- pir_sensor
"""
)
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "pir_sensor"),
SeverityEnum.detection,
)
def test_alert_label_wins_over_detection_label(self) -> None:
maintainer = self._make_maintainer(
"""
review:
alerts:
labels:
- person
detections:
labels:
- person
- dog
"""
)
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "person"),
SeverityEnum.alert,
)
def test_sub_label_is_stripped_before_categorizing(self) -> None:
maintainer = self._make_maintainer(
"""
review:
alerts:
labels:
- person
detections:
labels:
- person
"""
)
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "person: Bob"),
SeverityEnum.alert,
)
def test_alert_label_is_detection_when_alerts_disabled(self) -> None:
maintainer = self._make_maintainer(
"""
review:
alerts:
enabled: False
labels:
- person
detections:
labels:
- person
"""
)
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "person"),
SeverityEnum.detection,
)
def test_no_severity_when_alerts_disabled_and_label_not_a_detection(self) -> None:
maintainer = self._make_maintainer(
"""
review:
alerts:
enabled: False
detections:
labels:
- dog
"""
)
self.assertIsNone(
maintainer.get_manual_event_severity("front_door", "pir_sensor")
)
+1 -1
View File
@@ -8,7 +8,7 @@ from frigate.object_detection.base import ObjectDetectProcess
class StatsTrackingTypes(TypedDict):
camera_metrics: dict[str, CameraMetrics]
embeddings_metrics: DataProcessorMetrics | None
embeddings_metrics: DataProcessorMetrics
detectors: dict[str, ObjectDetectProcess]
started: int
latest_frigate_version: str
+133 -11
View File
@@ -1,4 +1,4 @@
import { test, expect } from "../fixtures/frigate-test";
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
@@ -575,7 +575,7 @@ test.describe("Multi-Review Export @high", () => {
await expect(dialog.getByText(/None/)).toBeVisible();
});
test("starting an export posts the expected payload and navigates to the case", async ({
test("starting an export posts the expected payload and stays on the review page", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
@@ -673,9 +673,15 @@ test.describe("Multi-Review Export @high", () => {
"mex-review-002",
]);
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
timeout: 5_000,
});
// Creating a case must not pull the user off the review they were
// working through — the case is offered as a link on the toast instead.
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
await expect(viewCase).toBeVisible({ timeout: 5_000 });
await expect(viewCase).toHaveAttribute(
"href",
/export\?caseId=new-case-xyz$/,
);
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
});
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
@@ -834,12 +840,128 @@ test.describe("Multi-Review Export @high", () => {
expect(payload.new_case_description).toBeUndefined();
expect(payload.items).toHaveLength(2);
// Navigate should hit /export. useSearchEffect consumes the caseId
// query param and strips it once the case is found in the cases list,
// so we assert on the path, not the query string.
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
timeout: 5_000,
});
// Attaching to a case leaves the user on the review page; the case is
// reachable from the toast action.
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
await expect(viewCase).toBeVisible({ timeout: 5_000 });
await expect(viewCase).toHaveAttribute(
"href",
/export\?caseId=existing-case-abc$/,
);
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
});
});
test.describe("Multi-Camera Export from History @high", () => {
// The recording view seeds the multi-camera range around the playback
// position, so the deep link has to land close to the live edge for the
// seeded end to run past the end of the timeline.
const playbackTime = Math.floor(Date.now() / 1000) - 300;
async function openRecordingView(frigateApp: FrigateApp) {
// The recording view pulls these while the timeline renders; the preview
// server 500s on them, which the error collector would flag.
await frigateApp.page.route("**/api/*/recordings**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
}
// Desktop opens the export form in a dialog from the Actions menu; mobile
// opens the same form inside the settings drawer.
async function openMultiCameraTab(frigateApp: FrigateApp) {
await openRecordingView(frigateApp);
if (frigateApp.isMobile) {
await frigateApp.page
.getByRole("button", { name: /filters/i })
.first()
.click({ timeout: 15_000 });
await frigateApp.page.getByRole("button", { name: /^export$/i }).click();
} else {
await frigateApp.page
.getByRole("button", { name: /actions/i })
.click({ timeout: 15_000 });
await frigateApp.page.getByRole("menuitem", { name: /export/i }).click();
}
const form = frigateApp.page.getByRole("dialog");
await expect(form).toBeVisible({ timeout: 5_000 });
await form.getByRole("tab", { name: /multi-camera/i }).click();
return form;
}
test("timeline selection renders both export handles on the timeline", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
await form
.getByRole("button", { name: "Select from Timeline" })
.click({ timeout: 5_000 });
await expect(form).toBeHidden({ timeout: 5_000 });
// A range seeded past the end of the timeline has no segment to anchor
// to, which leaves the handle unpositioned at the top of the timeline
// with an empty label until it is dragged.
for (const handle of [".export-start", ".export-end"]) {
const locator = frigateApp.page.locator(handle);
await expect(locator).toHaveText(/\d{1,2}:\d{2}/, { timeout: 5_000 });
await expect(locator).not.toHaveAttribute("style", /top:\s*0px/);
}
});
test("the time range picker opens without a configured timezone", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
// ui.timezone is null until the user sets one, which used to take the
// whole page down when the calendar worked out its disabled days
await form
.getByRole("button", { name: /^start time$/i })
.click({ timeout: 5_000 });
await expect(
frigateApp.page.getByRole("button", { name: /previous month/i }),
).toBeVisible({ timeout: 5_000 });
});
test("canceling timeline selection reopens the form with the case intact", async ({
frigateApp,
}) => {
await frigateApp.installDefaults();
const form = await openMultiCameraTab(frigateApp);
await form
.getByPlaceholder(/new case name/i)
.fill("Incident 7", { timeout: 5_000 });
await form
.getByPlaceholder(/case description/i)
.fill("Front gate follow-up");
await form.getByRole("button", { name: "Select from Timeline" }).click();
await expect(form).toBeHidden({ timeout: 5_000 });
await frigateApp.page.getByRole("button", { name: /cancel/i }).click();
await expect(form).toBeVisible({ timeout: 5_000 });
await expect(
form.getByRole("tab", { name: /multi-camera/i }),
).toHaveAttribute("aria-selected", "true");
await expect(form.getByPlaceholder(/new case name/i)).toHaveValue(
"Incident 7",
);
await expect(form.getByPlaceholder(/case description/i)).toHaveValue(
"Front gate follow-up",
);
});
});
-1
View File
@@ -426,7 +426,6 @@
"radio": "Radio",
"field_recording": "Field Recording",
"scream": "Scream",
"sodeling": "Sodeling",
"chird": "Chird",
"change_ringing": "Change Ringing",
"shofar": "Shofar",
+4 -6
View File
@@ -100,10 +100,8 @@
"exportButton_other": "Export {{count}} reviews",
"exportingButton": "Exporting...",
"toast": {
"started_one": "Started 1 export. Opening the case now.",
"started_other": "Started {{count}} exports. Opening the case now.",
"startedNoCase_one": "Started 1 export.",
"startedNoCase_other": "Started {{count}} exports.",
"started_one": "Started 1 export.",
"started_other": "Started {{count}} exports.",
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
}
@@ -116,8 +114,8 @@
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
"batchQueuedSuccess_one": "Queued 1 export. Opening the case now.",
"batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.",
"batchQueuedSuccess_one": "Queued 1 export.",
"batchQueuedSuccess_other": "Queued {{count}} exports.",
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
"error": {
@@ -258,6 +258,7 @@ export default function ReviewFilterGroup({
// not applicable as exports are not used
camera=""
latestTime={0}
earliestTime={0}
currentTime={0}
mode="none"
setMode={() => {}}
+115 -52
View File
@@ -39,6 +39,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import {
Command,
CommandGroup,
@@ -62,7 +63,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea";
import { useNavigate } from "react-router-dom";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isReplayCamera } from "@/utils/cameraUtil";
import { isValidIconName } from "@/utils/iconUtil";
@@ -79,9 +79,14 @@ const EXPORT_OPTIONS = [
type ExportOption = (typeof EXPORT_OPTIONS)[number];
export type ExportTab = "export" | "multi";
// length of a range seeded around the current playback time
const MULTI_CAMERA_RANGE_SECONDS = 3600;
const TIMELINE_SELECTION_SECONDS = 60;
type ExportDialogProps = {
camera: string;
latestTime: number;
earliestTime: number;
currentTime: number;
range?: TimeRange;
mode: ExportMode;
@@ -94,6 +99,7 @@ type ExportDialogProps = {
export default function ExportDialog({
camera,
latestTime,
earliestTime,
currentTime,
range,
mode,
@@ -107,9 +113,13 @@ export default function ExportDialog({
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [activeTab, setActiveTab] = useState<ExportTab>("export");
const [isStartingExport, setIsStartingExport] = useState(false);
const previousModeRef = useRef<ExportMode>(mode);
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
useEffect(() => {
const previousMode = previousModeRef.current;
@@ -188,6 +198,9 @@ export default function ExportDialog({
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setRange(undefined);
setMode("none");
return true;
@@ -223,14 +236,32 @@ export default function ExportDialog({
]);
const handleCancel = useCallback(() => {
if (mode == "timeline_multi") {
setRange(preTimelineRangeRef.current);
setMode("select");
return;
}
setName("");
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setMode("none");
setRange(undefined);
setActiveTab("export");
}, [setMode, setRange]);
}, [mode, setMode, setRange]);
const onSelectFromTimeline = useCallback(
(initialRange: TimeRange) => {
preTimelineRangeRef.current = range;
setRange(initialRange);
setMode("timeline_multi");
},
[range, setMode, setRange],
);
const Overlay = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
@@ -304,12 +335,16 @@ export default function ExportDialog({
>
<ExportContent
latestTime={latestTime}
earliestTime={earliestTime}
currentTime={currentTime}
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
batchCaseSelection={batchCaseSelection}
newCaseName={newCaseName}
newCaseDescription={newCaseDescription}
activeTab={activeTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
@@ -318,8 +353,12 @@ export default function ExportDialog({
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setBatchCaseSelection={setBatchCaseSelection}
setNewCaseName={setNewCaseName}
setNewCaseDescription={setNewCaseDescription}
setRange={setRange}
setMode={setMode}
onSelectFromTimeline={onSelectFromTimeline}
onCancel={handleCancel}
/>
</Content>
@@ -330,12 +369,16 @@ export default function ExportDialog({
type ExportContentProps = {
latestTime: number;
earliestTime: number;
currentTime: number;
range?: TimeRange;
name: string;
selectedCaseId?: string;
singleNewCaseName: string;
singleNewCaseDescription: string;
batchCaseSelection: string;
newCaseName: string;
newCaseDescription: string;
activeTab: ExportTab;
isStartingExport: boolean;
onStartExport: () => Promise<boolean>;
@@ -344,19 +387,27 @@ type ExportContentProps = {
setSelectedCaseId: (caseId: string | undefined) => void;
setSingleNewCaseName: (name: string) => void;
setSingleNewCaseDescription: (description: string) => void;
setBatchCaseSelection: (caseId: string) => void;
setNewCaseName: (name: string) => void;
setNewCaseDescription: (description: string) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
onSelectFromTimeline: (range: TimeRange) => void;
onCancel: () => void;
};
export function ExportContent({
latestTime,
earliestTime,
currentTime,
range,
name,
selectedCaseId,
singleNewCaseName,
singleNewCaseDescription,
batchCaseSelection,
newCaseName,
newCaseDescription,
activeTab,
isStartingExport,
onStartExport,
@@ -365,12 +416,15 @@ export function ExportContent({
setSelectedCaseId,
setSingleNewCaseName,
setSingleNewCaseDescription,
setBatchCaseSelection,
setNewCaseName,
setNewCaseDescription,
setRange,
setMode,
onSelectFromTimeline,
onCancel,
}: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
@@ -379,13 +433,8 @@ export function ExportContent({
range,
);
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
selectedCaseId || "none",
);
const [hasManualCameraSelection, setHasManualCameraSelection] =
useState(false);
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
const [cameraSearch, setCameraSearch] = useState("");
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
@@ -416,38 +465,47 @@ export function ExportContent({
return () => window.clearTimeout(timeoutId);
}, [activeTab, range]);
useEffect(() => {
if (activeTab !== "multi") {
return;
}
if (selectedCaseId) {
setBatchCaseSelection(selectedCaseId);
return;
}
if ((cases?.length ?? 0) === 0) {
setBatchCaseSelection("new");
return;
}
setBatchCaseSelection("new");
}, [activeTab, cases?.length, selectedCaseId]);
useEffect(() => {
setHasManualCameraSelection(false);
}, [multiRangeKey]);
const buildRangeAroundCurrentTime = useCallback(
(durationSeconds: number): TimeRange => ({
after: Math.max(earliestTime, currentTime - durationSeconds / 2),
before: Math.min(latestTime, currentTime + durationSeconds / 2),
}),
[currentTime, earliestTime, latestTime],
);
const clampRangeToTimeline = useCallback(
(candidate?: TimeRange): TimeRange => {
const fallback = buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS);
if (!candidate) {
return fallback;
}
const after = Math.min(
latestTime,
Math.max(earliestTime, candidate.after),
);
const before = Math.min(
latestTime,
Math.max(earliestTime, candidate.before),
);
return before > after ? { after, before } : fallback;
},
[buildRangeAroundCurrentTime, earliestTime, latestTime],
);
useEffect(() => {
if (activeTab !== "multi" || range) {
return;
}
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
}, [activeTab, currentTime, range, setRange]);
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
}, [activeTab, buildRangeAroundCurrentTime, range, setRange]);
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
activeTab === "multi" && debouncedRange
@@ -715,6 +773,16 @@ export function ExportContent({
return result.error ? `${cameraName}: ${result.error}` : cameraName;
})
.join(", ");
const exportCaseId = response.data.export_case_id;
const viewCaseAction = exportCaseId ? (
<a
href={`${baseUrl}export?caseId=${exportCaseId}`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("export.toast.view")}</Button>
</a>
) : undefined;
if (failedResults.length > 0 && successfulResults.length > 0) {
toast.success(
@@ -728,6 +796,7 @@ export function ExportContent({
{
position: "top-center",
description: failedSummary,
action: viewCaseAction,
},
);
} else if (failedResults.length > 0) {
@@ -748,7 +817,7 @@ export function ExportContent({
t("export.toast.batchQueuedSuccess", {
count: successfulResults.length,
}),
{ position: "top-center" },
{ position: "top-center", action: viewCaseAction },
);
}
@@ -761,9 +830,6 @@ export function ExportContent({
setRange(undefined);
setMode("none");
setActiveTab("export");
if (response.data.export_case_id) {
navigate(`/export?caseId=${response.data.export_case_id}`);
}
}
} catch (error) {
const apiError = error as {
@@ -794,12 +860,14 @@ export function ExportContent({
range,
selectedCameraIds,
setActiveTab,
setBatchCaseSelection,
setMode,
setName,
setNewCaseDescription,
setNewCaseName,
setRange,
setSelectedCaseId,
t,
navigate,
]);
return (
@@ -820,10 +888,8 @@ export function ExportContent({
onValueChange={(value) => {
const tab = value as ExportTab;
if (tab === "multi") {
setRange({
before: currentTime + 1800,
after: currentTime - 1800,
});
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
setBatchCaseSelection(selectedCaseId ?? "new");
} else {
onSelectTime(selectedOption);
}
@@ -975,23 +1041,18 @@ export function ExportContent({
className="size-9 shrink-0 p-0"
aria-label={t("export.multiCamera.selectFromTimeline")}
onClick={() => {
if (!range) {
setRange({
before: currentTime + 30,
after: currentTime - 30,
});
}
setActiveTab("multi");
setMode("timeline_multi");
onSelectFromTimeline(clampRangeToTimeline(range));
}}
>
<LuAudioLines className="size-4 -rotate-90" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("export.multiCamera.selectFromTimeline")}
</TooltipContent>
<TooltipPortal>
<TooltipContent>
{t("export.multiCamera.selectFromTimeline")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
@@ -1256,7 +1317,9 @@ export function ExportContent({
disabled={isStartingExport}
onClick={async () => {
if (selectedOption == "timeline") {
setRange({ before: currentTime + 30, after: currentTime - 30 });
setRange(
buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS),
);
setMode("timeline");
} else {
const didQueue = await onStartExport();
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { baseUrl } from "@/api/baseUrl";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
@@ -65,6 +65,7 @@ type MobileReviewSettingsDrawerProps = {
filter?: ReviewFilter;
currentSeverity?: ReviewSeverity;
latestTime: number;
earliestTime: number;
currentTime: number;
range?: TimeRange;
mode: ExportMode;
@@ -90,6 +91,7 @@ export default function MobileReviewSettingsDrawer({
filter,
currentSeverity,
latestTime,
earliestTime,
currentTime,
range,
mode,
@@ -142,7 +144,22 @@ export default function MobileReviewSettingsDrawer({
);
const [singleNewCaseName, setSingleNewCaseName] = useState("");
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingExport, setIsStartingExport] = useState(false);
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
const onSelectFromTimeline = useCallback(
(initialRange: TimeRange) => {
preTimelineRangeRef.current = range;
setRange(initialRange);
setMode("timeline_multi");
setDrawerMode("none");
},
[range, setMode, setRange],
);
const onStartExport = useCallback(async () => {
if (isStartingExport) {
return false;
@@ -214,6 +231,9 @@ export default function MobileReviewSettingsDrawer({
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setRange(undefined);
setMode("none");
return true;
@@ -433,12 +453,16 @@ export default function MobileReviewSettingsDrawer({
content = (
<ExportContent
latestTime={latestTime}
earliestTime={earliestTime}
currentTime={currentTime}
range={range}
name={name}
selectedCaseId={selectedCaseId}
singleNewCaseName={singleNewCaseName}
singleNewCaseDescription={singleNewCaseDescription}
batchCaseSelection={batchCaseSelection}
newCaseName={newCaseName}
newCaseDescription={newCaseDescription}
activeTab={exportTab}
isStartingExport={isStartingExport}
onStartExport={onStartExport}
@@ -447,6 +471,9 @@ export default function MobileReviewSettingsDrawer({
setSelectedCaseId={setSelectedCaseId}
setSingleNewCaseName={setSingleNewCaseName}
setSingleNewCaseDescription={setSingleNewCaseDescription}
setBatchCaseSelection={setBatchCaseSelection}
setNewCaseName={setNewCaseName}
setNewCaseDescription={setNewCaseDescription}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
@@ -455,12 +482,16 @@ export default function MobileReviewSettingsDrawer({
setDrawerMode("none");
}
}}
onSelectFromTimeline={onSelectFromTimeline}
onCancel={() => {
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
setSingleNewCaseName("");
setSingleNewCaseDescription("");
setBatchCaseSelection("new");
setNewCaseName("");
setNewCaseDescription("");
setExportTab("export");
setDrawerMode("select");
}}
@@ -639,6 +670,14 @@ export default function MobileReviewSettingsDrawer({
void onStartExport();
}}
onCancel={() => {
if (mode == "timeline_multi") {
setRange(preTimelineRangeRef.current);
setExportTab("multi");
setMode("select");
setDrawerMode("export");
return;
}
setExportTab("export");
setRange(undefined);
setMode("none");
@@ -3,7 +3,6 @@ import { isDesktop } from "react-device-detect";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
import {
@@ -43,6 +42,7 @@ import {
ExportCase,
} from "@/types/export";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useDateLocale } from "@/hooks/use-date-locale";
@@ -65,7 +65,6 @@ export default function MultiExportDialog({
}: MultiExportDialogProps) {
const { t } = useTranslation(["components/dialog", "common"]);
const locale = useDateLocale();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -203,19 +202,24 @@ export default function MultiExportDialog({
const results = response.data.results ?? [];
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
const exportCaseId = response.data.export_case_id;
const viewCaseAction = exportCaseId ? (
<a
href={`${baseUrl}export?caseId=${exportCaseId}`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("export.toast.view", { ns: "components/dialog" })}</Button>
</a>
) : undefined;
if (successful.length > 0 && failed.length === 0) {
toast.success(
t(
isAdmin
? "export.multi.toast.started"
: "export.multi.toast.startedNoCase",
{
ns: "components/dialog",
count: successful.length,
},
),
{ position: "top-center" },
t("export.multi.toast.started", {
ns: "components/dialog",
count: successful.length,
}),
{ position: "top-center", action: viewCaseAction },
);
} else if (successful.length > 0 && failed.length > 0) {
// Resolve each failure to its review via item_index so same-camera
@@ -229,7 +233,7 @@ export default function MultiExportDialog({
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
{ position: "top-center", action: viewCaseAction },
);
} else {
const failedLabels = failed.map(formatFailureLabel).join(", ");
@@ -247,9 +251,6 @@ export default function MultiExportDialog({
onStarted();
setOpen(false);
resetState();
if (response.data.export_case_id) {
navigate(`/export?caseId=${response.data.export_case_id}`);
}
}
} catch (error) {
const apiError = error as {
@@ -275,7 +276,6 @@ export default function MultiExportDialog({
formatFailureLabel,
isAdmin,
isNewCase,
navigate,
newCaseDescription,
newCaseName,
onStarted,
+6 -6
View File
@@ -318,12 +318,12 @@ export const formatSecondsToDuration = (
* @param timezone string representation of the timezone the user is requesting
* @returns number of minutes offset from UTC
*/
export const getUTCOffset = (
date: Date,
timezone: string = getResolvedTimeZone(),
): number => {
export const getUTCOffset = (date: Date, timezone?: string | null): number => {
// ui.timezone comes back as null until the user sets one
const resolvedTimezone = timezone || getResolvedTimeZone();
// If timezone is in UTC±HH:MM format, parse it to get offset
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
const utcOffsetMatch = resolvedTimezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
if (utcOffsetMatch) {
const hours = parseInt(utcOffsetMatch[2], 10);
const minutes = parseInt(utcOffsetMatch[3], 10);
@@ -334,7 +334,7 @@ export const getUTCOffset = (
const utcDate = new Date(date.getTime());
// locale of en-CA is required for proper locale format
let iso = utcDate
.toLocaleString("en-CA", { timeZone: timezone, hour12: false })
.toLocaleString("en-CA", { timeZone: resolvedTimezone, hour12: false })
.replace(", ", "T");
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
let target = new Date(`${iso}Z`);
@@ -1356,6 +1356,7 @@ export default function MotionSearchView({
camera={selectedCamera}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
@@ -1476,6 +1477,7 @@ export default function MotionSearchView({
camera={selectedCamera}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
@@ -677,6 +677,7 @@ export function RecordingView({
camera={mainCamera}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
mode={exportMode}
range={exportRange}
showPreview={showExportPreview}
@@ -810,6 +811,7 @@ export function RecordingView({
filter={filter}
currentTime={currentTime}
latestTime={timeRange.before}
earliestTime={timeRange.after}
recordingsSummary={recordingsSummary}
mode={exportMode}
range={exportRange}