Compare commits

..
16 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 416a9b7692 Validate preview filename and camera access (#22530)
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
* validate preview filename and camera access

* correctly handle camera names with dashes
2026-03-19 09:42:33 -05:00
Josh HawkinsandGitHub d11c26970d Fix cross-camera auth in timeline and media endpoints (#22522)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Fix cross-camera authorization bypass in timeline and event media endpoints

* formatting
2026-03-18 18:54:31 -06:00
Josh HawkinsandGitHub e78da2758d Restrict /api/config/raw to admin role to prevent credential leak to non-admin users (#22523) 2026-03-18 16:14:59 -06:00
GuoQing LiuandGitHub ae9b307dfc docs: remove onvif host environment variable (#22517)
* docs: remove onvif host environment variable

* Update ONVIF configuration notes in reference.md
2026-03-18 09:54:26 -05:00
Josh HawkinsandGitHub 01c16a9250 check for config update before state evaluation (#22495)
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 / Synaptics Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-03-16 17:23:36 -05:00
Josh HawkinsandGitHub d4731c1dea don't try to run cleanup if frigate is in safe mode (#22492) 2026-03-16 13:38:24 -06:00
65ca90db20 Add Strix to third party extensions (#22488)
Co-authored-by: eduard256 <ceo@webaweba.com>
2026-03-16 12:46:21 -05:00
Josh HawkinsandGitHub 3ec2305e6a sync Tracking Details timeline with keyframe-snapped vod clip start (#22454)
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
2026-03-15 12:21:42 -06:00
Josh HawkinsandGitHub 8b035be132 disable pip for animated event cards (#22438)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
2026-03-14 16:44:16 -06:00
Josh HawkinsandGitHub be79ad89b6 hide set password menu option when native auth is disabled (#22439) 2026-03-14 15:35:48 -05:00
Josh HawkinsandGitHub b147b53522 add padding to dropdown text (#22420)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-03-13 09:43:07 -05:00
leccelecceandGitHub d2b2faa2d7 Update dev contrib docs with Python checks (#22419) 2026-03-13 08:16:10 -06:00
Josh HawkinsandGitHub 614a6b39d4 consistently sort class names (#22415)
keep None at the bottom
2026-03-13 08:05:56 -05:00
Nicolas MowenandGitHub f29ee53fb4 Add handler for license plate which is not expected to be stationary (#22416) 2026-03-13 07:02:42 -06:00
Josh HawkinsandGitHub 544d3c6139 keep nav buttons visible (#22384)
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
nav buttons would be hidden when closing and reopening dialog after selecting the tracking details pane
2026-03-11 08:27:10 -06:00
Josh HawkinsandGitHub 104e623923 Filter push notifications by user role camera access (#22385)
* filter push notifications by user camera access with cached role resolution

* simplify
2026-03-11 08:26:09 -06:00
24 changed files with 366 additions and 151 deletions
+1 -1
View File
@@ -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}"
+2 -2
View File
@@ -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
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+3 -3
View File
@@ -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:
+4
View File
@@ -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()
+4
View File
@@ -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)
+4
View File
@@ -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 [
+11
View File
@@ -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) {
+15 -13
View File
@@ -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(
+18 -14
View File
@@ -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) => {
+1 -1
View File
@@ -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 && (
+3 -3
View File
@@ -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
+1
View File
@@ -346,6 +346,7 @@ export interface FrigateConfig {
}; };
auth: { auth: {
enabled: boolean;
roles: { roles: {
[roleName: string]: string[]; [roleName: string]: string[];
}; };
+4
View File
@@ -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>
</> </>