mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-05 17:51:36 +02:00
Merge 85741c02e4
into b781f06f9c
This commit is contained in:
commit
92b9791599
@ -32,6 +32,7 @@ from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
reload_go2rtc,
|
||||
)
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
@ -424,15 +425,38 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
if camera in old_config.cameras:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.edit, camera),
|
||||
settings,
|
||||
)
|
||||
continue_publish = False
|
||||
else:
|
||||
continue_publish = True
|
||||
elif field == "edit":
|
||||
settings = config.cameras[camera]
|
||||
if camera in old_config.cameras:
|
||||
logger.info(f"Publishing edit event for camera {camera}")
|
||||
continue_publish = True
|
||||
else:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, camera),
|
||||
settings,
|
||||
)
|
||||
continue_publish = False
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
continue_publish = True
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
continue_publish = True
|
||||
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
||||
settings,
|
||||
)
|
||||
if continue_publish:
|
||||
logger.info(f"Publishing {field} event for camera {camera}")
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
||||
settings,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
@ -530,6 +554,23 @@ def nvinfo():
|
||||
return JSONResponse(content=get_nvidia_driver_info())
|
||||
|
||||
|
||||
@router.post("/go2rtc/reload", dependencies=[Depends(require_role(["admin"]))])
|
||||
def reload_go2rtc_endpoint():
|
||||
"""reload go2rtc service"""
|
||||
try:
|
||||
reload_go2rtc()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "go2rtc reloaded"},
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"reload go2rtc failed: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"{str(e)}"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs/{service}", tags=[Tags.logs])
|
||||
async def logs(
|
||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||
|
@ -50,6 +50,7 @@ class CameraMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.edit,
|
||||
],
|
||||
)
|
||||
self.shm_count = self.__calculate_shm_frame_count()
|
||||
@ -194,6 +195,21 @@ class CameraMaintainer(threading.Thread):
|
||||
for update_type, updated_cameras in updates.items():
|
||||
if update_type == CameraConfigUpdateEnum.add.name:
|
||||
for camera in updated_cameras:
|
||||
self.__start_camera_processor(
|
||||
camera,
|
||||
self.update_subscriber.camera_configs[camera],
|
||||
runtime=True,
|
||||
)
|
||||
self.__start_camera_capture(
|
||||
camera,
|
||||
self.update_subscriber.camera_configs[camera],
|
||||
runtime=True,
|
||||
)
|
||||
elif update_type == CameraConfigUpdateEnum.edit.name:
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
|
||||
self.__start_camera_processor(
|
||||
camera,
|
||||
self.update_subscriber.camera_configs[camera],
|
||||
@ -205,8 +221,9 @@ class CameraMaintainer(threading.Thread):
|
||||
runtime=True,
|
||||
)
|
||||
elif update_type == CameraConfigUpdateEnum.remove.name:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera in self.camera_processes.keys():
|
||||
|
@ -1,11 +1,20 @@
|
||||
"""Convenience classes for updating configurations dynamically."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Dict
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.util.builtin import update_yaml_file_bulk
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CameraConfigUpdateEnum(str, Enum):
|
||||
@ -16,6 +25,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
audio_transcription = "audio_transcription"
|
||||
birdseye = "birdseye"
|
||||
detect = "detect"
|
||||
edit = "edit" # for editing an existing camera
|
||||
enabled = "enabled"
|
||||
motion = "motion" # includes motion and motion masks
|
||||
notifications = "notifications"
|
||||
@ -76,12 +86,61 @@ class CameraConfigUpdateSubscriber:
|
||||
self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any
|
||||
) -> None:
|
||||
if update_type == CameraConfigUpdateEnum.add:
|
||||
"""Add a new camera and handle go2rtc streams if needed"""
|
||||
self.config.cameras[camera] = updated_config
|
||||
self.camera_configs[camera] = updated_config
|
||||
|
||||
# Handle potential go2rtc streams for new camera
|
||||
if hasattr(updated_config, "ffmpeg") and hasattr(
|
||||
updated_config.ffmpeg, "inputs"
|
||||
):
|
||||
streams = self._extract_stream_names(updated_config)
|
||||
if (
|
||||
streams
|
||||
and hasattr(self.config, "go2rtc")
|
||||
and hasattr(self.config.go2rtc, "streams")
|
||||
):
|
||||
go2rtc_updated = False
|
||||
for stream_name in streams:
|
||||
logger.info(
|
||||
f"New go2rtc stream detected for camera {camera}: {stream_name}"
|
||||
)
|
||||
go2rtc_updated = True
|
||||
|
||||
if go2rtc_updated:
|
||||
self.reload_go2rtc()
|
||||
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.edit:
|
||||
"""Update camera configuration without stopping processes"""
|
||||
old_config = self.camera_configs.get(camera)
|
||||
|
||||
self._handle_go2rtc_stream_updates(camera, old_config, updated_config)
|
||||
|
||||
self.config.cameras[camera] = updated_config
|
||||
self.camera_configs[camera] = updated_config
|
||||
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.remove:
|
||||
self.config.cameras.pop(camera)
|
||||
self.camera_configs.pop(camera)
|
||||
"""Remove go2rtc streams with camera"""
|
||||
camera_config = self.camera_configs.get(camera)
|
||||
go2rtc_updated = False
|
||||
|
||||
# Use helper methods to handle go2rtc streams
|
||||
if camera_config:
|
||||
streams = self._extract_stream_names(camera_config)
|
||||
for stream_name in streams:
|
||||
if not self._is_stream_in_use(stream_name, camera):
|
||||
if self._remove_unused_stream(stream_name):
|
||||
go2rtc_updated = True
|
||||
|
||||
if go2rtc_updated:
|
||||
self.reload_go2rtc()
|
||||
|
||||
self.camera_configs.pop(camera, None)
|
||||
if camera in self.config.cameras:
|
||||
self.config.cameras.pop(camera)
|
||||
|
||||
return
|
||||
|
||||
config = self.camera_configs.get(camera)
|
||||
@ -145,3 +204,138 @@ class CameraConfigUpdateSubscriber:
|
||||
|
||||
def stop(self) -> None:
|
||||
self.subscriber.stop()
|
||||
|
||||
def _extract_stream_names(self, config):
|
||||
"""Extract go2rtc stream names from configuration"""
|
||||
streams = []
|
||||
|
||||
if not (hasattr(config, "ffmpeg") and hasattr(config.ffmpeg, "inputs")):
|
||||
return streams
|
||||
|
||||
for input_item in config.ffmpeg.inputs:
|
||||
if not (hasattr(input_item, "path") and isinstance(input_item.path, str)):
|
||||
continue
|
||||
|
||||
path = input_item.path
|
||||
if "rtsp://" in path and "127.0.0.1:8554/" in path:
|
||||
stream_name = (
|
||||
path.split("127.0.0.1:8554/")[-1].split("&")[0].split("?")[0]
|
||||
)
|
||||
streams.append(stream_name)
|
||||
|
||||
return streams
|
||||
|
||||
def _is_stream_in_use(self, stream_name, current_camera):
|
||||
"""Check if stream is used by other cameras"""
|
||||
for cam_name, cam_config in self.camera_configs.items():
|
||||
if cam_name == current_camera:
|
||||
continue
|
||||
|
||||
if not (
|
||||
hasattr(cam_config, "ffmpeg") and hasattr(cam_config.ffmpeg, "inputs")
|
||||
):
|
||||
continue
|
||||
|
||||
for input_item in cam_config.ffmpeg.inputs:
|
||||
if (
|
||||
hasattr(input_item, "path")
|
||||
and isinstance(input_item.path, str)
|
||||
and "127.0.0.1:8554/" + stream_name in input_item.path
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _remove_unused_stream(self, stream_name):
|
||||
"""Remove unused go2rtc stream"""
|
||||
if not (
|
||||
hasattr(self.config, "go2rtc")
|
||||
and hasattr(self.config.go2rtc, "streams")
|
||||
and stream_name in self.config.go2rtc.streams
|
||||
):
|
||||
return False
|
||||
|
||||
logger.info(f"Removing unused go2rtc stream: {stream_name}")
|
||||
self.config.go2rtc.streams.pop(stream_name)
|
||||
config_file = find_config_file()
|
||||
updates: Dict[str, Any] = {f"go2rtc.streams.{stream_name}": ""}
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
return True
|
||||
|
||||
def _handle_go2rtc_stream_updates(self, camera, old_config, updated_config):
|
||||
"""Handle go2rtc stream configuration updates"""
|
||||
if not (
|
||||
old_config
|
||||
and hasattr(self.config, "go2rtc")
|
||||
and hasattr(self.config.go2rtc, "streams")
|
||||
):
|
||||
return
|
||||
|
||||
old_streams = self._extract_stream_names(old_config)
|
||||
new_streams = self._extract_stream_names(updated_config)
|
||||
|
||||
if not (old_streams or new_streams):
|
||||
return
|
||||
|
||||
removed_streams = [s for s in old_streams if s not in new_streams]
|
||||
added_streams = [s for s in new_streams if s not in old_streams]
|
||||
common_streams = [s for s in old_streams if s in new_streams]
|
||||
|
||||
go2rtc_updated = False
|
||||
|
||||
for stream_name in added_streams:
|
||||
logger.info(f"New go2rtc stream detected: {stream_name}")
|
||||
go2rtc_updated = True
|
||||
|
||||
for stream_name in removed_streams:
|
||||
if not self._is_stream_in_use(stream_name, camera):
|
||||
if self._remove_unused_stream(stream_name):
|
||||
go2rtc_updated = True
|
||||
|
||||
for stream_name in common_streams:
|
||||
if stream_name in self.config.go2rtc.streams:
|
||||
for input_item in (
|
||||
updated_config.ffmpeg.inputs
|
||||
if (
|
||||
hasattr(updated_config, "ffmpeg")
|
||||
and hasattr(updated_config.ffmpeg, "inputs")
|
||||
)
|
||||
else []
|
||||
):
|
||||
if (
|
||||
hasattr(input_item, "path")
|
||||
and isinstance(input_item.path, str)
|
||||
and "127.0.0.1:8554/" + stream_name in input_item.path
|
||||
):
|
||||
logger.info(
|
||||
f"Checking for changes in go2rtc stream: {stream_name}"
|
||||
)
|
||||
go2rtc_updated = True
|
||||
break
|
||||
|
||||
if go2rtc_updated:
|
||||
self.reload_go2rtc()
|
||||
|
||||
def reload_go2rtc(self):
|
||||
"""Regenerate go2rtc config and restart the service."""
|
||||
try:
|
||||
create_config_path = "/usr/local/go2rtc/create_config.py"
|
||||
if os.path.exists(create_config_path):
|
||||
subprocess.run(["python3", create_config_path], check=True)
|
||||
logger.info("Successfully regenerated go2rtc config")
|
||||
else:
|
||||
logger.warning(f"Could not find {create_config_path}")
|
||||
|
||||
# Restart go2rtc service
|
||||
try:
|
||||
response = requests.post("http://127.0.0.1:1984/api/restart", timeout=5)
|
||||
if response.status_code == 200:
|
||||
logger.info("Successfully restarted go2rtc service")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to restart go2rtc service: {response.status_code}"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error restarting go2rtc service: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reloading go2rtc: {e}")
|
||||
|
@ -102,6 +102,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.edit,
|
||||
CameraConfigUpdateEnum.object_genai,
|
||||
CameraConfigUpdateEnum.review_genai,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
|
216
scripts/create_go2rtc_config.py
Normal file
216
scripts/create_go2rtc_config.py
Normal file
@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Creates a go2rtc config file with proper permissions."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
except ImportError:
|
||||
print("Installing required packages...")
|
||||
import subprocess
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "ruamel.yaml"])
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
# Add frigate to path
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
try:
|
||||
from frigate.const import (
|
||||
BIRDSEYE_PIPE,
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
INCLUDED_FFMPEG_VERSIONS,
|
||||
LIBAVFORMAT_VERSION_MAJOR,
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file
|
||||
except ImportError:
|
||||
# Fallback if imports fail
|
||||
print("Warning: Could not import from frigate module. Using fallback values.")
|
||||
BIRDSEYE_PIPE = "/tmp/birdseye"
|
||||
DEFAULT_FFMPEG_VERSION = "v5.1.2"
|
||||
INCLUDED_FFMPEG_VERSIONS = ["v5.1.2", "v4.4"]
|
||||
LIBAVFORMAT_VERSION_MAJOR = 59
|
||||
|
||||
def parse_preset_hardware_acceleration_encode(ffmpeg_path, hwaccel_args, input_args, output_args):
|
||||
return f"{ffmpeg_path} {hwaccel_args} {input_args} {output_args}"
|
||||
|
||||
def find_config_file():
|
||||
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
||||
if os.path.exists(config_file):
|
||||
return config_file
|
||||
|
||||
# Try common locations
|
||||
for ext in [".yml", ".yaml", ".json"]:
|
||||
for path in ["/config/config", "/etc/frigate/config"]:
|
||||
if os.path.exists(f"{path}{ext}"):
|
||||
return f"{path}{ext}"
|
||||
return "/config/config.yml"
|
||||
|
||||
# Remove frigate from path
|
||||
if "/opt/frigate" in sys.path:
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
# read docker secret files as env vars too
|
||||
if os.path.isdir("/run/secrets"):
|
||||
for secret_file in os.listdir("/run/secrets"):
|
||||
if secret_file.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[secret_file] = (
|
||||
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
|
||||
)
|
||||
|
||||
config_file = find_config_file()
|
||||
print(f"Using config file: {config_file}")
|
||||
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith((".yaml", ".yml")):
|
||||
config: dict[str, Any] = yaml.load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config: dict[str, Any] = json.loads(raw_config)
|
||||
except FileNotFoundError:
|
||||
print(f"Config file not found: {config_file}")
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
|
||||
|
||||
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
|
||||
if go2rtc_config.get("api") is None:
|
||||
go2rtc_config["api"] = {"origin": "*"}
|
||||
elif go2rtc_config["api"].get("origin") is None:
|
||||
go2rtc_config["api"]["origin"] = "*"
|
||||
|
||||
# Need to set default location for HA config
|
||||
if go2rtc_config.get("hass") is None:
|
||||
go2rtc_config["hass"] = {"config": "/homeassistant"}
|
||||
|
||||
# we want to ensure that logs are easy to read
|
||||
if go2rtc_config.get("log") is None:
|
||||
go2rtc_config["log"] = {"format": "text"}
|
||||
elif go2rtc_config["log"].get("format") is None:
|
||||
go2rtc_config["log"]["format"] = "text"
|
||||
|
||||
# ensure there is a default webrtc config
|
||||
if go2rtc_config.get("webrtc") is None:
|
||||
go2rtc_config["webrtc"] = {}
|
||||
|
||||
if go2rtc_config["webrtc"].get("candidates") is None:
|
||||
default_candidates = []
|
||||
# use internal candidate if it was discovered when running through the add-on
|
||||
internal_candidate = os.environ.get("FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL")
|
||||
if internal_candidate is not None:
|
||||
default_candidates.append(internal_candidate)
|
||||
# should set default stun server so webrtc can work
|
||||
default_candidates.append("stun:8555")
|
||||
|
||||
go2rtc_config["webrtc"]["candidates"] = default_candidates
|
||||
|
||||
if go2rtc_config.get("rtsp", {}).get("username") is not None:
|
||||
go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
)
|
||||
|
||||
if go2rtc_config.get("rtsp", {}).get("password") is not None:
|
||||
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
)
|
||||
|
||||
# ensure ffmpeg path is set correctly
|
||||
path = config.get("ffmpeg", {}).get("path", "default")
|
||||
if path == "default":
|
||||
ffmpeg_path = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
ffmpeg_path = f"/usr/lib/ffmpeg/{path}/bin/ffmpeg"
|
||||
else:
|
||||
ffmpeg_path = f"{path}/bin/ffmpeg"
|
||||
|
||||
if go2rtc_config.get("ffmpeg") is None:
|
||||
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}
|
||||
elif go2rtc_config["ffmpeg"].get("bin") is None:
|
||||
go2rtc_config["ffmpeg"]["bin"] = ffmpeg_path
|
||||
|
||||
# need to replace ffmpeg command when using ffmpeg4
|
||||
if LIBAVFORMAT_VERSION_MAJOR < 59:
|
||||
rtsp_args = "-fflags nobuffer -flags low_delay -stimeout 10000000 -user_agent go2rtc/ffmpeg -rtsp_transport tcp -i {input}"
|
||||
if go2rtc_config.get("ffmpeg") is None:
|
||||
go2rtc_config["ffmpeg"] = {"rtsp": rtsp_args}
|
||||
elif go2rtc_config["ffmpeg"].get("rtsp") is None:
|
||||
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
|
||||
|
||||
for name in go2rtc_config.get("streams", {}):
|
||||
stream = go2rtc_config["streams"][name]
|
||||
|
||||
if isinstance(stream, str):
|
||||
try:
|
||||
go2rtc_config["streams"][name] = go2rtc_config["streams"][name].format(
|
||||
**FRIGATE_ENV_VARS
|
||||
)
|
||||
except KeyError as e:
|
||||
print(
|
||||
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
||||
)
|
||||
sys.exit(e)
|
||||
|
||||
elif isinstance(stream, list):
|
||||
for i, stream in enumerate(stream):
|
||||
try:
|
||||
go2rtc_config["streams"][name][i] = stream.format(**FRIGATE_ENV_VARS)
|
||||
except KeyError as e:
|
||||
print(
|
||||
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
|
||||
)
|
||||
sys.exit(e)
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
input = f"-f rawvideo -pix_fmt yuv420p -video_size {birdseye.get('width', 1280)}x{birdseye.get('height', 720)} -r 10 -i {BIRDSEYE_PIPE}"
|
||||
ffmpeg_cmd = f"exec:{parse_preset_hardware_acceleration_encode(ffmpeg_path, config.get('ffmpeg', {}).get('hwaccel_args', ''), input, '-rtsp_transport tcp -f rtsp {output}')}"
|
||||
|
||||
if go2rtc_config.get("streams"):
|
||||
go2rtc_config["streams"]["birdseye"] = ffmpeg_cmd
|
||||
else:
|
||||
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
|
||||
|
||||
# Write go2rtc_config to multiple possible locations with proper permissions
|
||||
config_paths = [
|
||||
"/dev/shm/go2rtc.yaml", # Primary location
|
||||
"/tmp/go2rtc.yaml", # Fallback location 1
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "go2rtc.yaml") # Fallback location 2
|
||||
]
|
||||
|
||||
success = False
|
||||
for config_path in config_paths:
|
||||
try:
|
||||
# Try to create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
|
||||
# Write to a temporary file first
|
||||
temp_path = f"{config_path}.tmp"
|
||||
with open(temp_path, "w") as f:
|
||||
yaml.dump(go2rtc_config, f)
|
||||
|
||||
# Make the file world-readable and writable
|
||||
os.chmod(temp_path, 0o666)
|
||||
|
||||
# Move the temporary file to the final location
|
||||
shutil.move(temp_path, config_path)
|
||||
|
||||
print(f"Successfully wrote go2rtc config to {config_path}")
|
||||
success = True
|
||||
break
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"Failed to write to {config_path}: {e}")
|
||||
|
||||
if not success:
|
||||
print("ERROR: Could not write go2rtc config to any location")
|
||||
sys.exit(1)
|
@ -192,16 +192,66 @@
|
||||
"edit": "Edit Camera",
|
||||
"description": "Configure camera settings including stream inputs and roles.",
|
||||
"name": "Camera Name",
|
||||
"nameOnlyChangeToFriendlyName": "This will only change the Friendly Name.",
|
||||
"nameRequired": "Camera name is required",
|
||||
"nameLength": "Camera name must be less than 24 characters.",
|
||||
"namePlaceholder": "e.g., front_door",
|
||||
"enabled": "Enabled",
|
||||
"ffmpeg": {
|
||||
"inputs": "Input Streams",
|
||||
"go2rtc": {
|
||||
"title": "Use go2rtc stream",
|
||||
"description": "It will utilize go2rtc, enabling a smoother live viewing experience and allowing you to hear the camera audio.",
|
||||
"compatibility": {
|
||||
"title": "Video codec compatibility (Advanced)",
|
||||
"description": "Here you can configure common go2rtc FFmpeg encoding parameters. Some of these parameters may help resolve certain video or audio issues.",
|
||||
"codec": {
|
||||
"title": "Video Codec",
|
||||
"description": "You can use go2rtc to convert the stream to a specified encoding. Note that converting video from one format to another is a resource-intensive task, so additional conversion is not recommended if the original setup works properly.",
|
||||
"h264": "H.264",
|
||||
"h265": "H.265",
|
||||
"copy": "Copy (default)"
|
||||
},
|
||||
"audio": {
|
||||
"title": "Audio Codec",
|
||||
"description": "If your camera does not support AAC audio, you can use this parameter to convert the audio to AAC. If you can see the video but hear no sound, try adding this parameter.",
|
||||
"copy": "Copy (default)",
|
||||
"aac": "AAC",
|
||||
"opus": "Opus"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Rotation",
|
||||
"description": "Some cameras may not set the correct rotation flag in the video stream. If your camera's video appears rotated incorrectly, you can use this parameter to manually set the rotation.",
|
||||
"90": "90°",
|
||||
"180": "180°",
|
||||
"270": "270°"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware",
|
||||
"description": "If you are using a hardware encoder, you can use this parameter to specify the hardware encoder to use.",
|
||||
"useHardware": "Force use hardware"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"path": "Stream Path",
|
||||
"pathRequired": "Stream path is required",
|
||||
"pathPlaceholder": "rtsp://...",
|
||||
"roles": "Roles",
|
||||
"roles": {
|
||||
"title": "Roles",
|
||||
"record": {
|
||||
"label": "Record",
|
||||
"info": "5555"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio",
|
||||
"info": "6666"
|
||||
},
|
||||
"detect": {
|
||||
"label": "Detect",
|
||||
"info": "77777"
|
||||
}
|
||||
},
|
||||
"rolesRequired": "At least one role is required",
|
||||
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
|
||||
"addInput": "Add Input Stream",
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Input, MultiSelectInput } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@ -22,6 +22,14 @@ import { LuTrash2, LuPlus } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import i18n from "@/utils/i18n";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type ConfigSetBody = {
|
||||
requires_restart: number;
|
||||
@ -55,6 +63,100 @@ export default function CameraEditForm({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const go2rtc_ffmpegOptions = [
|
||||
{
|
||||
id: "codec",
|
||||
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.title"),
|
||||
description: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.description",
|
||||
),
|
||||
options: [
|
||||
{
|
||||
value: "#video=h264",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.h264",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "#video=h265",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.h265",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "#video=copy",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.copy",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "audio",
|
||||
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.title"),
|
||||
description: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.description",
|
||||
),
|
||||
options: [
|
||||
{
|
||||
value: "#audio=aac",
|
||||
label: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.aac"),
|
||||
},
|
||||
{
|
||||
value: "#audio=opus",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.opus",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "#audio=copy",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.copy",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rotate",
|
||||
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.title"),
|
||||
description: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.description",
|
||||
),
|
||||
options: [
|
||||
{
|
||||
value: "#rotate=90",
|
||||
label: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.90"),
|
||||
},
|
||||
{
|
||||
value: "#rotate=180",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.180",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "#rotate=270",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.270",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hardware",
|
||||
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.title"),
|
||||
description: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.description",
|
||||
),
|
||||
options: [
|
||||
{
|
||||
value: "#hardware",
|
||||
label: t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.useHardware",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
@ -72,6 +174,8 @@ export default function CameraEditForm({
|
||||
roles: z.array(RoleEnum).min(1, {
|
||||
message: t("camera.cameraConfig.ffmpeg.rolesRequired"),
|
||||
}),
|
||||
go2rtc: z.boolean(),
|
||||
go2rtc_ffmpeg: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1, {
|
||||
@ -133,6 +237,8 @@ export default function CameraEditForm({
|
||||
ffmpeg: {
|
||||
inputs: [
|
||||
{
|
||||
go2rtc: false,
|
||||
go2rtc_ffmpeg: "",
|
||||
path: "",
|
||||
roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
|
||||
},
|
||||
@ -144,11 +250,49 @@ export default function CameraEditForm({
|
||||
if (cameraName && config?.cameras[cameraName]) {
|
||||
const camera = config.cameras[cameraName];
|
||||
defaultValues.enabled = camera.enabled ?? true;
|
||||
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
||||
? camera.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
roles: input.roles as Role[],
|
||||
}))
|
||||
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs
|
||||
? camera.ffmpeg.inputs.map((input) => {
|
||||
const isGo2rtcPath = input.path.match(
|
||||
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
||||
);
|
||||
const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : null;
|
||||
|
||||
let originalPath = input.path;
|
||||
let ffmpegParams = "";
|
||||
|
||||
if (go2rtcStreamName && config.go2rtc?.streams) {
|
||||
Object.entries(config.go2rtc.streams).forEach(
|
||||
([streamKey, streamConfig]) => {
|
||||
if (streamKey === go2rtcStreamName) {
|
||||
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
|
||||
originalPath = streamConfig[0] || "";
|
||||
ffmpegParams = streamConfig[1] || "";
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
originalPath === input.path &&
|
||||
config.go2rtc.streams[go2rtcStreamName]
|
||||
) {
|
||||
const streamConfig = config.go2rtc.streams[go2rtcStreamName];
|
||||
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
|
||||
originalPath = streamConfig[0] || "";
|
||||
ffmpegParams = streamConfig[1] || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: isGo2rtcPath ? originalPath : input.path,
|
||||
roles: input.roles as Role[],
|
||||
go2rtc: !!isGo2rtcPath,
|
||||
go2rtc_ffmpeg: isGo2rtcPath
|
||||
? ffmpegParams
|
||||
: config.go2rtc?.streams?.[cameraName]?.[1] || "",
|
||||
};
|
||||
})
|
||||
: defaultValues.ffmpeg.inputs;
|
||||
}
|
||||
|
||||
@ -172,7 +316,9 @@ export default function CameraEditForm({
|
||||
let friendly_name: string | undefined = undefined;
|
||||
const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
|
||||
if (!isValidName) {
|
||||
finalCameraName = generateFixedHash(finalCameraName);
|
||||
finalCameraName = cameraName
|
||||
? cameraName
|
||||
: generateFixedHash(finalCameraName);
|
||||
friendly_name = values.cameraName;
|
||||
}
|
||||
|
||||
@ -183,7 +329,9 @@ export default function CameraEditForm({
|
||||
...(friendly_name && { friendly_name }),
|
||||
ffmpeg: {
|
||||
inputs: values.ffmpeg.inputs.map((input) => ({
|
||||
path: input.path,
|
||||
path: input.go2rtc
|
||||
? `rtsp://127.0.0.1:8554/${finalCameraName}_${input.roles.join("_")}`
|
||||
: input.path,
|
||||
roles: input.roles,
|
||||
})),
|
||||
},
|
||||
@ -191,6 +339,24 @@ export default function CameraEditForm({
|
||||
},
|
||||
};
|
||||
|
||||
const hasGo2rtcEnabled = values.ffmpeg.inputs.some((input) => input.go2rtc);
|
||||
|
||||
if (hasGo2rtcEnabled) {
|
||||
const go2rtcStreams: Record<string, string[]> = {};
|
||||
|
||||
values.ffmpeg.inputs.forEach((input) => {
|
||||
if (input.go2rtc) {
|
||||
const streamName = `${finalCameraName}_${input.roles.join("_")}`;
|
||||
|
||||
go2rtcStreams[streamName] = [input.path, input.go2rtc_ffmpeg || ""];
|
||||
}
|
||||
});
|
||||
|
||||
configData.go2rtc = {
|
||||
streams: go2rtcStreams,
|
||||
};
|
||||
}
|
||||
|
||||
const requestBody: ConfigSetBody = {
|
||||
requires_restart: 1,
|
||||
config_data: configData,
|
||||
@ -199,6 +365,8 @@ export default function CameraEditForm({
|
||||
// Add update_topic for new cameras
|
||||
if (!cameraName) {
|
||||
requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
|
||||
} else {
|
||||
requestBody.update_topic = `config/cameras/${finalCameraName}/edit`;
|
||||
}
|
||||
|
||||
axios
|
||||
@ -232,41 +400,7 @@ export default function CameraEditForm({
|
||||
};
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (
|
||||
cameraName &&
|
||||
values.cameraName !== cameraName &&
|
||||
values.cameraName !== cameraInfo?.friendly_name
|
||||
) {
|
||||
// If camera name changed, delete old camera config
|
||||
const deleteRequestBody: ConfigSetBody = {
|
||||
requires_restart: 1,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[cameraName]: "",
|
||||
},
|
||||
},
|
||||
update_topic: `config/cameras/${cameraName}/remove`,
|
||||
};
|
||||
|
||||
axios
|
||||
.put("config/set", deleteRequestBody)
|
||||
.then(() => saveCameraConfig(values))
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
saveCameraConfig(values);
|
||||
}
|
||||
saveCameraConfig(values);
|
||||
};
|
||||
|
||||
// Determine available roles for new streams
|
||||
@ -309,11 +443,19 @@ export default function CameraEditForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("camera.cameraConfig.name")}</FormLabel>
|
||||
{cameraName ? (
|
||||
<>
|
||||
<div className="my-3 text-sm text-muted-foreground">
|
||||
{t("camera.cameraConfig.nameOnlyChangeToFriendlyName")}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("camera.cameraConfig.namePlaceholder")}
|
||||
{...field}
|
||||
disabled={!!cameraName} // Prevent editing name for existing cameras
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -372,32 +514,56 @@ export default function CameraEditForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("camera.cameraConfig.ffmpeg.roles")}
|
||||
{t("camera.cameraConfig.ffmpeg.roles.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["audio", "detect", "record"] as const).map(
|
||||
(role) => (
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value.includes(role)}
|
||||
onChange={(e) => {
|
||||
const updatedRoles = e.target.checked
|
||||
? [...field.value, role]
|
||||
: field.value.filter((r) => r !== role);
|
||||
field.onChange(updatedRoles);
|
||||
}}
|
||||
disabled={
|
||||
!field.value.includes(role) &&
|
||||
getUsedRolesExcludingIndex(index).has(role)
|
||||
}
|
||||
/>
|
||||
<span>{role}</span>
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value.includes(role)}
|
||||
onChange={(e) => {
|
||||
const updatedRoles = e.target.checked
|
||||
? [...field.value, role]
|
||||
: field.value.filter(
|
||||
(r) => r !== role,
|
||||
);
|
||||
field.onChange(updatedRoles);
|
||||
}}
|
||||
disabled={
|
||||
!field.value.includes(role) &&
|
||||
getUsedRolesExcludingIndex(index).has(
|
||||
role,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{i18n.language === "en"
|
||||
? role
|
||||
: t(
|
||||
`camera.cameraConfig.ffmpeg.roles.${role}.label`,
|
||||
) +
|
||||
"(" +
|
||||
role +
|
||||
")"}
|
||||
</span>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t(
|
||||
`camera.cameraConfig.ffmpeg.roles.${role}.info`,
|
||||
)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@ -406,7 +572,73 @@ export default function CameraEditForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ffmpeg.inputs.${index}.go2rtc`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("camera.cameraConfig.ffmpeg.go2rtc.title")}
|
||||
<div className="my-3 text-sm text-muted-foreground">
|
||||
{t("camera.cameraConfig.ffmpeg.go2rtc.description")}
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel>
|
||||
{t("camera.cameraConfig.enabled")}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ffmpeg.inputs.${index}.go2rtc_ffmpeg`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{form.watch(`ffmpeg.inputs.${index}.go2rtc`) ? (
|
||||
<>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.title",
|
||||
)}
|
||||
</FormLabel>
|
||||
<div className="my-3 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.description",
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<MultiSelectInput
|
||||
parameterGroups={go2rtc_ffmpegOptions}
|
||||
onParameterChange={(params) => {
|
||||
const combinedValue =
|
||||
Object.values(params).join("");
|
||||
field.onChange(combinedValue);
|
||||
}}
|
||||
parameterPlaceholder={t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.select",
|
||||
)}
|
||||
inputPlaceholder={t(
|
||||
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.custom",
|
||||
)}
|
||||
{...field}
|
||||
disabled={!!cameraName} // Prevent editing name for existing cameras
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</>
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@ -426,7 +658,9 @@ export default function CameraEditForm({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => append({ path: "", roles: getAvailableRoles() })}
|
||||
onClick={() =>
|
||||
append({ path: "", roles: getAvailableRoles(), go2rtc: false })
|
||||
}
|
||||
>
|
||||
<LuPlus className="mr-2 h-4 w-4" />
|
||||
{t("camera.cameraConfig.ffmpeg.addInput")}
|
||||
|
@ -22,4 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
import { SelectInput } from "./select-input"
|
||||
import { MultiSelectInput } from "./multi-select-input"
|
||||
|
||||
export { Input, SelectInput, MultiSelectInput }
|
||||
|
166
web/src/components/ui/multi-select-input.tsx
Normal file
166
web/src/components/ui/multi-select-input.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import * as React from "react"
|
||||
import { Input, InputProps } from "./input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LuX } from "react-icons/lu"
|
||||
|
||||
export type ParameterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ParameterGroup = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
options: ParameterOption[]
|
||||
}
|
||||
|
||||
export interface MultiSelectInputProps extends InputProps {
|
||||
parameterGroups: ParameterGroup[]
|
||||
onParameterChange?: (parameters: Record<string, string>) => void
|
||||
parameterPlaceholder?: string
|
||||
inputPlaceholder?: string
|
||||
}
|
||||
|
||||
const MultiSelectInput = React.forwardRef<HTMLInputElement, MultiSelectInputProps>(
|
||||
({
|
||||
className,
|
||||
parameterGroups,
|
||||
onParameterChange,
|
||||
parameterPlaceholder = "Select...",
|
||||
inputPlaceholder = "input...",
|
||||
...props
|
||||
}, ref) => {
|
||||
const [selectedParameters, setSelectedParameters] = React.useState<Record<string, string>>({})
|
||||
const [currentParameter, setCurrentParameter] = React.useState<string | null>(null)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
|
||||
const getCurrentParameterOptions = () => {
|
||||
if (!currentParameter) return []
|
||||
const group = parameterGroups.find(g => g.id === currentParameter)
|
||||
return group?.options || []
|
||||
}
|
||||
|
||||
const handleParameterSelect = (parameterId: string) => {
|
||||
setCurrentParameter(parameterId)
|
||||
}
|
||||
|
||||
const handleOptionSelect = (value: string) => {
|
||||
if (currentParameter) {
|
||||
const newSelectedParameters = {
|
||||
...selectedParameters,
|
||||
[currentParameter]: value
|
||||
}
|
||||
setSelectedParameters(newSelectedParameters)
|
||||
|
||||
if (onParameterChange) {
|
||||
onParameterChange(newSelectedParameters)
|
||||
}
|
||||
|
||||
setCurrentParameter(null)
|
||||
}
|
||||
}
|
||||
|
||||
const removeParameter = (parameterId: string) => {
|
||||
const newSelectedParameters = { ...selectedParameters }
|
||||
delete newSelectedParameters[parameterId]
|
||||
setSelectedParameters(newSelectedParameters)
|
||||
|
||||
if (onParameterChange) {
|
||||
onParameterChange(newSelectedParameters)
|
||||
}
|
||||
}
|
||||
|
||||
const getParameterDisplayName = (parameterId: string) => {
|
||||
const group = parameterGroups.find(g => g.id === parameterId)
|
||||
return group?.name || parameterId
|
||||
}
|
||||
|
||||
const getOptionDisplayName = (parameterId: string, optionValue: string) => {
|
||||
const group = parameterGroups.find(g => g.id === parameterId)
|
||||
const option = group?.options.find(o => o.value === optionValue)
|
||||
return option?.label || optionValue
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Object.keys(selectedParameters).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(selectedParameters).map(([parameterId, optionValue]) => (
|
||||
<div
|
||||
key={parameterId}
|
||||
className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-sm text-green-800"
|
||||
>
|
||||
<span>{getParameterDisplayName(parameterId)}:</span>
|
||||
<span>{getOptionDisplayName(parameterId, optionValue)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeParameter(parameterId)}
|
||||
className="ml-1 rounded-full p-0.5 hover:bg-green-200"
|
||||
>
|
||||
<LuX className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={currentParameter || ""} onValueChange={handleParameterSelect}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={parameterPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{parameterGroups
|
||||
.filter(group => !(group.id in selectedParameters))
|
||||
.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentParameter ? (
|
||||
<Select value="" onValueChange={handleOptionSelect}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select item..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{getCurrentParameterOptions().map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className={cn("flex-1", className)}
|
||||
placeholder={inputPlaceholder}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="my-3 text-sm text-muted-foreground">{parameterGroups.find(g => g.id === currentParameter)?.description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MultiSelectInput.displayName = "MultiSelectInput"
|
||||
|
||||
export { MultiSelectInput }
|
83
web/src/components/ui/select-input.tsx
Normal file
83
web/src/components/ui/select-input.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import * as React from "react"
|
||||
import { Input, InputProps } from "./input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./select"
|
||||
|
||||
export interface SelectInputProps extends InputProps {
|
||||
options: { value: string; label: string }[]
|
||||
onSelectChange?: (value: string) => void
|
||||
selectPlaceholder?: string
|
||||
selectClassName?: string
|
||||
selectPosition?: "before" | "after"
|
||||
}
|
||||
|
||||
const SelectInput = React.forwardRef<HTMLInputElement, SelectInputProps>(
|
||||
({
|
||||
className,
|
||||
options,
|
||||
onSelectChange,
|
||||
selectPlaceholder = "Select...",
|
||||
selectClassName = "w-[180px]",
|
||||
selectPosition = "after",
|
||||
...props
|
||||
}, ref) => {
|
||||
const [selectedValue, setSelectedValue] = React.useState<string>("")
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
setSelectedValue(value)
|
||||
if (onSelectChange) {
|
||||
onSelectChange(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectPosition === "before" && (
|
||||
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className={selectClassName}>
|
||||
<SelectValue placeholder={selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Input className={className} ref={ref} {...props} />
|
||||
|
||||
{selectPosition === "after" && (
|
||||
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className={selectClassName}>
|
||||
<SelectValue placeholder={selectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SelectInput.displayName = "SelectInput"
|
||||
|
||||
export { SelectInput }
|
@ -75,7 +75,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
|
||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
|
||||
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
|
||||
problems.push({
|
||||
text: t("stats.cameraIsOffline", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
|
@ -421,7 +421,7 @@ export interface FrigateConfig {
|
||||
};
|
||||
|
||||
go2rtc: {
|
||||
streams: string[];
|
||||
streams: { [streamName: string]: string[] };
|
||||
webrtc: {
|
||||
candidates: string[];
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user