diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 9a91bd1a9..d2c9f60be 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -3,11 +3,15 @@ import json import logging import re +from importlib.util import find_spec +from pathlib import Path from urllib.parse import quote_plus import requests -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import JSONResponse +from onvif import ONVIFCamera, ONVIFError +from zeep.exceptions import Fault, TransportError from frigate.api.auth import require_role from frigate.api.defs.tags import Tags @@ -452,3 +456,471 @@ def _extract_fps(r_frame_rate: str) -> float | None: return round(float(num) / float(den), 2) except (ValueError, ZeroDivisionError): return None + + +@router.get( + "/onvif/probe", + dependencies=[Depends(require_role(["admin"]))], + summary="Probe ONVIF device", + description=( + "Probe an ONVIF device to determine capabilities and optionally test available stream URIs. " + "Query params: host (required), port (default 80), username, password, test (boolean)." + ), +) +async def onvif_probe( + request: Request, + host: str = Query(None), + port: int = Query(80), + username: str = Query(""), + password: str = Query(""), + test: bool = Query(False), +): + """ + Probe a single ONVIF device to determine capabilities. + + Connects to an ONVIF device and queries for: + - Device information (manufacturer, model) + - Media profiles count + - PTZ support + - Available presets + - Autotracking support + + Query Parameters: + host: Device host/IP address (required) + port: Device port (default 80) + username: ONVIF username (optional) + password: ONVIF password (optional) + test: run ffprobe on the stream (optional) + + Returns: + JSON with device capabilities information + """ + if not host: + return JSONResponse( + content={"success": False, "message": "host parameter is required"}, + status_code=400, + ) + + # Validate host format + if not _is_valid_host(host): + return JSONResponse( + content={"success": False, "message": "Invalid host format"}, + status_code=400, + ) + + onvif_camera = None + + try: + logger.debug(f"Probing ONVIF device at {host}:{port}") + + try: + wsdl_base = None + spec = find_spec("onvif") + if spec and getattr(spec, "origin", None): + wsdl_base = str(Path(spec.origin).parent / "wsdl") + except Exception: + wsdl_base = None + + onvif_camera = ONVIFCamera( + host, port, username or "", password or "", wsdl_dir=wsdl_base + ) + + await onvif_camera.update_xaddrs() + + # Get device information + device_info = { + "manufacturer": "Unknown", + "model": "Unknown", + "firmware_version": "Unknown", + } + try: + device_service = await onvif_camera.create_devicemgmt_service() + device_info_resp = await device_service.GetDeviceInformation() + manufacturer = getattr(device_info_resp, "Manufacturer", None) or ( + device_info_resp.get("Manufacturer") + if isinstance(device_info_resp, dict) + else None + ) + model = getattr(device_info_resp, "Model", None) or ( + device_info_resp.get("Model") + if isinstance(device_info_resp, dict) + else None + ) + firmware = getattr(device_info_resp, "FirmwareVersion", None) or ( + device_info_resp.get("FirmwareVersion") + if isinstance(device_info_resp, dict) + else None + ) + device_info.update( + { + "manufacturer": manufacturer or "Unknown", + "model": model or "Unknown", + "firmware_version": firmware or "Unknown", + } + ) + except Exception: + logger.debug("Failed to get device info") + + # Get media profiles + profiles = [] + profiles_count = 0 + first_profile_token = None + ptz_config_token = None + try: + media_service = await onvif_camera.create_media_service() + profiles = await media_service.GetProfiles() + profiles_count = len(profiles) if profiles else 0 + if profiles and len(profiles) > 0: + p = profiles[0] + first_profile_token = getattr(p, "token", None) or ( + p.get("token") if isinstance(p, dict) else None + ) + # Get PTZ configuration token from the profile + ptz_configuration = getattr(p, "PTZConfiguration", None) or ( + p.get("PTZConfiguration") if isinstance(p, dict) else None + ) + if ptz_configuration: + ptz_config_token = getattr(ptz_configuration, "token", None) or ( + ptz_configuration.get("token") + if isinstance(ptz_configuration, dict) + else None + ) + except Exception: + logger.debug("Failed to get media profiles") + + # Check PTZ support and capabilities + ptz_supported = False + presets_count = 0 + autotrack_supported = False + + try: + ptz_service = await onvif_camera.create_ptz_service() + + # Check if PTZ service is available + try: + await ptz_service.GetServiceCapabilities() + ptz_supported = True + logger.debug("PTZ service is available") + except Exception as e: + logger.debug(f"PTZ service not available: {e}") + ptz_supported = False + + # Try to get presets if PTZ is supported and we have a profile + if ptz_supported and first_profile_token: + try: + presets_resp = await ptz_service.GetPresets( + {"ProfileToken": first_profile_token} + ) + presets_count = len(presets_resp) if presets_resp else 0 + logger.debug(f"Found {presets_count} presets") + except Exception as e: + logger.debug(f"Failed to get presets: {e}") + presets_count = 0 + + # Check for autotracking support - requires both FOV relative movement and MoveStatus + if ptz_supported and first_profile_token and ptz_config_token: + # First check for FOV relative movement support + pt_r_fov_supported = False + try: + config_request = ptz_service.create_type("GetConfigurationOptions") + config_request.ConfigurationToken = ptz_config_token + ptz_config = await ptz_service.GetConfigurationOptions( + config_request + ) + + if ptz_config: + # Check for pt-r-fov support + spaces = getattr(ptz_config, "Spaces", None) or ( + ptz_config.get("Spaces") + if isinstance(ptz_config, dict) + else None + ) + + if spaces: + rel_pan_tilt_space = getattr( + spaces, "RelativePanTiltTranslationSpace", None + ) or ( + spaces.get("RelativePanTiltTranslationSpace") + if isinstance(spaces, dict) + else None + ) + + if rel_pan_tilt_space: + # Look for FOV space + for i, space in enumerate(rel_pan_tilt_space): + uri = None + if isinstance(space, dict): + uri = space.get("URI") + else: + uri = getattr(space, "URI", None) + + if uri and "TranslationSpaceFov" in uri: + pt_r_fov_supported = True + logger.debug( + "FOV relative movement (pt-r-fov) supported" + ) + break + + logger.debug(f"PTZ config spaces: {ptz_config}") + except Exception as e: + logger.debug(f"Failed to check FOV relative movement: {e}") + pt_r_fov_supported = False + + # Now check for MoveStatus support via GetServiceCapabilities + if pt_r_fov_supported: + try: + service_capabilities_request = ptz_service.create_type( + "GetServiceCapabilities" + ) + service_capabilities = await ptz_service.GetServiceCapabilities( + service_capabilities_request + ) + + # Look for MoveStatus in the capabilities + move_status_capable = False + if service_capabilities: + # Try to find MoveStatus key recursively + def find_move_status(obj, key="MoveStatus"): + if isinstance(obj, dict): + if key in obj: + return obj[key] + for v in obj.values(): + result = find_move_status(v, key) + if result is not None: + return result + elif hasattr(obj, key): + return getattr(obj, key) + elif hasattr(obj, "__dict__"): + for v in vars(obj).values(): + result = find_move_status(v, key) + if result is not None: + return result + return None + + move_status_value = find_move_status(service_capabilities) + + # MoveStatus should return "true" if supported + if isinstance(move_status_value, bool): + move_status_capable = move_status_value + elif isinstance(move_status_value, str): + move_status_capable = ( + move_status_value.lower() == "true" + ) + + logger.debug(f"MoveStatus capability: {move_status_value}") + + # Autotracking is supported if both conditions are met + autotrack_supported = pt_r_fov_supported and move_status_capable + + if autotrack_supported: + logger.debug( + "Autotracking fully supported (pt-r-fov + MoveStatus)" + ) + else: + logger.debug( + f"Autotracking not fully supported - pt-r-fov: {pt_r_fov_supported}, MoveStatus: {move_status_capable}" + ) + except Exception as e: + logger.debug(f"Failed to check MoveStatus support: {e}") + autotrack_supported = False + + except Exception as e: + logger.debug(f"Failed to probe PTZ service: {e}") + + result = { + "success": True, + "host": host, + "port": port, + "manufacturer": device_info["manufacturer"], + "model": device_info["model"], + "firmware_version": device_info["firmware_version"], + "profiles_count": profiles_count, + "ptz_supported": ptz_supported, + "presets_count": presets_count, + "autotrack_supported": autotrack_supported, + } + + # Gather RTSP candidates + rtsp_candidates: list[dict] = [] + try: + media_service = await onvif_camera.create_media_service() + if profiles_count and media_service: + for p in profiles or []: + token = getattr(p, "token", None) or ( + p.get("token") if isinstance(p, dict) else None + ) + if not token: + continue + try: + stream_setup = { + "Stream": "RTP-Unicast", + "Transport": {"Protocol": "RTSP"}, + } + stream_req = { + "ProfileToken": token, + "StreamSetup": stream_setup, + } + stream_uri_resp = await media_service.GetStreamUri(stream_req) + uri = ( + stream_uri_resp.get("Uri") + if isinstance(stream_uri_resp, dict) + else getattr(stream_uri_resp, "Uri", None) + ) + if uri: + logger.debug( + f"GetStreamUri returned for token {token}: {uri}" + ) + # If credentials were provided, do NOT add the unauthenticated URI. + try: + if isinstance(uri, str) and uri.startswith("rtsp://"): + if username and password and "@" not in uri: + # Inject URL-encoded credentials and add only the + # authenticated version. + cred = f"{quote_plus(username)}:{quote_plus(password)}@" + injected = uri.replace( + "rtsp://", f"rtsp://{cred}", 1 + ) + rtsp_candidates.append( + { + "source": "GetStreamUri", + "profile_token": token, + "uri": injected, + } + ) + else: + # No credentials provided or URI already contains + # credentials — add the URI as returned. + rtsp_candidates.append( + { + "source": "GetStreamUri", + "profile_token": token, + "uri": uri, + } + ) + else: + # Non-RTSP URIs (e.g., http-flv) — add as returned. + rtsp_candidates.append( + { + "source": "GetStreamUri", + "profile_token": token, + "uri": uri, + } + ) + except Exception as e: + logger.debug( + f"Skipping stream URI for token {token} due to processing error: {e}" + ) + continue + except Exception: + logger.debug( + f"GetStreamUri failed for token {token}", exc_info=True + ) + continue + + # Add common RTSP patterns as fallback + if not rtsp_candidates: + common_paths = [ + "/h264", + "/live.sdp", + "/media.amp", + "/Streaming/Channels/101", + "/Streaming/Channels/1", + "/stream1", + "/cam/realmonitor?channel=1&subtype=0", + "/11", + ] + # Use URL-encoded credentials for pattern fallback URIs when provided + auth_str = ( + f"{quote_plus(username)}:{quote_plus(password)}@" + if username and password + else "" + ) + rtsp_port = 554 + for path in common_paths: + uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}" + rtsp_candidates.append({"source": "pattern", "uri": uri}) + except Exception: + logger.debug("Failed to collect RTSP candidates") + + # Optionally test RTSP candidates using ffprobe_stream + tested_candidates = [] + if test and rtsp_candidates: + for c in rtsp_candidates: + uri = c["uri"] + to_test = [uri] + try: + if ( + username + and password + and isinstance(uri, str) + and uri.startswith("rtsp://") + and "@" not in uri + ): + cred = f"{quote_plus(username)}:{quote_plus(password)}@" + cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1) + if cred_uri not in to_test: + to_test.append(cred_uri) + except Exception: + pass + + for test_uri in to_test: + try: + probe = ffprobe_stream( + request.app.frigate_config.ffmpeg, test_uri, detailed=False + ) + print(probe) + ok = probe is not None and getattr(probe, "returncode", 1) == 0 + tested_candidates.append( + { + "uri": test_uri, + "source": c.get("source"), + "ok": ok, + "profile_token": c.get("profile_token"), + } + ) + except Exception as e: + logger.debug(f"Unable to probe stream: {e}") + tested_candidates.append( + { + "uri": test_uri, + "source": c.get("source"), + "ok": False, + "profile_token": c.get("profile_token"), + } + ) + + result["rtsp_candidates"] = rtsp_candidates + if test: + result["rtsp_tested"] = tested_candidates + + logger.debug(f"ONVIF probe successful: {result}") + return JSONResponse(content=result) + + except ONVIFError as e: + logger.warning(f"ONVIF error probing {host}:{port}: {e}") + return JSONResponse( + content={"success": False, "message": "ONVIF error"}, + status_code=400, + ) + except (Fault, TransportError) as e: + logger.warning(f"Connection error probing {host}:{port}: {e}") + return JSONResponse( + content={"success": False, "message": "Connection error"}, + status_code=503, + ) + except Exception as e: + logger.warning(f"Error probing ONVIF device at {host}:{port}, {e}") + return JSONResponse( + content={"success": False, "message": "Probe failed"}, + status_code=500, + ) + + finally: + # Best-effort cleanup of ONVIF camera client session + if onvif_camera is not None: + try: + # Check if the camera has a close method and call it + if hasattr(onvif_camera, "close"): + await onvif_camera.close() + except Exception as e: + logger.debug(f"Error closing ONVIF camera session: {e}") diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 626e5385a..4443f4d0a 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -154,6 +154,7 @@ "description": "Follow the steps below to add a new camera to your Frigate installation.", "steps": { "nameAndConnection": "Name & Connection", + "probeOrSnapshot": "Probe or Snapshot", "streamConfiguration": "Stream Configuration", "validationAndTesting": "Validation & Testing" }, @@ -172,7 +173,7 @@ "testFailed": "Stream test failed: {{error}}" }, "step1": { - "description": "Enter your camera details and test the connection.", + "description": "Enter your camera details and choose to probe the camera or manually select the brand.", "cameraName": "Camera Name", "cameraNamePlaceholder": "e.g., front_door or Back Yard Overview", "host": "Host/IP Address", @@ -188,33 +189,63 @@ "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", - "testing": { - "probingMetadata": "Probing camera metadata...", - "fetchingSnapshot": "Fetching camera snapshot..." - }, - "warnings": { - "noSnapshot": "Unable to fetch a snapshot from the configured stream." - }, + "connectionSettings": "Connection Settings", + "detectionMethod": "Stream Detection Method", + "onvifPort": "ONVIF Port", + "probeMode": "Probe camera", + "manualMode": "Manual selection", + "detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".", + "onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.", "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", - "customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.", - "brands": { - "reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard." - } - }, - "docs": { - "reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras" + "customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams." } }, "step2": { + "description": "Probe the camera for available streams or configure manual settings based on your selected detection method.", + "testSuccess": "Connection test successful!", + "testFailed": "Connection test failed. Please check your input and try again.", + "testFailedTitle": "Test Failed", + "streamDetails": "Stream Details", + "probing": "Probing camera...", + "retry": "Retry", + "testing": { + "probingMetadata": "Probing camera metadata...", + "fetchingSnapshot": "Fetching camera snapshot..." + }, + "probeFailed": "Failed to probe camera: {{error}}", + "probingDevice": "Probing device...", + "probeSuccessful": "Probe successful", + "probeError": "Probe Error", + "probeNoSuccess": "Probe unsuccessful", + "deviceInfo": "Device Information", + "manufacturer": "Manufacturer", + "model": "Model", + "firmware": "Firmware", + "profiles": "Profiles", + "ptzSupport": "PTZ Support", + "autotrackingSupport": "Autotracking Support", + "presets": "Presets", + "rtspCandidates": "RTSP Candidates", + "rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Test the connection to view stream metadata.", + "noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.", + "candidateStreamTitle": "Candidate {{number}}", + "useCandidate": "Use", + "uriCopy": "Copy", + "uriCopied": "URI copied to clipboard", + "testConnection": "Test Connection", + "toggleUriView": "Click to toggle full URI view", + "connected": "Connected", + "notConnected": "Not Connected", + "errors": { + "hostRequired": "Host/IP address is required" + } + }, + "step3": { "description": "Configure stream roles and add additional streams for your camera.", "streamsTitle": "Camera Streams", "addStream": "Add Stream", @@ -222,6 +253,9 @@ "streamTitle": "Stream {{number}}", "streamUrl": "Stream URL", "streamUrlPlaceholder": "rtsp://username:password@host:port/path", + "selectStream": "Select a stream", + "searchCandidates": "Search candidates...", + "noStreamFound": "No stream found", "url": "URL", "resolution": "Resolution", "selectResolution": "Select resolution", @@ -253,7 +287,7 @@ "description": "Use go2rtc restreaming to reduce connections to your camera." } }, - "step3": { + "step4": { "description": "Final validation and analysis before saving your new camera. Connect each stream before saving.", "validationTitle": "Stream Validation", "connectAllStreams": "Connect All Streams", @@ -289,6 +323,9 @@ "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.", + "brands": { + "reolink-rtsp": "Reolink RTSP is not recommended. Enable HTTP in the camera's firmware settings and restart the wizard." + }, "dahua": { "substreamWarning": "Substream 1 is locked to a low resolution. Many Dahua / Amcrest / EmpireTech cameras support additional substreams that need to be enabled in the camera's settings. It is recommended to check and utilize those streams if available." }, diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx index 6611a9dd6..f0a739c1e 100644 --- a/web/src/components/settings/CameraWizardDialog.tsx +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -12,15 +12,15 @@ 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 Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot"; +import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig"; +import Step4Validation from "@/components/settings/wizard/Step4Validation"; import type { WizardFormData, CameraConfigData, ConfigSetBody, } from "@/types/cameraWizard"; import { processCameraName } from "@/utils/cameraUtil"; -import { isDesktop } from "react-device-detect"; import { cn } from "@/lib/utils"; type WizardState = { @@ -57,6 +57,7 @@ const wizardReducer = ( const STEPS = [ "cameraWizard.steps.nameAndConnection", + "cameraWizard.steps.probeOrSnapshot", "cameraWizard.steps.streamConfiguration", "cameraWizard.steps.validationAndTesting", ]; @@ -100,20 +101,20 @@ export default function CameraWizardDialog({ 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 - ); + // Step 1: Can proceed if camera name is set + return !!state.wizardData.cameraName; case 1: - // Can proceed if at least one stream has 'detect' role + // Step 2: Can proceed if at least one stream exists (from probe or manual test) + return (state.wizardData.streams?.length ?? 0) > 0; + case 2: + // Step 3: 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) + case 3: + // Step 4: Always can proceed from final step (save will be handled there) return true; default: return false; @@ -340,13 +341,7 @@ export default function CameraWizardDialog({ 1 && "max-w-4xl", + "scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto", )} onInteractOutside={(e) => { e.preventDefault(); @@ -385,7 +380,16 @@ export default function CameraWizardDialog({ /> )} {currentStep === 1 && ( - + )} + {currentStep === 2 && ( + )} - {currentStep === 2 && ( - void; + selectedUris?: string[]; + testCandidate?: (uri: string) => void; + candidateTests?: CandidateTestMap; + testingCandidates?: Record; +}; + +export default function OnvifProbeResults({ + isLoading, + isError, + error, + probeResult, + onRetry, + selectedUris, + testCandidate, + candidateTests, + testingCandidates, +}: OnvifProbeResultsProps) { + const { t } = useTranslation(["views/settings"]); + const [copiedUri, setCopiedUri] = useState(null); + + const handleCopyUri = (uri: string) => { + navigator.clipboard.writeText(uri); + setCopiedUri(uri); + toast.success(t("cameraWizard.step2.uriCopied")); + setTimeout(() => setCopiedUri(null), 2000); + }; + + if (isLoading) { + return ( +
+ +

+ {t("cameraWizard.step2.probingDevice")} +

+
+ ); + } + + if (isError) { + return ( +
+ + + {t("cameraWizard.step2.probeError")} + {error && {error}} + + +
+ ); + } + + if (!probeResult?.success) { + return ( +
+ + + {t("cameraWizard.step2.probeNoSuccess")} + {probeResult?.message && ( + {probeResult.message} + )} + + +
+ ); + } + + const rtspCandidates = (probeResult.rtsp_candidates || []).filter( + (c) => c.source === "GetStreamUri", + ); + + if (probeResult?.success && rtspCandidates.length === 0) { + return ( +
+ + + {t("cameraWizard.step2.noRtspCandidates")} + +
+ ); + } + + return ( + <> +
+ {probeResult?.success && ( +
+ + {t("cameraWizard.step2.probeSuccessful")} +
+ )} +
{t("cameraWizard.step2.deviceInfo")}
+ + + {probeResult.manufacturer && ( +
+ + {t("cameraWizard.step2.manufacturer")}: + {" "} + + {probeResult.manufacturer} + +
+ )} + {probeResult.model && ( +
+ + {t("cameraWizard.step2.model")}: + {" "} + + {probeResult.model} + +
+ )} + {probeResult.firmware_version && ( +
+ + {t("cameraWizard.step2.firmware")}: + {" "} + + {probeResult.firmware_version} + +
+ )} + {probeResult.profiles_count !== undefined && ( +
+ + {t("cameraWizard.step2.profiles")}: + {" "} + + {probeResult.profiles_count} + +
+ )} + {probeResult.ptz_supported !== undefined && ( +
+ + {t("cameraWizard.step2.ptzSupport")}: + {" "} + + {probeResult.ptz_supported + ? t("yes", { ns: "common" }) + : t("no", { ns: "common" })} + +
+ )} + {probeResult.ptz_supported && probeResult.autotrack_supported && ( +
+ + {t("cameraWizard.step2.autotrackingSupport")}: + {" "} + + {t("yes", { ns: "common" })} + +
+ )} + {probeResult.ptz_supported && + probeResult.presets_count !== undefined && ( +
+ + {t("cameraWizard.step2.presets")}: + {" "} + + {probeResult.presets_count} + +
+ )} +
+
+
+
+ {rtspCandidates.length > 0 && ( +
+
+ {t("cameraWizard.step2.rtspCandidates")} +
+
+ {t("cameraWizard.step2.rtspCandidatesDescription")} +
+ +
+ {rtspCandidates.map((candidate, idx) => { + const isSelected = !!selectedUris?.includes(candidate.uri); + const candidateTest = candidateTests?.[candidate.uri]; + const isTesting = testingCandidates?.[candidate.uri]; + + return ( + handleCopyUri(candidate.uri)} + isSelected={isSelected} + testCandidate={testCandidate} + candidateTest={candidateTest} + isTesting={isTesting} + /> + ); + })} +
+
+ )} +
+ + ); +} + +type CandidateItemProps = { + candidate: OnvifRtspCandidate; + index?: number; + copiedUri: string | null; + onCopy: () => void; + isSelected?: boolean; + testCandidate?: (uri: string) => void; + candidateTest?: TestResult | { success: false; error: string }; + isTesting?: boolean; +}; + +function CandidateItem({ + index, + candidate, + copiedUri, + onCopy, + isSelected, + testCandidate, + candidateTest, + isTesting, +}: CandidateItemProps) { + const { t } = useTranslation(["views/settings"]); + const [showFull, setShowFull] = useState(false); + + const maskUri = (uri: string) => { + const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/); + if (match) return `rtsp://${match[1]}:••••@${match[3]}`; + return uri; + }; + + return ( + + +
+
+
+

+ {t("cameraWizard.step2.candidateStreamTitle", { + number: (index ?? 0) + 1, + })} +

+ {candidateTest?.success && ( +
+ {[ + candidateTest.resolution, + candidateTest.fps + ? `${candidateTest.fps} ${t( + "cameraWizard.testResultLabels.fps", + )}` + : null, + candidateTest.videoCodec, + candidateTest.audioCodec, + ] + .filter(Boolean) + .join(" · ")} +
+ )} +
+ +
+ {candidateTest?.success && ( +
+ + + {t("cameraWizard.step2.connected")} + +
+ )} + + {candidateTest && !candidateTest.success && ( +
+ + + {t("cameraWizard.step2.notConnected")} + +
+ )} +
+
+ +
+

setShowFull((s) => !s)} + title={t("cameraWizard.step2.toggleUriView")} + > + {showFull ? candidate.uri : maskUri(candidate.uri)} +

+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index 8895f82f6..0467b54b8 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -15,15 +16,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 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 { @@ -31,20 +30,13 @@ import { 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"; -import { detectReolinkCamera } from "@/utils/cameraUtil"; type Step1NameCameraProps = { wizardData: Partial; @@ -63,9 +55,9 @@ export default function Step1NameCamera({ const { t } = useTranslation(["views/settings"]); const { data: config } = useSWR("config"); const [showPassword, setShowPassword] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [testStatus, setTestStatus] = useState(""); - const [testResult, setTestResult] = useState(null); + const [probeMode, setProbeMode] = useState( + wizardData.probeMode ?? true, + ); const existingCameraNames = useMemo(() => { if (!config?.cameras) { @@ -88,6 +80,7 @@ export default function Step1NameCamera({ username: z.string().optional(), password: z.string().optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), + onvifPort: z.coerce.number().int().min(1).max(65535).optional(), customUrl: z .string() .optional() @@ -124,6 +117,7 @@ export default function Step1NameCamera({ ? (wizardData.brandTemplate as CameraBrand) : "dahua", customUrl: wizardData.customUrl || "", + onvifPort: wizardData.onvifPort ?? 80, }, mode: "onChange", }); @@ -132,271 +126,212 @@ export default function Step1NameCamera({ const watchedHost = form.watch("host"); const watchedCustomUrl = form.watch("customUrl"); - const isTestButtonEnabled = - watchedBrand === "other" - ? !!(watchedCustomUrl && watchedCustomUrl.trim()) - : !!(watchedHost && watchedHost.trim()); + const hostPresent = !!(watchedHost && watchedHost.trim()); + const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim()); + const cameraNamePresent = !!(form.getValues().cameraName || "").trim(); - const generateDynamicStreamUrl = useCallback( - async (data: z.infer): Promise => { - const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); - if (!brand || !data.host) return null; - - let protocol = undefined; - if (data.brandTemplate === "reolink" && data.username && data.password) { - try { - protocol = await detectReolinkCamera( - data.host, - data.username, - data.password, - ); - } catch (error) { - return null; - } - } - - // Use detected protocol or fallback to rtsp - const protocolKey = protocol || "rtsp"; - const templates: Record = brand.dynamicTemplates || {}; - - if (Object.keys(templates).includes(protocolKey)) { - const template = - templates[protocolKey as keyof typeof brand.dynamicTemplates]; - return template - .replace("{username}", data.username || "") - .replace("{password}", data.password || "") - .replace("{host}", data.host); - } - - return null; - }, - [], - ); - - const generateStreamUrl = useCallback( - async (data: z.infer): Promise => { - if (data.brandTemplate === "other") { - return data.customUrl || ""; - } - - const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); - if (!brand || !data.host) return ""; - - if (brand.template === "dynamic" && "dynamicTemplates" in brand) { - const dynamicUrl = await generateDynamicStreamUrl(data); - - if (dynamicUrl) { - return dynamicUrl; - } - - return ""; - } - - return brand.template - .replace("{username}", data.username || "") - .replace("{password}", data.password || "") - .replace("{host}", data.host); - }, - [generateDynamicStreamUrl], - ); - - const testConnection = useCallback(async () => { - const data = form.getValues(); - const streamUrl = await generateStreamUrl(data); - - if (!streamUrl) { - toast.error(t("cameraWizard.commonErrors.noUrl")); - return; - } - - setIsTesting(true); - setTestStatus(""); - setTestResult(null); - - try { - // First get probe data for metadata - setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); - const probeResponse = await axios.get("ffprobe", { - params: { paths: streamUrl, detailed: true }, - timeout: 10000, - }); - - 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) { - setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot")); - try { - const snapshotResponse = await axios.get("ffprobe/snapshot", { - params: { url: streamUrl }, - responseType: "blob", - timeout: 10000, - }); - 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("hevc"), - ); - - const audioStream = streams.find( - (s: FfprobeStream) => - s.codec_type === "audio" || - s.codec_name?.includes("aac") || - s.codec_name?.includes("mp3") || - s.codec_name?.includes("pcm_mulaw") || - s.codec_name?.includes("pcm_alaw"), - ); - - const resolution = videoStream - ? `${videoStream.width}x${videoStream.height}` - : undefined; - - // Extract FPS from rational (e.g., "15/1" -> 15) - const fps = videoStream?.avg_frame_rate - ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / - parseFloat(videoStream.avg_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); - onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] }); - toast.success(t("cameraWizard.step1.testSuccess")); - } else { - const error = - Array.isArray(probeResponse.data?.[0]?.stderr) && - probeResponse.data[0].stderr.length > 0 - ? probeResponse.data[0].stderr.join("\n") - : "Unable to probe stream"; - setTestResult({ - success: false, - error: error, - }); - toast.error(t("cameraWizard.commonErrors.testFailed", { error }), { - duration: 6000, - }); - } - } 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 }), - { - duration: 10000, - }, - ); - } finally { - setIsTesting(false); - setTestStatus(""); - } - }, [form, generateStreamUrl, t, onUpdate]); + const isContinueButtonEnabled = + cameraNamePresent && + (probeMode + ? hostPresent + : watchedBrand === "other" + ? customPresent + : hostPresent); const onSubmit = (data: z.infer) => { - onUpdate(data); + onUpdate({ ...data, probeMode }); }; const handleContinue = useCallback(async () => { - const data = form.getValues(); - const streamUrl = await 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]); + const isValid = await form.trigger(); + if (isValid) { + const data = form.getValues(); + onNext({ ...data, probeMode }); + } + }, [form, probeMode, onNext]); return (
- {!testResult?.success && ( - <> -
- {t("cameraWizard.step1.description")} -
+
+ {t("cameraWizard.step1.description")} +
-
- - ( - - - {t("cameraWizard.step1.cameraName")} - - + + + ( + + + {t("cameraWizard.step1.cameraName")} + + + + + + + )} + /> + +
+ ( + + + {t("cameraWizard.step1.host")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.username")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.password")} + + +
- - - - )} - /> + +
+
+ +
+ )} + /> +
+
+ + {t("cameraWizard.step1.detectionMethod")} + + { + setProbeMode(value === "probe"); + }} + > +
+ + +
+
+ + +
+
+ + {t("cameraWizard.step1.detectionMethodDescription")} + +
+ + {probeMode && ( + ( + + + {t("cameraWizard.step1.onvifPort")} + + + + + + {t("cameraWizard.step1.onvifPortDescription")} + + + {fieldState.error ? fieldState.error.message : null} + + + )} + /> + )} + + {!probeMode && ( +
- {watchedBrand !== "other" && ( - <> - ( - - - {t("cameraWizard.step1.host")} - - - - - - - )} - /> - - ( - - - {t("cameraWizard.step1.username")} - - - - - - - )} - /> - - ( - - - {t("cameraWizard.step1.password")} - - -
- - -
-
- -
- )} - /> - - )} - {watchedBrand == "other" && ( )} - - - - )} +
+ )} + + - {testResult?.success && ( -
-
- - {t("cameraWizard.step1.testSuccess")} -
- -
- {testResult.snapshot ? ( -
- Camera snapshot -
-
- -
-
-
- ) : ( - - - {t("cameraWizard.step1.streamDetails")} - - - - - - )} -
-
- )} - - {isTesting && ( -
- - {testStatus} -
- )}
+ - {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/Step2ProbeOrSnapshot.tsx b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx new file mode 100644 index 000000000..6cd5c5c9b --- /dev/null +++ b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx @@ -0,0 +1,724 @@ +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import { useState, useCallback, useEffect } from "react"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import axios from "axios"; +import { toast } from "sonner"; +import type { + WizardFormData, + TestResult, + StreamConfig, + StreamRole, + OnvifProbeResponse, + CandidateTestMap, + FfprobeStream, + FfprobeData, + FfprobeResponse, +} from "@/types/cameraWizard"; +import { FaCircleCheck } from "react-icons/fa6"; +import { Card, CardContent, CardTitle } from "../../ui/card"; +import OnvifProbeResults from "./OnvifProbeResults"; +import { CAMERA_BRANDS } from "@/types/cameraWizard"; +import { detectReolinkCamera } from "@/utils/cameraUtil"; + +type Step2ProbeOrSnapshotProps = { + wizardData: Partial; + onUpdate: (data: Partial) => void; + onNext: (data?: Partial) => void; + onBack: () => void; + probeMode: boolean; +}; + +export default function Step2ProbeOrSnapshot({ + wizardData, + onUpdate, + onNext, + onBack, + probeMode, +}: Step2ProbeOrSnapshotProps) { + const { t } = useTranslation(["views/settings"]); + const [isTesting, setIsTesting] = useState(false); + const [testStatus, setTestStatus] = useState(""); + const [testResult, setTestResult] = useState(null); + const [isProbing, setIsProbing] = useState(false); + const [probeError, setProbeError] = useState(null); + const [probeResult, setProbeResult] = useState( + null, + ); + const [testingCandidates, setTestingCandidates] = useState< + Record + >({} as Record); + const [candidateTests, setCandidateTests] = useState( + {} as CandidateTestMap, + ); + + const probeUri = useCallback( + async ( + uri: string, + fetchSnapshot = false, + setStatus?: (s: string) => void, + ): Promise => { + try { + const probeResponse = await axios.get("ffprobe", { + params: { paths: uri, detailed: true }, + timeout: 10000, + }); + + let probeData: FfprobeResponse | null = null; + if ( + probeResponse.data && + probeResponse.data.length > 0 && + probeResponse.data[0].return_code === 0 + ) { + probeData = probeResponse.data[0]; + } + + if (!probeData) { + const error = + Array.isArray(probeResponse.data?.[0]?.stderr) && + probeResponse.data[0].stderr.length > 0 + ? probeResponse.data[0].stderr.join("\n") + : "Unable to probe stream"; + return { success: false, error }; + } + + let ffprobeData: FfprobeData; + if (typeof probeData.stdout === "string") { + try { + ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData; + } catch { + ffprobeData = { streams: [] }; + } + } else { + ffprobeData = probeData.stdout as FfprobeData; + } + + const streams = ffprobeData.streams || []; + + const videoStream = streams.find( + (s: FfprobeStream) => + s.codec_type === "video" || + s.codec_name?.includes("h264") || + s.codec_name?.includes("hevc"), + ); + + const audioStream = streams.find( + (s: FfprobeStream) => + s.codec_type === "audio" || + s.codec_name?.includes("aac") || + s.codec_name?.includes("mp3") || + s.codec_name?.includes("pcm_mulaw") || + s.codec_name?.includes("pcm_alaw"), + ); + + let resolution: string | undefined = undefined; + if (videoStream) { + const width = Number(videoStream.width || 0); + const height = Number(videoStream.height || 0); + if (width > 0 && height > 0) { + resolution = `${width}x${height}`; + } + } + + const fps = videoStream?.avg_frame_rate + ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / + parseFloat(videoStream.avg_frame_rate.split("/")[1]) + : undefined; + + let snapshotBase64: string | undefined = undefined; + if (fetchSnapshot) { + if (setStatus) { + setStatus(t("cameraWizard.step2.testing.fetchingSnapshot")); + } + try { + const snapshotResponse = await axios.get("ffprobe/snapshot", { + params: { url: uri }, + responseType: "blob", + timeout: 10000, + }); + const snapshotBlob = snapshotResponse.data; + snapshotBase64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(snapshotBlob); + }); + } catch (snapshotError) { + snapshotBase64 = undefined; + } + } + + const streamTestResult: TestResult = { + success: true, + snapshot: snapshotBase64, + resolution, + videoCodec: videoStream?.codec_name, + audioCodec: audioStream?.codec_name, + fps: fps && !isNaN(fps) ? fps : undefined, + }; + + return streamTestResult; + } catch (err) { + const axiosError = err 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 }; + } + }, + [t], + ); + + const probeCamera = useCallback(async () => { + if (!wizardData.host) { + toast.error(t("cameraWizard.step2.errors.hostRequired")); + return; + } + + setIsProbing(true); + setProbeError(null); + setProbeResult(null); + + try { + const response = await axios.get("/onvif/probe", { + params: { + host: wizardData.host, + port: wizardData.onvifPort ?? 80, + username: wizardData.username || "", + password: wizardData.password || "", + test: false, + }, + timeout: 30000, + }); + + if (response.data && response.data.success) { + setProbeResult(response.data); + // Extract candidate URLs and pass to wizardData + const candidateUris = (response.data.rtsp_candidates || []) + .filter((c: { source: string }) => c.source === "GetStreamUri") + .map((c: { uri: string }) => c.uri); + onUpdate({ + probeMode: true, + probeCandidates: candidateUris, + candidateTests: {}, + }); + } else { + setProbeError(response.data?.message || "Probe failed"); + } + } 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 || + "Failed to probe camera"; + setProbeError(errorMessage); + toast.error(t("cameraWizard.step2.probeFailed", { error: errorMessage })); + } finally { + setIsProbing(false); + } + }, [wizardData, onUpdate, t]); + + const testAllSelectedCandidates = useCallback(async () => { + const uris = (probeResult?.rtsp_candidates || []) + .filter((c) => c.source === "GetStreamUri") + .map((c) => c.uri); + + if (!uris || uris.length === 0) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + // Prepare an initial stream so the wizard can proceed to step 3. + // Use the first candidate as the initial stream (no extra probing here). + const streamsToCreate: StreamConfig[] = []; + if (uris.length > 0) { + const first = uris[0]; + streamsToCreate.push({ + id: `stream_${Date.now()}`, + url: first, + roles: ["detect" as const], + testResult: candidateTests[first], + }); + } + + // Use existing candidateTests state (may contain entries from individual tests) + onNext({ + probeMode: true, + probeCandidates: uris, + candidateTests: candidateTests, + streams: streamsToCreate, + }); + }, [probeResult, candidateTests, onNext, t]); + + const testCandidate = useCallback( + async (uri: string) => { + if (!uri) return; + setTestingCandidates((s) => ({ ...s, [uri]: true })); + try { + const result = await probeUri(uri, false); + setCandidateTests((s) => ({ ...s, [uri]: result })); + } finally { + setTestingCandidates((s) => ({ ...s, [uri]: false })); + } + }, + [probeUri], + ); + + const generateDynamicStreamUrl = useCallback( + async (data: Partial): Promise => { + const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); + if (!brand || !data.host) return null; + + let protocol = undefined; + if (data.brandTemplate === "reolink" && data.username && data.password) { + try { + protocol = await detectReolinkCamera( + data.host, + data.username, + data.password, + ); + } catch (error) { + return null; + } + } + + const protocolKey = protocol || "rtsp"; + const templates: Record = brand.dynamicTemplates || {}; + + if (Object.keys(templates).includes(protocolKey)) { + const template = + templates[protocolKey as keyof typeof brand.dynamicTemplates]; + return template + .replace("{username}", data.username || "") + .replace("{password}", data.password || "") + .replace("{host}", data.host); + } + + return null; + }, + [], + ); + + const generateStreamUrl = useCallback( + async (data: Partial): Promise => { + if (data.brandTemplate === "other") { + return data.customUrl || ""; + } + + const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); + if (!brand || !data.host) return ""; + + if (brand.template === "dynamic" && "dynamicTemplates" in brand) { + const dynamicUrl = await generateDynamicStreamUrl(data); + + if (dynamicUrl) { + return dynamicUrl; + } + + return ""; + } + + return brand.template + .replace("{username}", data.username || "") + .replace("{password}", data.password || "") + .replace("{host}", data.host); + }, + [generateDynamicStreamUrl], + ); + + const testConnection = useCallback( + async (showToast = true) => { + const streamUrl = await generateStreamUrl(wizardData); + + if (!streamUrl) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + setIsTesting(true); + setTestStatus(""); + setTestResult(null); + + try { + setTestStatus(t("cameraWizard.step2.testing.probingMetadata")); + const result = await probeUri(streamUrl, true, setTestStatus); + + if (result && result.success) { + setTestResult(result); + const streamId = `stream_${Date.now()}`; + onUpdate({ + streams: [ + { + id: streamId, + url: streamUrl, + roles: ["detect"] as StreamRole[], + testResult: result, + }, + ], + }); + + if (showToast) { + toast.success(t("cameraWizard.step2.testSuccess")); + } + } else { + const errMsg = result?.error || "Unable to probe stream"; + setTestResult({ + success: false, + error: errMsg, + }); + + if (showToast) { + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errMsg }), + { + duration: 6000, + }, + ); + } + } + } 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, + }); + + if (showToast) { + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), + { + duration: 10000, + }, + ); + } + } finally { + setIsTesting(false); + setTestStatus(""); + } + }, + [wizardData, generateStreamUrl, t, onUpdate, probeUri], + ); + + const handleContinue = useCallback(() => { + onNext(); + }, [onNext]); + + // Auto-start probe or test when step loads + const [hasStarted, setHasStarted] = useState(false); + + useEffect(() => { + if (!hasStarted) { + setHasStarted(true); + if (probeMode) { + probeCamera(); + } else { + // Auto-run the connection test but suppress toasts to avoid duplicates + testConnection(false); + } + } + }, [hasStarted, probeMode, probeCamera, testConnection]); + + return ( +
+ {probeMode ? ( + // Probe mode: show probe results directly + <> + {probeResult && ( +
+ +
+ )} + + v) + } + candidateCount={ + (probeResult?.rtsp_candidates || []).filter( + (c) => c.source === "GetStreamUri", + ).length + } + /> + + ) : ( + // Manual mode: show snapshot and stream details + <> + {testResult?.success && ( +
+
+ + {t("cameraWizard.step2.testSuccess")} +
+ +
+ {testResult.snapshot ? ( +
+ Camera snapshot +
+
+ +
+
+
+ ) : ( + + + {t("cameraWizard.step2.streamDetails")} + + + + + + )} +
+
+ )} + + {isTesting && ( +
+ + {testStatus} +
+ )} + + {testResult && !testResult.success && ( +
+
{testResult.error}
+
+ )} + + v) + } + candidateCount={ + (probeResult?.rtsp_candidates || []).filter( + (c) => c.source === "GetStreamUri", + ).length + } + manualTestSuccess={!!testResult?.success} + onContinue={handleContinue} + onManualTest={testConnection} + /> + + )} +
+ ); +} + +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} +
+ )} + + ); +} + +type ProbeFooterProps = { + isProbing: boolean; + probeError: string | null; + onBack: () => void; + onTestAll: () => void; + onRetry: () => void; + isTesting: boolean; + candidateCount?: number; + mode?: "probe" | "manual"; + manualTestSuccess?: boolean; + onContinue?: () => void; + onManualTest?: () => void; +}; + +function ProbeFooterButtons({ + isProbing, + probeError, + onBack, + onTestAll, + onRetry, + isTesting, + candidateCount = 0, + mode = "probe", + manualTestSuccess, + onContinue, + onManualTest, +}: ProbeFooterProps) { + const { t } = useTranslation(["views/settings"]); + + // Loading footer + if (isProbing) { + return ( +
+
+ + {t("cameraWizard.step2.probing")} +
+
+ + +
+
+ ); + } + + // Error footer + if (probeError) { + return ( +
+
{probeError}
+
+ + +
+
+ ); + } + + // Default footer: show back + test (test disabled if none selected or testing) + // If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest) + if (mode === "manual") { + return ( +
+ + {manualTestSuccess ? ( + + ) : ( + + )} +
+ ); + } + + // Default probe footer + return ( +
+ + +
+ ); +} diff --git a/web/src/components/settings/wizard/Step2StreamConfig.tsx b/web/src/components/settings/wizard/Step2StreamConfig.tsx deleted file mode 100644 index a9cb00c2e..000000000 --- a/web/src/components/settings/wizard/Step2StreamConfig.tsx +++ /dev/null @@ -1,481 +0,0 @@ -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?.avg_frame_rate - ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / - parseFloat(videoStream.avg_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 stream = streams.find((s) => s.id === streamId); - if (!stream) return; - - updateStream(streamId, { restream: !stream.restream }); - }, - [streams, updateStream], - ); - - 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 && ( - - )} -
-
- -
-
- -
- - 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.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.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/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx new file mode 100644 index 000000000..f99ba90c0 --- /dev/null +++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx @@ -0,0 +1,757 @@ +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, + FfprobeData, + FfprobeResponse, + CandidateTestMap, +} from "@/types/cameraWizard"; +import { Label } from "../../ui/label"; +import { FaCircleCheck } from "react-icons/fa6"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; +import { isMobile } from "react-device-detect"; +import { + LuInfo, + LuExternalLink, + LuCheck, + LuChevronsUpDown, +} from "react-icons/lu"; +import { Link } from "react-router-dom"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import { cn } from "@/lib/utils"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; + +type Step3StreamConfigProps = { + wizardData: Partial; + onUpdate: (data: Partial) => void; + onBack?: () => void; + onNext?: () => void; + canProceed?: boolean; +}; + +export default function Step3StreamConfig({ + wizardData, + onUpdate, + onBack, + onNext, + canProceed, +}: Step3StreamConfigProps) { + const { t } = useTranslation(["views/settings", "components/dialog"]); + const { getLocaleDocUrl } = useDocDomain(); + const [testingStreams, setTestingStreams] = useState>(new Set()); + const [openCombobox, setOpenCombobox] = useState(null); + + const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]); + + // Probe mode candidate tracking + const probeCandidates = useMemo( + () => (wizardData.probeCandidates || []) as string[], + [wizardData.probeCandidates], + ); + + const candidateTests = useMemo( + () => (wizardData.candidateTests || {}) as CandidateTestMap, + [wizardData.candidateTests], + ); + + const isProbeMode = !!wizardData.probeMode; + + const addStream = useCallback(() => { + const newStreamId = `stream_${Date.now()}`; + + let initialUrl = ""; + if (isProbeMode && probeCandidates.length > 0) { + // pick first candidate not already used + const used = new Set(streams.map((s) => s.url).filter(Boolean)); + const firstAvailable = probeCandidates.find((c) => !used.has(c)); + if (firstAvailable) { + initialUrl = firstAvailable; + } + } + + const newStream: StreamConfig = { + id: newStreamId, + url: initialUrl, + roles: [], + testResult: initialUrl ? candidateTests[initialUrl] : undefined, + userTested: initialUrl ? !!candidateTests[initialUrl] : false, + }; + + onUpdate({ + streams: [...streams, newStream], + }); + }, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]); + + 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 getUsedUrlsExcludingStream = useCallback( + (excludeStreamId: string) => { + const used = new Set(); + streams.forEach((s) => { + if (s.id !== excludeStreamId && s.url) { + used.add(s.url); + } + }); + return used; + }, + [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( + async (stream: StreamConfig) => { + if (!stream.url.trim()) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + setTestingStreams((prev) => new Set(prev).add(stream.id)); + + try { + const response = await axios.get("ffprobe", { + params: { paths: stream.url, detailed: true }, + timeout: 10000, + }); + + let probeData: FfprobeResponse | null = null; + if ( + response.data && + response.data.length > 0 && + response.data[0].return_code === 0 + ) { + probeData = response.data[0]; + } + + if (!probeData) { + const error = + Array.isArray(response.data?.[0]?.stderr) && + response.data[0].stderr.length > 0 + ? response.data[0].stderr.join("\n") + : "Unable to probe stream"; + const failResult: TestResult = { success: false, error }; + updateStream(stream.id, { testResult: failResult, userTested: true }); + onUpdate({ + candidateTests: { + ...(wizardData.candidateTests || {}), + [stream.url]: failResult, + } as CandidateTestMap, + }); + toast.error(t("cameraWizard.commonErrors.testFailed", { error })); + return; + } + + let ffprobeData: FfprobeData; + if (typeof probeData.stdout === "string") { + try { + ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData; + } catch { + ffprobeData = { streams: [] } as FfprobeData; + } + } else { + ffprobeData = probeData.stdout as FfprobeData; + } + + const streamsArr = ffprobeData.streams || []; + + const videoStream = streamsArr.find( + (s: FfprobeStream) => + s.codec_type === "video" || + s.codec_name?.includes("h264") || + s.codec_name?.includes("hevc"), + ); + + const audioStream = streamsArr.find( + (s: FfprobeStream) => + s.codec_type === "audio" || + s.codec_name?.includes("aac") || + s.codec_name?.includes("mp3") || + s.codec_name?.includes("pcm_mulaw") || + s.codec_name?.includes("pcm_alaw"), + ); + + let resolution: string | undefined = undefined; + if (videoStream) { + const width = Number(videoStream.width || 0); + const height = Number(videoStream.height || 0); + if (width > 0 && height > 0) { + resolution = `${width}x${height}`; + } + } + + const fps = videoStream?.avg_frame_rate + ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / + parseFloat(videoStream.avg_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 }); + onUpdate({ + candidateTests: { + ...(wizardData.candidateTests || {}), + [stream.url]: testResult, + } as CandidateTestMap, + }); + toast.success(t("cameraWizard.step3.testSuccess")); + } 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"; + const catchResult: TestResult = { + success: false, + error: errorMessage, + }; + updateStream(stream.id, { testResult: catchResult, userTested: true }); + onUpdate({ + candidateTests: { + ...(wizardData.candidateTests || {}), + [stream.url]: catchResult, + } as CandidateTestMap, + }); + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), + ); + } finally { + setTestingStreams((prev) => { + const newSet = new Set(prev); + newSet.delete(stream.id); + return newSet; + }); + } + }, + [updateStream, t, onUpdate, wizardData.candidateTests], + ); + + const setRestream = useCallback( + (streamId: string) => { + const stream = streams.find((s) => s.id === streamId); + if (!stream) return; + + updateStream(streamId, { restream: !stream.restream }); + }, + [streams, updateStream], + ); + + const hasDetectRole = streams.some((s) => s.roles.includes("detect")); + + return ( +
+
+ {t("cameraWizard.step3.description")} +
+ +
+ {streams.map((stream, index) => ( + + +
+
+

+ {t("cameraWizard.step3.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.step3.connected")} + +
+ )} + {stream.testResult && !stream.testResult.success && ( +
+ + + {t("cameraWizard.step3.notConnected")} + +
+ )} + {streams.length > 1 && ( + + )} +
+
+ +
+
+ +
+ {isProbeMode && probeCandidates.length > 0 ? ( + // Responsive: Popover on desktop, Drawer on mobile + !isMobile ? ( + { + setOpenCombobox(isOpen ? stream.id : null); + }} + > + +
+ +
+
+ + + + + + {t("cameraWizard.step3.noStreamFound")} + + + {probeCandidates + .filter((c) => { + const used = getUsedUrlsExcludingStream( + stream.id, + ); + return !used.has(c); + }) + .map((candidate) => ( + { + updateStream(stream.id, { + url: candidate, + testResult: + candidateTests[candidate] || + undefined, + userTested: + !!candidateTests[candidate], + }); + setOpenCombobox(null); + }} + > + + {candidate} + + ))} + + + + +
+ ) : ( + + setOpenCombobox(isOpen ? stream.id : null) + } + > + +
+ +
+
+ +
+ + + + + {t("cameraWizard.step3.noStreamFound")} + + + {probeCandidates + .filter((c) => { + const used = getUsedUrlsExcludingStream( + stream.id, + ); + return !used.has(c); + }) + .map((candidate) => ( + { + updateStream(stream.id, { + url: candidate, + testResult: + candidateTests[candidate] || + undefined, + userTested: + !!candidateTests[candidate], + }); + setOpenCombobox(null); + }} + > + + {candidate} + + ))} + + + +
+
+
+ ) + ) : ( + + updateStream(stream.id, { + url: e.target.value, + testResult: undefined, + }) + } + className="h-8 flex-1" + placeholder={t( + "cameraWizard.step3.streamUrlPlaceholder", + )} + /> + )} + +
+
+
+ + {stream.testResult && + !stream.testResult.success && + stream.userTested && ( +
+
+ {t("cameraWizard.step3.testFailedTitle")} +
+
+ {stream.testResult.error} +
+
+ )} + +
+
+ + + + + + +
+
+ {t("cameraWizard.step3.rolesPopover.title")} +
+
+
+ detect -{" "} + {t("cameraWizard.step3.rolesPopover.detect")} +
+
+ record -{" "} + {t("cameraWizard.step3.rolesPopover.record")} +
+
+ audio -{" "} + {t("cameraWizard.step3.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.step3.featuresPopover.title")} +
+
+ {t("cameraWizard.step3.featuresPopover.description")} +
+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+
+
+
+
+ + {t("cameraWizard.step3.go2rtc")} + + setRestream(stream.id)} + /> +
+
+
+
+
+ ))} + + +
+ + {!hasDetectRole && ( +
+ {t("cameraWizard.step3.detectRoleWarning")} +
+ )} + +
+ {onBack && ( + + )} + {onNext && ( + + )} +
+
+ ); +} diff --git a/web/src/components/settings/wizard/Step3Validation.tsx b/web/src/components/settings/wizard/Step4Validation.tsx similarity index 92% rename from web/src/components/settings/wizard/Step3Validation.tsx rename to web/src/components/settings/wizard/Step4Validation.tsx index a0dd72e7e..f99a05305 100644 --- a/web/src/components/settings/wizard/Step3Validation.tsx +++ b/web/src/components/settings/wizard/Step4Validation.tsx @@ -19,7 +19,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; import { LuX } from "react-icons/lu"; import { Card, CardContent } from "../../ui/card"; -type Step3ValidationProps = { +type Step4ValidationProps = { wizardData: Partial; onUpdate: (data: Partial) => void; onSave: (config: WizardFormData) => void; @@ -27,13 +27,13 @@ type Step3ValidationProps = { isLoading?: boolean; }; -export default function Step3Validation({ +export default function Step4Validation({ wizardData, onUpdate, onSave, onBack, isLoading = false, -}: Step3ValidationProps) { +}: Step4ValidationProps) { const { t } = useTranslation(["views/settings"]); const [isValidating, setIsValidating] = useState(false); const [testingStreams, setTestingStreams] = useState>(new Set()); @@ -143,13 +143,13 @@ export default function Step3Validation({ if (testResult.success) { toast.success( - t("cameraWizard.step3.streamValidated", { + t("cameraWizard.step4.streamValidated", { number: streams.findIndex((s) => s.id === stream.id) + 1, }), ); } else { toast.error( - t("cameraWizard.step3.streamValidationFailed", { + t("cameraWizard.step4.streamValidationFailed", { number: streams.findIndex((s) => s.id === stream.id) + 1, }), ); @@ -200,16 +200,16 @@ export default function Step3Validation({ (r) => r.success, ).length; if (successfulTests === results.size) { - toast.success(t("cameraWizard.step3.reconnectionSuccess")); + toast.success(t("cameraWizard.step4.reconnectionSuccess")); } else { - toast.warning(t("cameraWizard.step3.reconnectionPartial")); + toast.warning(t("cameraWizard.step4.reconnectionPartial")); } } }, [streams, onUpdate, t, performStreamValidation]); const handleSave = useCallback(() => { if (!wizardData.cameraName || !wizardData.streams?.length) { - toast.error(t("cameraWizard.step3.saveError")); + toast.error(t("cameraWizard.step4.saveError")); return; } @@ -239,13 +239,13 @@ export default function Step3Validation({ return (
- {t("cameraWizard.step3.description")} + {t("cameraWizard.step4.description")}

- {t("cameraWizard.step3.validationTitle")} + {t("cameraWizard.step4.validationTitle")}

@@ -270,7 +270,7 @@ export default function Step3Validation({

- {t("cameraWizard.step3.streamTitle", { + {t("cameraWizard.step4.streamTitle", { number: index + 1, })}

@@ -331,7 +331,7 @@ export default function Step3Validation({
- {t("cameraWizard.step3.ffmpegModule")} + {t("cameraWizard.step4.ffmpegModule")} @@ -346,11 +346,11 @@ export default function Step3Validation({
- {t("cameraWizard.step3.ffmpegModule")} + {t("cameraWizard.step4.ffmpegModule")}
{t( - "cameraWizard.step3.ffmpegModuleDescription", + "cameraWizard.step4.ffmpegModuleDescription", )}
@@ -402,17 +402,17 @@ export default function Step3Validation({ )} {result?.success - ? t("cameraWizard.step3.disconnectStream") + ? t("cameraWizard.step4.disconnectStream") : testingStreams.has(stream.id) - ? t("cameraWizard.step3.connectingStream") - : t("cameraWizard.step3.connectStream")} + ? t("cameraWizard.step4.connectingStream") + : t("cameraWizard.step4.connectStream")}
{result && (
- {t("cameraWizard.step3.issues.title")} + {t("cameraWizard.step4.issues.title")}
} {isLoading ? t("button.saving", { ns: "common" }) - : t("cameraWizard.step3.saveAndApply")} + : t("cameraWizard.step4.saveAndApply")}
@@ -486,7 +486,7 @@ function StreamIssues({ if (streamUrl.startsWith("rtsp://")) { result.push({ type: "warning", - message: t("cameraWizard.step1.errors.brands.reolink-rtsp"), + message: t("cameraWizard.step4.issues.brands.reolink-rtsp"), }); } } @@ -497,7 +497,7 @@ function StreamIssues({ if (["h264", "h265", "hevc"].includes(videoCodec)) { result.push({ type: "good", - message: t("cameraWizard.step3.issues.videoCodecGood", { + message: t("cameraWizard.step4.issues.videoCodecGood", { codec: stream.testResult.videoCodec, }), }); @@ -511,20 +511,20 @@ function StreamIssues({ if (audioCodec === "aac") { result.push({ type: "good", - message: t("cameraWizard.step3.issues.audioCodecGood", { + message: t("cameraWizard.step4.issues.audioCodecGood", { codec: stream.testResult.audioCodec, }), }); } else { result.push({ type: "error", - message: t("cameraWizard.step3.issues.audioCodecRecordError"), + message: t("cameraWizard.step4.issues.audioCodecRecordError"), }); } } else { result.push({ type: "warning", - message: t("cameraWizard.step3.issues.noAudioWarning"), + message: t("cameraWizard.step4.issues.noAudioWarning"), }); } } @@ -534,7 +534,7 @@ function StreamIssues({ if (!stream.testResult?.audioCodec) { result.push({ type: "error", - message: t("cameraWizard.step3.issues.audioCodecRequired"), + message: t("cameraWizard.step4.issues.audioCodecRequired"), }); } } @@ -544,7 +544,7 @@ function StreamIssues({ if (stream.restream) { result.push({ type: "warning", - message: t("cameraWizard.step3.issues.restreamingWarning"), + message: t("cameraWizard.step4.issues.restreamingWarning"), }); } } @@ -557,14 +557,14 @@ function StreamIssues({ if (minDimension > 1080) { result.push({ type: "warning", - message: t("cameraWizard.step3.issues.resolutionHigh", { + message: t("cameraWizard.step4.issues.resolutionHigh", { resolution: stream.resolution, }), }); } else if (maxDimension < 640) { result.push({ type: "error", - message: t("cameraWizard.step3.issues.resolutionLow", { + message: t("cameraWizard.step4.issues.resolutionLow", { resolution: stream.resolution, }), }); @@ -580,7 +580,7 @@ function StreamIssues({ ) { result.push({ type: "warning", - message: t("cameraWizard.step3.issues.dahua.substreamWarning"), + message: t("cameraWizard.step4.issues.dahua.substreamWarning"), }); } if ( @@ -590,7 +590,7 @@ function StreamIssues({ ) { result.push({ type: "warning", - message: t("cameraWizard.step3.issues.hikvision.substreamWarning"), + message: t("cameraWizard.step4.issues.hikvision.substreamWarning"), }); } @@ -662,7 +662,7 @@ function BandwidthDisplay({ return (
- {t("cameraWizard.step3.estimatedBandwidth")}: + {t("cameraWizard.step4.estimatedBandwidth")}: {" "} {streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })} @@ -748,7 +748,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) { style={{ aspectRatio }} > - {t("cameraWizard.step3.streamUnavailable")} + {t("cameraWizard.step4.streamUnavailable")}
); @@ -771,7 +771,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) { > - {t("cameraWizard.step3.connecting")} + {t("cameraWizard.step4.connecting")}
); diff --git a/web/src/types/cameraWizard.ts b/web/src/types/cameraWizard.ts index a37eafafc..cea0f4dcc 100644 --- a/web/src/types/cameraWizard.ts +++ b/web/src/types/cameraWizard.ts @@ -99,6 +99,11 @@ export type TestResult = { error?: string; }; +export type CandidateTestMap = Record< + string, + TestResult | { success: false; error: string } +>; + export type WizardFormData = { cameraName?: string; host?: string; @@ -107,12 +112,17 @@ export type WizardFormData = { brandTemplate?: CameraBrand; customUrl?: string; streams?: StreamConfig[]; + probeMode?: boolean; // true for probe, false for manual + onvifPort?: number; + probeResult?: OnvifProbeResponse; + probeCandidates?: string[]; // candidate URLs from probe + candidateTests?: CandidateTestMap; // test results for candidates }; // API Response Types export type FfprobeResponse = { return_code: number; - stderr: string; + stderr: string | string[]; stdout: FfprobeData | string; }; @@ -167,3 +177,26 @@ export type ConfigSetBody = { config_data: CameraConfigData; update_topic?: string; }; + +export type OnvifRtspCandidate = { + source: "GetStreamUri" | "pattern"; + profile_token?: string; + uri: string; +}; + +export type OnvifProbeResponse = { + success: boolean; + host?: string; + port?: number; + manufacturer?: string; + model?: string; + firmware_version?: string; + profiles_count?: number; + ptz_supported?: boolean; + presets_count?: number; + autotrack_supported?: boolean; + move_status_supported?: boolean; + rtsp_candidates?: OnvifRtspCandidate[]; + message?: string; + detail?: string; +};