mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-31 08:09:02 +03:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416a9b7692 | ||
|
|
d11c26970d | ||
|
|
e78da2758d | ||
|
|
ae9b307dfc | ||
|
|
01c16a9250 | ||
|
|
d4731c1dea | ||
|
|
65ca90db20 | ||
|
|
3ec2305e6a | ||
|
|
8b035be132 | ||
|
|
be79ad89b6 | ||
|
|
b147b53522 | ||
|
|
d2b2faa2d7 | ||
|
|
614a6b39d4 | ||
|
|
f29ee53fb4 | ||
|
|
544d3c6139 | ||
|
|
104e623923 |
@@ -61,7 +61,7 @@ mqtt:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
onvif:
|
onvif:
|
||||||
host: "{FRIGATE_ONVIF_HOST}"
|
host: "192.168.1.12"
|
||||||
port: 8000
|
port: 8000
|
||||||
user: "{FRIGATE_RTSP_USER}"
|
user: "{FRIGATE_RTSP_USER}"
|
||||||
password: "{FRIGATE_RTSP_PASSWORD}"
|
password: "{FRIGATE_RTSP_PASSWORD}"
|
||||||
|
|||||||
@@ -908,8 +908,8 @@ cameras:
|
|||||||
onvif:
|
onvif:
|
||||||
# Required: host of the camera being connected to.
|
# Required: host of the camera being connected to.
|
||||||
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
|
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
|
||||||
# NOTE: ONVIF host, user, and password can be specified with environment variables or docker secrets
|
# NOTE: ONVIF user, and password can be specified with environment variables or docker secrets
|
||||||
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_HOST}'
|
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
# Optional: ONVIF port for device (default: shown below).
|
# Optional: ONVIF port for device (default: shown below).
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ After closing VS Code, you may still have containers running. To close everythin
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
#### Unit Tests
|
||||||
|
|
||||||
|
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
python3 -u -m unittest
|
||||||
|
```
|
||||||
|
|
||||||
#### FFMPEG Hardware Acceleration
|
#### FFMPEG Hardware Acceleration
|
||||||
|
|
||||||
The following commands are used inside the container to ensure hardware acceleration is working properly.
|
The following commands are used inside the container to ensure hardware acceleration is working properly.
|
||||||
@@ -125,6 +133,28 @@ ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format
|
|||||||
ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Submitting a pull request
|
||||||
|
|
||||||
|
Code must be formatted, linted and type-tested. GitHub will run these checks on pull requests, so it is advised to run them yourself prior to opening.
|
||||||
|
|
||||||
|
**Formatting**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ruff format frigate migrations docker *.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linting**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ruff check frigate migrations docker *.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**MyPy Static Typing**
|
||||||
|
|
||||||
|
```shell
|
||||||
|
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||||
|
```
|
||||||
|
|
||||||
## Web Interface
|
## Web Interface
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|||||||
@@ -42,3 +42,7 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
|||||||
## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge)
|
## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge)
|
||||||
|
|
||||||
[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is an plugin that allows to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate.
|
[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is an plugin that allows to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate.
|
||||||
|
|
||||||
|
## [Strix](https://github.com/eduard256/Strix)
|
||||||
|
|
||||||
|
[Strix](https://github.com/eduard256/Strix) auto-discovers working stream URLs for IP cameras and generates ready-to-use Frigate configs. It tests thousands of URL patterns against your camera and supports cameras without RTSP or ONVIF. 67K+ camera models from 3.6K+ brands.
|
||||||
|
|||||||
+17
-3
@@ -218,7 +218,7 @@ def config_raw_paths(request: Request):
|
|||||||
return JSONResponse(content=raw_paths)
|
return JSONResponse(content=raw_paths)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def config_raw():
|
def config_raw():
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
|
|
||||||
@@ -732,7 +732,12 @@ def get_recognized_license_plates(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
|
def timeline(
|
||||||
|
camera: str = "all",
|
||||||
|
limit: int = 100,
|
||||||
|
source_id: Optional[str] = None,
|
||||||
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
|
):
|
||||||
clauses = []
|
clauses = []
|
||||||
|
|
||||||
selected_columns = [
|
selected_columns = [
|
||||||
@@ -754,6 +759,9 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
|||||||
else:
|
else:
|
||||||
clauses.append((Timeline.source_id.in_(source_ids)))
|
clauses.append((Timeline.source_id.in_(source_ids)))
|
||||||
|
|
||||||
|
# Enforce per-camera access control
|
||||||
|
clauses.append((Timeline.camera << allowed_cameras))
|
||||||
|
|
||||||
if len(clauses) == 0:
|
if len(clauses) == 0:
|
||||||
clauses.append((True))
|
clauses.append((True))
|
||||||
|
|
||||||
@@ -769,7 +777,10 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
def hourly_timeline(
|
||||||
|
params: AppTimelineHourlyQueryParameters = Depends(),
|
||||||
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
|
):
|
||||||
"""Get hourly summary for timeline."""
|
"""Get hourly summary for timeline."""
|
||||||
cameras = params.cameras
|
cameras = params.cameras
|
||||||
labels = params.labels
|
labels = params.labels
|
||||||
@@ -787,6 +798,9 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
|||||||
camera_list = cameras.split(",")
|
camera_list = cameras.split(",")
|
||||||
clauses.append((Timeline.camera << camera_list))
|
clauses.append((Timeline.camera << camera_list))
|
||||||
|
|
||||||
|
# Enforce per-camera access control
|
||||||
|
clauses.append((Timeline.camera << allowed_cameras))
|
||||||
|
|
||||||
if labels != "all":
|
if labels != "all":
|
||||||
label_list = labels.split(",")
|
label_list = labels.split(",")
|
||||||
clauses.append((Timeline.data["label"] << label_list))
|
clauses.append((Timeline.data["label"] << label_list))
|
||||||
|
|||||||
+3
-1
@@ -67,7 +67,6 @@ def require_admin_by_default():
|
|||||||
"/stats",
|
"/stats",
|
||||||
"/stats/history",
|
"/stats/history",
|
||||||
"/config",
|
"/config",
|
||||||
"/config/raw",
|
|
||||||
"/vainfo",
|
"/vainfo",
|
||||||
"/nvinfo",
|
"/nvinfo",
|
||||||
"/labels",
|
"/labels",
|
||||||
@@ -837,6 +836,7 @@ def create_user(
|
|||||||
User.notification_tokens: [],
|
User.notification_tokens: [],
|
||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"username": body.username})
|
return JSONResponse(content={"username": body.username})
|
||||||
|
|
||||||
|
|
||||||
@@ -854,6 +854,7 @@ def delete_user(request: Request, username: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
User.delete_by_id(username)
|
User.delete_by_id(username)
|
||||||
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
|
||||||
@@ -973,6 +974,7 @@ async def update_role(
|
|||||||
)
|
)
|
||||||
|
|
||||||
User.set_by_id(username, {User.role: body.role})
|
User.set_by_id(username, {User.role: body.role})
|
||||||
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-11
@@ -1142,7 +1142,6 @@ async def event_snapshot(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events/{event_id}/thumbnail.{extension}",
|
"/events/{event_id}/thumbnail.{extension}",
|
||||||
dependencies=[Depends(require_camera_access)],
|
|
||||||
)
|
)
|
||||||
async def event_thumbnail(
|
async def event_thumbnail(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1344,12 +1343,12 @@ def grid_snapshot(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events/{event_id}/snapshot-clean.webp",
|
"/events/{event_id}/snapshot-clean.webp",
|
||||||
dependencies=[Depends(require_camera_access)],
|
|
||||||
)
|
)
|
||||||
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
async def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||||
webp_bytes = None
|
webp_bytes = None
|
||||||
try:
|
try:
|
||||||
event = Event.get(Event.id == event_id)
|
event = Event.get(Event.id == event_id)
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
|
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
|
||||||
if not (snapshot_config.enabled and event.has_snapshot):
|
if not (snapshot_config.enabled and event.has_snapshot):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -1470,7 +1469,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
|
"/events/{event_id}/clip.mp4",
|
||||||
)
|
)
|
||||||
async def event_clip(
|
async def event_clip(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1484,6 +1483,8 @@ async def event_clip(
|
|||||||
content={"success": False, "message": "Event not found"}, status_code=404
|
content={"success": False, "message": "Event not found"}, status_code=404
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
|
|
||||||
if not event.has_clip:
|
if not event.has_clip:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Clip not available"}, status_code=404
|
content={"success": False, "message": "Clip not available"}, status_code=404
|
||||||
@@ -1500,9 +1501,9 @@ async def event_clip(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
|
"/events/{event_id}/preview.gif",
|
||||||
)
|
)
|
||||||
def event_preview(request: Request, event_id: str):
|
async def event_preview(request: Request, event_id: str):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
@@ -1510,6 +1511,8 @@ def event_preview(request: Request, event_id: str):
|
|||||||
content={"success": False, "message": "Event not found"}, status_code=404
|
content={"success": False, "message": "Event not found"}, status_code=404
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await require_camera_access(event.camera, request=request)
|
||||||
|
|
||||||
start_ts = event.start_time
|
start_ts = event.start_time
|
||||||
end_ts = start_ts + (
|
end_ts = start_ts + (
|
||||||
min(event.end_time - event.start_time, 20) if event.end_time else 20
|
min(event.end_time - event.start_time, 20) if event.end_time else 20
|
||||||
@@ -1854,8 +1857,8 @@ def preview_mp4(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
@router.get("/review/{event_id}/preview")
|
||||||
def review_preview(
|
async def review_preview(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
format: str = Query(default="gif", enum=["gif", "mp4"]),
|
format: str = Query(default="gif", enum=["gif", "mp4"]),
|
||||||
@@ -1868,6 +1871,8 @@ def review_preview(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await require_camera_access(review.camera, request=request)
|
||||||
|
|
||||||
padding = 8
|
padding = 8
|
||||||
start_ts = review.start_time - padding
|
start_ts = review.start_time - padding
|
||||||
end_ts = (
|
end_ts = (
|
||||||
@@ -1881,12 +1886,14 @@ def review_preview(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
|
"/preview/{file_name}/thumbnail.jpg",
|
||||||
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
)
|
)
|
||||||
@router.get(
|
@router.get(
|
||||||
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
|
"/preview/{file_name}/thumbnail.webp",
|
||||||
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
)
|
)
|
||||||
def preview_thumbnail(file_name: str):
|
async def preview_thumbnail(request: Request, file_name: str):
|
||||||
"""Get a thumbnail from the cached preview frames."""
|
"""Get a thumbnail from the cached preview frames."""
|
||||||
if len(file_name) > 1000:
|
if len(file_name) > 1000:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -1896,6 +1903,17 @@ def preview_thumbnail(file_name: str):
|
|||||||
status_code=403,
|
status_code=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract camera name from preview filename (format: preview_{camera}-{timestamp}.ext)
|
||||||
|
if not file_name.startswith("preview_"):
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": "Invalid preview filename"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
# Use rsplit to handle camera names containing dashes (e.g. front-door)
|
||||||
|
name_part = file_name[len("preview_") :].rsplit(".", 1)[0] # strip extension
|
||||||
|
camera_name = name_part.rsplit("-", 1)[0] # split off timestamp
|
||||||
|
await require_camera_access(camera_name, request=request)
|
||||||
|
|
||||||
safe_file_name_current = sanitize_filename(file_name)
|
safe_file_name_current = sanitize_filename(file_name)
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
|
|||||||
+59
-11
@@ -17,6 +17,7 @@ from titlecase import titlecase
|
|||||||
from frigate.comms.base_communicator import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.auth import AuthConfig
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
@@ -58,6 +59,7 @@ class WebPushClient(Communicator):
|
|||||||
for c in self.config.cameras.values()
|
for c in self.config.cameras.values()
|
||||||
}
|
}
|
||||||
self.last_notification_time: float = 0
|
self.last_notification_time: float = 0
|
||||||
|
self.user_cameras: dict[str, set[str]] = {}
|
||||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||||
self.notification_thread = threading.Thread(
|
self.notification_thread = threading.Thread(
|
||||||
target=self._process_notifications, daemon=True
|
target=self._process_notifications, daemon=True
|
||||||
@@ -78,13 +80,12 @@ class WebPushClient(Communicator):
|
|||||||
for sub in user["notification_tokens"]:
|
for sub in user["notification_tokens"]:
|
||||||
self.web_pushers[user["username"]].append(WebPusher(sub))
|
self.web_pushers[user["username"]].append(WebPusher(sub))
|
||||||
|
|
||||||
# notification config updater
|
# notification and auth config updater
|
||||||
self.global_config_subscriber = ConfigSubscriber(
|
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||||
"config/notifications", exact=True
|
|
||||||
)
|
|
||||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
||||||
)
|
)
|
||||||
|
self._refresh_user_cameras()
|
||||||
|
|
||||||
def subscribe(self, receiver: Callable) -> None:
|
def subscribe(self, receiver: Callable) -> None:
|
||||||
"""Wrapper for allowing dispatcher to subscribe."""
|
"""Wrapper for allowing dispatcher to subscribe."""
|
||||||
@@ -164,13 +165,19 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
"""Wrapper for publishing when client is in valid state."""
|
"""Wrapper for publishing when client is in valid state."""
|
||||||
# check for updated notification config
|
# check for updated global config (notifications, auth)
|
||||||
_, updated_notification_config = (
|
while True:
|
||||||
self.global_config_subscriber.check_for_update()
|
config_topic, config_payload = (
|
||||||
)
|
self.global_config_subscriber.check_for_update()
|
||||||
|
)
|
||||||
if updated_notification_config:
|
if config_topic is None:
|
||||||
self.config.notifications = updated_notification_config
|
break
|
||||||
|
if config_topic == "config/notifications" and config_payload:
|
||||||
|
self.config.notifications = config_payload
|
||||||
|
elif config_topic == "config/auth":
|
||||||
|
if isinstance(config_payload, AuthConfig):
|
||||||
|
self.config.auth = config_payload
|
||||||
|
self._refresh_user_cameras()
|
||||||
|
|
||||||
updates = self.config_subscriber.check_for_updates()
|
updates = self.config_subscriber.check_for_updates()
|
||||||
|
|
||||||
@@ -291,6 +298,31 @@ class WebPushClient(Communicator):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing notification: {str(e)}")
|
logger.error(f"Error processing notification: {str(e)}")
|
||||||
|
|
||||||
|
def _refresh_user_cameras(self) -> None:
|
||||||
|
"""Rebuild the user-to-cameras access cache from the database."""
|
||||||
|
all_camera_names = set(self.config.cameras.keys())
|
||||||
|
roles_dict = self.config.auth.roles
|
||||||
|
updated: dict[str, set[str]] = {}
|
||||||
|
for user in User.select(User.username, User.role).dicts().iterator():
|
||||||
|
allowed = User.get_allowed_cameras(
|
||||||
|
user["role"], roles_dict, all_camera_names
|
||||||
|
)
|
||||||
|
updated[user["username"]] = set(allowed)
|
||||||
|
logger.debug(
|
||||||
|
"User %s has access to cameras: %s",
|
||||||
|
user["username"],
|
||||||
|
", ".join(allowed),
|
||||||
|
)
|
||||||
|
self.user_cameras = updated
|
||||||
|
|
||||||
|
def _user_has_camera_access(self, username: str, camera: str) -> bool:
|
||||||
|
"""Check if a user has access to a specific camera based on cached roles."""
|
||||||
|
allowed = self.user_cameras.get(username)
|
||||||
|
if allowed is None:
|
||||||
|
logger.debug(f"No camera access information found for user {username}")
|
||||||
|
return False
|
||||||
|
return camera in allowed
|
||||||
|
|
||||||
def _within_cooldown(self, camera: str) -> bool:
|
def _within_cooldown(self, camera: str) -> bool:
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
if now - self.last_notification_time < self.config.notifications.cooldown:
|
if now - self.last_notification_time < self.config.notifications.cooldown:
|
||||||
@@ -418,6 +450,14 @@ class WebPushClient(Communicator):
|
|||||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||||
|
|
||||||
for user in self.web_pushers:
|
for user in self.web_pushers:
|
||||||
|
if not self._user_has_camera_access(user, camera):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping notification for user %s - no access to camera %s",
|
||||||
|
user,
|
||||||
|
camera,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
self.send_push_notification(
|
self.send_push_notification(
|
||||||
user=user,
|
user=user,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
@@ -465,6 +505,14 @@ class WebPushClient(Communicator):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for user in self.web_pushers:
|
for user in self.web_pushers:
|
||||||
|
if not self._user_has_camera_access(user, camera):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping notification for user %s - no access to camera %s",
|
||||||
|
user,
|
||||||
|
camera,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
self.send_push_notification(
|
self.send_push_notification(
|
||||||
user=user,
|
user=user,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
|||||||
@@ -321,6 +321,9 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.start_or_restart_ffmpeg()
|
self.start_or_restart_ffmpeg()
|
||||||
|
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
|
# check if there is an updated config
|
||||||
|
self.config_subscriber.check_for_updates()
|
||||||
|
|
||||||
enabled = self.camera_config.enabled
|
enabled = self.camera_config.enabled
|
||||||
if enabled != self.was_enabled:
|
if enabled != self.was_enabled:
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -347,9 +350,6 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# check if there is an updated config
|
|
||||||
self.config_subscriber.check_for_updates()
|
|
||||||
|
|
||||||
self.read_audio()
|
self.read_audio()
|
||||||
|
|
||||||
if self.audio_listener:
|
if self.audio_listener:
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ class EventCleanup(threading.Thread):
|
|||||||
return events_to_update
|
return events_to_update
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
if self.config.safe_mode:
|
||||||
|
logger.info("Safe mode enabled, skipping event cleanup")
|
||||||
|
return
|
||||||
|
|
||||||
# only expire events every 5 minutes
|
# only expire events every 5 minutes
|
||||||
while not self.stop_event.wait(300):
|
while not self.stop_event.wait(300):
|
||||||
events_with_expired_clips = self.expire_clips()
|
events_with_expired_clips = self.expire_clips()
|
||||||
|
|||||||
@@ -350,6 +350,10 @@ class RecordingCleanup(threading.Thread):
|
|||||||
logger.debug("End expire recordings.")
|
logger.debug("End expire recordings.")
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
if self.config.safe_mode:
|
||||||
|
logger.info("Safe mode enabled, skipping recording cleanup")
|
||||||
|
return
|
||||||
|
|
||||||
# on startup sync recordings with disk if enabled
|
# on startup sync recordings with disk if enabled
|
||||||
if self.config.record.sync_recordings:
|
if self.config.record.sync_recordings:
|
||||||
sync_recordings(limited=False)
|
sync_recordings(limited=False)
|
||||||
|
|||||||
@@ -272,6 +272,10 @@ class StorageMaintainer(threading.Thread):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||||
|
if self.config.safe_mode:
|
||||||
|
logger.info("Safe mode enabled, skipping storage maintenance")
|
||||||
|
return
|
||||||
|
|
||||||
self.calculate_camera_bandwidth()
|
self.calculate_camera_bandwidth()
|
||||||
while not self.stop_event.wait(300):
|
while not self.stop_event.wait(300):
|
||||||
if not self.camera_storage_stats or True in [
|
if not self.camera_storage_stats or True in [
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ DYNAMIC_OBJECT_THRESHOLDS = StationaryThresholds(
|
|||||||
motion_classifier_enabled=True,
|
motion_classifier_enabled=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Thresholds for objects that are not expected to be stationary
|
||||||
|
NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
|
||||||
|
objects=["license_plate"],
|
||||||
|
known_active_iou=0.9,
|
||||||
|
stationary_check_iou=0.9,
|
||||||
|
max_stationary_history=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_stationary_threshold(label: str) -> StationaryThresholds:
|
def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||||
"""Get the stationary thresholds for a given object label."""
|
"""Get the stationary thresholds for a given object label."""
|
||||||
@@ -65,6 +73,9 @@ def get_stationary_threshold(label: str) -> StationaryThresholds:
|
|||||||
if label in DYNAMIC_OBJECT_THRESHOLDS.objects:
|
if label in DYNAMIC_OBJECT_THRESHOLDS.objects:
|
||||||
return DYNAMIC_OBJECT_THRESHOLDS
|
return DYNAMIC_OBJECT_THRESHOLDS
|
||||||
|
|
||||||
|
if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
|
||||||
|
return NON_STATIONARY_OBJECT_THRESHOLDS
|
||||||
|
|
||||||
return StationaryThresholds()
|
return StationaryThresholds()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export function AnimatedEventCard({
|
|||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
disableRemotePlayback
|
disableRemotePlayback
|
||||||
|
disablePictureInPicture
|
||||||
loop
|
loop
|
||||||
onTimeUpdate={() => {
|
onTimeUpdate={() => {
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
|
|||||||
@@ -126,19 +126,21 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
|||||||
|
|
||||||
<DropdownMenuSeparator className={isDesktop ? "my-2" : "my-2"} />
|
<DropdownMenuSeparator className={isDesktop ? "my-2" : "my-2"} />
|
||||||
|
|
||||||
{profile?.username && profile.username !== "anonymous" && (
|
{config?.auth?.enabled !== false &&
|
||||||
<MenuItem
|
profile?.username &&
|
||||||
className={cn(
|
profile.username !== "anonymous" && (
|
||||||
"flex w-full items-center gap-2",
|
<MenuItem
|
||||||
isDesktop ? "cursor-pointer" : "p-2 text-sm",
|
className={cn(
|
||||||
)}
|
"flex w-full items-center gap-2",
|
||||||
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
isDesktop ? "cursor-pointer" : "p-2 text-sm",
|
||||||
onClick={() => setPasswordDialogOpen(true)}
|
)}
|
||||||
>
|
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
||||||
<LuSquarePen className="mr-2 size-4" />
|
onClick={() => setPasswordDialogOpen(true)}
|
||||||
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
|
>
|
||||||
</MenuItem>
|
<LuSquarePen className="mr-2 size-4" />
|
||||||
)}
|
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -225,20 +225,24 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
<DropdownMenuSeparator
|
<DropdownMenuSeparator
|
||||||
className={isDesktop ? "mt-3" : "mt-1"}
|
className={isDesktop ? "mt-3" : "mt-1"}
|
||||||
/>
|
/>
|
||||||
{profile?.username && profile.username !== "anonymous" && (
|
{config?.auth?.enabled !== false &&
|
||||||
<MenuItem
|
profile?.username &&
|
||||||
className={
|
profile.username !== "anonymous" && (
|
||||||
isDesktop
|
<MenuItem
|
||||||
? "cursor-pointer"
|
className={
|
||||||
: "flex items-center p-2 text-sm"
|
isDesktop
|
||||||
}
|
? "cursor-pointer"
|
||||||
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
: "flex items-center p-2 text-sm"
|
||||||
onClick={() => setPasswordDialogOpen(true)}
|
}
|
||||||
>
|
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
||||||
<LuSquarePen className="mr-2 size-4" />
|
onClick={() => setPasswordDialogOpen(true)}
|
||||||
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
|
>
|
||||||
</MenuItem>
|
<LuSquarePen className="mr-2 size-4" />
|
||||||
)}
|
<span>
|
||||||
|
{t("menu.user.setPassword", { ns: "common" })}
|
||||||
|
</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
|
|||||||
@@ -125,17 +125,23 @@ export default function ClassificationSelectionDialog({
|
|||||||
isMobile && "gap-2 pb-4",
|
isMobile && "gap-2 pb-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{classes.sort().map((category) => (
|
{classes
|
||||||
<SelectorItem
|
.sort((a, b) => {
|
||||||
key={category}
|
if (a === "none") return 1;
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
if (b === "none") return -1;
|
||||||
onClick={() => onCategorizeImage(category)}
|
return a.localeCompare(b);
|
||||||
>
|
})
|
||||||
{category === "none"
|
.map((category) => (
|
||||||
? t("details.none")
|
<SelectorItem
|
||||||
: category.replaceAll("_", " ")}
|
key={category}
|
||||||
</SelectorItem>
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
))}
|
onClick={() => onCategorizeImage(category)}
|
||||||
|
>
|
||||||
|
{category === "none"
|
||||||
|
? t("details.none")
|
||||||
|
: category.replaceAll("_", " ")}
|
||||||
|
</SelectorItem>
|
||||||
|
))}
|
||||||
<Separator />
|
<Separator />
|
||||||
<SelectorItem
|
<SelectorItem
|
||||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||||
|
|||||||
@@ -495,6 +495,15 @@ export default function SearchDetailDialog({
|
|||||||
}
|
}
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesktop || !onPrevious || !onNext) {
|
||||||
|
setShowNavigationButtons(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowNavigationButtons(isOpen);
|
||||||
|
}, [isOpen, onNext, onPrevious]);
|
||||||
|
|
||||||
// show/hide annotation settings is handled inside TabsWithActions
|
// show/hide annotation settings is handled inside TabsWithActions
|
||||||
|
|
||||||
const searchTabs = useMemo(() => {
|
const searchTabs = useMemo(() => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator
|
|||||||
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { VideoResolutionType } from "@/types/live";
|
import { VideoResolutionType } from "@/types/live";
|
||||||
|
import { VodManifest } from "@/types/playback";
|
||||||
|
|
||||||
type TrackingDetailsProps = {
|
type TrackingDetailsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -117,19 +118,64 @@ export function TrackingDetails({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
|
||||||
|
// snapping. The backend may snap clipFrom backwards to a keyframe, making
|
||||||
|
// the video start earlier than the requested time.
|
||||||
|
const vodManifestUrl = useMemo(() => {
|
||||||
|
if (!event.camera) return null;
|
||||||
|
const startTime =
|
||||||
|
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
|
||||||
|
const endTime =
|
||||||
|
(event.end_time ?? Date.now() / 1000) +
|
||||||
|
annotationOffset / 1000 +
|
||||||
|
REVIEW_PADDING;
|
||||||
|
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
|
||||||
|
}, [event, annotationOffset]);
|
||||||
|
|
||||||
|
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
dedupingInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive the actual video start time from the VOD manifest's first clip.
|
||||||
|
// Without this correction the timeline-to-player-time mapping is off by
|
||||||
|
// the keyframe preroll amount.
|
||||||
|
const actualVideoStart = useMemo(() => {
|
||||||
|
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||||
|
|
||||||
|
if (!vodManifest?.sequences?.[0]?.clips?.[0] || !recordings?.length) {
|
||||||
|
return videoStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstClip = vodManifest.sequences[0].clips[0];
|
||||||
|
|
||||||
|
// Guard: clipFrom is only expected when the first recording starts before
|
||||||
|
// the requested start. If this doesn't hold, fall back.
|
||||||
|
if (recordings[0].start_time >= videoStartTime) {
|
||||||
|
return recordings[0].start_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstClip.clipFrom !== undefined) {
|
||||||
|
// clipFrom is in milliseconds from the start of the first recording
|
||||||
|
return recordings[0].start_time + firstClip.clipFrom / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clipFrom absent means the full recording is included (keyframe probe failed)
|
||||||
|
return recordings[0].start_time;
|
||||||
|
}, [vodManifest, recordings, eventStartRecord]);
|
||||||
|
|
||||||
// Convert a timeline timestamp to actual video player time, accounting for
|
// Convert a timeline timestamp to actual video player time, accounting for
|
||||||
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
|
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
|
||||||
const timestampToVideoTime = useCallback(
|
const timestampToVideoTime = useCallback(
|
||||||
(timestamp: number): number => {
|
(timestamp: number): number => {
|
||||||
if (!recordings || recordings.length === 0) {
|
if (!recordings || recordings.length === 0) {
|
||||||
// Fallback to simple calculation if no recordings data
|
// Fallback to simple calculation if no recordings data
|
||||||
return timestamp - (eventStartRecord - REVIEW_PADDING);
|
return timestamp - actualVideoStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
// If timestamp is before actual video start, return 0
|
||||||
|
if (timestamp < actualVideoStart) return 0;
|
||||||
// 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
|
// Check if timestamp is before the first recording or after the last
|
||||||
if (
|
if (
|
||||||
@@ -143,10 +189,10 @@ export function TrackingDetails({
|
|||||||
// Calculate the inpoint offset - the HLS video may start partway through the first segment
|
// Calculate the inpoint offset - the HLS video may start partway through the first segment
|
||||||
let inpointOffset = 0;
|
let inpointOffset = 0;
|
||||||
if (
|
if (
|
||||||
videoStartTime > recordings[0].start_time &&
|
actualVideoStart > recordings[0].start_time &&
|
||||||
videoStartTime < recordings[0].end_time
|
actualVideoStart < recordings[0].end_time
|
||||||
) {
|
) {
|
||||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
inpointOffset = actualVideoStart - recordings[0].start_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
let seekSeconds = 0;
|
let seekSeconds = 0;
|
||||||
@@ -164,7 +210,7 @@ export function TrackingDetails({
|
|||||||
if (segment === recordings[0]) {
|
if (segment === recordings[0]) {
|
||||||
// For the first segment, account for the inpoint offset
|
// For the first segment, account for the inpoint offset
|
||||||
seekSeconds +=
|
seekSeconds +=
|
||||||
timestamp - Math.max(segment.start_time, videoStartTime);
|
timestamp - Math.max(segment.start_time, actualVideoStart);
|
||||||
} else {
|
} else {
|
||||||
seekSeconds += timestamp - segment.start_time;
|
seekSeconds += timestamp - segment.start_time;
|
||||||
}
|
}
|
||||||
@@ -174,7 +220,7 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
return seekSeconds;
|
return seekSeconds;
|
||||||
},
|
},
|
||||||
[recordings, eventStartRecord],
|
[recordings, actualVideoStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert video player time back to timeline timestamp, accounting for
|
// Convert video player time back to timeline timestamp, accounting for
|
||||||
@@ -183,19 +229,16 @@ export function TrackingDetails({
|
|||||||
(playerTime: number): number => {
|
(playerTime: number): number => {
|
||||||
if (!recordings || recordings.length === 0) {
|
if (!recordings || recordings.length === 0) {
|
||||||
// Fallback to simple calculation if no recordings data
|
// Fallback to simple calculation if no recordings data
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
return playerTime + actualVideoStart;
|
||||||
return playerTime + videoStartTime;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
|
||||||
|
|
||||||
// Calculate the inpoint offset - the video may start partway through the first segment
|
// Calculate the inpoint offset - the video may start partway through the first segment
|
||||||
let inpointOffset = 0;
|
let inpointOffset = 0;
|
||||||
if (
|
if (
|
||||||
videoStartTime > recordings[0].start_time &&
|
actualVideoStart > recordings[0].start_time &&
|
||||||
videoStartTime < recordings[0].end_time
|
actualVideoStart < recordings[0].end_time
|
||||||
) {
|
) {
|
||||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
inpointOffset = actualVideoStart - recordings[0].start_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timestamp = 0;
|
let timestamp = 0;
|
||||||
@@ -212,7 +255,7 @@ export function TrackingDetails({
|
|||||||
if (segment === recordings[0]) {
|
if (segment === recordings[0]) {
|
||||||
// For the first segment, add the inpoint offset
|
// For the first segment, add the inpoint offset
|
||||||
timestamp =
|
timestamp =
|
||||||
Math.max(segment.start_time, videoStartTime) +
|
Math.max(segment.start_time, actualVideoStart) +
|
||||||
(playerTime - totalTime);
|
(playerTime - totalTime);
|
||||||
} else {
|
} else {
|
||||||
timestamp = segment.start_time + (playerTime - totalTime);
|
timestamp = segment.start_time + (playerTime - totalTime);
|
||||||
@@ -225,7 +268,7 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
return timestamp;
|
return timestamp;
|
||||||
},
|
},
|
||||||
[recordings, eventStartRecord],
|
[recordings, actualVideoStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
eventSequence?.map((event) => {
|
eventSequence?.map((event) => {
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export default function HlsVideoPlayer({
|
|||||||
>
|
>
|
||||||
{isDetailMode &&
|
{isDetailMode &&
|
||||||
camera &&
|
camera &&
|
||||||
currentTime &&
|
currentTime != null &&
|
||||||
loadedMetadata &&
|
loadedMetadata &&
|
||||||
videoDimensions.width > 0 &&
|
videoDimensions.width > 0 &&
|
||||||
videoDimensions.height > 0 && (
|
videoDimensions.height > 0 && (
|
||||||
|
|||||||
@@ -598,18 +598,18 @@ function LibrarySelector({
|
|||||||
{Object.values(faces).map((face) => (
|
{Object.values(faces).map((face) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={face}
|
key={face}
|
||||||
className="group flex items-center justify-between"
|
className="group flex items-center justify-between p-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex-grow cursor-pointer"
|
className="flex-grow cursor-pointer"
|
||||||
onClick={() => setPageToggle(face)}
|
onClick={() => setPageToggle(face)}
|
||||||
>
|
>
|
||||||
{face}
|
{face}
|
||||||
<span className="ml-2 text-muted-foreground">
|
<span className="ml-2 px-2 py-1.5 text-muted-foreground">
|
||||||
({faceData?.[face].length})
|
({faceData?.[face].length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5 px-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export interface FrigateConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
|
enabled: boolean;
|
||||||
roles: {
|
roles: {
|
||||||
[roleName: string]: string[];
|
[roleName: string]: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ export type PreviewPlayback = {
|
|||||||
preview: Preview | undefined;
|
preview: Preview | undefined;
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VodManifest = {
|
||||||
|
sequences: { clips: { clipFrom?: number }[] }[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -700,66 +700,72 @@ function LibrarySelector({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{Object.keys(dataset).map((id) => (
|
{Object.keys(dataset)
|
||||||
<DropdownMenuItem
|
.sort((a, b) => {
|
||||||
key={id}
|
if (a === "none") return 1;
|
||||||
className="group flex items-center justify-between"
|
if (b === "none") return -1;
|
||||||
>
|
return a.localeCompare(b);
|
||||||
<div
|
})
|
||||||
className="flex-grow cursor-pointer capitalize"
|
.map((id) => (
|
||||||
onClick={() => setPageToggle(id)}
|
<DropdownMenuItem
|
||||||
|
key={id}
|
||||||
|
className="group flex items-center justify-between p-0"
|
||||||
>
|
>
|
||||||
{id === "none" ? t("details.none") : id.replaceAll("_", " ")}
|
<div
|
||||||
<span className="ml-2 text-muted-foreground">
|
className="flex-grow cursor-pointer px-2 py-1.5 capitalize"
|
||||||
({dataset?.[id].length})
|
onClick={() => setPageToggle(id)}
|
||||||
</span>
|
>
|
||||||
</div>
|
{id === "none" ? t("details.none") : id.replaceAll("_", " ")}
|
||||||
{id != "none" && (
|
<span className="ml-2 text-muted-foreground">
|
||||||
<div className="flex gap-0.5">
|
({dataset?.[id].length})
|
||||||
<Tooltip>
|
</span>
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setRenameClass(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LuPencil className="size-4 text-primary" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.renameCategory")}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setConfirmDelete(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LuTrash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("button.deleteCategory")}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{id != "none" && (
|
||||||
</DropdownMenuItem>
|
<div className="flex gap-0.5 px-2">
|
||||||
))}
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRenameClass(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuPencil className="size-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("button.renameCategory")}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmDelete(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("button.deleteCategory")}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user