diff --git a/frigate/api/app.py b/frigate/api/app.py index d9e573d29..bbc83a356 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -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"]), diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py index 865fe4725..cd9fb0a15 100644 --- a/frigate/camera/maintainer.py +++ b/frigate/camera/maintainer.py @@ -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(): diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 125094f10..26e19381d 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -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}") diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index a129b9677..91d6d9c97 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -102,6 +102,7 @@ class EmbeddingMaintainer(threading.Thread): [ CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.edit, CameraConfigUpdateEnum.object_genai, CameraConfigUpdateEnum.review_genai, CameraConfigUpdateEnum.semantic_search, diff --git a/scripts/create_go2rtc_config.py b/scripts/create_go2rtc_config.py new file mode 100644 index 000000000..0f1b6a08f --- /dev/null +++ b/scripts/create_go2rtc_config.py @@ -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) \ No newline at end of file diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index e7c06b133..5b600580b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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", diff --git a/web/src/components/settings/CameraEditForm.tsx b/web/src/components/settings/CameraEditForm.tsx index 983a8167d..f7e8a32af 100644 --- a/web/src/components/settings/CameraEditForm.tsx +++ b/web/src/components/settings/CameraEditForm.tsx @@ -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("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 = {}; + + 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 }) => ( {t("camera.cameraConfig.name")} + {cameraName ? ( + <> +
+ {t("camera.cameraConfig.nameOnlyChangeToFriendlyName")} +
+ + ) : ( + "" + )} @@ -372,32 +514,56 @@ export default function CameraEditForm({ render={({ field }) => ( - {t("camera.cameraConfig.ffmpeg.roles")} + {t("camera.cameraConfig.ffmpeg.roles.title")}
{(["audio", "detect", "record"] as const).map( (role) => ( - + + + + + + + {t( + `camera.cameraConfig.ffmpeg.roles.${role}.info`, + )} + + + ), )}
@@ -406,7 +572,73 @@ export default function CameraEditForm({
)} /> - + ( + + + {t("camera.cameraConfig.ffmpeg.go2rtc.title")} +
+ {t("camera.cameraConfig.ffmpeg.go2rtc.description")} +
+
+ +
+ + + {t("camera.cameraConfig.enabled")} + +
+
+ +
+ )} + /> + ( + + {form.watch(`ffmpeg.inputs.${index}.go2rtc`) ? ( + <> + + {t( + "camera.cameraConfig.ffmpeg.go2rtc.compatibility.title", + )} + +
+ {t( + "camera.cameraConfig.ffmpeg.go2rtc.compatibility.description", + )} +
+ + { + 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 + /> + + + + ) : null} +
+ )} + /> + + ))} + + )} +
+ + {currentParameter ? ( + + ) : ( + setInputValue(e.target.value)} + ref={ref} + {...props} + /> + )} +
+
{parameterGroups.find(g => g.id === currentParameter)?.description}
+ + ) + } +) + +MultiSelectInput.displayName = "MultiSelectInput" + +export { MultiSelectInput } \ No newline at end of file diff --git a/web/src/components/ui/select-input.tsx b/web/src/components/ui/select-input.tsx new file mode 100644 index 000000000..c581addfe --- /dev/null +++ b/web/src/components/ui/select-input.tsx @@ -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( + ({ + className, + options, + onSelectChange, + selectPlaceholder = "Select...", + selectClassName = "w-[180px]", + selectPosition = "after", + ...props + }, ref) => { + const [selectedValue, setSelectedValue] = React.useState("") + + const handleValueChange = (value: string) => { + setSelectedValue(value) + if (onSelectChange) { + onSelectChange(value) + } + } + + return ( +
+ {selectPosition === "before" && ( + + )} + + + + {selectPosition === "after" && ( + + )} +
+ ) + } +) + +SelectInput.displayName = "SelectInput" + +export { SelectInput } \ No newline at end of file diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 040dd7b1a..d883be388 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -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)), diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index e3cc6455a..47fdc6090 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -421,7 +421,7 @@ export interface FrigateConfig { }; go2rtc: { - streams: string[]; + streams: { [streamName: string]: string[] }; webrtc: { candidates: string[]; };