diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 125094f10..630961de2 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -80,6 +80,31 @@ class CameraConfigUpdateSubscriber: self.camera_configs[camera] = updated_config return elif update_type == CameraConfigUpdateEnum.remove: + """Remove go2rtc streams with camera""" + camera_config = self.camera_configs.get(camera) + if ( + camera_config + and hasattr(self.config, "go2rtc") + and hasattr(camera_config, "ffmpeg") + and hasattr(camera_config.ffmpeg, "inputs") + ): + for input_item in camera_config.ffmpeg.inputs: + if hasattr(input_item, "path") and isinstance(input_item.path, str): + if ( + "rtsp://" in input_item.path + and "127.0.0.1:8554/" in input_item.path + ): + stream_name = ( + input_item.path.split("127.0.0.1:8554/")[-1] + .split("&")[0] + .split("?")[0] + ) + if ( + hasattr(self.config.go2rtc, "streams") + and stream_name in self.config.go2rtc.streams + ): + self.config.go2rtc.streams.pop(stream_name) + self.config.cameras.pop(camera) self.camera_configs.pop(camera) return diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index e7c06b133..b3ce0bc99 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -198,10 +198,59 @@ "enabled": "Enabled", "ffmpeg": { "inputs": "Input Streams", + "go2rtc": { + "title": "Use go2rtc stream", + "description": "will use go2rtc", + "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..b9c2b5ffb 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"], }, @@ -145,10 +251,51 @@ export default function CameraEditForm({ 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[], - })) + ? 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 || + streamKey.startsWith(`${cameraName}_`) + ) { + 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; } @@ -183,7 +330,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 +340,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, @@ -232,11 +399,7 @@ export default function CameraEditForm({ }; const onSubmit = (values: FormValues) => { - if ( - cameraName && - values.cameraName !== cameraName && - values.cameraName !== cameraInfo?.friendly_name - ) { + if (cameraName && values.cameraName !== cameraName) { // If camera name changed, delete old camera config const deleteRequestBody: ConfigSetBody = { requires_restart: 1, @@ -247,6 +410,20 @@ export default function CameraEditForm({ }, update_topic: `config/cameras/${cameraName}/remove`, }; + const camera = config?.cameras[cameraName]; + if (values.ffmpeg.inputs.some((input) => input.go2rtc)) { + camera?.ffmpeg.inputs.map((input) => { + const isGo2rtcPath = input.path.match( + /^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/, + ); + const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : ""; + deleteRequestBody.config_data.go2rtc = { + streams: { + [go2rtcStreamName]: "", + }, + }; + }); + } axios .put("config/set", deleteRequestBody) @@ -372,32 +549,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 +607,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/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 566020b05..b048eda2b 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -62,7 +62,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[]; };