Compare commits

..

5 Commits

Author SHA1 Message Date
dependabot[bot]
57f63127d3
Merge f440f86451 into 213a1fbd00 2025-11-20 00:07:47 +11:00
Josh Hawkins
213a1fbd00
Miscellaneous Fixes (#20951)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* ensure viewer roles are available in create user dialog

* admin-only endpoint to return unmaksed camera paths and go2rtc streams

* remove camera edit dropdown

pushing camera editing from the UI to 0.18

* clean up camera edit form

* rename component for clarity

CameraSettingsView is now CameraReviewSettingsView

* Catch case where user requsts clip for time that has no recordings

* ensure emergency cleanup also sets has_clip on overlapping events

improves https://github.com/blakeblackshear/frigate/discussions/20945

* use debug log instead of info

* update docs to recommend tmpfs

* improve display of in-progress events in explore tracking details

* improve seeking logic in tracking details

mimic the logic of DynamicVideoController

* only use ffprobe for duration to avoid blocking

fixes https://github.com/blakeblackshear/frigate/discussions/20737#discussioncomment-14999869

* Revert "only use ffprobe for duration to avoid blocking"

This reverts commit 8b15078005.

* update readme to link to object detector docs

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-11-18 15:33:42 -07:00
Josh Hawkins
fbf4388b37
Miscellaneous Fixes (#20897)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
* don't flatten the search result cache when updating

this would cause an infinite swr fetch if something was mutated and then fetch was called again

* Properly sort keys for recording summary in StorageMetrics

* tracked object description box tweaks

* Remove ability to right click on elements inside of face popup

* Update reprocess message

* don't show object track until video metadata is loaded

* fix blue line height calc for in progress events

* Use timeline tab by default for notifications but add a query arg for customization

* Try and improve notification opening behavior

* Reduce review item buffering behavior

* ensure logging config is passed to camera capture and tracker processes

* ensure on demand recording stops when browser closes

* improve active line progress height with resize observer

* remove icons and duplicate find similar link in explore context menu

* fix for initial broken image when creating trigger from explore

* display friendly names for triggers in toasts

* lpr and triggers docs updates

* remove icons from dropdowns in face and classification

* fix comma dangle linter issue

* re-add incorrectly removed face library button icons

* fix sidebar nav links on < 768px desktop layout

* allow text to wrap on mark as reviewed button

* match exact pixels

* clarify LPR docs

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-11-17 08:12:05 -06:00
GuoQing Liu
097673b845
chore: i18n use cache key (#20885)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* chore: i18n use cache key

* Fix indentation in Dockerfile for pip command

* Add build argument for GIT_COMMIT_HASH in CI workflow

* Add short-sha output to action.yml

* Update build args to use short SHA output

* build: use vite .env

* Remove unnecessary newline in Dockerfile

* Define proxy host variable in vite.config.ts

Add a new line to define the proxy host variable.
2025-11-14 09:36:46 -06:00
GuoQing Liu
d56cf59b9a
fix: fix "Always Show Camera Names" label switch id wrong (#20922) 2025-11-14 09:23:43 -06:00
39 changed files with 1440 additions and 1127 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ frigate/version.py
web/build web/build
web/node_modules web/node_modules
web/coverage web/coverage
web/.env
core core
!/web/**/*.ts !/web/**/*.ts
.idea/* .idea/*

View File

@ -14,6 +14,7 @@ push-boards: $(BOARDS:%=push-%)
version: version:
echo 'VERSION = "$(VERSION)-$(COMMIT_HASH)"' > frigate/version.py echo 'VERSION = "$(VERSION)-$(COMMIT_HASH)"' > frigate/version.py
echo 'VITE_GIT_COMMIT_HASH=$(COMMIT_HASH)' > web/.env
local: version local: version
docker buildx build --target=frigate --file docker/main/Dockerfile . \ docker buildx build --target=frigate --file docker/main/Dockerfile . \

View File

@ -12,7 +12,7 @@
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. Use of a GPU or AI accelerator is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. See Frigate's supported [object detectors](https://docs.frigate.video/configuration/object_detectors/).
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary

View File

@ -3,18 +3,18 @@ id: license_plate_recognition
title: License Plate Recognition (LPR) title: License Plate Recognition (LPR)
--- ---
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street. Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition. LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
When a plate is recognized, the details are: When a plate is recognized, the details are:
- Added as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) to a tracked object. - Added as a `sub_label` (if [known](#matching)) or the `recognized_license_plate` field (if unknown) to a tracked object.
- Viewable in the Review Item Details pane in Review (sub labels). - Viewable in the Details pane in Review/History.
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates). - Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
- Filterable through the More Filters menu in Explore. - Filterable through the More Filters menu in Explore.
- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object. - Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if known) and `plate`. - Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
## Model Requirements ## Model Requirements
@ -31,6 +31,7 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
## Minimum System Requirements ## Minimum System Requirements
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required. License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM is required.
## Configuration ## Configuration
License plate recognition is disabled by default. Enable it in your config file: License plate recognition is disabled by default. Enable it in your config file:
@ -73,8 +74,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
- Default: `small` - Default: `small`
- This can be `small` or `large`. - This can be `small` or `large`.
- The `small` model is fast and identifies groups of Latin and Chinese characters. - The `small` model is fast and identifies groups of Latin and Chinese characters.
- The `large` model identifies Latin characters only, but uses an enhanced text detector and is more capable at finding characters on multi-line plates. It is significantly slower than the `small` model. Note that using the `large` model does not improve _text recognition_, but it may improve _text detection_. - The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. It is significantly slower than the `small` model.
- For most users, the `small` model is recommended. - If your country or region does not use multi-line plates, you should use the `small` model as performance is much better for single-line plates.
### Recognition ### Recognition
@ -177,7 +178,7 @@ lpr:
:::note :::note
If you want to detect cars on cameras but don't want to use resources to run LPR on those cars, you should disable LPR for those specific cameras. If a camera is configured to detect `car` or `motorcycle` but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
```yaml ```yaml
cameras: cameras:
@ -305,7 +306,7 @@ With this setup:
- Review items will always be classified as a `detection`. - Review items will always be classified as a `detection`.
- Snapshots will always be saved. - Snapshots will always be saved.
- Zones and object masks are **not** used. - Zones and object masks are **not** used.
- The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a known plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field. - The `frigate/events` MQTT topic will **not** publish tracked object updates with the license plate bounding box and score, though `frigate/reviews` will publish if recordings are enabled. If a plate is recognized as a [known](#matching) plate, publishing will occur with an updated `sub_label` field. If characters are recognized, publishing will occur with an updated `recognized_license_plate` field.
- License plate snapshots are saved at the highest-scoring moment and appear in Explore. - License plate snapshots are saved at the highest-scoring moment and appear in Explore.
- Debug view will not show `license_plate` bounding boxes. - Debug view will not show `license_plate` bounding boxes.

View File

@ -141,7 +141,7 @@ Triggers are best configured through the Frigate UI.
Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT. Check the `Add Attribute` box to add the trigger's internal ID (e.g., "red_car_alert") to a data attribute on the tracked object that can be processed via the API or MQTT.
5. Save the trigger to update the configuration and store the embedding in the database. 5. Save the trigger to update the configuration and store the embedding in the database.
When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. When a trigger fires, the UI highlights the trigger with a blue dot for 3 seconds for easy identification. Additionally, the UI will show the last date/time and tracked object ID that activated your trigger. The last triggered timestamp is not saved to the database or persisted through restarts of Frigate.
### Usage and Best Practices ### Usage and Best Practices

View File

@ -56,7 +56,7 @@ services:
volumes: volumes:
- /path/to/your/config:/config - /path/to/your/config:/config
- /path/to/your/storage:/media/frigate - /path/to/your/storage:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear - type: tmpfs # Recommended: 1GB of memory
target: /tmp/cache target: /tmp/cache
tmpfs: tmpfs:
size: 1000000000 size: 1000000000
@ -310,7 +310,7 @@ services:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /path/to/your/config:/config - /path/to/your/config:/config
- /path/to/your/storage:/media/frigate - /path/to/your/storage:/media/frigate
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear - type: tmpfs # Recommended: 1GB of memory
target: /tmp/cache target: /tmp/cache
tmpfs: tmpfs:
size: 1000000000 size: 1000000000

View File

@ -179,6 +179,36 @@ def config(request: Request):
return JSONResponse(content=config) return JSONResponse(content=config)
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
def config_raw_paths(request: Request):
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
config_obj: FrigateConfig = request.app.frigate_config
raw_paths = {"cameras": {}, "go2rtc": {"streams": {}}}
# Extract raw camera ffmpeg input paths
for camera_name, camera in config_obj.cameras.items():
raw_paths["cameras"][camera_name] = {
"ffmpeg": {
"inputs": [
{"path": input.path, "roles": input.roles}
for input in camera.ffmpeg.inputs
]
}
}
# Extract raw go2rtc stream URLs
go2rtc_config = config_obj.go2rtc.model_dump(
mode="json", warnings="none", exclude_none=True
)
for stream_name, stream in go2rtc_config.get("streams", {}).items():
if stream is None:
continue
raw_paths["go2rtc"]["streams"][stream_name] = stream
return JSONResponse(content=raw_paths)
@router.get("/config/raw") @router.get("/config/raw")
def config_raw(): def config_raw():
config_file = find_config_file() config_file = find_config_file()

View File

@ -1781,9 +1781,8 @@ def create_trigger_embedding(
logger.debug( logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera_name}." f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception(
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}" f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
) )
@ -1807,8 +1806,8 @@ def create_trigger_embedding(
status_code=200, status_code=200,
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception("Error creating trigger embedding")
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
@ -1917,9 +1916,8 @@ def update_trigger_embedding(
logger.debug( logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}." f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception(
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}" f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
) )
@ -1958,9 +1956,8 @@ def update_trigger_embedding(
logger.debug( logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera_name}." f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception(
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}" f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
) )
@ -1972,8 +1969,8 @@ def update_trigger_embedding(
status_code=200, status_code=200,
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception("Error updating trigger embedding")
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
@ -2033,9 +2030,8 @@ def delete_trigger_embedding(
logger.debug( logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}." f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception(
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}" f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
) )
@ -2047,8 +2043,8 @@ def delete_trigger_embedding(
status_code=200, status_code=200,
) )
except Exception as e: except Exception:
logger.error(e.with_traceback()) logger.exception("Error deleting trigger embedding")
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,

View File

@ -762,6 +762,15 @@ async def recording_clip(
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
) )
if recordings.count() == 0:
return JSONResponse(
content={
"success": False,
"message": "No recordings found for the specified time range",
},
status_code=400,
)
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
file_path = os.path.join(CACHE_DIR, file_name) file_path = os.path.join(CACHE_DIR, file_name)
with open(file_path, "w") as file: with open(file_path, "w") as file:

View File

@ -136,6 +136,7 @@ class CameraMaintainer(threading.Thread):
self.ptz_metrics[name], self.ptz_metrics[name],
self.region_grids[name], self.region_grids[name],
self.stop_event, self.stop_event,
self.config.logger,
) )
self.camera_processes[config.name] = camera_process self.camera_processes[config.name] = camera_process
camera_process.start() camera_process.start()
@ -156,7 +157,11 @@ class CameraMaintainer(threading.Thread):
self.frame_manager.create(f"{config.name}_frame{i}", frame_size) self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
capture_process = CameraCapture( capture_process = CameraCapture(
config, count, self.camera_metrics[name], self.stop_event config,
count,
self.camera_metrics[name],
self.stop_event,
self.config.logger,
) )
capture_process.daemon = True capture_process.daemon = True
self.capture_processes[name] = capture_process self.capture_processes[name] = capture_process

View File

@ -132,17 +132,15 @@ class ReviewDescriptionProcessor(PostProcessorApi):
if image_source == ImageSourceEnum.recordings: if image_source == ImageSourceEnum.recordings:
duration = final_data["end_time"] - final_data["start_time"] duration = final_data["end_time"] - final_data["start_time"]
buffer_extension = min( buffer_extension = min(5, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
)
# Ensure minimum total duration for short review items # Ensure minimum total duration for short review items
# This provides better context for brief events # This provides better context for brief events
total_duration = duration + (2 * buffer_extension) total_duration = duration + (2 * buffer_extension)
if total_duration < MIN_RECORDING_DURATION: if total_duration < MIN_RECORDING_DURATION:
# Expand buffer to reach minimum duration, still respecting max of 10s per side # Expand buffer to reach minimum duration, still respecting max of 5s per side
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2 additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
buffer_extension = min(10, additional_buffer_per_side) buffer_extension = min(5, additional_buffer_per_side)
thumbs = self.get_recording_frames( thumbs = self.get_recording_frames(
camera, camera,

View File

@ -424,7 +424,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
if not res: if not res:
return { return {
"message": "No face was recognized.", "message": "Model is still training, please try again in a few moments.",
"success": False, "success": False,
} }

View File

@ -113,6 +113,7 @@ class StorageMaintainer(threading.Thread):
recordings: Recordings = ( recordings: Recordings = (
Recordings.select( Recordings.select(
Recordings.id, Recordings.id,
Recordings.camera,
Recordings.start_time, Recordings.start_time,
Recordings.end_time, Recordings.end_time,
Recordings.segment_size, Recordings.segment_size,
@ -137,7 +138,7 @@ class StorageMaintainer(threading.Thread):
) )
event_start = 0 event_start = 0
deleted_recordings = set() deleted_recordings = []
for recording in recordings: for recording in recordings:
# check if 1 hour of storage has been reclaimed # check if 1 hour of storage has been reclaimed
if deleted_segments_size > hourly_bandwidth: if deleted_segments_size > hourly_bandwidth:
@ -172,7 +173,7 @@ class StorageMaintainer(threading.Thread):
if not keep: if not keep:
try: try:
clear_and_unlink(Path(recording.path), missing_ok=False) clear_and_unlink(Path(recording.path), missing_ok=False)
deleted_recordings.add(recording.id) deleted_recordings.append(recording)
deleted_segments_size += recording.segment_size deleted_segments_size += recording.segment_size
except FileNotFoundError: except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up # this file was not found so we must assume no space was cleaned up
@ -186,6 +187,9 @@ class StorageMaintainer(threading.Thread):
recordings = ( recordings = (
Recordings.select( Recordings.select(
Recordings.id, Recordings.id,
Recordings.camera,
Recordings.start_time,
Recordings.end_time,
Recordings.path, Recordings.path,
Recordings.segment_size, Recordings.segment_size,
) )
@ -201,7 +205,7 @@ class StorageMaintainer(threading.Thread):
try: try:
clear_and_unlink(Path(recording.path), missing_ok=False) clear_and_unlink(Path(recording.path), missing_ok=False)
deleted_segments_size += recording.segment_size deleted_segments_size += recording.segment_size
deleted_recordings.add(recording.id) deleted_recordings.append(recording)
except FileNotFoundError: except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up # this file was not found so we must assume no space was cleaned up
pass pass
@ -211,7 +215,50 @@ class StorageMaintainer(threading.Thread):
logger.debug(f"Expiring {len(deleted_recordings)} recordings") logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time # delete up to 100,000 at a time
max_deletes = 100000 max_deletes = 100000
deleted_recordings_list = list(deleted_recordings)
# Update has_clip for events that overlap with deleted recordings
if deleted_recordings:
# Group deleted recordings by camera
camera_recordings = {}
for recording in deleted_recordings:
if recording.camera not in camera_recordings:
camera_recordings[recording.camera] = {
"min_start": recording.start_time,
"max_end": recording.end_time,
}
else:
camera_recordings[recording.camera]["min_start"] = min(
camera_recordings[recording.camera]["min_start"],
recording.start_time,
)
camera_recordings[recording.camera]["max_end"] = max(
camera_recordings[recording.camera]["max_end"],
recording.end_time,
)
# Find all events that overlap with deleted recordings time range per camera
events_to_update = []
for camera, time_range in camera_recordings.items():
overlapping_events = Event.select(Event.id).where(
Event.camera == camera,
Event.has_clip == True,
Event.start_time < time_range["max_end"],
Event.end_time > time_range["min_start"],
)
for event in overlapping_events:
events_to_update.append(event.id)
# Update has_clip to False for overlapping events
if events_to_update:
for i in range(0, len(events_to_update), max_deletes):
batch = events_to_update[i : i + max_deletes]
Event.update(has_clip=False).where(Event.id << batch).execute()
logger.debug(
f"Updated has_clip to False for {len(events_to_update)} events"
)
deleted_recordings_list = [r.id for r in deleted_recordings]
for i in range(0, len(deleted_recordings_list), max_deletes): for i in range(0, len(deleted_recordings_list), max_deletes):
Recordings.delete().where( Recordings.delete().where(
Recordings.id << deleted_recordings_list[i : i + max_deletes] Recordings.id << deleted_recordings_list[i : i + max_deletes]

View File

@ -16,7 +16,7 @@ from frigate.comms.recordings_updater import (
RecordingsDataSubscriber, RecordingsDataSubscriber,
RecordingsDataTypeEnum, RecordingsDataTypeEnum,
) )
from frigate.config import CameraConfig, DetectConfig, ModelConfig from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
from frigate.config.camera.camera import CameraTypeEnum from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
@ -539,6 +539,7 @@ class CameraCapture(FrigateProcess):
shm_frame_count: int, shm_frame_count: int,
camera_metrics: CameraMetrics, camera_metrics: CameraMetrics,
stop_event: MpEvent, stop_event: MpEvent,
log_config: LoggerConfig | None = None,
) -> None: ) -> None:
super().__init__( super().__init__(
stop_event, stop_event,
@ -549,9 +550,10 @@ class CameraCapture(FrigateProcess):
self.config = config self.config = config
self.shm_frame_count = shm_frame_count self.shm_frame_count = shm_frame_count
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.log_config = log_config
def run(self) -> None: def run(self) -> None:
self.pre_run_setup() self.pre_run_setup(self.log_config)
camera_watchdog = CameraWatchdog( camera_watchdog = CameraWatchdog(
self.config, self.config,
self.shm_frame_count, self.shm_frame_count,
@ -577,6 +579,7 @@ class CameraTracker(FrigateProcess):
ptz_metrics: PTZMetrics, ptz_metrics: PTZMetrics,
region_grid: list[list[dict[str, Any]]], region_grid: list[list[dict[str, Any]]],
stop_event: MpEvent, stop_event: MpEvent,
log_config: LoggerConfig | None = None,
) -> None: ) -> None:
super().__init__( super().__init__(
stop_event, stop_event,
@ -592,9 +595,10 @@ class CameraTracker(FrigateProcess):
self.camera_metrics = camera_metrics self.camera_metrics = camera_metrics
self.ptz_metrics = ptz_metrics self.ptz_metrics = ptz_metrics
self.region_grid = region_grid self.region_grid = region_grid
self.log_config = log_config
def run(self) -> None: def run(self) -> None:
self.pre_run_setup() self.pre_run_setup(self.log_config)
frame_queue = self.camera_metrics.frame_queue frame_queue = self.camera_metrics.frame_queue
frame_shape = self.config.frame_shape frame_shape = self.config.frame_shape

1
web/.gitignore vendored
View File

@ -22,3 +22,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env

View File

@ -44,11 +44,16 @@ self.addEventListener("notificationclick", (event) => {
switch (event.action ?? "default") { switch (event.action ?? "default") {
case "markReviewed": case "markReviewed":
if (event.notification.data) { if (event.notification.data) {
event.waitUntil(
fetch("/api/reviews/viewed", { fetch("/api/reviews/viewed", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": 1 }, headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": 1,
},
body: JSON.stringify({ ids: [event.notification.data.id] }), body: JSON.stringify({ ids: [event.notification.data.id] }),
}); }), // eslint-disable-line comma-dangle
);
} }
break; break;
default: default:
@ -58,7 +63,7 @@ self.addEventListener("notificationclick", (event) => {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
if (clients.openWindow) { if (clients.openWindow) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
return clients.openWindow(url); event.waitUntil(clients.openWindow(url));
} }
} }
} }

View File

@ -398,11 +398,7 @@ export function GroupedClassificationCard({
threshold={threshold} threshold={threshold}
selected={false} selected={false}
i18nLibrary={i18nLibrary} i18nLibrary={i18nLibrary}
onClick={(data, meta) => { onClick={() => {}}
if (meta || selectedItems.length > 0) {
onClick(data);
}
}}
> >
{children?.(data)} {children?.(data)}
</ClassificationCard> </ClassificationCard>

View File

@ -4,9 +4,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner"; import { toast } from "sonner";
import axios from "axios"; import axios from "axios";
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi"; import { FiMoreVertical } from "react-icons/fi";
import { MdImageSearch } from "react-icons/md";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { import {
ContextMenu, ContextMenu,
@ -31,11 +29,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import useSWR from "swr"; import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { BsFillLightningFill } from "react-icons/bs";
import BlurredIconButton from "../button/BlurredIconButton"; import BlurredIconButton from "../button/BlurredIconButton";
import { PiPath } from "react-icons/pi";
type SearchResultActionsProps = { type SearchResultActionsProps = {
searchResult: SearchResult; searchResult: SearchResult;
@ -98,7 +93,6 @@ export default function SearchResultActions({
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`} href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`} download={`${searchResult.camera}_${searchResult.label}.mp4`}
> >
<LuDownload className="mr-2 size-4" />
<span>{t("itemMenu.downloadVideo.label")}</span> <span>{t("itemMenu.downloadVideo.label")}</span>
</a> </a>
</MenuItem> </MenuItem>
@ -110,7 +104,6 @@ export default function SearchResultActions({
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`} href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`} download={`${searchResult.camera}_${searchResult.label}.jpg`}
> >
<LuCamera className="mr-2 size-4" />
<span>{t("itemMenu.downloadSnapshot.label")}</span> <span>{t("itemMenu.downloadSnapshot.label")}</span>
</a> </a>
</MenuItem> </MenuItem>
@ -120,16 +113,15 @@ export default function SearchResultActions({
aria-label={t("itemMenu.viewTrackingDetails.aria")} aria-label={t("itemMenu.viewTrackingDetails.aria")}
onClick={showTrackingDetails} onClick={showTrackingDetails}
> >
<PiPath className="mr-2 size-4" />
<span>{t("itemMenu.viewTrackingDetails.label")}</span> <span>{t("itemMenu.viewTrackingDetails.label")}</span>
</MenuItem> </MenuItem>
)} )}
{config?.semantic_search?.enabled && isContextMenu && ( {config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<MenuItem <MenuItem
aria-label={t("itemMenu.findSimilar.aria")} aria-label={t("itemMenu.findSimilar.aria")}
onClick={findSimilar} onClick={findSimilar}
> >
<MdImageSearch className="mr-2 size-4" />
<span>{t("itemMenu.findSimilar.label")}</span> <span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem> </MenuItem>
)} )}
@ -139,25 +131,13 @@ export default function SearchResultActions({
aria-label={t("itemMenu.addTrigger.aria")} aria-label={t("itemMenu.addTrigger.aria")}
onClick={addTrigger} onClick={addTrigger}
> >
<BsFillLightningFill className="mr-2 size-4" />
<span>{t("itemMenu.addTrigger.label")}</span> <span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem> </MenuItem>
)} )}
{config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<MenuItem
aria-label={t("itemMenu.findSimilar.aria")}
onClick={findSimilar}
>
<MdImageSearch className="mr-2 size-4" />
<span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem>
)}
<MenuItem <MenuItem
aria-label={t("itemMenu.deleteTrackedObject.label")} aria-label={t("itemMenu.deleteTrackedObject.label")}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >
<LuTrash2 className="mr-2 size-4" />
<span>{t("button.delete", { ns: "common" })}</span> <span>{t("button.delete", { ns: "common" })}</span>
</MenuItem> </MenuItem>
</> </>

View File

@ -46,13 +46,13 @@ export default function NavItem({
onClick={onClick} onClick={onClick}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"flex flex-col items-center justify-center rounded-lg", "flex flex-col items-center justify-center rounded-lg p-[6px]",
className, className,
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"], variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
) )
} }
> >
<Icon className="size-5 md:m-[6px]" /> <Icon className="size-5" />
</NavLink> </NavLink>
); );

View File

@ -13,7 +13,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import useSWR from "swr";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -35,6 +36,7 @@ import { LuCheck, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { import {
MobilePage, MobilePage,
MobilePageContent, MobilePageContent,
@ -54,9 +56,15 @@ export default function CreateUserDialog({
onCreate, onCreate,
onCancel, onCancel,
}: CreateUserOverlayProps) { }: CreateUserOverlayProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const roles = useMemo(() => {
const existingRoles = config ? Object.keys(config.auth?.roles || {}) : [];
return Array.from(new Set(["admin", "viewer", ...(existingRoles || [])]));
}, [config]);
const formSchema = z const formSchema = z
.object({ .object({
user: z user: z
@ -69,7 +77,7 @@ export default function CreateUserDialog({
confirmPassword: z confirmPassword: z
.string() .string()
.min(1, t("users.dialog.createUser.confirmPassword")), .min(1, t("users.dialog.createUser.confirmPassword")),
role: z.enum(["admin", "viewer"]), role: z.string().min(1),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
message: t("users.dialog.form.password.notMatch"), message: t("users.dialog.form.password.notMatch"),
@ -246,24 +254,22 @@ export default function CreateUserDialog({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{roles.map((r) => (
<SelectItem <SelectItem
value="admin" value={r}
key={r}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{r === "admin" ? (
<Shield className="h-4 w-4 text-primary" /> <Shield className="h-4 w-4 text-primary" />
<span>{t("role.admin", { ns: "common" })}</span> ) : (
</div>
</SelectItem>
<SelectItem
value="viewer"
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
<span>{t("role.viewer", { ns: "common" })}</span> )}
<span>{t(`role.${r}`, { ns: "common" }) || r}</span>
</div> </div>
</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription className="text-xs text-muted-foreground"> <FormDescription className="text-xs text-muted-foreground">

View File

@ -12,6 +12,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@ -20,7 +21,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { LuPlus, LuScanFace } from "react-icons/lu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import React, { ReactNode, useMemo, useState } from "react"; import React, { ReactNode, useMemo, useState } from "react";
@ -89,27 +89,26 @@ export default function FaceSelectionDialog({
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel> <DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
<div <div
className={cn( className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto", "flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4", isMobile && "gap-2 pb-4",
)} )}
> >
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)}
>
<LuPlus />
{t("createFaceLibrary.new")}
</SelectorItem>
{faceNames.sort().map((faceName) => ( {faceNames.sort().map((faceName) => (
<SelectorItem <SelectorItem
key={faceName} key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize" className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onTrainAttempt(faceName)} onClick={() => onTrainAttempt(faceName)}
> >
<LuScanFace />
{faceName} {faceName}
</SelectorItem> </SelectorItem>
))} ))}
<DropdownMenuSeparator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => setNewFace(true)}
>
{t("createFaceLibrary.new")}
</SelectorItem>
</div> </div>
</SelectorContent> </SelectorContent>
</Selector> </Selector>

View File

@ -171,6 +171,18 @@ export default function ImagePicker({
alt={selectedImage?.label || "Selected image"} alt={selectedImage?.label || "Selected image"}
className="size-16 rounded object-cover" className="size-16 rounded object-cover"
onLoad={() => handleImageLoad(selectedImageId || "")} onLoad={() => handleImageLoad(selectedImageId || "")}
onError={(e) => {
// If trigger thumbnail fails to load, fall back to event thumbnail
if (!selectedImage) {
const target = e.target as HTMLImageElement;
if (
target.src.includes("clips/triggers") &&
selectedImageId
) {
target.src = `${apiHost}api/events/${selectedImageId}/thumbnail.webp`;
}
}
}}
loading="lazy" loading="lazy"
/> />
{selectedImageId && !loadedImages.has(selectedImageId) && ( {selectedImageId && !loadedImages.has(selectedImageId) && (

View File

@ -683,6 +683,22 @@ function ObjectDetailsTab({
const mutate = useGlobalMutation(); const mutate = useGlobalMutation();
// Helper to map over SWR cached search results while preserving
// either paginated format (SearchResult[][]) or flat format (SearchResult[])
const mapSearchResults = useCallback(
(
currentData: SearchResult[][] | SearchResult[] | undefined,
fn: (event: SearchResult) => SearchResult,
) => {
if (!currentData) return currentData;
if (Array.isArray(currentData[0])) {
return (currentData as SearchResult[][]).map((page) => page.map(fn));
}
return (currentData as SearchResult[]).map(fn);
},
[],
);
// users // users
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
@ -810,17 +826,12 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
// optimistic update
return currentData
.flat()
.map((event) =>
event.id === search.id event.id === search.id
? { ...event, data: { ...event.data, description: desc } } ? { ...event, data: { ...event.data, description: desc } }
: event, : event,
); ),
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -843,7 +854,7 @@ function ObjectDetailsTab({
); );
setDesc(search.data.description); setDesc(search.data.description);
}); });
}, [desc, search, mutate, t]); }, [desc, search, mutate, t, mapSearchResults]);
const regenerateDescription = useCallback( const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => { (source: "snapshot" | "thumbnails") => {
@ -915,9 +926,8 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
return currentData.flat().map((event) =>
event.id === search.id event.id === search.id
? { ? {
...event, ...event,
@ -928,8 +938,7 @@ function ObjectDetailsTab({
}, },
} }
: event, : event,
); ),
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -963,7 +972,7 @@ function ObjectDetailsTab({
); );
}); });
}, },
[search, apiHost, mutate, setSearch, t], [search, apiHost, mutate, setSearch, t, mapSearchResults],
); );
// recognized plate // recognized plate
@ -992,9 +1001,8 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
return currentData.flat().map((event) =>
event.id === search.id event.id === search.id
? { ? {
...event, ...event,
@ -1005,8 +1013,7 @@ function ObjectDetailsTab({
}, },
} }
: event, : event,
); ),
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -1040,7 +1047,7 @@ function ObjectDetailsTab({
); );
}); });
}, },
[search, apiHost, mutate, setSearch, t], [search, apiHost, mutate, setSearch, t, mapSearchResults],
); );
// speech transcription // speech transcription
@ -1102,17 +1109,12 @@ function ObjectDetailsTab({
(key.includes("events") || (key.includes("events") ||
key.includes("events/search") || key.includes("events/search") ||
key.includes("events/explore")), key.includes("events/explore")),
(currentData: SearchResult[][] | SearchResult[] | undefined) => { (currentData: SearchResult[][] | SearchResult[] | undefined) =>
if (!currentData) return currentData; mapSearchResults(currentData, (event) =>
// optimistic update
return currentData
.flat()
.map((event) =>
event.id === search.id event.id === search.id
? { ...event, plus_id: "new_upload" } ? { ...event, plus_id: "new_upload" }
: event, : event,
); ),
},
{ {
optimisticData: true, optimisticData: true,
rollbackOnError: true, rollbackOnError: true,
@ -1120,7 +1122,7 @@ function ObjectDetailsTab({
}, },
); );
}, },
[search, mutate], [search, mutate, mapSearchResults],
); );
const popoverContainerRef = useRef<HTMLDivElement | null>(null); const popoverContainerRef = useRef<HTMLDivElement | null>(null);
@ -1503,7 +1505,7 @@ function ObjectDetailsTab({
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Textarea <Textarea
className="text-md h-32" className="text-md h-32 md:text-sm"
placeholder={t("details.description.placeholder")} placeholder={t("details.description.placeholder")}
value={desc} value={desc}
onChange={(e) => setDesc(e.target.value)} onChange={(e) => setDesc(e.target.value)}
@ -1511,25 +1513,7 @@ function ObjectDetailsTab({
onBlur={handleDescriptionBlur} onBlur={handleDescriptionBlur}
autoFocus autoFocus
/> />
<div className="flex flex-row justify-end gap-4"> <div className="mb-10 flex flex-row justify-end gap-5">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={t("button.save", { ns: "common" })}
className="text-primary/40 hover:text-primary/80"
onClick={() => {
setIsEditingDesc(false);
updateDescription();
}}
>
<FaCheck className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("button.save", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -1540,13 +1524,31 @@ function ObjectDetailsTab({
setDesc(originalDescRef.current ?? ""); setDesc(originalDescRef.current ?? "");
}} }}
> >
<FaTimes className="size-4" /> <FaTimes className="size-5" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={t("button.save", { ns: "common" })}
className="text-primary/40 hover:text-primary/80"
onClick={() => {
setIsEditingDesc(false);
updateDescription();
}}
>
<FaCheck className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("button.save", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@ -1,5 +1,6 @@
import useSWR from "swr"; import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "@/hooks/resize-observer";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
@ -11,7 +12,11 @@ import { cn } from "@/lib/utils";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { REVIEW_PADDING } from "@/types/review"; import { REVIEW_PADDING } from "@/types/review";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
Recording,
} from "@/types/record";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -74,6 +79,139 @@ export function TrackingDetails({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// Fetch recording segments for the event's time range to handle motion-only gaps
const eventStartRecord = useMemo(
() => (event.start_time ?? 0) + annotationOffset / 1000,
[event.start_time, annotationOffset],
);
const eventEndRecord = useMemo(
() => (event.end_time ?? Date.now() / 1000) + annotationOffset / 1000,
[event.end_time, annotationOffset],
);
const { data: recordings } = useSWR<Recording[]>(
event.camera
? [
`${event.camera}/recordings`,
{
after: eventStartRecord - REVIEW_PADDING,
before: eventEndRecord + REVIEW_PADDING,
},
]
: null,
);
// Convert a timeline timestamp to actual video player time, accounting for
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
const timestampToVideoTime = useCallback(
(timestamp: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
return timestamp - (eventStartRecord - REVIEW_PADDING);
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// If timestamp is before video start, return 0
if (timestamp < videoStartTime) return 0;
// Check if timestamp is before the first recording or after the last
if (
timestamp < recordings[0].start_time ||
timestamp > recordings[recordings.length - 1].end_time
) {
// No recording available at this timestamp
return 0;
}
// Calculate the inpoint offset - the HLS video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
}
let seekSeconds = 0;
for (const segment of recordings) {
// Skip segments that end before our timestamp
if (segment.end_time <= timestamp) {
// Add this segment's duration, but subtract inpoint offset from first segment
if (segment === recordings[0]) {
seekSeconds += segment.duration - inpointOffset;
} else {
seekSeconds += segment.duration;
}
} else if (segment.start_time <= timestamp) {
// The timestamp is within this segment
if (segment === recordings[0]) {
// For the first segment, account for the inpoint offset
seekSeconds +=
timestamp - Math.max(segment.start_time, videoStartTime);
} else {
seekSeconds += timestamp - segment.start_time;
}
break;
}
}
return seekSeconds;
},
[recordings, eventStartRecord],
);
// Convert video player time back to timeline timestamp, accounting for
// motion-only recording gaps. Reverse of timestampToVideoTime.
const videoTimeToTimestamp = useCallback(
(playerTime: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
const videoStartTime = eventStartRecord - REVIEW_PADDING;
return playerTime + videoStartTime;
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// Calculate the inpoint offset - the video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
}
let timestamp = 0;
let totalTime = 0;
for (const segment of recordings) {
const segmentDuration =
segment === recordings[0]
? segment.duration - inpointOffset
: segment.duration;
if (totalTime + segmentDuration > playerTime) {
// The player time is within this segment
if (segment === recordings[0]) {
// For the first segment, add the inpoint offset
timestamp =
Math.max(segment.start_time, videoStartTime) +
(playerTime - totalTime);
} else {
timestamp = segment.start_time + (playerTime - totalTime);
}
break;
} else {
totalTime += segmentDuration;
}
}
return timestamp;
},
[recordings, eventStartRecord],
);
eventSequence?.map((event) => { eventSequence?.map((event) => {
event.data.zones_friendly_names = event.data?.zones?.map((zone) => { event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
return resolveZoneName(config, zone); return resolveZoneName(config, zone);
@ -89,9 +227,16 @@ export function TrackingDetails({
}, [manualOverride, currentTime, annotationOffset]); }, [manualOverride, currentTime, annotationOffset]);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const [_selectedZone, setSelectedZone] = useState(""); const [_selectedZone, setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]); const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null); const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const [lineBottomOffsetPx, setLineBottomOffsetPx] = useState<number>(32);
const [lineTopOffsetPx, setLineTopOffsetPx] = useState<number>(8);
const [blueLineHeightPx, setBlueLineHeightPx] = useState<number>(0);
const [timelineSize] = useResizeObserver(timelineContainerRef);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!config) { if (!config) {
@ -140,17 +285,14 @@ export function TrackingDetails({
return; return;
} }
// For video mode: convert to video-relative time and seek player // For video mode: convert to video-relative time (accounting for motion-only gaps)
const eventStartRecord = const relativeTime = timestampToVideoTime(targetTimeRecord);
(event.start_time ?? 0) + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = targetTimeRecord - videoStartTime;
if (videoRef.current) { if (videoRef.current) {
videoRef.current.currentTime = relativeTime; videoRef.current.currentTime = relativeTime;
} }
}, },
[event.start_time, annotationOffset, displaySource], [annotationOffset, displaySource, timestampToVideoTime],
); );
const formattedStart = config const formattedStart = config
@ -169,8 +311,9 @@ export function TrackingDetails({
}) })
: ""; : "";
const formattedEnd = config const formattedEnd =
? formatUnixTimestampToDateTime(event.end_time ?? 0, { config && event.end_time != null
? formatUnixTimestampToDateTime(event.end_time, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: date_format:
config.ui.time_format == "24hour" config.ui.time_format == "24hour"
@ -202,79 +345,83 @@ export function TrackingDetails({
} }
// seekToTimestamp is a record stream timestamp // seekToTimestamp is a record stream timestamp
// event.start_time is detect stream time, convert to record // Convert to video position (accounting for motion-only recording gaps)
// The video clip starts at (eventStartRecord - REVIEW_PADDING)
if (!videoRef.current) return; if (!videoRef.current) return;
const eventStartRecord = event.start_time + annotationOffset / 1000; const relativeTime = timestampToVideoTime(seekToTimestamp);
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = seekToTimestamp - videoStartTime;
if (relativeTime >= 0) { if (relativeTime >= 0) {
videoRef.current.currentTime = relativeTime; videoRef.current.currentTime = relativeTime;
} }
setSeekToTimestamp(null); setSeekToTimestamp(null);
}, [ }, [seekToTimestamp, displaySource, timestampToVideoTime]);
seekToTimestamp,
event.start_time,
annotationOffset,
apiHost,
event.camera,
displaySource,
]);
const isWithinEventRange = const isWithinEventRange = useMemo(() => {
effectiveTime !== undefined && if (effectiveTime === undefined || event.start_time === undefined) {
event.start_time !== undefined && return false;
event.end_time !== undefined && }
effectiveTime >= event.start_time && // If an event has not ended yet, fall back to last timestamp in eventSequence
effectiveTime <= event.end_time; let eventEnd = event.end_time;
if (eventEnd == null && eventSequence && eventSequence.length > 0) {
// Calculate how far down the blue line should extend based on effectiveTime const last = eventSequence[eventSequence.length - 1];
const calculateLineHeight = useCallback(() => { if (last && last.timestamp !== undefined) {
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) { eventEnd = last.timestamp;
return 0; }
} }
const currentTime = effectiveTime ?? 0; if (eventEnd == null) {
return false;
}
return effectiveTime >= event.start_time && effectiveTime <= eventEnd;
}, [effectiveTime, event.start_time, event.end_time, eventSequence]);
// Find which events have been passed // Dynamically compute pixel offsets so the timeline line starts at the
let lastPassedIndex = -1; // first row midpoint and ends at the last row midpoint. For accuracy,
for (let i = 0; i < eventSequence.length; i++) { // measure the center Y of each lifecycle row and interpolate the current
if (currentTime >= (eventSequence[i].timestamp ?? 0)) { // effective time into a pixel position; then set the blue line height
lastPassedIndex = i; // so it reaches the center dot at the same time the dot becomes active.
useEffect(() => {
if (!timelineContainerRef.current || !eventSequence) return;
const containerRect = timelineContainerRef.current.getBoundingClientRect();
const validRefs = rowRefs.current.filter((r) => r !== null);
if (validRefs.length === 0) return;
const centers = validRefs.map((n) => {
const r = n.getBoundingClientRect();
return r.top + r.height / 2 - containerRect.top;
});
const topOffset = Math.max(0, centers[0]);
const bottomOffset = Math.max(
0,
containerRect.height - centers[centers.length - 1],
);
setLineTopOffsetPx(Math.round(topOffset));
setLineBottomOffsetPx(Math.round(bottomOffset));
const eff = effectiveTime ?? 0;
const timestamps = eventSequence.map((s) => s.timestamp ?? 0);
let pixelPos = centers[0];
if (eff <= timestamps[0]) {
pixelPos = centers[0];
} else if (eff >= timestamps[timestamps.length - 1]) {
pixelPos = centers[centers.length - 1];
} else { } else {
for (let i = 0; i < timestamps.length - 1; i++) {
const t1 = timestamps[i];
const t2 = timestamps[i + 1];
if (eff >= t1 && eff <= t2) {
const ratio = t2 > t1 ? (eff - t1) / (t2 - t1) : 0;
pixelPos = centers[i] + ratio * (centers[i + 1] - centers[i]);
break; break;
} }
} }
}
// No events passed yet const bluePx = Math.round(Math.max(0, pixelPos - topOffset));
if (lastPassedIndex < 0) return 0; setBlueLineHeightPx(bluePx);
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
// All events passed
if (lastPassedIndex >= eventSequence.length - 1) return 100;
// Calculate percentage based on item position, not time
// Each item occupies an equal visual space regardless of time gaps
const itemPercentage = 100 / (eventSequence.length - 1);
// Find progress between current and next event for smooth transition
const currentEvent = eventSequence[lastPassedIndex];
const nextEvent = eventSequence[lastPassedIndex + 1];
const currentTimestamp = currentEvent.timestamp ?? 0;
const nextTimestamp = nextEvent.timestamp ?? 0;
// Calculate interpolation between the two events
const timeBetween = nextTimestamp - currentTimestamp;
const timeElapsed = currentTime - currentTimestamp;
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
// Base position plus interpolated progress to next item
return Math.min(
100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
);
}, [eventSequence, effectiveTime, isWithinEventRange]);
const blueLineHeight = calculateLineHeight();
const videoSource = useMemo(() => { const videoSource = useMemo(() => {
// event.start_time and event.end_time are in DETECT stream time // event.start_time and event.end_time are in DETECT stream time
@ -312,14 +459,13 @@ export function TrackingDetails({
const handleTimeUpdate = useCallback( const handleTimeUpdate = useCallback(
(time: number) => { (time: number) => {
// event.start_time is detect stream time, convert to record // Convert video player time back to timeline timestamp
const eventStartRecord = event.start_time + annotationOffset / 1000; // accounting for motion-only recording gaps
const videoStartTime = eventStartRecord - REVIEW_PADDING; const absoluteTime = videoTimeToTimestamp(time);
const absoluteTime = time + videoStartTime;
setCurrentTime(absoluteTime); setCurrentTime(absoluteTime);
}, },
[event.start_time, annotationOffset], [videoTimeToTimestamp],
); );
const [src, setSrc] = useState( const [src, setSrc] = useState(
@ -503,9 +649,16 @@ export function TrackingDetails({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="capitalize">{label}</span> <span className="capitalize">{label}</span>
<span className="md:text-md text-xs text-secondary-foreground"> <div className="md:text-md flex items-center text-xs text-secondary-foreground">
{formattedStart ?? ""} - {formattedEnd ?? ""} {formattedStart ?? ""}
</span> {event.end_time != null ? (
<> - {formattedEnd}</>
) : (
<div className="inline-block">
<ActivityIndicator className="ml-3 size-4" />
</div>
)}
</div>
{event.data?.recognized_license_plate && ( {event.data?.recognized_license_plate && (
<> <>
<span className="text-secondary-foreground">·</span> <span className="text-secondary-foreground">·</span>
@ -531,12 +684,21 @@ export function TrackingDetails({
{t("detail.noObjectDetailData", { ns: "views/events" })} {t("detail.noObjectDetailData", { ns: "views/events" })}
</div> </div>
) : ( ) : (
<div className="-pb-2 relative mx-0"> <div
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" /> className="-pb-2 relative mx-0"
ref={timelineContainerRef}
>
<div
className="absolute -top-2 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground"
style={{ bottom: lineBottomOffsetPx }}
/>
{isWithinEventRange && ( {isWithinEventRange && (
<div <div
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300" className="absolute left-6 z-[5] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }} style={{
top: `${lineTopOffsetPx}px`,
height: `${blueLineHeightPx}px`,
}}
/> />
)} )}
<div className="space-y-2"> <div className="space-y-2">
@ -589,8 +751,13 @@ export function TrackingDetails({
: undefined; : undefined;
return ( return (
<LifecycleIconRow <div
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`} key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
ref={(el) => {
rowRefs.current[idx] = el;
}}
>
<LifecycleIconRow
item={item} item={item}
isActive={isActive} isActive={isActive}
formattedEventTimestamp={formattedEventTimestamp} formattedEventTimestamp={formattedEventTimestamp}
@ -603,6 +770,7 @@ export function TrackingDetails({
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
isTimelineActive={isWithinEventRange} isTimelineActive={isWithinEventRange}
/> />
</div>
); );
})} })}
</div> </div>

View File

@ -318,6 +318,7 @@ export default function HlsVideoPlayer({
{isDetailMode && {isDetailMode &&
camera && camera &&
currentTime && currentTime &&
loadedMetadata &&
videoDimensions.width > 0 && videoDimensions.width > 0 &&
videoDimensions.height > 0 && ( videoDimensions.height > 0 && (
<div className="absolute z-50 size-full"> <div className="absolute z-50 size-full">

View File

@ -18,7 +18,7 @@ import { z } from "zod";
import axios from "axios"; import axios from "axios";
import { toast, Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { LuTrash2, LuPlus } from "react-icons/lu"; import { LuTrash2, LuPlus } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -42,7 +42,15 @@ export default function CameraEditForm({
onCancel, onCancel,
}: CameraEditFormProps) { }: CameraEditFormProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config, mutate: mutateConfig } =
useSWR<FrigateConfig>("config");
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
cameras: Record<
string,
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
>;
go2rtc: { streams: Record<string, string | string[]> };
}>(cameraName ? "config/raw_paths" : null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const formSchema = useMemo( const formSchema = useMemo(
@ -145,14 +153,23 @@ export default function CameraEditForm({
if (cameraName && config?.cameras[cameraName]) { if (cameraName && config?.cameras[cameraName]) {
const camera = config.cameras[cameraName]; const camera = config.cameras[cameraName];
defaultValues.enabled = camera.enabled ?? true; defaultValues.enabled = camera.enabled ?? true;
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
// Use raw paths from the admin endpoint if available, otherwise fall back to masked paths
const rawCameraData = rawPaths?.cameras?.[cameraName];
defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length
? rawCameraData.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
}))
: camera.ffmpeg?.inputs?.length
? camera.ffmpeg.inputs.map((input) => ({ ? camera.ffmpeg.inputs.map((input) => ({
path: input.path, path: input.path,
roles: input.roles as Role[], roles: input.roles as Role[],
})) }))
: defaultValues.ffmpeg.inputs; : defaultValues.ffmpeg.inputs;
const go2rtcStreams = config.go2rtc?.streams || {}; const go2rtcStreams =
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
const cameraStreams: Record<string, string[]> = {}; const cameraStreams: Record<string, string[]> = {};
// get candidate stream names for this camera. could be the camera's own name, // get candidate stream names for this camera. could be the camera's own name,
@ -196,6 +213,60 @@ export default function CameraEditForm({
mode: "onChange", mode: "onChange",
}); });
// Update form values when rawPaths loads
useEffect(() => {
if (
cameraName &&
config?.cameras[cameraName] &&
rawPaths?.cameras?.[cameraName]
) {
const camera = config.cameras[cameraName];
const rawCameraData = rawPaths.cameras[cameraName];
// Update ffmpeg inputs with raw paths
if (rawCameraData.ffmpeg?.inputs?.length) {
form.setValue(
"ffmpeg.inputs",
rawCameraData.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
})),
);
}
// Update go2rtc streams with raw URLs
if (rawPaths.go2rtc?.streams) {
const validNames = new Set<string>();
validNames.add(cameraName);
camera.ffmpeg?.inputs?.forEach((input) => {
const restreamMatch = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
);
if (restreamMatch) {
validNames.add(restreamMatch[1]);
}
});
const liveStreams = camera?.live?.streams;
if (liveStreams) {
Object.keys(liveStreams).forEach((key) => validNames.add(key));
}
const cameraStreams: Record<string, string[]> = {};
Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => {
if (validNames.has(name)) {
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
}
});
if (Object.keys(cameraStreams).length > 0) {
form.setValue("go2rtcStreams", cameraStreams);
}
}
}
}, [cameraName, config, rawPaths, form]);
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control: form.control, control: form.control,
name: "ffmpeg.inputs", name: "ffmpeg.inputs",
@ -268,6 +339,8 @@ export default function CameraEditForm({
}), }),
{ position: "top-center" }, { position: "top-center" },
); );
mutateConfig();
mutateRawPaths();
if (onSave) onSave(); if (onSave) onSave();
}); });
} else { } else {
@ -277,6 +350,8 @@ export default function CameraEditForm({
}), }),
{ position: "top-center" }, { position: "top-center" },
); );
mutateConfig();
mutateRawPaths();
if (onSave) onSave(); if (onSave) onSave();
} }
} else { } else {

View File

@ -15,6 +15,7 @@ import {
ReviewSummary, ReviewSummary,
SegmentedReviewData, SegmentedReviewData,
} from "@/types/review"; } from "@/types/review";
import { TimelineType } from "@/types/timeline";
import { import {
getBeginningOfDayTimestamp, getBeginningOfDayTimestamp,
getEndOfDayTimestamp, getEndOfDayTimestamp,
@ -49,6 +50,16 @@ export default function Events() {
false, false,
); );
const [notificationTab, setNotificationTab] =
useState<TimelineType>("timeline");
useSearchEffect("tab", (tab: string) => {
if (tab === "timeline" || tab === "events" || tab === "detail") {
setNotificationTab(tab as TimelineType);
}
return true;
});
useSearchEffect("id", (reviewId: string) => { useSearchEffect("id", (reviewId: string) => {
axios axios
.get(`review/${reviewId}`) .get(`review/${reviewId}`)
@ -66,7 +77,7 @@ export default function Events() {
camera: resp.data.camera, camera: resp.data.camera,
startTime, startTime,
severity: resp.data.severity, severity: resp.data.severity,
timelineType: "detail", timelineType: notificationTab,
}, },
true, true,
); );

View File

@ -26,7 +26,7 @@ import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch"; import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas"; import { PolygonType } from "@/types/canvas";
import CameraSettingsView from "@/views/settings/CameraSettingsView"; import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
import CameraManagementView from "@/views/settings/CameraManagementView"; import CameraManagementView from "@/views/settings/CameraManagementView";
import MotionTunerView from "@/views/settings/MotionTunerView"; import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
@ -93,7 +93,7 @@ const settingsGroups = [
label: "cameras", label: "cameras",
items: [ items: [
{ key: "cameraManagement", component: CameraManagementView }, { key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraSettingsView }, { key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView }, { key: "masksAndZones", component: MasksAndZonesView },
{ key: "motionTuner", component: MotionTunerView }, { key: "motionTuner", component: MotionTunerView },
], ],

View File

@ -1,4 +1,5 @@
import { ReviewSeverity } from "./review"; import { ReviewSeverity } from "./review";
import { TimelineType } from "./timeline";
export type Recording = { export type Recording = {
id: string; id: string;
@ -37,7 +38,7 @@ export type RecordingStartingPoint = {
camera: string; camera: string;
startTime: number; startTime: number;
severity: ReviewSeverity; severity: ReviewSeverity;
timelineType?: "timeline" | "events" | "detail"; timelineType?: TimelineType;
}; };
export type RecordingPlayerError = "stalled" | "startup"; export type RecordingPlayerError = "stalled" | "startup";

View File

@ -33,7 +33,7 @@ i18n
fallbackLng: "en", // use en if detected lng is not available fallbackLng: "en", // use en if detected lng is not available
backend: { backend: {
loadPath: "locales/{{lng}}/{{ns}}.json", loadPath: `locales/{{lng}}/{{ns}}.json?v=${import.meta.env.VITE_GIT_COMMIT_HASH || "unknown"}`,
}, },
ns: [ ns: [

View File

@ -16,7 +16,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa"; import { FaFolderPlus } from "react-icons/fa";
import { MdModelTraining } from "react-icons/md"; import { MdModelTraining } from "react-icons/md";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi"; import { FiMoreVertical } from "react-icons/fi";
import useSWR from "swr"; import useSWR from "swr";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
@ -352,11 +351,9 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<DropdownMenuItem onClick={handleEditClick}> <DropdownMenuItem onClick={handleEditClick}>
<LuPencil className="mr-2 size-4" />
<span>{t("button.edit", { ns: "common" })}</span> <span>{t("button.edit", { ns: "common" })}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleDeleteClick}> <DropdownMenuItem onClick={handleDeleteClick}>
<LuTrash2 className="mr-2 size-4" />
<span>{t("button.delete", { ns: "common" })}</span> <span>{t("button.delete", { ns: "common" })}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@ -799,7 +799,7 @@ function DetectionReview({
(itemsToReview ?? 0) > 0 && ( (itemsToReview ?? 0) > 0 && (
<div className="col-span-full flex items-center justify-center"> <div className="col-span-full flex items-center justify-center">
<Button <Button
className="text-white" className="text-balance text-white"
aria-label={t("markTheseItemsAsReviewed")} aria-label={t("markTheseItemsAsReviewed")}
variant="select" variant="select"
onClick={() => { onClick={() => {

View File

@ -850,6 +850,29 @@ function FrigateCameraFeatures({
} }
}, [activeToastId, t]); }, [activeToastId, t]);
const endEventViaBeacon = useCallback(() => {
if (!recordingEventIdRef.current) return;
const url = `${window.location.origin}/api/events/${recordingEventIdRef.current}/end`;
const payload = JSON.stringify({
end_time: Math.ceil(Date.now() / 1000),
});
// this needs to be a synchronous XMLHttpRequest to guarantee the PUT
// reaches the server before the browser kills the page
const xhr = new XMLHttpRequest();
try {
xhr.open("PUT", url, false);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("X-CSRF-TOKEN", "1");
xhr.setRequestHeader("X-CACHE-BYPASS", "1");
xhr.withCredentials = true;
xhr.send(payload);
} catch (e) {
// Silently ignore errors during unload
}
}, []);
const handleEventButtonClick = useCallback(() => { const handleEventButtonClick = useCallback(() => {
if (isRecording) { if (isRecording) {
endEvent(); endEvent();
@ -887,8 +910,19 @@ function FrigateCameraFeatures({
}, [camera.name, isRestreamed, preferredLiveMode, t]); }, [camera.name, isRestreamed, preferredLiveMode, t]);
useEffect(() => { useEffect(() => {
// Handle page unload/close (browser close, tab close, refresh, navigation to external site)
const handleBeforeUnload = () => {
if (recordingEventIdRef.current) {
endEventViaBeacon();
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
// ensure manual event is stopped when component unmounts // ensure manual event is stopped when component unmounts
return () => { return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
if (recordingEventIdRef.current) { if (recordingEventIdRef.current) {
endEvent(); endEvent();
} }

View File

@ -5,17 +5,9 @@ import { Button } from "@/components/ui/button";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
@ -90,31 +82,6 @@ export default function CameraManagementView({
</Button> </Button>
{cameras.length > 0 && ( {cameras.length > 0 && (
<> <>
<div className="my-4 flex flex-col gap-2">
<Label>{t("cameraManagement.editCamera")}</Label>
<Select
onValueChange={(value) => {
setEditCameraName(value);
setViewMode("edit");
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue
placeholder={t("cameraManagement.selectCamera")}
/>
</SelectTrigger>
<SelectContent>
{cameras.map((camera) => {
return (
<SelectItem key={camera} value={camera}>
<CameraNameLabel camera={camera} />
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<div className="max-w-7xl space-y-4"> <div className="max-w-7xl space-y-4">
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">

View File

@ -0,0 +1,738 @@
import Heading from "@/components/ui/heading";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Toaster, toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Checkbox } from "@/components/ui/checkbox";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import axios from "axios";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import {
useAlertsState,
useDetectionsState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { formatList } from "@/utils/stringUtil";
type CameraReviewSettingsViewProps = {
selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type CameraReviewSettingsValueType = {
alerts_zones: string[];
detections_zones: string[];
};
export default function CameraReviewSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraReviewSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectDetections, setSelectDetections] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels
const getZoneName = useCallback(
(zoneId: string, cameraId?: string) =>
resolveZoneName(config, zoneId, cameraId),
[config],
);
const zones = useMemo(() => {
if (cameraConfig) {
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name,
name,
friendly_name: cameraConfig.zones[name].friendly_name,
objects: zoneData.objects,
color: zoneData.color,
}));
}
}, [cameraConfig]);
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? formatList(
cameraConfig.review.detections.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
// form
const formSchema = z.object({
alerts_zones: z.array(z.string()),
detections_zones: z.array(z.string()),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
},
});
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
useDetectionsState(selectedCamera);
const { payload: objDescState, send: sendObjDesc } =
useObjectDescriptionState(selectedCamera);
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(selectedCamera);
const handleCheckedChange = useCallback(
(isChecked: boolean) => {
if (!isChecked) {
form.reset({
alerts_zones: watchedAlertsZones,
detections_zones: [],
});
}
setChangedValue(true);
setSelectDetections(isChecked as boolean);
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[watchedAlertsZones],
);
const saveToConfig = useCallback(
async (
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
) => {
const createQuery = (zones: string[], type: "alerts" | "detections") =>
zones.length
? zones
.map(
(zone) =>
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
)
.join("")
: cameraConfig?.review[type]?.required_zones &&
cameraConfig?.review[type]?.required_zones.length > 0
? `&cameras.${selectedCamera}.review.${type}.required_zones`
: "";
const alertQueries = createQuery(alerts_zones, "alerts");
const detectionQueries = createQuery(detections_zones, "detections");
axios
.put(`config/set?${alertQueries}${detectionQueries}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("cameraReview.reviewClassification.toast.success"),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
if (!cameraConfig) {
return;
}
setChangedValue(false);
setUnsavedChanges(false);
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
form.reset({
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
});
setSelectDetections(
!!cameraConfig?.review.detections.required_zones?.length,
);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
useEffect(() => {
onCancel();
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera]);
useEffect(() => {
if (changedValue) {
addMessage(
"camera_settings",
t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
`review_classification_settings_${selectedCamera}`,
);
} else {
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue, selectedCamera]);
function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
saveToConfig(values as CameraReviewSettingsValueType);
}
useEffect(() => {
document.title = t("documentTitle.cameraReview");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<Heading as="h4" className="mb-2">
{t("cameraReview.title")}
</Heading>
<Heading as="h4" className="my-2">
<Trans ns="views/settings">cameraReview.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={alertsState == "ON"}
onCheckedChange={(isChecked) => {
sendAlerts(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="detections-enabled"
className="mr-3"
checked={detectionsState == "ON"}
onCheckedChange={(isChecked) => {
sendDetections(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
{cameraConfig?.objects?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.object_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={objDescState == "ON"}
onCheckedChange={(isChecked) => {
sendObjDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
</>
)}
{cameraConfig?.review?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.review_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={revDescState == "ON"}
onCheckedChange={(isChecked) => {
sendRevDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.reviewClassification.title
</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/review")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<div
className={cn(
"w-full max-w-5xl space-y-0",
zones &&
zones?.length > 0 &&
"grid items-start gap-5 md:grid-cols-2",
)}
>
<FormField
control={form.control}
name="alerts_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="alerts_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
setChangedValue(true);
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<FormMessage />
<div className="text-sm">
{watchedAlertsZones && watchedAlertsZones.length > 0
? t(
"cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: formatList(
watchedAlertsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
},
)
: t(
"cameraReview.reviewClassification.objectAlertsTips",
{
alertsLabels,
cameraName: selectCameraName,
},
)}
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="detections_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 && (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
{selectDetections && (
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="detections_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
)}
<FormMessage />
<div className="mb-0 flex flex-row items-center gap-2">
<Checkbox
id="select-detections"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectDetections}
onCheckedChange={handleCheckedChange}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
</>
)}
<div className="text-sm">
{watchedDetectionsZones &&
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
) : (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)}
</div>
</FormItem>
)}
/>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
type="button"
>
<Trans>button.reset</Trans>
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
<Trans>button.saving</Trans>
</span>
</div>
) : (
<Trans>button.save</Trans>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
</>
);
}

View File

@ -1,794 +0,0 @@
import Heading from "@/components/ui/heading";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Toaster, toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Checkbox } from "@/components/ui/checkbox";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import axios from "axios";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import {
useAlertsState,
useDetectionsState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { formatList } from "@/utils/stringUtil";
type CameraSettingsViewProps = {
selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type CameraReviewSettingsValueType = {
alerts_zones: string[];
detections_zones: string[];
};
export default function CameraSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectDetections, setSelectDetections] = useState(false);
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
"settings",
); // Control view state
const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels
const getZoneName = useCallback(
(zoneId: string, cameraId?: string) =>
resolveZoneName(config, zoneId, cameraId),
[config],
);
const zones = useMemo(() => {
if (cameraConfig) {
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
camera: cameraConfig.name,
name,
friendly_name: cameraConfig.zones[name].friendly_name,
objects: zoneData.objects,
color: zoneData.color,
}));
}
}, [cameraConfig]);
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? formatList(
cameraConfig.review.detections.labels.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
// form
const formSchema = z.object({
alerts_zones: z.array(z.string()),
detections_zones: z.array(z.string()),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
alerts_zones: cameraConfig?.review.alerts.required_zones || [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
},
});
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
useDetectionsState(selectedCamera);
const { payload: objDescState, send: sendObjDesc } =
useObjectDescriptionState(selectedCamera);
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(selectedCamera);
const handleCheckedChange = useCallback(
(isChecked: boolean) => {
if (!isChecked) {
form.reset({
alerts_zones: watchedAlertsZones,
detections_zones: [],
});
}
setChangedValue(true);
setSelectDetections(isChecked as boolean);
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[watchedAlertsZones],
);
const saveToConfig = useCallback(
async (
{ alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form
) => {
const createQuery = (zones: string[], type: "alerts" | "detections") =>
zones.length
? zones
.map(
(zone) =>
`&cameras.${selectedCamera}.review.${type}.required_zones=${zone}`,
)
.join("")
: cameraConfig?.review[type]?.required_zones &&
cameraConfig?.review[type]?.required_zones.length > 0
? `&cameras.${selectedCamera}.review.${type}.required_zones`
: "";
const alertQueries = createQuery(alerts_zones, "alerts");
const detectionQueries = createQuery(detections_zones, "detections");
axios
.put(`config/set?${alertQueries}${detectionQueries}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("cameraReview.reviewClassification.toast.success"),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
if (!cameraConfig) {
return;
}
setChangedValue(false);
setUnsavedChanges(false);
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
form.reset({
alerts_zones: cameraConfig?.review.alerts.required_zones ?? [],
detections_zones: cameraConfig?.review.detections.required_zones || [],
});
setSelectDetections(
!!cameraConfig?.review.detections.required_zones?.length,
);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [removeMessage, selectedCamera, setUnsavedChanges, cameraConfig]);
useEffect(() => {
onCancel();
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCamera]);
useEffect(() => {
if (changedValue) {
addMessage(
"camera_settings",
t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
`review_classification_settings_${selectedCamera}`,
);
} else {
removeMessage(
"camera_settings",
`review_classification_settings_${selectedCamera}`,
);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue, selectedCamera]);
function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
saveToConfig(values as CameraReviewSettingsValueType);
}
useEffect(() => {
document.title = t("documentTitle.cameraReview");
}, [t]);
// Handle back navigation from add/edit form
const handleBack = useCallback(() => {
setViewMode("settings");
setEditCameraName(undefined);
updateConfig();
}, [updateConfig]);
if (!cameraConfig && !selectedCamera && viewMode === "settings") {
return <ActivityIndicator />;
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("cameraReview.title")}
</Heading>
<Heading as="h4" className="my-2">
<Trans ns="views/settings">cameraReview.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={alertsState == "ON"}
onCheckedChange={(isChecked) => {
sendAlerts(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">
cameraReview.review.alerts
</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="detections-enabled"
className="mr-3"
checked={detectionsState == "ON"}
onCheckedChange={(isChecked) => {
sendDetections(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">
camera.review.detections
</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
{cameraConfig?.objects?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.object_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={objDescState == "ON"}
onCheckedChange={(isChecked) => {
sendObjDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
</>
)}
{cameraConfig?.review?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.review_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={revDescState == "ON"}
onCheckedChange={(isChecked) => {
sendRevDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="genai-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
</>
)}
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.reviewClassification.title
</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/review")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<div
className={cn(
"w-full max-w-5xl space-y-0",
zones &&
zones?.length > 0 &&
"grid items-start gap-5 md:grid-cols-2",
)}
>
<FormField
control={form.control}
name="alerts_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="alerts_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
setChangedValue(true);
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<FormMessage />
<div className="text-sm">
{watchedAlertsZones && watchedAlertsZones.length > 0
? t(
"cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: formatList(
watchedAlertsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
},
)
: t(
"cameraReview.reviewClassification.objectAlertsTips",
{
alertsLabels,
cameraName: selectCameraName,
},
)}
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="detections_zones"
render={() => (
<FormItem>
{zones && zones?.length > 0 && (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
<Trans ns="views/settings">
camera.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
<Trans ns="views/settings">
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
{selectDetections && (
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones?.map((zone) => (
<FormField
key={zone.name}
control={form.control}
name="detections_zones"
render={({ field }) => (
<FormItem
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={field.value?.includes(
zone.name,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
zone.name,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== zone.name,
),
);
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
)}
<FormMessage />
<div className="mb-0 flex flex-row items-center gap-2">
<Checkbox
id="select-detections"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectDetections}
onCheckedChange={handleCheckedChange}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
</>
)}
<div className="text-sm">
{watchedDetectionsZones &&
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
) : (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)}
</div>
</FormItem>
)}
/>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
type="button"
>
<Trans>button.reset</Trans>
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>
<Trans>button.saving</Trans>
</span>
</div>
) : (
<Trans>button.save</Trans>
)}
</Button>
</div>
</form>
</Form>
</>
) : (
<>
<div className="mb-4 flex items-center gap-2">
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
<div className="md:max-w-5xl">
<CameraEditForm
cameraName={viewMode === "edit" ? editCameraName : undefined}
onSave={handleBack}
onCancel={handleBack}
/>
</div>
</>
)}
</div>
</div>
<CameraWizardDialog
open={showWizard}
onClose={() => setShowWizard(false)}
/>
</>
);
}

View File

@ -201,12 +201,17 @@ export default function TriggerView({
.then((configResponse) => { .then((configResponse) => {
if (configResponse.status === 200) { if (configResponse.status === 200) {
updateConfig(); updateConfig();
const displayName =
friendly_name && friendly_name !== ""
? `${friendly_name} (${name})`
: name;
toast.success( toast.success(
t( t(
isEdit isEdit
? "triggers.toast.success.updateTrigger" ? "triggers.toast.success.updateTrigger"
: "triggers.toast.success.createTrigger", : "triggers.toast.success.createTrigger",
{ name }, { name: displayName },
), ),
{ position: "top-center" }, { position: "top-center" },
); );
@ -351,8 +356,19 @@ export default function TriggerView({
.then((configResponse) => { .then((configResponse) => {
if (configResponse.status === 200) { if (configResponse.status === 200) {
updateConfig(); updateConfig();
const friendly =
config?.cameras?.[selectedCamera]?.semantic_search
?.triggers?.[name]?.friendly_name;
const displayName =
friendly && friendly !== ""
? `${friendly} (${name})`
: name;
toast.success( toast.success(
t("triggers.toast.success.deleteTrigger", { name }), t("triggers.toast.success.deleteTrigger", {
name: displayName,
}),
{ {
position: "top-center", position: "top-center",
}, },
@ -381,7 +397,7 @@ export default function TriggerView({
setIsLoading(false); setIsLoading(false);
}); });
}, },
[t, updateConfig, selectedCamera, setUnsavedChanges], [t, updateConfig, selectedCamera, setUnsavedChanges, config],
); );
useEffect(() => { useEffect(() => {
@ -843,7 +859,14 @@ export default function TriggerView({
/> />
<DeleteTriggerDialog <DeleteTriggerDialog
show={showDelete} show={showDelete}
triggerName={selectedTrigger?.name ?? ""} triggerName={
selectedTrigger
? selectedTrigger.friendly_name &&
selectedTrigger.friendly_name !== ""
? `${selectedTrigger.friendly_name} (${selectedTrigger.name})`
: selectedTrigger.name
: ""
}
isLoading={isLoading} isLoading={isLoading}
onCancel={() => { onCancel={() => {
setShowDelete(false); setShowDelete(false);

View File

@ -157,7 +157,7 @@ export default function UiSettingsView() {
checked={cameraNames} checked={cameraNames}
onCheckedChange={setCameraName} onCheckedChange={setCameraName}
/> />
<Label className="cursor-pointer" htmlFor="auto-live"> <Label className="cursor-pointer" htmlFor="camera-names">
{t("general.liveDashboard.displayCameraNames.label")} {t("general.liveDashboard.displayCameraNames.label")}
</Label> </Label>
</div> </div>

View File

@ -72,8 +72,7 @@ export default function StorageMetrics({
const earliestDate = useMemo(() => { const earliestDate = useMemo(() => {
const keys = Object.keys(recordingsSummary || {}); const keys = Object.keys(recordingsSummary || {});
return keys.length return keys.length
? new TZDate(keys[keys.length - 1] + "T00:00:00", timezone).getTime() / ? new TZDate(keys[0] + "T00:00:00", timezone).getTime() / 1000
1000
: null; : null;
}, [recordingsSummary, timezone]); }, [recordingsSummary, timezone]);