diff --git a/frigate/api/app.py b/frigate/api/app.py
index 5c2c132fd..3d5d27e8b 100644
--- a/frigate/api/app.py
+++ b/frigate/api/app.py
@@ -43,6 +43,7 @@ from frigate.util.builtin import (
update_yaml_file_bulk,
)
from frigate.util.config import find_config_file
+from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.services import (
ffprobe_stream,
get_nvidia_driver_info,
@@ -107,6 +108,80 @@ def go2rtc_camera_stream(request: Request, camera_name: str):
return JSONResponse(content=stream_data)
+@router.put(
+ "/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
+)
+def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
+ """Add or update a go2rtc stream configuration."""
+ try:
+ params = {"name": stream_name}
+ if src:
+ params["src"] = src
+
+ r = requests.put(
+ "http://127.0.0.1:1984/api/streams",
+ params=params,
+ timeout=10,
+ )
+ if not r.ok:
+ logger.error(f"Failed to add go2rtc stream {stream_name}: {r.text}")
+ return JSONResponse(
+ content=(
+ {"success": False, "message": f"Failed to add stream: {r.text}"}
+ ),
+ status_code=r.status_code,
+ )
+ return JSONResponse(
+ content={"success": True, "message": "Stream added successfully"}
+ )
+ except requests.RequestException as e:
+ logger.error(f"Error communicating with go2rtc: {e}")
+ return JSONResponse(
+ content=(
+ {
+ "success": False,
+ "message": "Error communicating with go2rtc",
+ }
+ ),
+ status_code=500,
+ )
+
+
+@router.delete(
+ "/go2rtc/streams/{stream_name}", dependencies=[Depends(require_role(["admin"]))]
+)
+def go2rtc_delete_stream(stream_name: str):
+ """Delete a go2rtc stream."""
+ try:
+ r = requests.delete(
+ "http://127.0.0.1:1984/api/streams",
+ params={"src": stream_name},
+ timeout=10,
+ )
+ if not r.ok:
+ logger.error(f"Failed to delete go2rtc stream {stream_name}: {r.text}")
+ return JSONResponse(
+ content=(
+ {"success": False, "message": f"Failed to delete stream: {r.text}"}
+ ),
+ status_code=r.status_code,
+ )
+ return JSONResponse(
+ content={"success": True, "message": "Stream deleted successfully"}
+ )
+ except requests.RequestException as e:
+ logger.error(f"Error communicating with go2rtc: {e}")
+ return JSONResponse(
+ content=(
+ {
+ "success": False,
+ "message": "Error communicating with go2rtc",
+ }
+ ),
+ status_code=500,
+ )
+
+
@router.get("/version", response_class=PlainTextResponse)
def version():
return VERSION
@@ -453,7 +528,7 @@ def config_set(request: Request, body: AppConfigSetBody):
@router.get("/ffprobe")
-def ffprobe(request: Request, paths: str = ""):
+def ffprobe(request: Request, paths: str = "", detailed: bool = False):
path_param = paths
if not path_param:
@@ -492,26 +567,132 @@ def ffprobe(request: Request, paths: str = ""):
output = []
for path in paths:
- ffprobe = ffprobe_stream(request.app.frigate_config.ffmpeg, path.strip())
- output.append(
- {
- "return_code": ffprobe.returncode,
- "stderr": (
- ffprobe.stderr.decode("unicode_escape").strip()
- if ffprobe.returncode != 0
- else ""
- ),
- "stdout": (
- json.loads(ffprobe.stdout.decode("unicode_escape").strip())
- if ffprobe.returncode == 0
- else ""
- ),
- }
+ ffprobe = ffprobe_stream(
+ request.app.frigate_config.ffmpeg, path.strip(), detailed=detailed
)
+ result = {
+ "return_code": ffprobe.returncode,
+ "stderr": (
+ ffprobe.stderr.decode("unicode_escape").strip()
+ if ffprobe.returncode != 0
+ else ""
+ ),
+ "stdout": (
+ json.loads(ffprobe.stdout.decode("unicode_escape").strip())
+ if ffprobe.returncode == 0
+ else ""
+ ),
+ }
+
+ # Add detailed metadata if requested and probe was successful
+ if detailed and ffprobe.returncode == 0 and result["stdout"]:
+ try:
+ probe_data = result["stdout"]
+ metadata = {}
+
+ # Extract video stream information
+ video_stream = None
+ audio_stream = None
+
+ for stream in probe_data.get("streams", []):
+ if stream.get("codec_type") == "video":
+ video_stream = stream
+ elif stream.get("codec_type") == "audio":
+ audio_stream = stream
+
+ # Video metadata
+ if video_stream:
+ metadata["video"] = {
+ "codec": video_stream.get("codec_name"),
+ "width": video_stream.get("width"),
+ "height": video_stream.get("height"),
+ "fps": _extract_fps(video_stream.get("r_frame_rate")),
+ "pixel_format": video_stream.get("pix_fmt"),
+ "profile": video_stream.get("profile"),
+ "level": video_stream.get("level"),
+ }
+
+ # Calculate resolution string
+ if video_stream.get("width") and video_stream.get("height"):
+ metadata["video"]["resolution"] = (
+ f"{video_stream['width']}x{video_stream['height']}"
+ )
+
+ # Audio metadata
+ if audio_stream:
+ metadata["audio"] = {
+ "codec": audio_stream.get("codec_name"),
+ "channels": audio_stream.get("channels"),
+ "sample_rate": audio_stream.get("sample_rate"),
+ "channel_layout": audio_stream.get("channel_layout"),
+ }
+
+ # Container/format metadata
+ if probe_data.get("format"):
+ format_info = probe_data["format"]
+ metadata["container"] = {
+ "format": format_info.get("format_name"),
+ "duration": format_info.get("duration"),
+ "size": format_info.get("size"),
+ }
+
+ result["metadata"] = metadata
+
+ except Exception as e:
+ logger.warning(f"Failed to extract detailed metadata: {e}")
+ # Continue without metadata if parsing fails
+
+ output.append(result)
+
return JSONResponse(content=output)
+@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))])
+def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
+ """Get a snapshot from a stream URL using ffmpeg."""
+ if not url:
+ return JSONResponse(
+ content={"success": False, "message": "URL parameter is required"},
+ status_code=400,
+ )
+
+ config: FrigateConfig = request.app.frigate_config
+
+ image_data, error = run_ffmpeg_snapshot(
+ config.ffmpeg, url, "mjpeg", timeout=timeout
+ )
+
+ if image_data:
+ return Response(
+ image_data,
+ media_type="image/jpeg",
+ headers={"Cache-Control": "no-store"},
+ )
+ elif error == "timeout":
+ return JSONResponse(
+ content={"success": False, "message": "Timeout capturing snapshot"},
+ status_code=408,
+ )
+ else:
+ logger.error(f"ffmpeg failed: {error}")
+ return JSONResponse(
+ content={"success": False, "message": "Failed to capture snapshot"},
+ status_code=500,
+ )
+
+
+def _extract_fps(r_frame_rate: str) -> float | None:
+ """Extract FPS from ffprobe r_frame_rate string (e.g., '30/1' -> 30.0)"""
+ if not r_frame_rate:
+ return None
+ try:
+ num, den = r_frame_rate.split("/")
+ return round(float(num) / float(den), 2)
+ except (ValueError, ZeroDivisionError):
+ return None
+
+
@router.get("/vainfo")
def vainfo():
vainfo = vainfo_hwaccel()
diff --git a/frigate/util/image.py b/frigate/util/image.py
index 0ebd2f1a1..ea9fb0a0a 100644
--- a/frigate/util/image.py
+++ b/frigate/util/image.py
@@ -943,6 +943,58 @@ def add_mask(mask: str, mask_img: np.ndarray):
cv2.fillPoly(mask_img, pts=[contour], color=(0))
+def run_ffmpeg_snapshot(
+ ffmpeg,
+ input_path: str,
+ codec: str,
+ seek_time: Optional[float] = None,
+ height: Optional[int] = None,
+ timeout: Optional[int] = None,
+) -> tuple[Optional[bytes], str]:
+ """Run ffmpeg to extract a snapshot/image from a video source."""
+ ffmpeg_cmd = [
+ ffmpeg.ffmpeg_path,
+ "-hide_banner",
+ "-loglevel",
+ "warning",
+ ]
+
+ if seek_time is not None:
+ ffmpeg_cmd.extend(["-ss", f"00:00:{seek_time}"])
+
+ ffmpeg_cmd.extend(
+ [
+ "-i",
+ input_path,
+ "-frames:v",
+ "1",
+ "-c:v",
+ codec,
+ "-f",
+ "image2pipe",
+ "-",
+ ]
+ )
+
+ if height is not None:
+ ffmpeg_cmd.insert(-3, "-vf")
+ ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
+
+ try:
+ process = sp.run(
+ ffmpeg_cmd,
+ capture_output=True,
+ timeout=timeout,
+ )
+
+ if process.returncode == 0 and process.stdout:
+ return process.stdout, ""
+ else:
+ return None, process.stderr.decode() if process.stderr else "ffmpeg failed"
+ except sp.TimeoutExpired:
+ return None, "timeout"
+
+
def get_image_from_recording(
ffmpeg, # Ffmpeg Config
file_path: str,
@@ -952,37 +1004,11 @@ def get_image_from_recording(
) -> Optional[Any]:
"""retrieve a frame from given time in recording file."""
- ffmpeg_cmd = [
- ffmpeg.ffmpeg_path,
- "-hide_banner",
- "-loglevel",
- "warning",
- "-ss",
- f"00:00:{relative_frame_time}",
- "-i",
- file_path,
- "-frames:v",
- "1",
- "-c:v",
- codec,
- "-f",
- "image2pipe",
- "-",
- ]
-
- if height is not None:
- ffmpeg_cmd.insert(-3, "-vf")
- ffmpeg_cmd.insert(-3, f"scale=-1:{height}")
-
- process = sp.run(
- ffmpeg_cmd,
- capture_output=True,
+ image_data, _ = run_ffmpeg_snapshot(
+ ffmpeg, file_path, codec, seek_time=relative_frame_time, height=height
)
- if process.returncode == 0:
- return process.stdout
- else:
- return None
+ return image_data
def get_histogram(image, x_min, y_min, x_max, y_max):
diff --git a/frigate/util/services.py b/frigate/util/services.py
index 28497e803..ed21e7b00 100644
--- a/frigate/util/services.py
+++ b/frigate/util/services.py
@@ -515,9 +515,20 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
return results
-def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
+def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)
+
+ # Base entries that are always included
+ stream_entries = "codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate"
+
+ # Additional detailed entries
+ if detailed:
+ stream_entries += ",codec_name,profile,level,pix_fmt,channels,sample_rate,channel_layout,r_frame_rate"
+ format_entries = "format_name,size,bit_rate,duration"
+ else:
+ format_entries = None
+
ffprobe_cmd = [
ffmpeg.ffprobe_path,
"-timeout",
@@ -525,11 +536,15 @@ def ffprobe_stream(ffmpeg, path: str) -> sp.CompletedProcess:
"-print_format",
"json",
"-show_entries",
- "stream=codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate",
- "-loglevel",
- "quiet",
- clean_path,
+ f"stream={stream_entries}",
]
+
+ # Add format entries for detailed mode
+ if detailed and format_entries:
+ ffprobe_cmd.extend(["-show_entries", f"format={format_entries}"])
+
+ ffprobe_cmd.extend(["-loglevel", "quiet", clean_path])
+
return sp.run(ffprobe_cmd, capture_output=True)
diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json
index 501f2d4bc..924dc0d0e 100644
--- a/web/public/locales/en/common.json
+++ b/web/public/locales/en/common.json
@@ -82,6 +82,14 @@
"length": {
"feet": "feet",
"meters": "meters"
+ },
+ "data": {
+ "kbps": "kB/s",
+ "mbps": "MB/s",
+ "gbps": "GB/s",
+ "kbph": "kB/hour",
+ "mbph": "MB/hour",
+ "gbph": "GB/hour"
}
},
"label": {
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 912d4f693..dead8e775 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -2,7 +2,8 @@
"documentTitle": {
"default": "Settings - Frigate",
"authentication": "Authentication Settings - Frigate",
- "camera": "Camera Settings - Frigate",
+ "cameraManagement": "Manage Cameras - Frigate",
+ "cameraReview": "Camera Review Settings - Frigate",
"enrichments": "Enrichments Settings - Frigate",
"masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate",
@@ -14,7 +15,8 @@
"menu": {
"ui": "UI",
"enrichments": "Enrichments",
- "cameras": "Camera Settings",
+ "cameraManagement": "Management",
+ "cameraReview": "Review",
"masksAndZones": "Masks / Zones",
"motionTuner": "Motion Tuner",
"triggers": "Triggers",
@@ -143,12 +145,176 @@
"error": "Failed to save config changes: {{errorMessage}}"
}
},
- "camera": {
- "title": "Camera Settings",
+ "cameraWizard": {
+ "title": "Add Camera",
+ "description": "Follow the steps below to add a new camera to your Frigate installation.",
+ "steps": {
+ "nameAndConnection": "Name & Connection",
+ "streamConfiguration": "Stream Configuration",
+ "validationAndTesting": "Validation & Testing"
+ },
+ "save": {
+ "success": "Successfully saved new camera {{cameraName}}.",
+ "failure": "Error saving {{cameraName}}."
+ },
+ "testResultLabels": {
+ "resolution": "Resolution",
+ "video": "Video",
+ "audio": "Audio",
+ "fps": "FPS"
+ },
+ "commonErrors": {
+ "noUrl": "Please provide a valid stream URL",
+ "testFailed": "Stream test failed: {{error}}"
+ },
+ "step1": {
+ "description": "Enter your camera details and test the connection.",
+ "cameraName": "Camera Name",
+ "cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
+ "host": "Host/IP Address",
+ "port": "Port",
+ "username": "Username",
+ "usernamePlaceholder": "Optional",
+ "password": "Password",
+ "passwordPlaceholder": "Optional",
+ "selectTransport": "Select transport protocol",
+ "cameraBrand": "Camera Brand",
+ "selectBrand": "Select camera brand for URL template",
+ "customUrl": "Custom Stream URL",
+ "brandInformation": "Brand information",
+ "brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
+ "customUrlPlaceholder": "rtsp://username:password@host:port/path",
+ "testConnection": "Test Connection",
+ "testSuccess": "Connection test successful!",
+ "testFailed": "Connection test failed. Please check your input and try again.",
+ "streamDetails": "Stream Details",
+ "warnings": {
+ "noSnapshot": "Unable to fetch a snapshot from the configured stream."
+ },
+ "errors": {
+ "brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
+ "nameRequired": "Camera name is required",
+ "nameLength": "Camera name must be 64 characters or less",
+ "invalidCharacters": "Camera name contains invalid characters",
+ "nameExists": "Camera name already exists"
+ }
+ },
+ "step2": {
+ "description": "Configure stream roles and add additional streams for your camera.",
+ "streamsTitle": "Camera Streams",
+ "addStream": "Add Stream",
+ "addAnotherStream": "Add Another Stream",
+ "streamTitle": "Stream {{number}}",
+ "streamUrl": "Stream URL",
+ "streamUrlPlaceholder": "rtsp://username:password@host:port/path",
+ "url": "URL",
+ "resolution": "Resolution",
+ "selectResolution": "Select resolution",
+ "quality": "Quality",
+ "selectQuality": "Select quality",
+ "roles": "Roles",
+ "roleLabels": {
+ "detect": "Object Detection",
+ "record": "Recording",
+ "audio": "Audio"
+ },
+ "testStream": "Test Connection",
+ "testSuccess": "Stream test successful!",
+ "testFailed": "Stream test failed",
+ "testFailedTitle": "Test Failed",
+ "connected": "Connected",
+ "notConnected": "Not Connected",
+ "featuresTitle": "Features",
+ "go2rtc": "Reduce connections to camera",
+ "detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.",
+ "rolesPopover": {
+ "title": "Stream Roles",
+ "detect": "Main feed for object detection.",
+ "record": "Saves segments of the video feed based on configuration settings.",
+ "audio": "Feed for audio based detection."
+ },
+ "featuresPopover": {
+ "title": "Stream Features",
+ "description": "Use go2rtc restreaming to reduce connections to your camera."
+ }
+ },
+ "step3": {
+ "description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
+ "validationTitle": "Stream Validation",
+ "connectAllStreams": "Connect All Streams",
+ "reconnectionSuccess": "Reconnection successful.",
+ "reconnectionPartial": "Some streams failed to reconnect.",
+ "streamUnavailable": "Stream preview unavailable",
+ "reload": "Reload",
+ "connecting": "Connecting...",
+ "streamTitle": "Stream {{number}}",
+ "valid": "Valid",
+ "failed": "Failed",
+ "notTested": "Not tested",
+ "connectStream": "Connect",
+ "connectingStream": "Connecting",
+ "disconnectStream": "Disconnect",
+ "estimatedBandwidth": "Estimated Bandwidth",
+ "roles": "Roles",
+ "none": "None",
+ "error": "Error",
+ "streamValidated": "Stream {{number}} validated successfully",
+ "streamValidationFailed": "Stream {{number}} validation failed",
+ "saveAndApply": "Save New Camera",
+ "saveError": "Invalid configuration. Please check your settings.",
+ "issues": {
+ "title": "Stream Validation",
+ "videoCodecGood": "Video codec is {{codec}}.",
+ "audioCodecGood": "Audio codec is {{codec}}.",
+ "noAudioWarning": "No audio detected for this stream, recordings will not have audio.",
+ "audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
+ "audioCodecRequired": "An audio stream is required to support audio detection.",
+ "restreamingWarning": "Reducing connections to the camera for the record stream may increase CPU usage slightly."
+ }
+ }
+ },
+ "cameraManagement": {
+ "title": "Manage Cameras",
+ "addCamera": "Add New Camera",
+ "editCamera": "Edit Camera:",
+ "selectCamera": "Select a Camera",
+ "backToSettings": "Back to Camera Settings",
"streams": {
- "title": "Streams",
+ "title": "Enable / Disable Cameras",
"desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams."
},
+ "cameraConfig": {
+ "add": "Add Camera",
+ "edit": "Edit Camera",
+ "description": "Configure camera settings including stream inputs and roles.",
+ "name": "Camera Name",
+ "nameRequired": "Camera name is required",
+ "nameLength": "Camera name must be less than 64 characters.",
+ "namePlaceholder": "e.g., front_door or Back Yard Overview",
+ "enabled": "Enabled",
+ "ffmpeg": {
+ "inputs": "Input Streams",
+ "path": "Stream Path",
+ "pathRequired": "Stream path is required",
+ "pathPlaceholder": "rtsp://...",
+ "roles": "Roles",
+ "rolesRequired": "At least one role is required",
+ "rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
+ "addInput": "Add Input Stream",
+ "removeInput": "Remove Input Stream",
+ "inputsRequired": "At least one input stream is required"
+ },
+ "go2rtcStreams": "go2rtc Streams",
+ "streamUrls": "Stream URLs",
+ "addUrl": "Add URL",
+ "addGo2rtcStream": "Add go2rtc Stream",
+ "toast": {
+ "success": "Camera {{cameraName}} saved successfully"
+ }
+ }
+ },
+ "cameraReview": {
+ "title": "Camera Review Settings",
"object_descriptions": {
"title": "Generative AI Object Descriptions",
"desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera."
@@ -183,35 +349,6 @@
"toast": {
"success": "Review Classification configuration has been saved. Restart Frigate to apply changes."
}
- },
- "addCamera": "Add New Camera",
- "editCamera": "Edit Camera:",
- "selectCamera": "Select a Camera",
- "backToSettings": "Back to Camera Settings",
- "cameraConfig": {
- "add": "Add Camera",
- "edit": "Edit Camera",
- "description": "Configure camera settings including stream inputs and roles.",
- "name": "Camera 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",
- "path": "Stream Path",
- "pathRequired": "Stream path is required",
- "pathPlaceholder": "rtsp://...",
- "roles": "Roles",
- "rolesRequired": "At least one role is required",
- "rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
- "addInput": "Add Input Stream",
- "removeInput": "Remove Input Stream",
- "inputsRequired": "At least one input stream is required"
- },
- "toast": {
- "success": "Camera {{cameraName}} saved successfully"
- }
}
},
"masksAndZones": {
diff --git a/web/src/components/indicators/StepIndicator.tsx b/web/src/components/indicators/StepIndicator.tsx
index a6255fd0f..282527f42 100644
--- a/web/src/components/indicators/StepIndicator.tsx
+++ b/web/src/components/indicators/StepIndicator.tsx
@@ -4,17 +4,43 @@ import { useTranslation } from "react-i18next";
type StepIndicatorProps = {
steps: string[];
currentStep: number;
- translationNameSpace: string;
+ variant?: "default" | "dots";
+ translationNameSpace?: string;
+ className?: string;
};
+
export default function StepIndicator({
steps,
currentStep,
+ variant = "default",
translationNameSpace,
+ className,
}: StepIndicatorProps) {
const { t } = useTranslation(translationNameSpace);
+ if (variant == "dots") {
+ return (
+
+ {steps.map((_, idx) => (
+
idx
+ ? "bg-muted-foreground"
+ : "bg-muted",
+ )}
+ />
+ ))}
+
+ );
+ }
+
+ // Default variant (original behavior)
return (
-
+
{steps.map((name, idx) => (
{
- const encoded = encodeURIComponent(name);
- const base64 = btoa(encoded);
- const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
- return `cam_${cleanHash.toLowerCase()}`;
-};
+import { processCameraName } from "@/utils/cameraUtil";
+import { Label } from "@/components/ui/label";
+import { ConfigSetBody } from "@/types/cameraWizard";
const RoleEnum = z.enum(["audio", "detect", "record"]);
type Role = z.infer
;
@@ -60,22 +50,26 @@ export default function CameraEditForm({
z.object({
cameraName: z
.string()
- .min(1, { message: t("camera.cameraConfig.nameRequired") }),
+ .min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }),
enabled: z.boolean(),
ffmpeg: z.object({
inputs: z
.array(
z.object({
path: z.string().min(1, {
- message: t("camera.cameraConfig.ffmpeg.pathRequired"),
+ message: t(
+ "cameraManagement.cameraConfig.ffmpeg.pathRequired",
+ ),
}),
roles: z.array(RoleEnum).min(1, {
- message: t("camera.cameraConfig.ffmpeg.rolesRequired"),
+ message: t(
+ "cameraManagement.cameraConfig.ffmpeg.rolesRequired",
+ ),
}),
}),
)
.min(1, {
- message: t("camera.cameraConfig.ffmpeg.inputsRequired"),
+ message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"),
})
.refine(
(inputs) => {
@@ -93,11 +87,12 @@ export default function CameraEditForm({
);
},
{
- message: t("camera.cameraConfig.ffmpeg.rolesUnique"),
+ message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"),
path: ["inputs"],
},
),
}),
+ go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(),
}),
[t],
);
@@ -110,6 +105,7 @@ export default function CameraEditForm({
friendly_name: undefined,
name: cameraName || "",
roles: new Set(),
+ go2rtcStreams: {},
};
}
@@ -120,10 +116,14 @@ export default function CameraEditForm({
input.roles.forEach((role) => roles.add(role as Role));
});
+ // Load existing go2rtc streams
+ const go2rtcStreams = config.go2rtc?.streams || {};
+
return {
friendly_name: camera?.friendly_name || cameraName,
name: cameraName,
roles,
+ go2rtcStreams,
};
}, [cameraName, config]);
@@ -138,6 +138,7 @@ export default function CameraEditForm({
},
],
},
+ go2rtcStreams: {},
};
// Load existing camera config if editing
@@ -150,6 +151,41 @@ export default function CameraEditForm({
roles: input.roles as Role[],
}))
: defaultValues.ffmpeg.inputs;
+
+ // Load go2rtc streams for this camera
+ const go2rtcStreams = config.go2rtc?.streams || {};
+ const cameraStreams: Record = {};
+
+ // Find streams that match this camera's name pattern
+ Object.entries(go2rtcStreams).forEach(([streamName, urls]) => {
+ if (streamName.startsWith(cameraName) || streamName === cameraName) {
+ cameraStreams[streamName] = Array.isArray(urls) ? urls : [urls];
+ }
+ });
+
+ // Also deduce go2rtc streams from restream URLs in camera inputs
+ camera.ffmpeg?.inputs?.forEach((input, index) => {
+ const restreamMatch = input.path.match(
+ /^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
+ );
+ if (restreamMatch) {
+ const streamName = restreamMatch[1];
+ // Find the corresponding go2rtc stream
+ const go2rtcStream = Object.entries(go2rtcStreams).find(
+ ([name]) =>
+ name === streamName ||
+ name === `${cameraName}_${index + 1}` ||
+ name === cameraName,
+ );
+ if (go2rtcStream) {
+ cameraStreams[go2rtcStream[0]] = Array.isArray(go2rtcStream[1])
+ ? go2rtcStream[1]
+ : [go2rtcStream[1]];
+ }
+ }
+ });
+
+ defaultValues.go2rtcStreams = cameraStreams;
}
const form = useForm({
@@ -166,21 +202,20 @@ export default function CameraEditForm({
// Watch ffmpeg.inputs to track used roles
const watchedInputs = form.watch("ffmpeg.inputs");
+ // Watch go2rtc streams
+ const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {};
+
const saveCameraConfig = (values: FormValues) => {
setIsLoading(true);
- let finalCameraName = values.cameraName;
- let friendly_name: string | undefined = undefined;
- const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName);
- if (!isValidName) {
- finalCameraName = generateFixedHash(finalCameraName);
- friendly_name = values.cameraName;
- }
+ const { finalCameraName, friendlyName } = processCameraName(
+ values.cameraName,
+ );
const configData: ConfigSetBody["config_data"] = {
cameras: {
[finalCameraName]: {
enabled: values.enabled,
- ...(friendly_name && { friendly_name }),
+ ...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path,
@@ -191,6 +226,13 @@ export default function CameraEditForm({
},
};
+ // Add go2rtc streams if provided
+ if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) {
+ configData.go2rtc = {
+ streams: values.go2rtcStreams,
+ };
+ }
+
const requestBody: ConfigSetBody = {
requires_restart: 1,
config_data: configData,
@@ -205,13 +247,36 @@ export default function CameraEditForm({
.put("config/set", requestBody)
.then((res) => {
if (res.status === 200) {
- toast.success(
- t("camera.cameraConfig.toast.success", {
- cameraName: values.cameraName,
- }),
- { position: "top-center" },
- );
- if (onSave) onSave();
+ // Update running go2rtc instance if streams were configured
+ if (
+ values.go2rtcStreams &&
+ Object.keys(values.go2rtcStreams).length > 0
+ ) {
+ const updatePromises = Object.entries(values.go2rtcStreams).map(
+ ([streamName, urls]) =>
+ axios.put(
+ `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
+ ),
+ );
+
+ Promise.allSettled(updatePromises).then(() => {
+ toast.success(
+ t("cameraManagement.cameraConfig.toast.success", {
+ cameraName: values.cameraName,
+ }),
+ { position: "top-center" },
+ );
+ if (onSave) onSave();
+ });
+ } else {
+ toast.success(
+ t("cameraManagement.cameraConfig.toast.success", {
+ cameraName: values.cameraName,
+ }),
+ { position: "top-center" },
+ );
+ if (onSave) onSave();
+ }
} else {
throw new Error(res.statusText);
}
@@ -238,11 +303,11 @@ export default function CameraEditForm({
values.cameraName !== cameraInfo?.friendly_name
) {
// If camera name changed, delete old camera config
- const deleteRequestBody: ConfigSetBody = {
+ const deleteRequestBody = {
requires_restart: 1,
config_data: {
cameras: {
- [cameraName]: "",
+ [cameraName]: null,
},
},
update_topic: `config/cameras/${cameraName}/remove`,
@@ -289,15 +354,15 @@ export default function CameraEditForm({
};
return (
- <>
+
{cameraName
- ? t("camera.cameraConfig.edit")
- : t("camera.cameraConfig.add")}
+ ? t("cameraManagement.cameraConfig.edit")
+ : t("cameraManagement.cameraConfig.add")}
- {t("camera.cameraConfig.description")}
+ {t("cameraManagement.cameraConfig.description")}
@@ -308,10 +373,12 @@ export default function CameraEditForm({
name="cameraName"
render={({ field }) => (
- {t("camera.cameraConfig.name")}
+ {t("cameraManagement.cameraConfig.name")}
@@ -332,107 +399,251 @@ export default function CameraEditForm({
onCheckedChange={field.onChange}
/>
- {t("camera.cameraConfig.enabled")}
+
+ {t("cameraManagement.cameraConfig.enabled")}
+
)}
/>
-
-
{t("camera.cameraConfig.ffmpeg.inputs")}
+
+
{fields.map((field, index) => (
-
-
(
-
-
- {t("camera.cameraConfig.ffmpeg.path")}
-
-
-
-
-
-
- )}
- />
+
+
+
+
+ {t("cameraWizard.step2.streamTitle", {
+ number: index + 1,
+ })}
+
+
+
- (
-
-
- {t("camera.cameraConfig.ffmpeg.roles")}
-
-
-
- {(["audio", "detect", "record"] as const).map(
- (role) => (
-
+
+
+
))}
{form.formState.errors.ffmpeg?.inputs?.root &&
form.formState.errors.ffmpeg.inputs.root.message}
+ {/* go2rtc Streams Section */}
+ {Object.keys(watchedGo2rtcStreams).length > 0 && (
+
+
+ {t("cameraManagement.cameraConfig.go2rtcStreams")}
+
+ {Object.entries(watchedGo2rtcStreams).map(
+ ([streamName, urls]) => (
+
+
+
+
{streamName}
+
+
+
+
+
+ {t("cameraManagement.cameraConfig.streamUrls")}
+
+ {(Array.isArray(urls) ? urls : [urls]).map(
+ (url, urlIndex) => (
+
+ {
+ const updatedStreams = {
+ ...watchedGo2rtcStreams,
+ };
+ const currentUrls = Array.isArray(
+ updatedStreams[streamName],
+ )
+ ? updatedStreams[streamName]
+ : [updatedStreams[streamName]];
+ currentUrls[urlIndex] = e.target.value;
+ updatedStreams[streamName] = currentUrls;
+ form.setValue(
+ "go2rtcStreams",
+ updatedStreams,
+ );
+ }}
+ placeholder="rtsp://username:password@host:port/path"
+ />
+ {(Array.isArray(urls) ? urls : [urls]).length >
+ 1 && (
+
+ )}
+
+ ),
+ )}
+
+
+
+
+ ),
+ )}
+
+
+ )}
+
);
}
diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx
new file mode 100644
index 000000000..12b150e37
--- /dev/null
+++ b/web/src/components/settings/CameraWizardDialog.tsx
@@ -0,0 +1,398 @@
+import StepIndicator from "@/components/indicators/StepIndicator";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useTranslation } from "react-i18next";
+import { useCallback, useState, useEffect, useReducer } from "react";
+import { toast } from "sonner";
+import useSWR from "swr";
+import axios from "axios";
+import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
+import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig";
+import Step3Validation from "@/components/settings/wizard/Step3Validation";
+import type {
+ WizardFormData,
+ CameraConfigData,
+ ConfigSetBody,
+} from "@/types/cameraWizard";
+import { processCameraName } from "@/utils/cameraUtil";
+
+type WizardState = {
+ wizardData: Partial
;
+ shouldNavigateNext: boolean;
+};
+
+type WizardAction =
+ | { type: "UPDATE_DATA"; payload: Partial }
+ | { type: "UPDATE_AND_NEXT"; payload: Partial }
+ | { type: "RESET_NAVIGATE" };
+
+const wizardReducer = (
+ state: WizardState,
+ action: WizardAction,
+): WizardState => {
+ switch (action.type) {
+ case "UPDATE_DATA":
+ return {
+ ...state,
+ wizardData: { ...state.wizardData, ...action.payload },
+ };
+ case "UPDATE_AND_NEXT":
+ return {
+ wizardData: { ...state.wizardData, ...action.payload },
+ shouldNavigateNext: true,
+ };
+ case "RESET_NAVIGATE":
+ return { ...state, shouldNavigateNext: false };
+ default:
+ return state;
+ }
+};
+
+const STEPS = [
+ "cameraWizard.steps.nameAndConnection",
+ "cameraWizard.steps.streamConfiguration",
+ "cameraWizard.steps.validationAndTesting",
+];
+
+type CameraWizardDialogProps = {
+ open: boolean;
+ onClose: () => void;
+};
+
+export default function CameraWizardDialog({
+ open,
+ onClose,
+}: CameraWizardDialogProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const { mutate: updateConfig } = useSWR("config");
+ const [currentStep, setCurrentStep] = useState(0);
+ const [isLoading, setIsLoading] = useState(false);
+ const [state, dispatch] = useReducer(wizardReducer, {
+ wizardData: { streams: [] },
+ shouldNavigateNext: false,
+ });
+
+ // Reset wizard when opened
+ useEffect(() => {
+ if (open) {
+ setCurrentStep(0);
+ dispatch({ type: "UPDATE_DATA", payload: { streams: [] } });
+ }
+ }, [open]);
+
+ const handleClose = useCallback(() => {
+ setCurrentStep(0);
+ dispatch({ type: "UPDATE_DATA", payload: { streams: [] } });
+ onClose();
+ }, [onClose]);
+
+ const onUpdate = useCallback((data: Partial) => {
+ dispatch({ type: "UPDATE_DATA", payload: data });
+ }, []);
+
+ const canProceedToNext = useCallback((): boolean => {
+ switch (currentStep) {
+ case 0:
+ // Can proceed if camera name is set and at least one stream exists
+ return !!(
+ state.wizardData.cameraName &&
+ (state.wizardData.streams?.length ?? 0) > 0
+ );
+ case 1:
+ // Can proceed if at least one stream has 'detect' role
+ return !!(
+ state.wizardData.streams?.some((stream) =>
+ stream.roles.includes("detect"),
+ ) ?? false
+ );
+ case 2:
+ // Always can proceed from final step (save will be handled there)
+ return true;
+ default:
+ return false;
+ }
+ }, [currentStep, state.wizardData]);
+
+ const handleNext = useCallback(
+ (data?: Partial) => {
+ if (data) {
+ // Atomic update and navigate
+ dispatch({ type: "UPDATE_AND_NEXT", payload: data });
+ } else {
+ // Just navigate
+ if (currentStep < STEPS.length - 1 && canProceedToNext()) {
+ setCurrentStep((s) => s + 1);
+ }
+ }
+ },
+ [currentStep, canProceedToNext],
+ );
+
+ const handleBack = () => {
+ if (currentStep > 0) {
+ setCurrentStep(currentStep - 1);
+ }
+ };
+
+ // Handle navigation after atomic update
+ useEffect(() => {
+ if (state.shouldNavigateNext) {
+ if (currentStep < STEPS.length - 1 && canProceedToNext()) {
+ setCurrentStep((s) => s + 1);
+ }
+ dispatch({ type: "RESET_NAVIGATE" });
+ }
+ }, [state.shouldNavigateNext, currentStep, canProceedToNext]);
+
+ // Handle wizard save
+ const handleSave = useCallback(
+ (wizardData: WizardFormData) => {
+ if (!wizardData.cameraName || !wizardData.streams) {
+ toast.error("Invalid wizard data");
+ return;
+ }
+
+ setIsLoading(true);
+
+ // Process camera name and friendly name
+ const { finalCameraName, friendlyName } = processCameraName(
+ wizardData.cameraName,
+ );
+
+ // Convert wizard data to Frigate config format
+ const configData: CameraConfigData = {
+ cameras: {
+ [finalCameraName]: {
+ enabled: true,
+ ...(friendlyName && { friendly_name: friendlyName }),
+ ffmpeg: {
+ inputs: wizardData.streams.map((stream, index) => {
+ const isRestreamed =
+ wizardData.restreamIds?.includes(stream.id) ?? false;
+ if (isRestreamed) {
+ const go2rtcStreamName =
+ wizardData.streams!.length === 1
+ ? finalCameraName
+ : `${finalCameraName}_${index + 1}`;
+ return {
+ path: `rtsp://127.0.0.1:8554/${go2rtcStreamName}`,
+ input_args: "preset-rtsp-restream",
+ roles: stream.roles,
+ };
+ } else {
+ return {
+ path: stream.url,
+ roles: stream.roles,
+ };
+ }
+ }),
+ },
+ },
+ },
+ };
+
+ // Add live.streams configuration for go2rtc streams
+ if (wizardData.streams && wizardData.streams.length > 0) {
+ configData.cameras[finalCameraName].live = {
+ streams: {},
+ };
+ wizardData.streams.forEach((_, index) => {
+ const go2rtcStreamName =
+ wizardData.streams!.length === 1
+ ? finalCameraName
+ : `${finalCameraName}_${index + 1}`;
+ configData.cameras[finalCameraName].live!.streams[
+ `Stream ${index + 1}`
+ ] = go2rtcStreamName;
+ });
+ }
+
+ const requestBody: ConfigSetBody = {
+ requires_restart: 1,
+ config_data: configData,
+ update_topic: `config/cameras/${finalCameraName}/add`,
+ };
+
+ axios
+ .put("config/set", requestBody)
+ .then((response) => {
+ if (response.status === 200) {
+ // Configure go2rtc streams for all streams
+ if (wizardData.streams && wizardData.streams.length > 0) {
+ const go2rtcStreams: Record = {};
+
+ wizardData.streams.forEach((stream, index) => {
+ // Use camera name with index suffix for multiple streams
+ const streamName =
+ wizardData.streams!.length === 1
+ ? finalCameraName
+ : `${finalCameraName}_${index + 1}`;
+ go2rtcStreams[streamName] = [stream.url];
+ });
+
+ if (Object.keys(go2rtcStreams).length > 0) {
+ // Update frigate go2rtc config for persistence
+ const go2rtcConfigData = {
+ go2rtc: {
+ streams: go2rtcStreams,
+ },
+ };
+
+ const go2rtcRequestBody = {
+ requires_restart: 0,
+ config_data: go2rtcConfigData,
+ };
+
+ axios
+ .put("config/set", go2rtcRequestBody)
+ .then(() => {
+ // also update the running go2rtc instance for immediate effect
+ const updatePromises = Object.entries(go2rtcStreams).map(
+ ([streamName, urls]) =>
+ axios.put(
+ `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
+ ),
+ );
+
+ Promise.allSettled(updatePromises).then(() => {
+ toast.success(
+ t("cameraWizard.save.success", {
+ cameraName: friendlyName || finalCameraName,
+ }),
+ { position: "top-center" },
+ );
+ updateConfig();
+ onClose();
+ });
+ })
+ .catch(() => {
+ // log the error but don't fail the entire save
+ toast.warning(
+ t("cameraWizard.save.failure", {
+ cameraName: friendlyName || finalCameraName,
+ }),
+ { position: "top-center" },
+ );
+ updateConfig();
+ onClose();
+ });
+ } else {
+ // No valid streams found
+ toast.success(
+ t("cameraWizard.save.failure", {
+ cameraName: friendlyName || finalCameraName,
+ }),
+ { position: "top-center" },
+ );
+ updateConfig();
+ onClose();
+ }
+ } else {
+ toast.success(
+ t("camera.cameraConfig.toast.success", {
+ cameraName: wizardData.cameraName,
+ }),
+ { position: "top-center" },
+ );
+ updateConfig();
+ onClose();
+ }
+ } else {
+ throw new Error(response.statusText);
+ }
+ })
+ .catch((error) => {
+ const apiError = error as {
+ response?: { data?: { message?: string; detail?: string } };
+ message?: string;
+ };
+ const errorMessage =
+ apiError.response?.data?.message ||
+ apiError.response?.data?.detail ||
+ apiError.message ||
+ "Unknown error";
+
+ toast.error(
+ t("toast.save.error.title", {
+ errorMessage,
+ ns: "common",
+ }),
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ },
+ [updateConfig, t, onClose],
+ );
+
+ return (
+
+ );
+}
diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx
new file mode 100644
index 000000000..87eb74f06
--- /dev/null
+++ b/web/src/components/settings/wizard/Step1NameCamera.tsx
@@ -0,0 +1,615 @@
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { useTranslation } from "react-i18next";
+import { useState, useCallback, useMemo } from "react";
+import { LuEye, LuEyeOff } from "react-icons/lu";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
+import axios from "axios";
+import { toast } from "sonner";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import {
+ WizardFormData,
+ CameraBrand,
+ CAMERA_BRANDS,
+ CAMERA_BRAND_VALUES,
+ TestResult,
+ FfprobeStream,
+ StreamRole,
+ StreamConfig,
+} from "@/types/cameraWizard";
+import { FaCircleCheck } from "react-icons/fa6";
+import { Card, CardContent, CardTitle } from "../../ui/card";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { LuInfo } from "react-icons/lu";
+
+type Step1NameCameraProps = {
+ wizardData: Partial;
+ onUpdate: (data: Partial) => void;
+ onNext: (data?: Partial) => void;
+ onCancel: () => void;
+ canProceed?: boolean;
+};
+
+export default function Step1NameCamera({
+ wizardData,
+ onUpdate,
+ onNext,
+ onCancel,
+}: Step1NameCameraProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const { data: config } = useSWR("config");
+ const [showPassword, setShowPassword] = useState(false);
+ const [isTesting, setIsTesting] = useState(false);
+ const [testResult, setTestResult] = useState(null);
+
+ const existingCameraNames = useMemo(() => {
+ if (!config?.cameras) {
+ return [];
+ }
+ return Object.keys(config.cameras);
+ }, [config]);
+
+ const step1FormData = z
+ .object({
+ cameraName: z
+ .string()
+ .min(1, t("cameraWizard.step1.errors.nameRequired"))
+ .max(64, t("cameraWizard.step1.errors.nameLength"))
+ .regex(
+ /^[a-zA-Z0-9\s_-]+$/,
+ t("cameraWizard.step1.errors.invalidCharacters"),
+ )
+ .refine(
+ (value) => !existingCameraNames.includes(value),
+ t("cameraWizard.step1.errors.nameExists"),
+ ),
+ host: z.string().optional(),
+ username: z.string().optional(),
+ password: z.string().optional(),
+ brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
+ customUrl: z.string().optional(),
+ })
+ .refine(
+ (data) => {
+ // If brand is "other", customUrl is required
+ if (data.brandTemplate === "other") {
+ return data.customUrl && data.customUrl.trim().length > 0;
+ }
+ // If brand is not "other", host is required
+ return data.host && data.host.trim().length > 0;
+ },
+ {
+ message: t("cameraWizard.step1.errors.brandOrCustomUrlRequired"),
+ path: ["customUrl"],
+ },
+ );
+
+ const form = useForm>({
+ resolver: zodResolver(step1FormData),
+ defaultValues: {
+ cameraName: wizardData.cameraName || "",
+ host: wizardData.host || "",
+ username: wizardData.username || "",
+ password: wizardData.password || "",
+ brandTemplate:
+ wizardData.brandTemplate &&
+ CAMERA_BRAND_VALUES.includes(wizardData.brandTemplate as CameraBrand)
+ ? (wizardData.brandTemplate as CameraBrand)
+ : "dahua",
+ customUrl: wizardData.customUrl || "",
+ },
+ mode: "onChange",
+ });
+
+ const watchedBrand = form.watch("brandTemplate");
+ const watchedHost = form.watch("host");
+ const watchedCustomUrl = form.watch("customUrl");
+
+ const isTestButtonEnabled =
+ watchedBrand === "other"
+ ? !!(watchedCustomUrl && watchedCustomUrl.trim())
+ : !!(watchedHost && watchedHost.trim());
+
+ const generateStreamUrl = useCallback(
+ (data: z.infer): string => {
+ if (data.brandTemplate === "other") {
+ return data.customUrl || "";
+ }
+
+ const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
+ if (!brand || !data.host) return "";
+
+ return brand.template
+ .replace("{username}", data.username || "")
+ .replace("{password}", data.password || "")
+ .replace("{host}", data.host);
+ },
+ [],
+ );
+
+ const testConnection = useCallback(async () => {
+ const data = form.getValues();
+ const streamUrl = generateStreamUrl(data);
+
+ if (!streamUrl) {
+ toast.error(t("cameraWizard.commonErrors.noUrl"));
+ return;
+ }
+
+ setIsTesting(true);
+ setTestResult(null);
+
+ // First get probe data for metadata
+ const probePromise = axios.get("ffprobe", {
+ params: { paths: streamUrl, detailed: true },
+ timeout: 10000,
+ });
+
+ // Then get snapshot for preview
+ const snapshotPromise = axios.get("ffprobe/snapshot", {
+ params: { url: streamUrl },
+ responseType: "blob",
+ timeout: 10000,
+ });
+
+ try {
+ // First get probe data for metadata
+ const probeResponse = await probePromise;
+ let probeData = null;
+ if (
+ probeResponse.data &&
+ probeResponse.data.length > 0 &&
+ probeResponse.data[0].return_code === 0
+ ) {
+ probeData = probeResponse.data[0];
+ }
+
+ // Then get snapshot for preview (only if probe succeeded)
+ let snapshotBlob = null;
+ if (probeData) {
+ try {
+ const snapshotResponse = await snapshotPromise;
+ snapshotBlob = snapshotResponse.data;
+ } catch (snapshotError) {
+ // Snapshot is optional, don't fail if it doesn't work
+ toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
+ }
+ }
+
+ if (probeData) {
+ const ffprobeData = probeData.stdout;
+ const streams = ffprobeData.streams || [];
+
+ const videoStream = streams.find(
+ (s: FfprobeStream) =>
+ s.codec_type === "video" ||
+ s.codec_name?.includes("h264") ||
+ s.codec_name?.includes("h265"),
+ );
+
+ const audioStream = streams.find(
+ (s: FfprobeStream) =>
+ s.codec_type === "audio" ||
+ s.codec_name?.includes("aac") ||
+ s.codec_name?.includes("mp3"),
+ );
+
+ const resolution = videoStream
+ ? `${videoStream.width}x${videoStream.height}`
+ : undefined;
+
+ // Extract FPS from rational (e.g., "15/1" -> 15)
+ const fps = videoStream?.r_frame_rate
+ ? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
+ parseFloat(videoStream.r_frame_rate.split("/")[1])
+ : undefined;
+
+ // Convert snapshot blob to base64 if available
+ let snapshotBase64 = undefined;
+ if (snapshotBlob) {
+ snapshotBase64 = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.readAsDataURL(snapshotBlob);
+ });
+ }
+
+ const testResult: TestResult = {
+ success: true,
+ snapshot: snapshotBase64,
+ resolution,
+ videoCodec: videoStream?.codec_name,
+ audioCodec: audioStream?.codec_name,
+ fps: fps && !isNaN(fps) ? fps : undefined,
+ };
+
+ setTestResult(testResult);
+ toast.success(t("cameraWizard.step1.testSuccess"));
+ } else {
+ const error = probeData?.stderr || "Unknown error";
+ setTestResult({
+ success: false,
+ error: error,
+ });
+ toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
+ }
+ } catch (error) {
+ const axiosError = error as {
+ response?: { data?: { message?: string; detail?: string } };
+ message?: string;
+ };
+ const errorMessage =
+ axiosError.response?.data?.message ||
+ axiosError.response?.data?.detail ||
+ axiosError.message ||
+ "Connection failed";
+ setTestResult({
+ success: false,
+ error: errorMessage,
+ });
+ toast.error(
+ t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
+ );
+ } finally {
+ setIsTesting(false);
+ }
+ }, [form, generateStreamUrl, t]);
+
+ const onSubmit = (data: z.infer) => {
+ onUpdate(data);
+ };
+
+ const handleContinue = useCallback(() => {
+ const data = form.getValues();
+ const streamUrl = generateStreamUrl(data);
+ const streamId = `stream_${Date.now()}`;
+
+ const streamConfig: StreamConfig = {
+ id: streamId,
+ url: streamUrl,
+ roles: ["detect" as StreamRole],
+ resolution: testResult?.resolution,
+ testResult: testResult || undefined,
+ userTested: false,
+ };
+
+ const updatedData = {
+ ...data,
+ streams: [streamConfig],
+ };
+
+ onNext(updatedData);
+ }, [form, generateStreamUrl, testResult, onNext]);
+
+ return (
+
+ {!testResult?.success && (
+ <>
+
+ {t("cameraWizard.step1.description")}
+
+
+
+
+ >
+ )}
+
+ {testResult?.success && (
+
+
+
+ {t("cameraWizard.step1.testSuccess")}
+
+
+
+ {testResult.snapshot ? (
+
+

+
+
+ ) : (
+
+
+ {t("cameraWizard.step1.streamDetails")}
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+
+ {testResult?.success ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function StreamDetails({ testResult }: { testResult: TestResult }) {
+ const { t } = useTranslation(["views/settings"]);
+
+ return (
+ <>
+ {testResult.resolution && (
+
+
+ {t("cameraWizard.testResultLabels.resolution")}:
+ {" "}
+ {testResult.resolution}
+
+ )}
+ {testResult.fps && (
+
+
+ {t("cameraWizard.testResultLabels.fps")}:
+ {" "}
+ {testResult.fps}
+
+ )}
+ {testResult.videoCodec && (
+
+
+ {t("cameraWizard.testResultLabels.video")}:
+ {" "}
+ {testResult.videoCodec}
+
+ )}
+ {testResult.audioCodec && (
+
+
+ {t("cameraWizard.testResultLabels.audio")}:
+ {" "}
+ {testResult.audioCodec}
+
+ )}
+ >
+ );
+}
diff --git a/web/src/components/settings/wizard/Step2StreamConfig.tsx b/web/src/components/settings/wizard/Step2StreamConfig.tsx
new file mode 100644
index 000000000..0a3419940
--- /dev/null
+++ b/web/src/components/settings/wizard/Step2StreamConfig.tsx
@@ -0,0 +1,487 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { useTranslation } from "react-i18next";
+import { useState, useCallback, useMemo } from "react";
+import { LuPlus, LuTrash2, LuX } from "react-icons/lu";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
+import axios from "axios";
+import { toast } from "sonner";
+import {
+ WizardFormData,
+ StreamConfig,
+ StreamRole,
+ TestResult,
+ FfprobeStream,
+} from "@/types/cameraWizard";
+import { Label } from "../../ui/label";
+import { FaCircleCheck } from "react-icons/fa6";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { LuInfo, LuExternalLink } from "react-icons/lu";
+import { Link } from "react-router-dom";
+import { useDocDomain } from "@/hooks/use-doc-domain";
+
+type Step2StreamConfigProps = {
+ wizardData: Partial;
+ onUpdate: (data: Partial) => void;
+ onBack?: () => void;
+ onNext?: () => void;
+ canProceed?: boolean;
+};
+
+export default function Step2StreamConfig({
+ wizardData,
+ onUpdate,
+ onBack,
+ onNext,
+ canProceed,
+}: Step2StreamConfigProps) {
+ const { t } = useTranslation(["views/settings", "components/dialog"]);
+ const { getLocaleDocUrl } = useDocDomain();
+ const [testingStreams, setTestingStreams] = useState>(new Set());
+
+ const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
+
+ const addStream = useCallback(() => {
+ const newStream: StreamConfig = {
+ id: `stream_${Date.now()}`,
+ url: "",
+ roles: [],
+ };
+ onUpdate({
+ streams: [...streams, newStream],
+ });
+ }, [streams, onUpdate]);
+
+ const removeStream = useCallback(
+ (streamId: string) => {
+ onUpdate({
+ streams: streams.filter((s) => s.id !== streamId),
+ });
+ },
+ [streams, onUpdate],
+ );
+
+ const updateStream = useCallback(
+ (streamId: string, updates: Partial) => {
+ onUpdate({
+ streams: streams.map((s) =>
+ s.id === streamId ? { ...s, ...updates } : s,
+ ),
+ });
+ },
+ [streams, onUpdate],
+ );
+
+ const getUsedRolesExcludingStream = useCallback(
+ (excludeStreamId: string) => {
+ const roles = new Set();
+ streams.forEach((stream) => {
+ if (stream.id !== excludeStreamId) {
+ stream.roles.forEach((role) => roles.add(role));
+ }
+ });
+ return roles;
+ },
+ [streams],
+ );
+
+ const toggleRole = useCallback(
+ (streamId: string, role: StreamRole) => {
+ const stream = streams.find((s) => s.id === streamId);
+ if (!stream) return;
+
+ const hasRole = stream.roles.includes(role);
+ if (hasRole) {
+ // Allow removing the role
+ const newRoles = stream.roles.filter((r) => r !== role);
+ updateStream(streamId, { roles: newRoles });
+ } else {
+ // Check if role is already used in another stream
+ const usedRoles = getUsedRolesExcludingStream(streamId);
+ if (!usedRoles.has(role)) {
+ // Allow adding the role
+ const newRoles = [...stream.roles, role];
+ updateStream(streamId, { roles: newRoles });
+ }
+ }
+ },
+ [streams, updateStream, getUsedRolesExcludingStream],
+ );
+
+ const testStream = useCallback(
+ (stream: StreamConfig) => {
+ if (!stream.url.trim()) {
+ toast.error(t("cameraWizard.commonErrors.noUrl"));
+ return;
+ }
+
+ setTestingStreams((prev) => new Set(prev).add(stream.id));
+
+ axios
+ .get("ffprobe", {
+ params: { paths: stream.url, detailed: true },
+ timeout: 10000,
+ })
+ .then((response) => {
+ if (response.data?.[0]?.return_code === 0) {
+ const probeData = response.data[0];
+ const streams = probeData.stdout.streams || [];
+
+ const videoStream = streams.find(
+ (s: FfprobeStream) =>
+ s.codec_type === "video" ||
+ s.codec_name?.includes("h264") ||
+ s.codec_name?.includes("h265"),
+ );
+
+ const audioStream = streams.find(
+ (s: FfprobeStream) =>
+ s.codec_type === "audio" ||
+ s.codec_name?.includes("aac") ||
+ s.codec_name?.includes("mp3"),
+ );
+
+ const resolution = videoStream
+ ? `${videoStream.width}x${videoStream.height}`
+ : undefined;
+
+ const fps = videoStream?.r_frame_rate
+ ? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
+ parseFloat(videoStream.r_frame_rate.split("/")[1])
+ : undefined;
+
+ const testResult: TestResult = {
+ success: true,
+ resolution,
+ videoCodec: videoStream?.codec_name,
+ audioCodec: audioStream?.codec_name,
+ fps: fps && !isNaN(fps) ? fps : undefined,
+ };
+
+ updateStream(stream.id, { testResult, userTested: true });
+ toast.success(t("cameraWizard.step2.testSuccess"));
+ } else {
+ const error = response.data?.[0]?.stderr || "Unknown error";
+ updateStream(stream.id, {
+ testResult: { success: false, error },
+ userTested: true,
+ });
+ toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
+ }
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Connection failed";
+ updateStream(stream.id, {
+ testResult: { success: false, error: errorMessage },
+ userTested: true,
+ });
+ toast.error(
+ t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
+ );
+ })
+ .finally(() => {
+ setTestingStreams((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(stream.id);
+ return newSet;
+ });
+ });
+ },
+ [updateStream, t],
+ );
+
+ const setRestream = useCallback(
+ (streamId: string) => {
+ const currentIds = wizardData.restreamIds || [];
+ const isSelected = currentIds.includes(streamId);
+ const newIds = isSelected
+ ? currentIds.filter((id) => id !== streamId)
+ : [...currentIds, streamId];
+ onUpdate({
+ restreamIds: newIds,
+ });
+ },
+ [wizardData.restreamIds, onUpdate],
+ );
+
+ const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
+
+ return (
+
+
+ {t("cameraWizard.step2.description")}
+
+
+
+ {streams.map((stream, index) => (
+
+
+
+
+
+ {t("cameraWizard.step2.streamTitle", { number: index + 1 })}
+
+ {stream.testResult && stream.testResult.success && (
+
+ {[
+ stream.testResult.resolution,
+ stream.testResult.fps
+ ? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
+ : null,
+ stream.testResult.videoCodec,
+ stream.testResult.audioCodec,
+ ]
+ .filter(Boolean)
+ .join(" ยท ")}
+
+ )}
+
+
+ {stream.testResult?.success && (
+
+
+
+ {t("cameraWizard.step2.connected")}
+
+
+ )}
+ {stream.testResult && !stream.testResult.success && (
+
+
+
+ {t("cameraWizard.step2.notConnected")}
+
+
+ )}
+ {streams.length > 1 && (
+
+ )}
+
+
+
+
+
+
+ {t("cameraWizard.step2.url")}
+
+
+
+ updateStream(stream.id, {
+ url: e.target.value,
+ testResult: undefined,
+ })
+ }
+ className="h-8 flex-1"
+ placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
+ />
+
+
+
+
+
+ {stream.testResult &&
+ !stream.testResult.success &&
+ stream.userTested && (
+
+
+ {t("cameraWizard.step2.testFailedTitle")}
+
+
+ {stream.testResult.error}
+
+
+ )}
+
+
+
+
+ {t("cameraWizard.step2.roles")}
+
+
+
+
+
+
+
+
+ {t("cameraWizard.step2.rolesPopover.title")}
+
+
+
+ detect -{" "}
+ {t("cameraWizard.step2.rolesPopover.detect")}
+
+
+ record -{" "}
+ {t("cameraWizard.step2.rolesPopover.record")}
+
+
+ audio -{" "}
+ {t("cameraWizard.step2.rolesPopover.audio")}
+
+
+
+
+ {t("readTheDocumentation", { ns: "common" })}
+
+
+
+
+
+
+
+
+
+ {(["detect", "record", "audio"] as const).map((role) => {
+ const isUsedElsewhere = getUsedRolesExcludingStream(
+ stream.id,
+ ).has(role);
+ const isChecked = stream.roles.includes(role);
+ return (
+
+ {role}
+ toggleRole(stream.id, role)}
+ disabled={!isChecked && isUsedElsewhere}
+ />
+
+ );
+ })}
+
+
+
+
+
+
+
+ {t("cameraWizard.step2.featuresTitle")}
+
+
+
+
+
+
+
+
+ {t("cameraWizard.step2.featuresPopover.title")}
+
+
+ {t("cameraWizard.step2.featuresPopover.description")}
+
+
+
+ {t("readTheDocumentation", { ns: "common" })}
+
+
+
+
+
+
+
+
+
+
+ {t("cameraWizard.step2.go2rtc")}
+
+ setRestream(stream.id)}
+ />
+
+
+
+
+
+ ))}
+
+
+
+
+ {!hasDetectRole && (
+
+ {t("cameraWizard.step2.detectRoleWarning")}
+
+ )}
+
+
+ {onBack && (
+
+ )}
+ {onNext && (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/settings/wizard/Step3Validation.tsx b/web/src/components/settings/wizard/Step3Validation.tsx
new file mode 100644
index 000000000..195d4223a
--- /dev/null
+++ b/web/src/components/settings/wizard/Step3Validation.tsx
@@ -0,0 +1,690 @@
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useTranslation } from "react-i18next";
+import { LuRotateCcw } from "react-icons/lu";
+import { useState, useCallback, useMemo, useEffect } from "react";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
+import axios from "axios";
+import { toast } from "sonner";
+import MSEPlayer from "@/components/player/MsePlayer";
+import { WizardFormData, StreamConfig, TestResult } from "@/types/cameraWizard";
+import { PlayerStatsType } from "@/types/live";
+import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
+import { LuX } from "react-icons/lu";
+import { Card, CardContent } from "../../ui/card";
+
+type Step3ValidationProps = {
+ wizardData: Partial;
+ onUpdate: (data: Partial) => void;
+ onSave: (config: WizardFormData) => void;
+ onBack?: () => void;
+ isLoading?: boolean;
+};
+
+export default function Step3Validation({
+ wizardData,
+ onUpdate,
+ onSave,
+ onBack,
+ isLoading = false,
+}: Step3ValidationProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const [isValidating, setIsValidating] = useState(false);
+ const [testingStreams, setTestingStreams] = useState>(new Set());
+ const [measuredBandwidth, setMeasuredBandwidth] = useState<
+ Map
+ >(new Map());
+
+ const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
+
+ const handleBandwidthUpdate = useCallback(
+ (streamId: string, bandwidth: number) => {
+ setMeasuredBandwidth((prev) => new Map(prev).set(streamId, bandwidth));
+ },
+ [],
+ );
+
+ // Use test results from Step 2, but allow re-validation in Step 3
+ const validationResults = useMemo(() => {
+ const results = new Map();
+ streams.forEach((stream) => {
+ if (stream.testResult) {
+ results.set(stream.id, stream.testResult);
+ }
+ });
+ return results;
+ }, [streams]);
+
+ const performStreamValidation = useCallback(
+ async (stream: StreamConfig): Promise => {
+ try {
+ const response = await axios.get("ffprobe", {
+ params: { paths: stream.url, detailed: true },
+ timeout: 10000,
+ });
+
+ if (response.data?.[0]?.return_code === 0) {
+ const probeData = response.data[0];
+ const streamData = probeData.stdout.streams || [];
+
+ const videoStream = streamData.find(
+ (s: { codec_type?: string; codec_name?: string }) =>
+ s.codec_type === "video" ||
+ s.codec_name?.includes("h264") ||
+ s.codec_name?.includes("h265"),
+ );
+
+ const audioStream = streamData.find(
+ (s: { codec_type?: string; codec_name?: string }) =>
+ s.codec_type === "audio" ||
+ s.codec_name?.includes("aac") ||
+ s.codec_name?.includes("mp3"),
+ );
+
+ const resolution = videoStream
+ ? `${videoStream.width}x${videoStream.height}`
+ : undefined;
+
+ const fps = videoStream?.r_frame_rate
+ ? parseFloat(videoStream.r_frame_rate.split("/")[0]) /
+ parseFloat(videoStream.r_frame_rate.split("/")[1])
+ : undefined;
+
+ return {
+ success: true,
+ resolution,
+ videoCodec: videoStream?.codec_name,
+ audioCodec: audioStream?.codec_name,
+ fps: fps && !isNaN(fps) ? fps : undefined,
+ };
+ } else {
+ const error = response.data?.[0]?.stderr || "Unknown error";
+ return { success: false, error };
+ }
+ } catch (error) {
+ const axiosError = error as {
+ response?: { data?: { message?: string; detail?: string } };
+ message?: string;
+ };
+ const errorMessage =
+ axiosError.response?.data?.message ||
+ axiosError.response?.data?.detail ||
+ axiosError.message ||
+ "Connection failed";
+
+ return { success: false, error: errorMessage };
+ }
+ },
+ [],
+ );
+
+ const validateStream = useCallback(
+ async (stream: StreamConfig) => {
+ if (!stream.url.trim()) {
+ toast.error(t("cameraWizard.commonErrors.noUrl"));
+ return;
+ }
+
+ setTestingStreams((prev) => new Set(prev).add(stream.id));
+
+ const testResult = await performStreamValidation(stream);
+
+ onUpdate({
+ streams: streams.map((s) =>
+ s.id === stream.id ? { ...s, testResult } : s,
+ ),
+ });
+
+ if (testResult.success) {
+ toast.success(
+ t("cameraWizard.step3.streamValidated", {
+ number: streams.findIndex((s) => s.id === stream.id) + 1,
+ }),
+ );
+ } else {
+ toast.error(
+ t("cameraWizard.step3.streamValidationFailed", {
+ number: streams.findIndex((s) => s.id === stream.id) + 1,
+ }),
+ );
+ }
+
+ setTestingStreams((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(stream.id);
+ return newSet;
+ });
+ },
+ [streams, onUpdate, t, performStreamValidation],
+ );
+
+ const validateAllStreams = useCallback(async () => {
+ setIsValidating(true);
+ const results = new Map();
+
+ // Only test streams that haven't been tested or failed
+ const streamsToTest = streams.filter(
+ (stream) => !stream.testResult || !stream.testResult.success,
+ );
+
+ for (const stream of streamsToTest) {
+ if (!stream.url.trim()) continue;
+
+ const testResult = await performStreamValidation(stream);
+ results.set(stream.id, testResult);
+ }
+
+ // Update wizard data with new test results
+ if (results.size > 0) {
+ const updatedStreams = streams.map((stream) => {
+ const newResult = results.get(stream.id);
+ if (newResult) {
+ return { ...stream, testResult: newResult };
+ }
+ return stream;
+ });
+
+ onUpdate({ streams: updatedStreams });
+ }
+
+ setIsValidating(false);
+
+ if (results.size > 0) {
+ const successfulTests = Array.from(results.values()).filter(
+ (r) => r.success,
+ ).length;
+ if (successfulTests === results.size) {
+ toast.success(t("cameraWizard.step3.reconnectionSuccess"));
+ } else {
+ toast.warning(t("cameraWizard.step3.reconnectionPartial"));
+ }
+ }
+ }, [streams, onUpdate, t, performStreamValidation]);
+
+ const handleSave = useCallback(() => {
+ if (!wizardData.cameraName || !wizardData.streams?.length) {
+ toast.error(t("cameraWizard.step3.saveError"));
+ return;
+ }
+
+ // Convert wizard data to final config format
+ const configData = {
+ cameraName: wizardData.cameraName,
+ host: wizardData.host,
+ username: wizardData.username,
+ password: wizardData.password,
+ brandTemplate: wizardData.brandTemplate,
+ customUrl: wizardData.customUrl,
+ streams: wizardData.streams,
+ restreamIds: wizardData.restreamIds,
+ };
+
+ onSave(configData);
+ }, [wizardData, onSave, t]);
+
+ const canSave = useMemo(() => {
+ return (
+ wizardData.cameraName &&
+ wizardData.streams?.length &&
+ wizardData.streams.some((s) => s.roles.includes("detect")) &&
+ wizardData.streams.every((s) => s.testResult) // All streams must be tested
+ );
+ }, [wizardData]);
+
+ return (
+
+
+ {t("cameraWizard.step3.description")}
+
+
+
+
+
+ {t("cameraWizard.step3.validationTitle")}
+
+
+
+
+
+ {streams.map((stream, index) => {
+ const result = validationResults.get(stream.id);
+ return (
+
+
+
+
+
+
+
+ {t("cameraWizard.step3.streamTitle", {
+ number: index + 1,
+ })}
+
+ {stream.roles.map((role) => (
+
+ {role}
+
+ ))}
+
+ {result && result.success && (
+
+ {[
+ result.resolution,
+ result.fps
+ ? `${result.fps} ${t("cameraWizard.testResultLabels.fps")}`
+ : null,
+ result.videoCodec,
+ result.audioCodec,
+ ]
+ .filter(Boolean)
+ .join(" ยท ")}
+
+ )}
+
+
+ {result?.success && (
+
+
+
+ {t("cameraWizard.step2.connected")}
+
+
+ )}
+ {result && !result.success && (
+
+
+
+ {t("cameraWizard.step2.notConnected")}
+
+
+ )}
+
+
+ {result?.success && (
+
+
+
+ )}
+
+
+
+ {stream.url}
+
+
+
+
+ {result && (
+
+
+ {t("cameraWizard.step3.issues.title")}
+
+
+
+
+
+ )}
+
+ {result && !result.success && (
+
+
+ {t("cameraWizard.step2.testFailedTitle")}
+
+
{result.error}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ {onBack && (
+
+ )}
+
+
+
+ );
+}
+
+type StreamIssuesProps = {
+ stream: StreamConfig;
+ measuredBandwidth: Map;
+ wizardData: Partial;
+};
+
+function StreamIssues({
+ stream,
+ measuredBandwidth,
+ wizardData,
+}: StreamIssuesProps) {
+ const { t } = useTranslation(["views/settings"]);
+
+ const issues = useMemo(() => {
+ const result: Array<{
+ type: "good" | "warning" | "error";
+ message: string;
+ }> = [];
+
+ // Video codec check
+ if (stream.testResult?.videoCodec) {
+ const videoCodec = stream.testResult.videoCodec.toLowerCase();
+ if (["h264", "h265", "hevc"].includes(videoCodec)) {
+ result.push({
+ type: "good",
+ message: t("cameraWizard.step3.issues.videoCodecGood", {
+ codec: stream.testResult.videoCodec,
+ }),
+ });
+ }
+ }
+
+ // Audio codec check
+ if (stream.roles.includes("record")) {
+ if (stream.testResult?.audioCodec) {
+ const audioCodec = stream.testResult.audioCodec.toLowerCase();
+ if (audioCodec === "aac") {
+ result.push({
+ type: "good",
+ message: t("cameraWizard.step3.issues.audioCodecGood", {
+ codec: stream.testResult.audioCodec,
+ }),
+ });
+ } else {
+ result.push({
+ type: "error",
+ message: t("cameraWizard.step3.issues.audioCodecRecordError"),
+ });
+ }
+ } else {
+ result.push({
+ type: "warning",
+ message: t("cameraWizard.step3.issues.noAudioWarning"),
+ });
+ }
+ }
+
+ // Audio detection check
+ if (stream.roles.includes("audio")) {
+ if (!stream.testResult?.audioCodec) {
+ result.push({
+ type: "error",
+ message: t("cameraWizard.step3.issues.audioCodecRequired"),
+ });
+ }
+ }
+
+ // Restreaming check
+ if (stream.roles.includes("record")) {
+ const restreamIds = wizardData.restreamIds || [];
+ if (restreamIds.includes(stream.id)) {
+ result.push({
+ type: "warning",
+ message: t("cameraWizard.step3.issues.restreamingWarning"),
+ });
+ }
+ }
+
+ return result;
+ }, [stream, wizardData, t]);
+
+ if (issues.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ {issues.map((issue, index) => (
+
+ {issue.type === "good" && (
+
+ )}
+ {issue.type === "warning" && (
+
+ )}
+ {issue.type === "error" && (
+
+ )}
+
+ {issue.message}
+
+
+ ))}
+
+
+ );
+}
+
+type BandwidthDisplayProps = {
+ streamId: string;
+ measuredBandwidth: Map;
+};
+
+function BandwidthDisplay({
+ streamId,
+ measuredBandwidth,
+}: BandwidthDisplayProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const streamBandwidth = measuredBandwidth.get(streamId);
+
+ if (!streamBandwidth) return null;
+
+ const perHour = streamBandwidth * 3600; // kB/hour
+ const perHourDisplay =
+ perHour >= 1000000
+ ? `${(perHour / 1000000).toFixed(1)} ${t("unit.data.gbph", { ns: "common" })}`
+ : perHour >= 1000
+ ? `${(perHour / 1000).toFixed(1)} ${t("unit.data.mbph", { ns: "common" })}`
+ : `${perHour.toFixed(0)} ${t("unit.data.kbph", { ns: "common" })}`;
+
+ return (
+
+
+ {t("cameraWizard.step3.estimatedBandwidth")}:
+ {" "}
+
+ {streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
+
+ ({perHourDisplay})
+
+ );
+}
+
+type StreamPreviewProps = {
+ stream: StreamConfig;
+ onBandwidthUpdate?: (streamId: string, bandwidth: number) => void;
+};
+
+// live stream preview using MSEPlayer with temp go2rtc streams
+function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const [streamId, setStreamId] = useState(`wizard_${stream.id}_${Date.now()}`);
+ const [registered, setRegistered] = useState(false);
+ const [error, setError] = useState(false);
+
+ const handleStats = useCallback(
+ (stats: PlayerStatsType) => {
+ if (stats.bandwidth > 0) {
+ onBandwidthUpdate?.(stream.id, stats.bandwidth);
+ }
+ },
+ [stream.id, onBandwidthUpdate],
+ );
+
+ const handleReload = useCallback(async () => {
+ // Clean up old stream first
+ if (streamId) {
+ axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
+ // do nothing on cleanup errors - go2rtc won't consume the streams
+ });
+ }
+
+ // Reset state and create new stream ID
+ setError(false);
+ setRegistered(false);
+ setStreamId(`wizard_${stream.id}_${Date.now()}`);
+ }, [stream.id, streamId]);
+
+ useEffect(() => {
+ // Register stream with go2rtc
+ axios
+ .put(`go2rtc/streams/${streamId}`, null, {
+ params: { src: stream.url },
+ })
+ .then(() => {
+ // Add small delay to allow go2rtc api to run and initialize the stream
+ setTimeout(() => {
+ setRegistered(true);
+ }, 500);
+ })
+ .catch(() => {
+ setError(true);
+ });
+
+ // Cleanup on unmount
+ return () => {
+ axios.delete(`go2rtc/streams/${streamId}`).catch(() => {
+ // do nothing on cleanup errors - go2rtc won't consume the streams
+ });
+ };
+ }, [stream.url, streamId]);
+
+ const resolution = stream.testResult?.resolution;
+ let aspectRatio = "16/9";
+ if (resolution) {
+ const [width, height] = resolution.split("x").map(Number);
+ if (width && height) {
+ aspectRatio = `${width}/${height}`;
+ }
+ }
+
+ if (error) {
+ return (
+
+
+ {t("cameraWizard.step3.streamUnavailable")}
+
+
+
+ );
+ }
+
+ if (!registered) {
+ return (
+
+
+
+ {t("cameraWizard.step3.connecting")}
+
+
+ );
+ }
+
+ return (
+
+ setError(true)}
+ />
+
+ );
+}
diff --git a/web/src/components/ui/form.tsx b/web/src/components/ui/form.tsx
index 4603f8b3d..dc6102ea2 100644
--- a/web/src/components/ui/form.tsx
+++ b/web/src/components/ui/form.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
-import * as LabelPrimitive from "@radix-ui/react-label"
-import { Slot } from "@radix-ui/react-slot"
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { Slot } from "@radix-ui/react-slot";
import {
Controller,
ControllerProps,
@@ -8,27 +8,27 @@ import {
FieldValues,
FormProvider,
useFormContext,
-} from "react-hook-form"
+} from "react-hook-form";
-import { cn } from "@/lib/utils"
-import { Label } from "@/components/ui/label"
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
-const Form = FormProvider
+const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
- TName extends FieldPath = FieldPath
+ TName extends FieldPath = FieldPath,
> = {
- name: TName
-}
+ name: TName;
+};
const FormFieldContext = React.createContext(
- {} as FormFieldContextValue
-)
+ {} as FormFieldContextValue,
+);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
- TName extends FieldPath = FieldPath
+ TName extends FieldPath = FieldPath,
>({
...props
}: ControllerProps) => {
@@ -36,21 +36,21 @@ const FormField = <
- )
-}
+ );
+};
const useFormField = () => {
- const fieldContext = React.useContext(FormFieldContext)
- const itemContext = React.useContext(FormItemContext)
- const { getFieldState, formState } = useFormContext()
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
- const fieldState = getFieldState(fieldContext.name, formState)
+ const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
- throw new Error("useFormField should be used within ")
+ throw new Error("useFormField should be used within ");
}
- const { id } = itemContext
+ const { id } = itemContext;
return {
id,
@@ -59,36 +59,36 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
- }
-}
+ };
+};
type FormItemContextValue = {
- id: string
-}
+ id: string;
+};
const FormItemContext = React.createContext(
- {} as FormItemContextValue
-)
+ {} as FormItemContextValue,
+);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => {
- const id = React.useId()
+ const id = React.useId();
return (
-
+
- )
-})
-FormItem.displayName = "FormItem"
+ );
+});
+FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => {
- const { error, formItemId } = useFormField()
+ const { error, formItemId } = useFormField();
return (
- )
-})
-FormLabel.displayName = "FormLabel"
+ );
+});
+FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ ...props }, ref) => {
- const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
return (
- )
-})
-FormControl.displayName = "FormControl"
+ );
+});
+FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => {
- const { formDescriptionId } = useFormField()
+ const { formDescriptionId } = useFormField();
return (
- )
-})
-FormDescription.displayName = "FormDescription"
+ );
+});
+FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, children, ...props }, ref) => {
- const { error, formMessageId } = useFormField()
- const body = error ? String(error?.message) : children
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message) : children;
if (!body) {
- return null
+ return null;
}
return (
@@ -160,9 +161,9 @@ const FormMessage = React.forwardRef<
>
{body}
- )
-})
-FormMessage.displayName = "FormMessage"
+ );
+});
+FormMessage.displayName = "FormMessage";
export {
useFormField,
@@ -173,4 +174,4 @@ export {
FormDescription,
FormMessage,
FormField,
-}
+};
diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
index 677d05fd6..c0c40df35 100644
--- a/web/src/components/ui/input.tsx
+++ b/web/src/components/ui/input.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes {}
@@ -11,15 +11,15 @@ const Input = React.forwardRef(
- )
- }
-)
-Input.displayName = "Input"
+ );
+ },
+);
+Input.displayName = "Input";
-export { Input }
+export { Input };
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
index fe56d4d3a..8eabc4651 100644
--- a/web/src/components/ui/select.tsx
+++ b/web/src/components/ui/select.tsx
@@ -1,14 +1,14 @@
-import * as React from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import { Check, ChevronDown, ChevronUp } from "lucide-react"
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
-const Select = SelectPrimitive.Root
+const Select = SelectPrimitive.Root;
-const SelectGroup = SelectPrimitive.Group
+const SelectGroup = SelectPrimitive.Group;
-const SelectValue = SelectPrimitive.Value
+const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef,
@@ -17,8 +17,8 @@ const SelectTrigger = React.forwardRef<
span]:line-clamp-1",
- className
+ "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-background_alt [&>span]:line-clamp-1",
+ className,
)}
{...props}
>
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
-))
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef,
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
- className
+ className,
)}
{...props}
>
-))
-SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef,
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
- className
+ className,
)}
{...props}
>
-))
+));
SelectScrollDownButton.displayName =
- SelectPrimitive.ScrollDownButton.displayName
+ SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef,
@@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
- className
+ className,
)}
position={position}
{...props}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
-))
-SelectContent.displayName = SelectPrimitive.Content.displayName
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef,
@@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
-))
-SelectLabel.displayName = SelectPrimitive.Label.displayName
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef,
@@ -116,8 +116,8 @@ const SelectItem = React.forwardRef<
@@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
{children}
-))
-SelectItem.displayName = SelectPrimitive.Item.displayName
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef,
@@ -141,8 +141,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
-))
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@@ -155,4 +155,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
-}
+};
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index 1c9b5166b..844329fc7 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -27,6 +27,7 @@ import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
import CameraSettingsView from "@/views/settings/CameraSettingsView";
+import CameraManagementView from "@/views/settings/CameraManagementView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import UsersView from "@/views/settings/UsersView";
@@ -70,7 +71,8 @@ import {
const allSettingsViews = [
"ui",
"enrichments",
- "cameras",
+ "cameraManagement",
+ "cameraReview",
"masksAndZones",
"motionTuner",
"triggers",
@@ -90,7 +92,8 @@ const settingsGroups = [
{
label: "cameras",
items: [
- { key: "cameras", component: CameraSettingsView },
+ { key: "cameraManagement", component: CameraManagementView },
+ { key: "cameraReview", component: CameraSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView },
{ key: "motionTuner", component: MotionTunerView },
],
@@ -119,6 +122,16 @@ const settingsGroups = [
},
];
+const CAMERA_SELECT_BUTTON_PAGES = [
+ "debug",
+ "cameraReview",
+ "masksAndZones",
+ "motionTuner",
+ "triggers",
+];
+
+const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
+
const getCurrentComponent = (page: SettingsType) => {
for (const group of settingsGroups) {
for (const item of group.items) {
@@ -172,13 +185,8 @@ export default function Settings() {
const isAdmin = useIsAdmin();
- const allowedViewsForViewer: SettingsType[] = [
- "ui",
- "debug",
- "notifications",
- ];
const visibleSettingsViews = !isAdmin
- ? allowedViewsForViewer
+ ? ALLOWED_VIEWS_FOR_VIEWER
: allSettingsViews;
// TODO: confirm leave page
@@ -242,7 +250,7 @@ export default function Settings() {
setSelectedCamera(firstEnabledCamera.name);
} else if (
!cameraEnabledStates[selectedCamera] &&
- pageToggle !== "cameras"
+ pageToggle !== "cameraReview"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
@@ -257,7 +265,10 @@ export default function Settings() {
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings
- if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) {
+ if (
+ !isAdmin &&
+ !ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
+ ) {
setPageToggle("ui");
} else {
setPageToggle(page as SettingsType);
@@ -321,7 +332,9 @@ export default function Settings() {
onSelect={(key) => {
if (
!isAdmin &&
- !allowedViewsForViewer.includes(key as SettingsType)
+ !ALLOWED_VIEWS_FOR_VIEWER.includes(
+ key as SettingsType,
+ )
) {
setPageToggle("ui");
} else {
@@ -348,13 +361,7 @@ export default function Settings() {
className="top-0 mb-0"
onClose={() => navigate(-1)}
actions={
- [
- "debug",
- "cameras",
- "masksAndZones",
- "motionTuner",
- "triggers",
- ].includes(pageToggle) ? (
+ CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? (
{pageToggle == "masksAndZones" && (
{t("menu.settings", { ns: "common" })}
- {[
- "debug",
- "cameras",
- "masksAndZones",
- "motionTuner",
- "triggers",
- ].includes(page) && (
+ {CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
{pageToggle == "masksAndZones" && (
{
if (
!isAdmin &&
- !allowedViewsForViewer.includes(
+ !ALLOWED_VIEWS_FOR_VIEWER.includes(
filteredItems[0].key as SettingsType,
)
) {
@@ -512,7 +513,7 @@ export default function Settings() {
onClick={() => {
if (
!isAdmin &&
- !allowedViewsForViewer.includes(
+ !ALLOWED_VIEWS_FOR_VIEWER.includes(
item.key as SettingsType,
)
) {
@@ -635,7 +636,7 @@ function CameraSelectButton({
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
- const isCameraSettingsPage = currentPage === "cameras";
+ const isCameraSettingsPage = currentPage === "cameraReview";
return (
brand.value,
+) as unknown as [
+ (typeof CAMERA_BRANDS)[number]["value"],
+ ...(typeof CAMERA_BRANDS)[number]["value"][],
+];
+
+export type CameraBrand = (typeof CAMERA_BRANDS)[number]["value"];
+
+export type StreamRole = "detect" | "record" | "audio";
+
+export type StreamConfig = {
+ id: string;
+ url: string;
+ roles: StreamRole[];
+ resolution?: string;
+ quality?: string;
+ testResult?: TestResult;
+ userTested?: boolean;
+};
+
+export type TestResult = {
+ success: boolean;
+ snapshot?: string; // base64 image
+ resolution?: string;
+ videoCodec?: string;
+ audioCodec?: string;
+ fps?: number;
+ error?: string;
+};
+
+export type WizardFormData = {
+ cameraName?: string;
+ host?: string;
+ username?: string;
+ password?: string;
+ brandTemplate?: CameraBrand;
+ customUrl?: string;
+ streams?: StreamConfig[];
+ restreamIds?: string[];
+};
+
+// API Response Types
+export type FfprobeResponse = {
+ return_code: number;
+ stderr: string;
+ stdout: FfprobeData | string;
+};
+
+export type FfprobeData = {
+ streams: FfprobeStream[];
+};
+
+export type FfprobeStream = {
+ index?: number;
+ codec_name?: string;
+ codec_long_name?: string;
+ codec_type?: "video" | "audio";
+ profile?: string;
+ width?: number;
+ height?: number;
+ pix_fmt?: string;
+ level?: number;
+ r_frame_rate?: string;
+ avg_frame_rate?: string;
+ sample_rate?: string;
+ channels?: number;
+ channel_layout?: string;
+};
+
+// Config API Types
+export type CameraConfigData = {
+ cameras: {
+ [cameraName: string]: {
+ enabled: boolean;
+ friendly_name?: string;
+ ffmpeg: {
+ inputs: {
+ path: string;
+ roles: string[];
+ }[];
+ };
+ live?: {
+ streams: Record;
+ };
+ };
+ };
+ go2rtc?: {
+ streams: {
+ [streamName: string]: string[];
+ };
+ };
+};
+
+export type ConfigSetBody = {
+ requires_restart: number;
+ config_data: CameraConfigData;
+ update_topic?: string;
+};
diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts
new file mode 100644
index 000000000..6b5d9e584
--- /dev/null
+++ b/web/src/utils/cameraUtil.ts
@@ -0,0 +1,60 @@
+/**
+ * Generates a fixed-length hash from a camera name for use as a valid camera identifier.
+ * Works safely with Unicode input while outputting Latin-only identifiers.
+ *
+ * @param name - The original camera name/display name
+ * @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
+ */
+export function generateFixedHash(name: string): string {
+ // Safely encode Unicode as UTF-8 bytes
+ const utf8Bytes = new TextEncoder().encode(name);
+
+ // Convert to base64 manually
+ let binary = "";
+ for (const byte of utf8Bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ const base64 = btoa(binary);
+
+ // Strip out non-alphanumeric characters and truncate
+ const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
+
+ return `cam_${cleanHash.toLowerCase()}`;
+}
+
+/**
+ * Checks if a string is a valid camera name identifier.
+ * Valid camera names contain only ASCII letters, numbers, underscores, and hyphens.
+ *
+ * @param name - The camera name to validate
+ * @returns True if the name is valid, false otherwise
+ */
+export function isValidCameraName(name: string): boolean {
+ return /^[a-zA-Z0-9_-]+$/.test(name);
+}
+
+/**
+ * Processes a user-entered camera name and returns both the final camera name
+ * and friendly name for Frigate configuration.
+ *
+ * @param userInput - The name entered by the user (could be display name)
+ * @returns Object with finalCameraName and friendlyName
+ */
+export function processCameraName(userInput: string): {
+ finalCameraName: string;
+ friendlyName?: string;
+} {
+ const normalizedInput = userInput.replace(/\s+/g, "_").toLowerCase();
+
+ if (isValidCameraName(normalizedInput)) {
+ return {
+ finalCameraName: normalizedInput,
+ friendlyName: userInput.includes(" ") ? userInput : undefined,
+ };
+ }
+
+ return {
+ finalCameraName: generateFixedHash(userInput),
+ friendlyName: userInput,
+ };
+}
diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx
new file mode 100644
index 000000000..22c44fc9e
--- /dev/null
+++ b/web/src/views/settings/CameraManagementView.tsx
@@ -0,0 +1,199 @@
+import Heading from "@/components/ui/heading";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Toaster } from "sonner";
+import { Button } from "@/components/ui/button";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { useTranslation } from "react-i18next";
+import { Label } from "@/components/ui/label";
+import CameraEditForm from "@/components/settings/CameraEditForm";
+import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
+import { LuPlus } from "react-icons/lu";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { IoMdArrowRoundBack } from "react-icons/io";
+import { isDesktop } from "react-device-detect";
+import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
+import { Switch } from "@/components/ui/switch";
+import { Trans } from "react-i18next";
+import { Separator } from "@/components/ui/separator";
+import { useEnabledState } from "@/api/ws";
+
+type CameraManagementViewProps = {
+ setUnsavedChanges: React.Dispatch>;
+};
+
+export default function CameraManagementView({
+ setUnsavedChanges,
+}: CameraManagementViewProps) {
+ const { t } = useTranslation(["views/settings"]);
+
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+
+ const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
+ "settings",
+ ); // Control view state
+ const [editCameraName, setEditCameraName] = useState(
+ undefined,
+ ); // Track camera being edited
+ const [showWizard, setShowWizard] = useState(false);
+
+ // List of cameras for dropdown
+ const cameras = useMemo(() => {
+ if (config) {
+ return Object.keys(config.cameras).sort();
+ }
+ return [];
+ }, [config]);
+
+ useEffect(() => {
+ document.title = t("documentTitle.cameraManagement");
+ }, [t]);
+
+ // Handle back navigation from add/edit form
+ const handleBack = useCallback(() => {
+ setViewMode("settings");
+ setEditCameraName(undefined);
+ setUnsavedChanges(false);
+ updateConfig();
+ }, [updateConfig, setUnsavedChanges]);
+
+ return (
+ <>
+
+
+
+ {viewMode === "settings" ? (
+ <>
+
+ {t("cameraManagement.title")}
+
+
+
+ {cameras.length > 0 && (
+ <>
+
+ {t("cameraManagement.editCamera")}
+
+
+
+
+
+
+
+ cameraManagement.streams.title
+
+
+
+
+ cameraManagement.streams.desc
+
+
+
+
+ {cameras.map((camera) => (
+
+
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ setShowWizard(false)}
+ />
+ >
+ );
+}
+
+type CameraEnableSwitchProps = {
+ cameraName: string;
+};
+
+function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
+ const { payload: enabledState, send: sendEnabled } =
+ useEnabledState(cameraName);
+
+ return (
+
+ {
+ sendEnabled(isChecked ? "ON" : "OFF");
+ }}
+ />
+
+ );
+}
diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx
index 7d0c7e4e1..70bdf1308 100644
--- a/web/src/views/settings/CameraSettingsView.tsx
+++ b/web/src/views/settings/CameraSettingsView.tsx
@@ -34,23 +34,14 @@ import { getTranslatedLabel } from "@/utils/i18n";
import {
useAlertsState,
useDetectionsState,
- useEnabledState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import CameraEditForm from "@/components/settings/CameraEditForm";
-import { LuPlus } from "react-icons/lu";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
-import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type CameraSettingsViewProps = {
selectedCamera: string;
@@ -87,17 +78,10 @@ export default function CameraSettingsView({
const [editCameraName, setEditCameraName] = useState(
undefined,
); // Track camera being edited
+ const [showWizard, setShowWizard] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
- // List of cameras for dropdown
- const cameras = useMemo(() => {
- if (config) {
- return Object.keys(config.cameras).sort();
- }
- return [];
- }, [config]);
-
const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels
@@ -148,8 +132,6 @@ export default function CameraSettingsView({
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
- const { payload: enabledState, send: sendEnabled } =
- useEnabledState(selectedCamera);
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
@@ -202,9 +184,12 @@ export default function CameraSettingsView({
})
.then((res) => {
if (res.status === 200) {
- toast.success(t("camera.reviewClassification.toast.success"), {
- position: "top-center",
- });
+ toast.success(
+ t("cameraReview.reviewClassification.toast.success"),
+ {
+ position: "top-center",
+ },
+ );
updateConfig();
} else {
toast.error(
@@ -272,7 +257,7 @@ export default function CameraSettingsView({
if (changedValue) {
addMessage(
"camera_settings",
- t("camera.reviewClassification.unsavedChanges", {
+ t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
@@ -295,7 +280,7 @@ export default function CameraSettingsView({
}
useEffect(() => {
- document.title = t("documentTitle.camera");
+ document.title = t("documentTitle.cameraReview");
}, [t]);
// Handle back navigation from add/edit form
@@ -317,70 +302,11 @@ export default function CameraSettingsView({
{viewMode === "settings" ? (
<>
- {t("camera.title")}
-
-
-
- {cameras.length > 0 && (
-
- {t("camera.editCamera")}
-
-
- )}
-
-
-
-
- camera.streams.title
+ {t("cameraReview.title")}
-
-
{
- sendEnabled(isChecked ? "ON" : "OFF");
- }}
- />
-
-
- button.enabled
-
-
-
-
- camera.streams.desc
-
-
-
- camera.review.title
+ cameraReview.review.title
@@ -395,7 +321,9 @@ export default function CameraSettingsView({
/>
- camera.review.alerts
+
+ cameraReview.review.alerts
+
@@ -418,7 +346,7 @@ export default function CameraSettingsView({
- camera.review.desc
+ cameraReview.review.desc
@@ -428,7 +356,7 @@ export default function CameraSettingsView({
- camera.object_descriptions.title
+ cameraReview.object_descriptions.title
@@ -450,7 +378,7 @@ export default function CameraSettingsView({
- camera.object_descriptions.desc
+ cameraReview.object_descriptions.desc
@@ -463,7 +391,7 @@ export default function CameraSettingsView({
- camera.review_descriptions.title
+ cameraReview.review_descriptions.title
@@ -485,7 +413,7 @@ export default function CameraSettingsView({
- camera.review_descriptions.desc
+ cameraReview.review_descriptions.desc
@@ -496,7 +424,7 @@ export default function CameraSettingsView({
- camera.reviewClassification.title
+ cameraReview.reviewClassification.title
@@ -504,7 +432,7 @@ export default function CameraSettingsView({
- camera.reviewClassification.desc
+ cameraReview.reviewClassification.desc
@@ -550,7 +478,7 @@ export default function CameraSettingsView({
- camera.reviewClassification.selectAlertsZones
+ cameraReview.reviewClassification.selectAlertsZones
@@ -599,7 +527,7 @@ export default function CameraSettingsView({
) : (
- camera.reviewClassification.noDefinedZones
+ cameraReview.reviewClassification.noDefinedZones
)}
@@ -607,7 +535,7 @@ export default function CameraSettingsView({
{watchedAlertsZones && watchedAlertsZones.length > 0
? t(
- "camera.reviewClassification.zoneObjectAlertsTips",
+ "cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: watchedAlertsZones
@@ -622,7 +550,7 @@ export default function CameraSettingsView({
},
)
: t(
- "camera.reviewClassification.objectAlertsTips",
+ "cameraReview.reviewClassification.objectAlertsTips",
{
alertsLabels,
cameraName: selectCameraName,
@@ -650,7 +578,7 @@ export default function CameraSettingsView({
{selectDetections && (
- camera.reviewClassification.selectDetectionsZones
+ cameraReview.reviewClassification.selectDetectionsZones
)}
@@ -713,7 +641,7 @@ export default function CameraSettingsView({
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
- camera.reviewClassification.limitDetections
+ cameraReview.reviewClassification.limitDetections
@@ -726,7 +654,7 @@ export default function CameraSettingsView({
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
) : (
+
+
setShowWizard(false)}
+ />
>
);
}