mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-30 11:56:49 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73a14db5d | ||
|
|
4883e20898 |
@@ -8,6 +8,7 @@ amdgpu
|
|||||||
analyzeduration
|
analyzeduration
|
||||||
Annke
|
Annke
|
||||||
apexcharts
|
apexcharts
|
||||||
|
Aqara
|
||||||
arange
|
arange
|
||||||
argmax
|
argmax
|
||||||
argmin
|
argmin
|
||||||
@@ -64,6 +65,7 @@ dsize
|
|||||||
dtype
|
dtype
|
||||||
ECONNRESET
|
ECONNRESET
|
||||||
edgetpu
|
edgetpu
|
||||||
|
Eufy
|
||||||
facenet
|
facenet
|
||||||
fastapi
|
fastapi
|
||||||
faststart
|
faststart
|
||||||
|
|||||||
@@ -293,6 +293,10 @@ networking:
|
|||||||
|
|
||||||
This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations.
|
This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations.
|
||||||
|
|
||||||
|
The internal and external ports must be different port numbers, and Frigate will refuse to start otherwise. Requests arriving on the internal port are treated as authenticated admins, so pointing both at the same port would remove authentication from the external one.
|
||||||
|
|
||||||
|
Nginx binds these ports when it starts, so port changes only take effect after Frigate restarts.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Customizing the Nginx configuration
|
### Customizing the Nginx configuration
|
||||||
|
|||||||
@@ -67,4 +67,6 @@ If your stream won't play, has no audio, uses excessive CPU, or otherwise misbeh
|
|||||||
|
|
||||||
## Homekit Configuration
|
## Homekit Configuration
|
||||||
|
|
||||||
To add camera streams to Homekit Frigate must be configured in docker to use `host` networking mode. Once that is done, you can use the go2rtc WebUI (accessed via port 1984, which is disabled by default) to export a camera to Homekit. Any changes made will automatically be saved to `/config/go2rtc_homekit.yml`.
|
To export camera streams to HomeKit, Frigate must be configured in docker to use `host` networking mode. HomeKit settings are stored in `/config/go2rtc_homekit.yml` rather than in your Frigate config, and are edited through the go2rtc config editor at `http://<frigate_host>:1984/editor.html`. Pairings are saved back to that file automatically.
|
||||||
|
|
||||||
|
See the [HomeKit integration docs](/integrations/homekit) for the full setup, including the video and audio requirements HomeKit places on the stream.
|
||||||
|
|||||||
@@ -3,35 +3,100 @@ id: homekit
|
|||||||
title: HomeKit
|
title: HomeKit
|
||||||
---
|
---
|
||||||
|
|
||||||
Frigate cameras can be integrated with Apple HomeKit through go2rtc. This allows you to view your camera streams directly in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
|
Frigate cameras can be exported to Apple HomeKit through go2rtc. Each exported camera appears as an accessory in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
HomeKit integration is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server to expose your cameras to HomeKit.
|
Exporting cameras is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server, so your camera is published to HomeKit as an accessory in its own right.
|
||||||
|
|
||||||
## Setup
|
:::note
|
||||||
|
|
||||||
All HomeKit configuration and pairing should be done through the **go2rtc WebUI**.
|
This is the opposite of importing a HomeKit camera. go2rtc can also pair with an existing HomeKit camera (Aqara, Eve, Eufy, and similar) and use it as a stream source, which is what the `add` page of the go2rtc WebUI is for. That page discovers HomeKit accessories on your network and will not list your Frigate cameras. It is not used for exporting.
|
||||||
|
|
||||||
### Accessing the go2rtc WebUI
|
:::
|
||||||
|
|
||||||
The go2rtc WebUI is available at:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://<frigate_host>:1984
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `<frigate_host>` with the IP address or hostname of your Frigate server.
|
|
||||||
|
|
||||||
### Pairing Cameras
|
|
||||||
|
|
||||||
1. Navigate to the go2rtc WebUI at `http://<frigate_host>:1984`
|
|
||||||
2. Use the `add` section to add a new camera to HomeKit
|
|
||||||
3. Follow the on-screen instructions to generate pairing codes for your cameras
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Frigate must be accessible on your local network using host network_mode
|
- Frigate must be running with `network_mode: host` so that HomeKit can discover your cameras over mDNS
|
||||||
- Your iOS device must be on the same network as Frigate
|
- Your Apple device must be on the same network as Frigate
|
||||||
- Port 1984 must be accessible for the go2rtc WebUI
|
- Port 1984 must be accessible so you can reach the go2rtc WebUI
|
||||||
- For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc)
|
|
||||||
|
HomeKit also places strict limits on the stream itself. go2rtc passes your stream through without resizing or re-encoding it, so the stream you export must already meet these requirements:
|
||||||
|
|
||||||
|
- **Video:** H.264 at 1920x1080, 1280x720, or 320x240
|
||||||
|
- **Audio:** Opus, mono, 16 kHz
|
||||||
|
|
||||||
|
A camera's full resolution stream usually does not qualify. See [Exporting a compatible stream](#exporting-a-compatible-stream) below.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
HomeKit settings are stored in `/config/go2rtc_homekit.yml`. This is a separate file from your Frigate config, because go2rtc needs to write your pairings back to it when you pair a device.
|
||||||
|
|
||||||
|
Edit it using the go2rtc config editor, which writes to that file directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<frigate_host>:1984/editor.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<frigate_host>` with the IP address or hostname of your Frigate server. The editor will be empty until you add a HomeKit section, since this file holds only your HomeKit settings and not the rest of your go2rtc config.
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
|
||||||
|
Do not put the `homekit:` section in the `go2rtc:` section of your Frigate config.
|
||||||
|
|
||||||
|
Frigate regenerates that config on every startup, so go2rtc cannot save your pairings to it. Pairing will appear to succeed and then fail after the next restart with `PairVerify with unknown client_id`. If the section exists in both places, your saved pairings are erased on every restart.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Add an entry for each camera you want to export. The key must match the name of a go2rtc stream, and the pin must be 8 digits. This is the number the Home app calls the setup code:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
homekit:
|
||||||
|
front_door:
|
||||||
|
name: Front Door
|
||||||
|
pin: "12345678"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the key does not match a go2rtc stream, go2rtc logs `[homekit] missing stream:` at startup and the camera will not appear in the Home app.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
go2rtc derives each accessory's HomeKit identity from this key, so renaming it later means the camera appears as a new accessory and has to be paired again. Settle on the name before you pair.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
Frigate keeps only the `homekit:` section of this file when it starts, so do not store streams or other go2rtc settings in it.
|
||||||
|
|
||||||
|
### Exporting a compatible stream
|
||||||
|
|
||||||
|
If a camera's stream does not meet the requirements listed above, define a scaled restream in your Frigate config and point HomeKit at that stream instead of the original:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
go2rtc:
|
||||||
|
streams:
|
||||||
|
front_door:
|
||||||
|
- rtsp://user:password@192.168.1.50:554/stream
|
||||||
|
front_door_homekit:
|
||||||
|
- "ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /config/go2rtc_homekit.yml
|
||||||
|
homekit:
|
||||||
|
front_door_homekit:
|
||||||
|
name: Front Door
|
||||||
|
pin: "12345678"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `#hardware=cuda`, `#hardware=vaapi`, or the appropriate value for your system to transcode using your GPU. Note that NVENC cannot encode H.264 wider than 4096 pixels, so very wide streams must be scaled down as shown above rather than only re-encoded.
|
||||||
|
|
||||||
|
## Pairing Cameras
|
||||||
|
|
||||||
|
1. Restart Frigate after adding the `homekit:` section
|
||||||
|
2. In the Apple Home app, choose **Add Accessory**, then **More options** to enter a code manually
|
||||||
|
3. Select your camera and enter the pin you configured as the setup code
|
||||||
|
4. Confirm that a `pairings:` list now appears under the camera in `/config/go2rtc_homekit.yml`
|
||||||
|
|
||||||
|
Pairings are saved back to that file automatically. If step 4 shows no `pairings:` list, check the Frigate log for `[homekit] can't save`, which means the `homekit:` section is missing from `/config/go2rtc_homekit.yml`.
|
||||||
|
|
||||||
|
For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc).
|
||||||
|
|||||||
+9
-9
@@ -31,7 +31,7 @@ from frigate.api.media_auth import (
|
|||||||
deny_response_for_media_uri,
|
deny_response_for_media_uri,
|
||||||
is_role_restricted,
|
is_role_restricted,
|
||||||
)
|
)
|
||||||
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
|
from frigate.config import AuthConfig, ProxyConfig
|
||||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
|
|
||||||
@@ -620,18 +620,18 @@ def resolve_role(
|
|||||||
def auth(request: Request):
|
def auth(request: Request):
|
||||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||||
networking_config: NetworkingConfig = request.app.frigate_config.networking
|
|
||||||
|
|
||||||
success_response = Response("", status_code=202)
|
success_response = Response("", status_code=202)
|
||||||
|
|
||||||
# handle case where internal port is a string with ip:port
|
|
||||||
internal_port = networking_config.listen.internal
|
|
||||||
if type(internal_port) is str:
|
|
||||||
internal_port = int(internal_port.split(":")[-1])
|
|
||||||
|
|
||||||
# dont require auth if the request is on the internal port
|
# dont require auth if the request is on the internal port
|
||||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
# this header is set by Frigate's nginx proxy, so it cant be spoofed.
|
||||||
if int(request.headers.get("x-server-port", default=0)) == internal_port:
|
# the port is the boot-time snapshot rather than the live config value:
|
||||||
|
# nginx's listeners are fixed at container start, so an in-memory config
|
||||||
|
# change must never move the port that is trusted here
|
||||||
|
if (
|
||||||
|
int(request.headers.get("x-server-port", default=0))
|
||||||
|
== request.app.auth_internal_port
|
||||||
|
):
|
||||||
success_response.headers["remote-user"] = "anonymous"
|
success_response.headers["remote-user"] = "anonymous"
|
||||||
success_response.headers["remote-role"] = "admin"
|
success_response.headers["remote-role"] = "admin"
|
||||||
return success_response
|
return success_response
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ def create_fastapi_app(
|
|||||||
app.include_router(debug_replay.router)
|
app.include_router(debug_replay.router)
|
||||||
# App Properties
|
# App Properties
|
||||||
app.frigate_config = frigate_config
|
app.frigate_config = frigate_config
|
||||||
|
# snapshot the port nginx bound at startup, the live config can be swapped
|
||||||
|
app.auth_internal_port = frigate_config.networking.listen.internal_port
|
||||||
app.genai_manager = GenAIClientManager(frigate_config)
|
app.genai_manager = GenAIClientManager(frigate_config)
|
||||||
app.embeddings = embeddings
|
app.embeddings = embeddings
|
||||||
app.detected_frames_processor = detected_frames_processor
|
app.detected_frames_processor = detected_frames_processor
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ async def get_motion_search_status_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
job = get_motion_search_job(job_id)
|
job = get_motion_search_job(job_id)
|
||||||
if not job:
|
if not job or job.camera != camera_name:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Job not found"},
|
content={"success": False, "message": "Job not found"},
|
||||||
status_code=404,
|
status_code=404,
|
||||||
@@ -253,7 +253,7 @@ async def cancel_motion_search_endpoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
job = get_motion_search_job(job_id)
|
job = get_motion_search_job(job_id)
|
||||||
if not job:
|
if not job or job.camera != camera_name:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"success": False, "message": "Job not found"},
|
content={"success": False, "message": "Job not found"},
|
||||||
status_code=404,
|
status_code=404,
|
||||||
|
|||||||
+10
-22
@@ -122,7 +122,6 @@ class FrigateApp:
|
|||||||
self.ptz_metrics: dict[str, PTZMetrics] = {}
|
self.ptz_metrics: dict[str, PTZMetrics] = {}
|
||||||
self.processes: dict[str, int] = {}
|
self.processes: dict[str, int] = {}
|
||||||
self.embeddings: EmbeddingsContext | None = None
|
self.embeddings: EmbeddingsContext | None = None
|
||||||
self.profile_manager: ProfileManager | None = None
|
|
||||||
self.config_holder = ConfigHolder(config)
|
self.config_holder = ConfigHolder(config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -355,25 +354,6 @@ class FrigateApp:
|
|||||||
)
|
)
|
||||||
self.dispatcher.profile_manager = self.profile_manager
|
self.dispatcher.profile_manager = self.profile_manager
|
||||||
|
|
||||||
def restore_active_profile(self) -> None:
|
|
||||||
"""Re-activate the persisted profile after subscribers are connected.
|
|
||||||
|
|
||||||
ZMQ PUB/SUB drops messages with no subscribers, so activation must
|
|
||||||
run after every config_updater subscriber is up.
|
|
||||||
"""
|
|
||||||
if self.profile_manager is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
persisted = ProfileManager.load_persisted_profile()
|
|
||||||
if persisted and any(
|
|
||||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
|
||||||
):
|
|
||||||
logger.info("Restoring persisted profile '%s'", persisted)
|
|
||||||
# runtime overrides are layered on top via restore_runtime_state()
|
|
||||||
self.profile_manager.activate_profile(
|
|
||||||
persisted, clear_runtime_overrides=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def start_detectors(self) -> None:
|
def start_detectors(self) -> None:
|
||||||
for name in self.config.cameras.keys():
|
for name in self.config.cameras.keys():
|
||||||
try:
|
try:
|
||||||
@@ -622,6 +602,13 @@ class FrigateApp:
|
|||||||
self.start_detectors()
|
self.start_detectors()
|
||||||
self.init_dispatcher()
|
self.init_dispatcher()
|
||||||
self.init_profile_manager()
|
self.init_profile_manager()
|
||||||
|
|
||||||
|
# workers get a copy of the config and can miss the broadcast below, so
|
||||||
|
# apply both layers here. must stay after init_profile_manager(), which
|
||||||
|
# snapshots the base config that profile deactivation resets to
|
||||||
|
self.profile_manager.restore_persisted_profile_to_config()
|
||||||
|
self.dispatcher.reapply_runtime_state_to_config()
|
||||||
|
|
||||||
self.init_embeddings_client()
|
self.init_embeddings_client()
|
||||||
self.start_video_output_processor()
|
self.start_video_output_processor()
|
||||||
self.start_ptz_autotracker()
|
self.start_ptz_autotracker()
|
||||||
@@ -636,8 +623,9 @@ class FrigateApp:
|
|||||||
self.start_record_cleanup()
|
self.start_record_cleanup()
|
||||||
self.start_watchdog()
|
self.start_watchdog()
|
||||||
|
|
||||||
# restore persisted runtime overrides on top of config
|
# publish for the recording/review/embeddings processes, which start
|
||||||
self.restore_active_profile()
|
# before the config can be corrected, and for the retained MQTT states
|
||||||
|
self.profile_manager.restore_persisted_profile()
|
||||||
self.dispatcher.restore_runtime_state()
|
self.dispatcher.restore_runtime_state()
|
||||||
|
|
||||||
self.init_auth()
|
self.init_auth()
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
from pydantic import Field
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
from .base import FrigateBaseModel
|
from .base import FrigateBaseModel
|
||||||
|
|
||||||
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
|
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_listen_port(value: int | str) -> int:
|
||||||
|
"""Return the port number from a bare port or an "address:port" value."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return int(value.split(":")[-1])
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class IPv6Config(FrigateBaseModel):
|
class IPv6Config(FrigateBaseModel):
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -25,6 +33,21 @@ class ListenConfig(FrigateBaseModel):
|
|||||||
description="External listening port for Frigate (default 8971).",
|
description="External listening port for Frigate (default 8971).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_port(self) -> int:
|
||||||
|
return parse_listen_port(self.internal)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def external_port(self) -> int:
|
||||||
|
return parse_listen_port(self.external)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_distinct_ports(self) -> "ListenConfig":
|
||||||
|
if self.internal_port == self.external_port:
|
||||||
|
raise ValueError("internal and external must listen on different ports")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class NetworkingConfig(FrigateBaseModel):
|
class NetworkingConfig(FrigateBaseModel):
|
||||||
ipv6: IPv6Config = Field(
|
ipv6: IPv6Config = Field(
|
||||||
|
|||||||
@@ -169,6 +169,93 @@ class ProfileManager:
|
|||||||
self.config.active_profile = None
|
self.config.active_profile = None
|
||||||
self._persist_active_profile(None)
|
self._persist_active_profile(None)
|
||||||
|
|
||||||
|
def _validate_profile_name(self, profile_name: str | None) -> str | None:
|
||||||
|
"""Return an error message if the name is not a defined profile."""
|
||||||
|
if profile_name is not None and profile_name not in self.config.profiles:
|
||||||
|
return f"Profile '{profile_name}' is not defined in the profiles section"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _apply_to_config(
|
||||||
|
self, profile_name: str | None
|
||||||
|
) -> tuple[dict[str, set[str]], str | None]:
|
||||||
|
"""Reset every camera to base, then apply the named profile on top.
|
||||||
|
|
||||||
|
Returns the changed camera/section pairs, plus an error message if
|
||||||
|
applying the profile failed partway through.
|
||||||
|
"""
|
||||||
|
changed: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
self._reset_to_base(changed)
|
||||||
|
|
||||||
|
if profile_name is not None:
|
||||||
|
err = self._apply_profile_overrides(profile_name, changed)
|
||||||
|
if err:
|
||||||
|
return changed, err
|
||||||
|
|
||||||
|
return changed, None
|
||||||
|
|
||||||
|
def apply_profile_to_config(self, profile_name: str | None) -> str | None:
|
||||||
|
"""Apply a profile to the in-memory config, without publishing it.
|
||||||
|
|
||||||
|
Safe to call ahead of activate_profile: both reset to the base config
|
||||||
|
first, so the later call re-derives the same state and still reports
|
||||||
|
every section as changed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None on success, or an error message string on failure.
|
||||||
|
"""
|
||||||
|
err = self._validate_profile_name(profile_name)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
return self._apply_to_config(profile_name)[1]
|
||||||
|
|
||||||
|
def _persisted_profile_to_restore(self) -> str | None:
|
||||||
|
"""Return the persisted profile name, if it still applies to a camera."""
|
||||||
|
persisted = self.load_persisted_profile()
|
||||||
|
|
||||||
|
if not persisted or not any(
|
||||||
|
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return persisted
|
||||||
|
|
||||||
|
def restore_persisted_profile_to_config(self) -> None:
|
||||||
|
"""Restore the persisted profile into the config, without publishing.
|
||||||
|
|
||||||
|
Called before worker processes start, so they are handed a config that
|
||||||
|
already carries the profile rather than relying on the broadcast that
|
||||||
|
restore_persisted_profile() sends later.
|
||||||
|
"""
|
||||||
|
persisted = self._persisted_profile_to_restore()
|
||||||
|
|
||||||
|
if persisted is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
err = self.apply_profile_to_config(persisted)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
logger.error("Failed to apply persisted profile '%s': %s", persisted, err)
|
||||||
|
|
||||||
|
def restore_persisted_profile(self) -> None:
|
||||||
|
"""Re-activate the persisted profile once subscribers are connected.
|
||||||
|
|
||||||
|
The config already carries the profile; this pass publishes it for the
|
||||||
|
processes that start before the config can be corrected, and for the
|
||||||
|
retained MQTT states.
|
||||||
|
"""
|
||||||
|
persisted = self._persisted_profile_to_restore()
|
||||||
|
|
||||||
|
if persisted is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Restoring persisted profile '%s'", persisted)
|
||||||
|
# runtime overrides are layered on top by the dispatcher's replay
|
||||||
|
self.activate_profile(persisted, clear_runtime_overrides=False)
|
||||||
|
|
||||||
def activate_profile(
|
def activate_profile(
|
||||||
self,
|
self,
|
||||||
profile_name: str | None,
|
profile_name: str | None,
|
||||||
@@ -187,23 +274,16 @@ class ProfileManager:
|
|||||||
Returns:
|
Returns:
|
||||||
None on success, or an error message string on failure.
|
None on success, or an error message string on failure.
|
||||||
"""
|
"""
|
||||||
if profile_name is not None:
|
err = self._validate_profile_name(profile_name)
|
||||||
if profile_name not in self.config.profiles:
|
|
||||||
return (
|
if err:
|
||||||
f"Profile '{profile_name}' is not defined in the profiles section"
|
return err
|
||||||
)
|
|
||||||
|
|
||||||
# Track which camera/section pairs get changed for ZMQ publishing
|
# Track which camera/section pairs get changed for ZMQ publishing
|
||||||
changed: dict[str, set[str]] = {}
|
changed, err = self._apply_to_config(profile_name)
|
||||||
|
|
||||||
# Reset all cameras to base config
|
if err:
|
||||||
self._reset_to_base(changed)
|
return err
|
||||||
|
|
||||||
# Apply new profile overrides if activating
|
|
||||||
if profile_name is not None:
|
|
||||||
err = self._apply_profile_overrides(profile_name, changed)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
# Publish ZMQ updates only for sections that actually changed
|
# Publish ZMQ updates only for sections that actually changed
|
||||||
self._publish_updates(changed)
|
self._publish_updates(changed)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Tests that the internal port trusted by /auth cannot be moved at runtime."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import ruamel.yaml
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
|
from frigate.const import JWT_SECRET_ENV_VAR
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||||
|
class TestAuthInternalPort(BaseTestHttp):
|
||||||
|
"""/auth grants anonymous admin by port, so that port must stay put.
|
||||||
|
|
||||||
|
nginx binds its listeners once at container start and never reloads them,
|
||||||
|
but /api/config/set can swap the live config object mid-process. If /auth
|
||||||
|
read the port off the live config, saving networking.listen.internal would
|
||||||
|
hand unauthenticated admin to whoever can reach the external port.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||||
|
self.minimal_config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"auth": {"enabled": True},
|
||||||
|
"networking": {"listen": {"internal": 5000, "external": 8971}},
|
||||||
|
"cameras": {
|
||||||
|
"front_door": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_app(self):
|
||||||
|
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
|
mock_publisher.publisher = MagicMock()
|
||||||
|
|
||||||
|
app = create_fastapi_app(
|
||||||
|
FrigateConfig(**self.minimal_config),
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
mock_publisher,
|
||||||
|
None,
|
||||||
|
enforce_default_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_get_current_user(request: Request):
|
||||||
|
return {
|
||||||
|
"username": request.headers.get("remote-user"),
|
||||||
|
"role": request.headers.get("remote-role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||||
|
return list(self.minimal_config.get("cameras", {}).keys())
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||||
|
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
def _write_config_file(self):
|
||||||
|
"""Write the minimal config to a temp YAML file and return the path."""
|
||||||
|
yaml = ruamel.yaml.YAML()
|
||||||
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||||
|
yaml.dump(self.minimal_config, f)
|
||||||
|
f.close()
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
def test_internal_port_is_anonymous_admin(self):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.get("/auth", headers={"x-server-port": "5000"})
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-user"], "anonymous")
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||||
|
|
||||||
|
def test_external_port_requires_auth(self):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_swapped_config_does_not_move_the_trusted_port(self):
|
||||||
|
"""The live config is not what /auth trusts.
|
||||||
|
|
||||||
|
Stands in for every path that can rebind app.frigate_config while the
|
||||||
|
process runs, whatever restart flag the caller claimed.
|
||||||
|
"""
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
swapped = FrigateConfig(
|
||||||
|
**{
|
||||||
|
**self.minimal_config,
|
||||||
|
"networking": {"listen": {"internal": 8971, "external": 5000}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
app.frigate_config = swapped
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
# nginx is still listening where it was told to at boot
|
||||||
|
resp = client.get("/auth", headers={"x-server-port": "5000"})
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_config_set_rejects_internal_matching_external(self, mock_find_config):
|
||||||
|
"""Saving the internal port onto the external one is refused outright."""
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"networking": {"listen": {"internal": 8971}}},
|
||||||
|
"update_topic": "config/networking",
|
||||||
|
"requires_restart": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
self.assertFalse(resp.json()["success"])
|
||||||
|
|
||||||
|
# the rejected save must not have reached the live config
|
||||||
|
self.assertEqual(
|
||||||
|
app.frigate_config.networking.listen.internal_port, 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
with open(config_path) as f:
|
||||||
|
self.assertNotIn("8971", f.read().split("external")[0])
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -5,6 +5,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from frigate.app import FrigateApp
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||||
|
|
||||||
@@ -363,5 +364,94 @@ class TestReapplyRuntimeStateToConfig(unittest.TestCase):
|
|||||||
dispatcher.reapply_runtime_state_to_config()
|
dispatcher.reapply_runtime_state_to_config()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase):
|
||||||
|
"""Both layers must reach the config before config-carrying workers start.
|
||||||
|
|
||||||
|
A worker started before a layer is applied keeps the yaml value for the
|
||||||
|
rest of the session: the config_updater broadcast sent later is dropped
|
||||||
|
for subscribers that have not connected yet, and nothing re-sends it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONFIG_LAYERS = (
|
||||||
|
"profile_manager.restore_persisted_profile_to_config",
|
||||||
|
"dispatcher.reapply_runtime_state_to_config",
|
||||||
|
)
|
||||||
|
|
||||||
|
# started with a copy of the camera config
|
||||||
|
CONFIG_CARRYING_WORKERS = (
|
||||||
|
"start_video_output_processor",
|
||||||
|
"start_ptz_autotracker",
|
||||||
|
"start_detected_frames_processor",
|
||||||
|
"start_camera_processor",
|
||||||
|
"start_audio_processor",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_call_order(self) -> list[str]:
|
||||||
|
"""Return the names FrigateApp.start() calls, in order."""
|
||||||
|
app = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("frigate.app.set_file_limit"),
|
||||||
|
patch("frigate.app.cleanup_replay_cameras"),
|
||||||
|
patch("frigate.app.reap_stale_exports"),
|
||||||
|
patch("frigate.app.create_fastapi_app"),
|
||||||
|
patch("frigate.app.uvicorn"),
|
||||||
|
):
|
||||||
|
FrigateApp.start(app)
|
||||||
|
|
||||||
|
return [name for name, _, _ in app.mock_calls]
|
||||||
|
|
||||||
|
def test_applied_before_any_config_carrying_worker(self) -> None:
|
||||||
|
order = self._start_call_order()
|
||||||
|
|
||||||
|
for layer in self.CONFIG_LAYERS:
|
||||||
|
for worker in self.CONFIG_CARRYING_WORKERS:
|
||||||
|
self.assertLess(order.index(layer), order.index(worker))
|
||||||
|
|
||||||
|
def test_applied_after_the_dispatcher_exists(self) -> None:
|
||||||
|
order = self._start_call_order()
|
||||||
|
|
||||||
|
for layer in self.CONFIG_LAYERS:
|
||||||
|
self.assertLess(order.index("init_dispatcher"), order.index(layer))
|
||||||
|
|
||||||
|
def test_applied_after_the_profile_base_is_snapshotted(self) -> None:
|
||||||
|
# ProfileManager snapshots the config as the "no profile" base that
|
||||||
|
# deactivation resets to, so neither layer may be in the config yet
|
||||||
|
order = self._start_call_order()
|
||||||
|
|
||||||
|
for layer in self.CONFIG_LAYERS:
|
||||||
|
self.assertLess(order.index("init_profile_manager"), order.index(layer))
|
||||||
|
|
||||||
|
def test_layers_applied_in_order(self) -> None:
|
||||||
|
# a runtime toggle is the layer the user set last, so it goes on top
|
||||||
|
order = self._start_call_order()
|
||||||
|
|
||||||
|
self.assertLess(
|
||||||
|
order.index("profile_manager.restore_persisted_profile_to_config"),
|
||||||
|
order.index("dispatcher.reapply_runtime_state_to_config"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_overrides_still_re_applied_after_the_profile_is_restored(self) -> None:
|
||||||
|
# activation resets the sections it owns to the base first, so the
|
||||||
|
# overrides have to land on top again
|
||||||
|
order = self._start_call_order()
|
||||||
|
|
||||||
|
self.assertLess(
|
||||||
|
order.index("profile_manager.restore_persisted_profile"),
|
||||||
|
order.index("dispatcher.restore_runtime_state"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_broadcast_replay_still_runs_at_the_end(self) -> None:
|
||||||
|
# the broadcast is the only channel for the recording, review, and
|
||||||
|
# embeddings processes, which start before the config can be corrected
|
||||||
|
order = self._start_call_order()
|
||||||
|
|
||||||
|
for replay in (
|
||||||
|
"profile_manager.restore_persisted_profile",
|
||||||
|
"dispatcher.restore_runtime_state",
|
||||||
|
):
|
||||||
|
self.assertLess(order.index("start_audio_processor"), order.index(replay))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for networking config validation."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from frigate.config.network import ListenConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestListenConfig(unittest.TestCase):
|
||||||
|
def test_defaults_are_distinct(self):
|
||||||
|
listen = ListenConfig()
|
||||||
|
|
||||||
|
self.assertEqual(listen.internal_port, 5000)
|
||||||
|
self.assertEqual(listen.external_port, 8971)
|
||||||
|
|
||||||
|
def test_address_and_port_string_is_parsed(self):
|
||||||
|
listen = ListenConfig(internal="127.0.0.1:5000", external="0.0.0.0:8971")
|
||||||
|
|
||||||
|
self.assertEqual(listen.internal_port, 5000)
|
||||||
|
self.assertEqual(listen.external_port, 8971)
|
||||||
|
|
||||||
|
def test_identical_ports_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
ListenConfig(internal=8971, external=8971)
|
||||||
|
|
||||||
|
def test_same_port_on_different_addresses_rejected(self):
|
||||||
|
# nginx would accept these as distinct listeners, but /auth decides on
|
||||||
|
# the port alone, so the external one would inherit anonymous admin
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
ListenConfig(internal="127.0.0.1:8971", external="0.0.0.0:8971")
|
||||||
|
|
||||||
|
def test_distinct_ports_accepted(self):
|
||||||
|
listen = ListenConfig(internal=5001, external="0.0.0.0:8971")
|
||||||
|
|
||||||
|
self.assertEqual(listen.internal_port, 5001)
|
||||||
|
self.assertEqual(listen.external_port, 8971)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -785,6 +785,98 @@ class TestProfileManager(unittest.TestCase):
|
|||||||
manager.activate_profile("armed", clear_runtime_overrides=False)
|
manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||||
dispatcher.clear_runtime_state.assert_not_called()
|
dispatcher.clear_runtime_state.assert_not_called()
|
||||||
|
|
||||||
|
def test_apply_profile_to_config_mutates_the_config(self):
|
||||||
|
"""The config-only half applies the same overrides as activation."""
|
||||||
|
err = self.manager.apply_profile_to_config("armed")
|
||||||
|
assert err is None
|
||||||
|
|
||||||
|
front = self.config.cameras["front"]
|
||||||
|
assert front.notifications.enabled is True
|
||||||
|
assert front.objects.track == ["person", "car", "package"]
|
||||||
|
|
||||||
|
def test_apply_profile_to_config_makes_no_zmq_mqtt_or_disk_writes(self):
|
||||||
|
"""Workers are started with the values, so nothing is published yet."""
|
||||||
|
dispatcher = MagicMock()
|
||||||
|
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||||
|
|
||||||
|
with patch.object(ProfileManager, "_persist_active_profile") as mock_persist:
|
||||||
|
manager.apply_profile_to_config("armed")
|
||||||
|
|
||||||
|
self.mock_updater.publish_update.assert_not_called()
|
||||||
|
dispatcher.publish.assert_not_called()
|
||||||
|
mock_persist.assert_not_called()
|
||||||
|
# bookkeeping stays with activate_profile
|
||||||
|
assert self.config.active_profile is None
|
||||||
|
|
||||||
|
def test_apply_profile_to_config_rejects_an_unknown_profile(self):
|
||||||
|
err = self.manager.apply_profile_to_config("nonexistent")
|
||||||
|
assert err is not None
|
||||||
|
assert "not defined" in err
|
||||||
|
|
||||||
|
def test_restore_persisted_profile_to_config_applies_it(self):
|
||||||
|
"""The startup config pass restores what was persisted."""
|
||||||
|
with patch.object(
|
||||||
|
ProfileManager, "load_persisted_profile", return_value="armed"
|
||||||
|
):
|
||||||
|
self.manager.restore_persisted_profile_to_config()
|
||||||
|
|
||||||
|
assert self.config.cameras["front"].notifications.enabled is True
|
||||||
|
# still the config-only half, so nothing is published or persisted
|
||||||
|
self.mock_updater.publish_update.assert_not_called()
|
||||||
|
assert self.config.active_profile is None
|
||||||
|
|
||||||
|
def test_restore_persisted_profile_to_config_no_op_when_none_persisted(self):
|
||||||
|
with patch.object(ProfileManager, "load_persisted_profile", return_value=None):
|
||||||
|
self.manager.restore_persisted_profile_to_config()
|
||||||
|
|
||||||
|
assert self.config.cameras["front"].notifications.enabled is False
|
||||||
|
|
||||||
|
def test_restore_persisted_profile_to_config_ignores_a_stale_name(self):
|
||||||
|
"""A profile no longer offered by any camera must not be applied."""
|
||||||
|
with patch.object(
|
||||||
|
ProfileManager, "load_persisted_profile", return_value="ghost"
|
||||||
|
):
|
||||||
|
self.manager.restore_persisted_profile_to_config()
|
||||||
|
|
||||||
|
assert self.config.cameras["front"].notifications.enabled is False
|
||||||
|
|
||||||
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
|
def test_restore_persisted_profile_activates_and_publishes(self, mock_persist):
|
||||||
|
"""The startup publish pass runs a full activation."""
|
||||||
|
dispatcher = MagicMock()
|
||||||
|
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ProfileManager, "load_persisted_profile", return_value="armed"
|
||||||
|
):
|
||||||
|
manager.restore_persisted_profile()
|
||||||
|
|
||||||
|
assert self.config.active_profile == "armed"
|
||||||
|
self.mock_updater.publish_update.assert_called()
|
||||||
|
# a startup replay must not wipe the runtime overrides layered on top
|
||||||
|
dispatcher.clear_runtime_state.assert_not_called()
|
||||||
|
|
||||||
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
|
def test_activation_after_apply_still_publishes_every_section(self, mock_persist):
|
||||||
|
"""Re-deriving the same state must not skip the broadcast.
|
||||||
|
|
||||||
|
The processes that started before the config was corrected have no
|
||||||
|
other channel.
|
||||||
|
"""
|
||||||
|
self.manager.apply_profile_to_config("armed")
|
||||||
|
self.mock_updater.publish_update.reset_mock()
|
||||||
|
|
||||||
|
err = self.manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||||
|
assert err is None
|
||||||
|
|
||||||
|
published = {
|
||||||
|
call.args[0].update_type.name
|
||||||
|
for call in self.mock_updater.publish_update.call_args_list
|
||||||
|
}
|
||||||
|
assert "notifications" in published
|
||||||
|
assert "objects" in published
|
||||||
|
assert self.config.active_profile == "armed"
|
||||||
|
|
||||||
@patch.object(ProfileManager, "_persist_active_profile")
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
def test_update_config_preserves_runtime_state_with_active_profile(
|
def test_update_config_preserves_runtime_state_with_active_profile(
|
||||||
self, mock_persist
|
self, mock_persist
|
||||||
|
|||||||
@@ -327,17 +327,20 @@ export function ReviewTimeline({
|
|||||||
documentInstance?.addEventListener("touchmove", handleMouseMove);
|
documentInstance?.addEventListener("touchmove", handleMouseMove);
|
||||||
documentInstance?.addEventListener("mouseup", handleMouseUp);
|
documentInstance?.addEventListener("mouseup", handleMouseUp);
|
||||||
documentInstance?.addEventListener("touchend", handleMouseUp);
|
documentInstance?.addEventListener("touchend", handleMouseUp);
|
||||||
|
documentInstance?.addEventListener("touchcancel", handleMouseUp);
|
||||||
} else {
|
} else {
|
||||||
documentInstance?.removeEventListener("mousemove", handleMouseMove);
|
documentInstance?.removeEventListener("mousemove", handleMouseMove);
|
||||||
documentInstance?.removeEventListener("touchmove", handleMouseMove);
|
documentInstance?.removeEventListener("touchmove", handleMouseMove);
|
||||||
documentInstance?.removeEventListener("mouseup", handleMouseUp);
|
documentInstance?.removeEventListener("mouseup", handleMouseUp);
|
||||||
documentInstance?.removeEventListener("touchend", handleMouseUp);
|
documentInstance?.removeEventListener("touchend", handleMouseUp);
|
||||||
|
documentInstance?.removeEventListener("touchcancel", handleMouseUp);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
documentInstance?.removeEventListener("mousemove", handleMouseMove);
|
documentInstance?.removeEventListener("mousemove", handleMouseMove);
|
||||||
documentInstance?.removeEventListener("touchmove", handleMouseMove);
|
documentInstance?.removeEventListener("touchmove", handleMouseMove);
|
||||||
documentInstance?.removeEventListener("mouseup", handleMouseUp);
|
documentInstance?.removeEventListener("mouseup", handleMouseUp);
|
||||||
documentInstance?.removeEventListener("touchend", handleMouseUp);
|
documentInstance?.removeEventListener("touchend", handleMouseUp);
|
||||||
|
documentInstance?.removeEventListener("touchcancel", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [handleMouseMove, handleMouseUp, isDragging]);
|
}, [handleMouseMove, handleMouseUp, isDragging]);
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export const VirtualizedEventSegments = forwardRef<
|
|||||||
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
|
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
|
||||||
OVERSCAN_COUNT,
|
OVERSCAN_COUNT,
|
||||||
);
|
);
|
||||||
setVisibleRange({ start, end });
|
setVisibleRange((prev) =>
|
||||||
|
prev.start === start && prev.end === end ? prev : { start, end },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [segments.length, timelineRef]);
|
}, [segments.length, timelineRef]);
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export const VirtualizedMotionSegments = forwardRef<
|
|||||||
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
|
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
|
||||||
OVERSCAN_COUNT,
|
OVERSCAN_COUNT,
|
||||||
);
|
);
|
||||||
setVisibleRange({ start, end });
|
setVisibleRange((prev) =>
|
||||||
|
prev.start === start && prev.end === end ? prev : { start, end },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [segments.length, timelineRef]);
|
}, [segments.length, timelineRef]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTimelineUtils } from "./use-timeline-utils";
|
import { useTimelineUtils } from "./use-timeline-utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@@ -8,6 +8,8 @@ import { useTimeFormat } from "./use-date-utils";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useUserInteraction from "./use-user-interaction";
|
import useUserInteraction from "./use-user-interaction";
|
||||||
|
|
||||||
|
const DRAG_STATE_COMMIT_MS = 100;
|
||||||
|
|
||||||
type DraggableElementProps = {
|
type DraggableElementProps = {
|
||||||
contentRef: React.RefObject<HTMLElement | null>;
|
contentRef: React.RefObject<HTMLElement | null>;
|
||||||
timelineRef: React.RefObject<HTMLDivElement | null>;
|
timelineRef: React.RefObject<HTMLDivElement | null>;
|
||||||
@@ -61,6 +63,8 @@ function useDraggableElement({
|
|||||||
|
|
||||||
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
|
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
|
||||||
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
|
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
|
||||||
|
const lastDragTimeCommitRef = useRef(0);
|
||||||
|
const pendingDragTimeRef = useRef<number | null>(null);
|
||||||
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
|
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
|
||||||
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
|
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
|
||||||
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
|
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
|
||||||
@@ -126,6 +130,7 @@ function useDraggableElement({
|
|||||||
}
|
}
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
pendingDragTimeRef.current = null;
|
||||||
|
|
||||||
let clientY;
|
let clientY;
|
||||||
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
|
||||||
@@ -154,9 +159,14 @@ function useDraggableElement({
|
|||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setInitialClickAdjustment(0);
|
setInitialClickAdjustment(0);
|
||||||
|
|
||||||
|
if (pendingDragTimeRef.current !== null && setDraggableElementTime) {
|
||||||
|
setDraggableElementTime(pendingDragTimeRef.current);
|
||||||
|
pendingDragTimeRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDragging, setIsDragging],
|
[isDragging, setIsDragging, setDraggableElementTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const timestampToPixels = useCallback(
|
const timestampToPixels = useCallback(
|
||||||
@@ -346,9 +356,21 @@ function useDraggableElement({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (setDraggableElementTime) {
|
if (setDraggableElementTime) {
|
||||||
setDraggableElementTime(
|
const newTime =
|
||||||
targetSegmentTime + segmentDuration * (offset / segmentHeight),
|
targetSegmentTime + segmentDuration * (offset / segmentHeight);
|
||||||
);
|
const now = performance.now();
|
||||||
|
|
||||||
|
// don't commit on every animation frame, only commit it at a
|
||||||
|
// set interval to avoid React's nested update limit
|
||||||
|
if (now - lastDragTimeCommitRef.current >= DRAG_STATE_COMMIT_MS) {
|
||||||
|
lastDragTimeCommitRef.current = now;
|
||||||
|
pendingDragTimeRef.current = null;
|
||||||
|
setDraggableElementTime(newTime);
|
||||||
|
} else {
|
||||||
|
// Hold the newest value; handleMouseUp flushes it so the
|
||||||
|
// release still lands exactly where the handle was dropped.
|
||||||
|
pendingDragTimeRef.current = newTime;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draggingAtTopEdge || draggingAtBottomEdge) {
|
if (draggingAtTopEdge || draggingAtBottomEdge) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) {
|
|||||||
const [userInteracting, setUserInteracting] = useState(false);
|
const [userInteracting, setUserInteracting] = useState(false);
|
||||||
const interactionTimeout = useRef<NodeJS.Timeout>(undefined);
|
const interactionTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
const isProgrammaticScroll = useRef(false);
|
const isProgrammaticScroll = useRef(false);
|
||||||
|
const userInteractingRef = useRef(false);
|
||||||
|
|
||||||
const setProgrammaticScroll = useCallback(() => {
|
const setProgrammaticScroll = useCallback(() => {
|
||||||
isProgrammaticScroll.current = true;
|
isProgrammaticScroll.current = true;
|
||||||
@@ -16,13 +17,18 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUserInteraction = () => {
|
const handleUserInteraction = () => {
|
||||||
if (!isProgrammaticScroll.current) {
|
if (!isProgrammaticScroll.current) {
|
||||||
setUserInteracting(true);
|
// Only commit state on the leading edge
|
||||||
|
if (!userInteractingRef.current) {
|
||||||
|
userInteractingRef.current = true;
|
||||||
|
setUserInteracting(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (interactionTimeout.current) {
|
if (interactionTimeout.current) {
|
||||||
clearTimeout(interactionTimeout.current);
|
clearTimeout(interactionTimeout.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
interactionTimeout.current = setTimeout(() => {
|
interactionTimeout.current = setTimeout(() => {
|
||||||
|
userInteractingRef.current = false;
|
||||||
setUserInteracting(false);
|
setUserInteracting(false);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
|||||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||||
|
|
||||||
import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review";
|
import {
|
||||||
|
MotionData,
|
||||||
|
REVIEW_PADDING,
|
||||||
|
ReviewSegment,
|
||||||
|
ZoomLevel,
|
||||||
|
} from "@/types/review";
|
||||||
import {
|
import {
|
||||||
ASPECT_VERTICAL_LAYOUT,
|
ASPECT_VERTICAL_LAYOUT,
|
||||||
ASPECT_WIDE_LAYOUT,
|
ASPECT_WIDE_LAYOUT,
|
||||||
@@ -85,6 +90,7 @@ type MotionSearchViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_EXPORT_WINDOW_SECONDS = 60;
|
const DEFAULT_EXPORT_WINDOW_SECONDS = 60;
|
||||||
|
const NO_REVIEW_EVENTS: ReviewSegment[] = [];
|
||||||
|
|
||||||
export default function MotionSearchView({
|
export default function MotionSearchView({
|
||||||
config,
|
config,
|
||||||
@@ -514,6 +520,12 @@ export default function MotionSearchView({
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timelineMotionEvents = useMemo(() => motionData ?? [], [motionData]);
|
||||||
|
const timelineNoRecordings = useMemo(
|
||||||
|
() => noRecordings ?? [],
|
||||||
|
[noRecordings],
|
||||||
|
);
|
||||||
|
|
||||||
const recordingParams = useMemo(
|
const recordingParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
before: currentTimeRange.before,
|
before: currentTimeRange.before,
|
||||||
@@ -1054,11 +1066,11 @@ export default function MotionSearchView({
|
|||||||
showHandlebar={true}
|
showHandlebar={true}
|
||||||
handlebarTime={currentTime}
|
handlebarTime={currentTime}
|
||||||
setHandlebarTime={setCurrentTime}
|
setHandlebarTime={setCurrentTime}
|
||||||
events={[]}
|
events={NO_REVIEW_EVENTS}
|
||||||
motion_events={motionData ?? []}
|
motion_events={timelineMotionEvents}
|
||||||
noRecordingRanges={noRecordings ?? []}
|
noRecordingRanges={timelineNoRecordings}
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
onHandlebarDraggingChange={setScrubbing}
|
||||||
showExportHandles={
|
showExportHandles={
|
||||||
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||||
Boolean(exportRange)
|
Boolean(exportRange)
|
||||||
@@ -1489,7 +1501,7 @@ export default function MotionSearchView({
|
|||||||
isDesktop
|
isDesktop
|
||||||
? mainCameraAspect === "tall"
|
? mainCameraAspect === "tall"
|
||||||
? "mr-2 h-full min-h-0 min-w-0 flex-1 items-center"
|
? "mr-2 h-full min-h-0 min-w-0 flex-1 items-center"
|
||||||
: "mr-2 h-full min-h-0 min-w-0 flex-1"
|
: "mx-2 h-full min-h-0 min-w-0 flex-1"
|
||||||
: mainCameraAspect === "tall"
|
: mainCameraAspect === "tall"
|
||||||
? "flex-1 portrait:h-[40dvh] portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto portrait:items-center portrait:justify-center"
|
? "flex-1 portrait:h-[40dvh] portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto portrait:items-center portrait:justify-center"
|
||||||
: "flex-1 portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto landscape:items-center landscape:justify-center",
|
: "flex-1 portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto landscape:items-center landscape:justify-center",
|
||||||
|
|||||||
@@ -1199,7 +1199,7 @@ function Timeline({
|
|||||||
motion_events={motionData ?? []}
|
motion_events={motionData ?? []}
|
||||||
noRecordingRanges={noRecordings ?? []}
|
noRecordingRanges={noRecordings ?? []}
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
onHandlebarDraggingChange={setScrubbing}
|
||||||
isZooming={isZooming}
|
isZooming={isZooming}
|
||||||
zoomDirection={zoomDirection}
|
zoomDirection={zoomDirection}
|
||||||
onZoomChange={handleZoomChange}
|
onZoomChange={handleZoomChange}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
esbuild: {
|
||||||
|
keepNames: true,
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
Reference in New Issue
Block a user