Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
b3915fb99c
Merge f440f86451 into de066d0062 2025-11-12 10:58:43 +01:00
GuoQing Liu
de066d0062
Fix i18n (#20857)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* fix: fix the missing i18n key

* fix: fix trackedObject i18n keys count variable

* fix: fix some pages audio label missing i18n

* fix: add 6214d52 missing variable

* fix: add more missing i18n

* fix: add menu missing key
2025-11-11 17:23:30 -06:00
Nicolas Mowen
f1a05d0f9b
Miscellaneous fixes (#20875)
* Improve stream fetching logic

* Reduce need to revalidate stream info

* fix frigate+ frame submission

* add UI setting to configure jsmpeg fallback timeout

* hide settings dropdown when fullscreen

* Fix arcface running on OpenVINO

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-11 17:00:54 -06:00
Josh Hawkins
a623150811
Add Camera Wizard tweaks (#20889)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* digest auth backend

* frontend

* i18n

* update field description language to include note about onvif specific credentials

* mask util helper function

* language

* mask passwords in http-flv and others where a url param is password
2025-11-11 06:46:23 -07:00
Josh Hawkins
e4eac4ac81
Add Camera Wizard improvements (#20876)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* backend api endpoint

* don't add no-credentials version of streams to rtsp_candidates

* frontend types

* improve types

* add optional probe dialog to wizard step 1

* i18n

* form description and field change

* add onvif form description

* match onvif probe pane with other steps in the wizard

* refactor to add probe and snapshot as step 2

* consolidate probe dialog

* don't change dialog size

* radio button style

* refactor to select onvif urls via combobox in step 3

* i18n

* add scrollbar container

* i18n cleanup

* fix button activity indicator

* match test parsing in step 3 with step 2

* hide resolution if both width and height are zero

* use drawer for stream selection on mobile in step 3

* suppress double toasts

* api endpoint description
2025-11-10 15:49:52 -06:00
Josh Hawkins
c371fc0c87
Miscellaneous Fixes (#20866)
* Don't warn when event ids have expired for trigger sync

* Import faster_whisper conditinally to avoid illegal instruction

* Catch OpenVINO runtime error

* fix race condition in detail stream context

navigating between tracked objects in Explore would sometimes prevent the object track from appearing

* Handle case where classification images are deleted

* Adjust default rounded corners on larger screens

* Improve flow handling for classification state

* Remove images when wizard is cancelled

* Improve deletion handling for classes

* Set constraints on review buffers

* Update to support correct data format

* Set minimum duration for recording based review items

* Use friendly name in review genai prompt

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2025-11-10 10:03:56 -07:00
Nicolas Mowen
99a363c047
Improve classification (#20863)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2025-11-09 16:21:13 -06:00
Nicolas Mowen
a374a60756
Miscellaneous Fixes (#20850)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Fix wrongly added detection objects to alert

* Fix CudaGraph inverse condition

* Add debug logs

* Formatting
2025-11-09 08:38:38 -06:00
dependabot[bot]
f440f86451
Bump onnx from 1.14.0 to 1.19.1 in /docker/tensorrt
Bumps [onnx](https://github.com/onnx/onnx) from 1.14.0 to 1.19.1.
- [Release notes](https://github.com/onnx/onnx/releases)
- [Changelog](https://github.com/onnx/onnx/blob/main/docs/Changelog-ml.md)
- [Commits](https://github.com/onnx/onnx/compare/v1.14.0...v1.19.1)

---
updated-dependencies:
- dependency-name: onnx
  dependency-version: 1.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 18:41:44 +00:00
54 changed files with 3969 additions and 1540 deletions

View File

@ -13,6 +13,6 @@ nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64' nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64' nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64' nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
onnx==1.16.*; platform_machine == 'x86_64' onnx==1.19.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64' onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64'

View File

@ -1,2 +1,2 @@
onnx == 1.14.0; platform_machine == 'aarch64' onnx == 1.19.1; platform_machine == 'aarch64'
protobuf == 3.20.3; platform_machine == 'aarch64' protobuf == 3.20.3; platform_machine == 'aarch64'

View File

@ -3,11 +3,17 @@
import json import json
import logging import logging
import re import re
from importlib.util import find_spec
from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
import httpx
import requests import requests
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from onvif import ONVIFCamera, ONVIFError
from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport
from frigate.api.auth import require_role from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
@ -452,3 +458,537 @@ def _extract_fps(r_frame_rate: str) -> float | None:
return round(float(num) / float(den), 2) return round(float(num) / float(den), 2)
except (ValueError, ZeroDivisionError): except (ValueError, ZeroDivisionError):
return None 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), "
"auth_type (basic or digest, default basic)."
),
)
async def onvif_probe(
request: Request,
host: str = Query(None),
port: int = Query(80),
username: str = Query(""),
password: str = Query(""),
test: bool = Query(False),
auth_type: str = Query("basic"), # Add auth_type parameter
):
"""
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)
auth_type: Authentication type - "basic" or "digest" (default "basic")
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,
)
# Validate auth_type
if auth_type not in ["basic", "digest"]:
return JSONResponse(
content={
"success": False,
"message": "auth_type must be 'basic' or 'digest'",
},
status_code=400,
)
onvif_camera = None
try:
logger.debug(f"Probing ONVIF device at {host}:{port} with {auth_type} auth")
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
)
# Configure digest authentication if requested
if auth_type == "digest" and username and password:
# Create httpx client with digest auth
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
# Replace the transport in the zeep client
transport = AsyncTransport(client=client)
# Update the xaddr before setting transport
await onvif_camera.update_xaddrs()
# Replace transport in all services
if hasattr(onvif_camera, "devicemgmt"):
onvif_camera.devicemgmt.zeep_client.transport = transport
if hasattr(onvif_camera, "media"):
onvif_camera.media.zeep_client.transport = transport
if hasattr(onvif_camera, "ptz"):
onvif_camera.ptz.zeep_client.transport = transport
logger.debug("Configured digest authentication")
else:
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()
# Update transport for device service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
device_service.zeep_client.transport = transport
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 as e:
logger.debug(f"Failed to get device info: {e}")
# Get media profiles
profiles = []
profiles_count = 0
first_profile_token = None
ptz_config_token = None
try:
media_service = await onvif_camera.create_media_service()
# Update transport for media service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
media_service.zeep_client.transport = transport
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 as e:
logger.debug(f"Failed to get media profiles: {e}")
# Check PTZ support and capabilities
ptz_supported = False
presets_count = 0
autotrack_supported = False
try:
ptz_service = await onvif_camera.create_ptz_service()
# Update transport for PTZ service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
ptz_service.zeep_client.transport = transport
# 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()
# Update transport for media service if digest auth
if auth_type == "digest" and username and password:
auth = httpx.DigestAuth(username, password)
client = httpx.AsyncClient(auth=auth, timeout=10.0)
transport = AsyncTransport(client=client)
media_service.zeep_client.transport = transport
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}")

View File

@ -37,6 +37,8 @@ from frigate.models import Event
from frigate.util.classification import ( from frigate.util.classification import (
collect_object_classification_examples, collect_object_classification_examples,
collect_state_classification_examples, collect_state_classification_examples,
get_dataset_image_count,
read_training_metadata,
) )
from frigate.util.file import get_event_snapshot from frigate.util.file import get_event_snapshot
@ -564,23 +566,59 @@ def get_classification_dataset(name: str):
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset") dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
if not os.path.exists(dataset_dir): if not os.path.exists(dataset_dir):
return JSONResponse(status_code=200, content={}) return JSONResponse(
status_code=200, content={"categories": {}, "training_metadata": None}
)
for name in os.listdir(dataset_dir): for category_name in os.listdir(dataset_dir):
category_dir = os.path.join(dataset_dir, name) category_dir = os.path.join(dataset_dir, category_name)
if not os.path.isdir(category_dir): if not os.path.isdir(category_dir):
continue continue
dataset_dict[name] = [] dataset_dict[category_name] = []
for file in filter( for file in filter(
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))), lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
os.listdir(category_dir), os.listdir(category_dir),
): ):
dataset_dict[name].append(file) dataset_dict[category_name].append(file)
return JSONResponse(status_code=200, content=dataset_dict) # Get training metadata
metadata = read_training_metadata(sanitize_filename(name))
current_image_count = get_dataset_image_count(sanitize_filename(name))
if metadata is None:
training_metadata = {
"has_trained": False,
"last_training_date": None,
"last_training_image_count": 0,
"current_image_count": current_image_count,
"new_images_count": current_image_count,
"dataset_changed": current_image_count > 0,
}
else:
last_training_count = metadata.get("last_training_image_count", 0)
# Dataset has changed if count is different (either added or deleted images)
dataset_changed = current_image_count != last_training_count
# Only show positive count for new images (ignore deletions in the count display)
new_images_count = max(0, current_image_count - last_training_count)
training_metadata = {
"has_trained": True,
"last_training_date": metadata.get("last_training_date"),
"last_training_image_count": last_training_count,
"current_image_count": current_image_count,
"new_images_count": new_images_count,
"dataset_changed": dataset_changed,
}
return JSONResponse(
status_code=200,
content={
"categories": dataset_dict,
"training_metadata": training_metadata,
},
)
@router.get( @router.get(
@ -915,31 +953,29 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Delete a classification model", summary="Delete a classification model",
description="""Deletes a specific classification model and all its associated data. description="""Deletes a specific classification model and all its associated data.
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""", Works even if the model is not in the config (e.g., partially created during wizard).
Returns a success message.""",
) )
def delete_classification_model(request: Request, name: str): def delete_classification_model(request: Request, name: str):
config: FrigateConfig = request.app.frigate_config sanitized_name = sanitize_filename(name)
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
# Delete the classification model's data directory in clips # Delete the classification model's data directory in clips
data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name)) data_dir = os.path.join(CLIPS_DIR, sanitized_name)
if os.path.exists(data_dir): if os.path.exists(data_dir):
try:
shutil.rmtree(data_dir) shutil.rmtree(data_dir)
logger.info(f"Deleted classification data directory for {name}")
except Exception as e:
logger.debug(f"Failed to delete data directory for {name}: {e}")
# Delete the classification model's files in model_cache # Delete the classification model's files in model_cache
model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name)) model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name)
if os.path.exists(model_dir): if os.path.exists(model_dir):
try:
shutil.rmtree(model_dir) shutil.rmtree(model_dir)
logger.info(f"Deleted classification model directory for {name}")
except Exception as e:
logger.debug(f"Failed to delete model directory for {name}: {e}")
return JSONResponse( return JSONResponse(
content=( content=(

View File

@ -177,6 +177,12 @@ class CameraConfig(FrigateBaseModel):
def ffmpeg_cmds(self) -> list[dict[str, list[str]]]: def ffmpeg_cmds(self) -> list[dict[str, list[str]]]:
return self._ffmpeg_cmds return self._ffmpeg_cmds
def get_formatted_name(self) -> str:
"""Return the friendly name if set, otherwise return a formatted version of the camera name."""
if self.friendly_name:
return self.friendly_name
return self.name.replace("_", " ").title() if self.name else ""
def create_ffmpeg_cmds(self): def create_ffmpeg_cmds(self):
if "_ffmpeg_cmds" in self: if "_ffmpeg_cmds" in self:
return return

View File

@ -56,6 +56,12 @@ class ZoneConfig(BaseModel):
def contour(self) -> np.ndarray: def contour(self) -> np.ndarray:
return self._contour return self._contour
def get_formatted_name(self, zone_name: str) -> str:
"""Return the friendly name if set, otherwise return a formatted version of the zone name."""
if self.friendly_name:
return self.friendly_name
return zone_name.replace("_", " ").title()
@field_validator("objects", mode="before") @field_validator("objects", mode="before")
@classmethod @classmethod
def validate_objects(cls, v): def validate_objects(cls, v):

View File

@ -4,7 +4,6 @@ import logging
import os import os
import sherpa_onnx import sherpa_onnx
from faster_whisper.utils import download_model
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
@ -25,6 +24,9 @@ class AudioTranscriptionModelRunner:
if model_size == "large": if model_size == "large":
# use the Whisper download function instead of our own # use the Whisper download function instead of our own
# Import dynamically to avoid crashes on systems without AVX support
from faster_whisper.utils import download_model
logger.debug("Downloading Whisper audio transcription model") logger.debug("Downloading Whisper audio transcription model")
download_model( download_model(
size_or_id="small" if device == "cuda" else "tiny", size_or_id="small" if device == "cuda" else "tiny",

View File

@ -6,7 +6,6 @@ import threading
import time import time
from typing import Optional from typing import Optional
from faster_whisper import WhisperModel
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@ -51,6 +50,9 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
def __build_recognizer(self) -> None: def __build_recognizer(self) -> None:
try: try:
# Import dynamically to avoid crashes on systems without AVX support
from faster_whisper import WhisperModel
self.recognizer = WhisperModel( self.recognizer = WhisperModel(
model_size_or_path="small", model_size_or_path="small",
device="cuda" device="cuda"

View File

@ -16,6 +16,7 @@ from peewee import DoesNotExist
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera import CameraConfig
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION
from frigate.data_processing.types import PostProcessDataEnum from frigate.data_processing.types import PostProcessDataEnum
@ -30,6 +31,7 @@ from ..types import DataProcessorMetrics
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10 RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
MIN_RECORDING_DURATION = 10
class ReviewDescriptionProcessor(PostProcessorApi): class ReviewDescriptionProcessor(PostProcessorApi):
@ -130,7 +132,17 @@ class ReviewDescriptionProcessor(PostProcessorApi):
if image_source == ImageSourceEnum.recordings: if image_source == ImageSourceEnum.recordings:
duration = final_data["end_time"] - final_data["start_time"] duration = final_data["end_time"] - final_data["start_time"]
buffer_extension = duration * RECORDING_BUFFER_EXTENSION_PERCENT buffer_extension = min(
10, max(2, duration * RECORDING_BUFFER_EXTENSION_PERCENT)
)
# Ensure minimum total duration for short review items
# This provides better context for brief events
total_duration = duration + (2 * buffer_extension)
if total_duration < MIN_RECORDING_DURATION:
# Expand buffer to reach minimum duration, still respecting max of 10s per side
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
buffer_extension = min(10, additional_buffer_per_side)
thumbs = self.get_recording_frames( thumbs = self.get_recording_frames(
camera, camera,
@ -182,7 +194,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
self.requestor, self.requestor,
self.genai_client, self.genai_client,
self.review_desc_speed, self.review_desc_speed,
camera, camera_config,
final_data, final_data,
thumbs, thumbs,
camera_config.review.genai, camera_config.review.genai,
@ -411,7 +423,7 @@ def run_analysis(
requestor: InterProcessRequestor, requestor: InterProcessRequestor,
genai_client: GenAIClient, genai_client: GenAIClient,
review_inference_speed: InferenceSpeed, review_inference_speed: InferenceSpeed,
camera: str, camera_config: CameraConfig,
final_data: dict[str, str], final_data: dict[str, str],
thumbs: list[bytes], thumbs: list[bytes],
genai_config: GenAIReviewConfig, genai_config: GenAIReviewConfig,
@ -419,10 +431,19 @@ def run_analysis(
attribute_labels: list[str], attribute_labels: list[str],
) -> None: ) -> None:
start = datetime.datetime.now().timestamp() start = datetime.datetime.now().timestamp()
# Format zone names using zone config friendly names if available
formatted_zones = []
for zone_name in final_data["data"]["zones"]:
if zone_name in camera_config.zones:
formatted_zones.append(
camera_config.zones[zone_name].get_formatted_name(zone_name)
)
analytics_data = { analytics_data = {
"id": final_data["id"], "id": final_data["id"],
"camera": camera, "camera": camera_config.get_formatted_name(),
"zones": final_data["data"]["zones"], "zones": formatted_zones,
"start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime( "start": datetime.datetime.fromtimestamp(final_data["start_time"]).strftime(
"%A, %I:%M %p" "%A, %I:%M %p"
), ),

View File

@ -227,6 +227,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
self.tensor_output_details[0]["index"] self.tensor_output_details[0]["index"]
)[0] )[0]
probs = res / res.sum(axis=0) probs = res / res.sum(axis=0)
logger.debug(
f"{self.model_config.name} Ran state classification with probabilities: {probs}"
)
best_id = np.argmax(probs) best_id = np.argmax(probs)
score = round(probs[best_id], 2) score = round(probs[best_id], 2)
self.__update_metrics(datetime.datetime.now().timestamp() - now) self.__update_metrics(datetime.datetime.now().timestamp() - now)
@ -455,6 +458,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
self.tensor_output_details[0]["index"] self.tensor_output_details[0]["index"]
)[0] )[0]
probs = res / res.sum(axis=0) probs = res / res.sum(axis=0)
logger.debug(
f"{self.model_config.name} Ran object classification with probabilities: {probs}"
)
best_id = np.argmax(probs) best_id = np.argmax(probs)
score = round(probs[best_id], 2) score = round(probs[best_id], 2)
self.__update_metrics(datetime.datetime.now().timestamp() - now) self.__update_metrics(datetime.datetime.now().timestamp() - now)

View File

@ -255,6 +255,7 @@ class OpenVINOModelRunner(BaseModelRunner):
def __init__(self, model_path: str, device: str, model_type: str, **kwargs): def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path self.model_path = model_path
self.device = device self.device = device
self.model_type = model_type
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported( if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
model_type model_type
@ -341,6 +342,13 @@ class OpenVINOModelRunner(BaseModelRunner):
# Lock prevents concurrent access to infer_request # Lock prevents concurrent access to infer_request
# Needed for JinaV2: genai thread (text) + embeddings thread (vision) # Needed for JinaV2: genai thread (text) + embeddings thread (vision)
with self._inference_lock: with self._inference_lock:
from frigate.embeddings.types import EnrichmentModelTypeEnum
if self.model_type in [EnrichmentModelTypeEnum.arcface.value]:
# For face recognition models, create a fresh infer_request
# for each inference to avoid state pollution that causes incorrect results.
self.infer_request = self.compiled_model.create_infer_request()
# Handle single input case for backward compatibility # Handle single input case for backward compatibility
if ( if (
len(inputs) == 1 len(inputs) == 1
@ -394,7 +402,11 @@ class OpenVINOModelRunner(BaseModelRunner):
self.infer_request.set_input_tensor(input_index, input_tensor) self.infer_request.set_input_tensor(input_index, input_tensor)
# Run inference # Run inference
try:
self.infer_request.infer() self.infer_request.infer()
except Exception as e:
logger.error(f"Error during OpenVINO inference: {e}")
return []
# Get all output tensors # Get all output tensors
outputs = [] outputs = []
@ -529,7 +541,7 @@ def get_optimized_runner(
return OpenVINOModelRunner(model_path, device, model_type, **kwargs) return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
if ( if (
not CudaGraphRunner.is_model_supported(model_type) CudaGraphRunner.is_model_supported(model_type)
and providers[0] == "CUDAExecutionProvider" and providers[0] == "CUDAExecutionProvider"
): ):
options[0] = { options[0] = {

View File

@ -472,7 +472,7 @@ class Embeddings:
) )
thumbnail_missing = True thumbnail_missing = True
except DoesNotExist: except DoesNotExist:
logger.warning( logger.debug(
f"Event ID {trigger.data} for trigger {trigger_name} does not exist." f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
) )
continue continue

View File

@ -51,8 +51,7 @@ class GenAIClient:
def get_concern_prompt() -> str: def get_concern_prompt() -> str:
if concerns: if concerns:
concern_list = "\n - ".join(concerns) concern_list = "\n - ".join(concerns)
return f""" return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
- {concern_list}""" - {concern_list}"""
else: else:
return "" return ""
@ -70,7 +69,7 @@ class GenAIClient:
return "\n- (No objects detected)" return "\n- (No objects detected)"
context_prompt = f""" context_prompt = f"""
Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"].replace("_", " ")} security camera. Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"]} security camera.
## Normal Activity Patterns for This Property ## Normal Activity Patterns for This Property
@ -110,7 +109,7 @@ Your response MUST be a flat JSON object with:
- Frame 1 = earliest, Frame {len(thumbnails)} = latest - Frame 1 = earliest, Frame {len(thumbnails)} = latest
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds - Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
- Zones involved: {", ".join(z.replace("_", " ").title() for z in review_data["zones"]) or "None"} - Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
## Objects in Scene ## Objects in Scene

View File

@ -407,6 +407,19 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.last_detection_time = frame_time segment.last_detection_time = frame_time
for object in activity.get_all_objects(): for object in activity.get_all_objects():
# Alert-level objects should always be added (they extend/upgrade the segment)
# Detection-level objects should only be added if:
# - The segment is a detection segment (matching severity), OR
# - The segment is an alert AND the object started before the alert ended
# (objects starting after will be in the new detection segment)
is_alert_object = object in activity.categorized_objects["alerts"]
if not is_alert_object and segment.severity == SeverityEnum.alert:
# This is a detection-level object
# Only add if it started during the alert's active period
if object["start_time"] > segment.last_alert_time:
continue
if not object["sub_label"]: if not object["sub_label"]:
segment.detections[object["id"]] = object["label"] segment.detections[object["id"]] = object["label"]
elif object["sub_label"][0] in self.config.model.all_attributes: elif object["sub_label"][0] in self.config.model.all_attributes:

View File

@ -23,6 +23,7 @@ class ModelStatusTypesEnum(str, Enum):
error = "error" error = "error"
training = "training" training = "training"
complete = "complete" complete = "complete"
failed = "failed"
class TrackedObjectUpdateTypesEnum(str, Enum): class TrackedObjectUpdateTypesEnum(str, Enum):

View File

@ -1,5 +1,7 @@
"""Util for classification models.""" """Util for classification models."""
import datetime
import json
import logging import logging
import os import os
import random import random
@ -27,10 +29,96 @@ from frigate.util.process import FrigateProcess
BATCH_SIZE = 16 BATCH_SIZE = 16
EPOCHS = 50 EPOCHS = 50
LEARNING_RATE = 0.001 LEARNING_RATE = 0.001
TRAINING_METADATA_FILE = ".training_metadata.json"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def write_training_metadata(model_name: str, image_count: int) -> None:
"""
Write training metadata to a hidden file in the model's clips directory.
Args:
model_name: Name of the classification model
image_count: Number of images used in training
"""
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
os.makedirs(clips_model_dir, exist_ok=True)
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
metadata = {
"last_training_date": datetime.datetime.now().isoformat(),
"last_training_image_count": image_count,
}
try:
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
logger.info(f"Wrote training metadata for {model_name}: {image_count} images")
except Exception as e:
logger.error(f"Failed to write training metadata for {model_name}: {e}")
def read_training_metadata(model_name: str) -> dict[str, any] | None:
"""
Read training metadata from the hidden file in the model's clips directory.
Args:
model_name: Name of the classification model
Returns:
Dictionary with last_training_date and last_training_image_count, or None if not found
"""
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
if not os.path.exists(metadata_path):
return None
try:
with open(metadata_path, "r") as f:
metadata = json.load(f)
return metadata
except Exception as e:
logger.error(f"Failed to read training metadata for {model_name}: {e}")
return None
def get_dataset_image_count(model_name: str) -> int:
"""
Count the total number of images in the model's dataset directory.
Args:
model_name: Name of the classification model
Returns:
Total count of images across all categories
"""
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
if not os.path.exists(dataset_dir):
return 0
total_count = 0
try:
for category in os.listdir(dataset_dir):
category_dir = os.path.join(dataset_dir, category)
if not os.path.isdir(category_dir):
continue
image_files = [
f
for f in os.listdir(category_dir)
if f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))
]
total_count += len(image_files)
except Exception as e:
logger.error(f"Failed to count dataset images for {model_name}: {e}")
return 0
return total_count
class ClassificationTrainingProcess(FrigateProcess): class ClassificationTrainingProcess(FrigateProcess):
def __init__(self, model_name: str) -> None: def __init__(self, model_name: str) -> None:
super().__init__( super().__init__(
@ -42,7 +130,8 @@ class ClassificationTrainingProcess(FrigateProcess):
def run(self) -> None: def run(self) -> None:
self.pre_run_setup() self.pre_run_setup()
self.__train_classification_model() success = self.__train_classification_model()
exit(0 if success else 1)
def __generate_representative_dataset_factory(self, dataset_dir: str): def __generate_representative_dataset_factory(self, dataset_dir: str):
def generate_representative_dataset(): def generate_representative_dataset():
@ -65,17 +154,17 @@ class ClassificationTrainingProcess(FrigateProcess):
@redirect_output_to_logger(logger, logging.DEBUG) @redirect_output_to_logger(logger, logging.DEBUG)
def __train_classification_model(self) -> bool: def __train_classification_model(self) -> bool:
"""Train a classification model.""" """Train a classification model."""
try:
# import in the function so that tensorflow is not initialized multiple times # import in the function so that tensorflow is not initialized multiple times
import tensorflow as tf import tensorflow as tf
from tensorflow.keras import layers, models, optimizers from tensorflow.keras import layers, models, optimizers
from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.applications import MobileNetV2
from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.preprocessing.image import ImageDataGenerator
logger.info(f"Kicking off classification training for {self.model_name}.")
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset") dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name) model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
os.makedirs(model_dir, exist_ok=True) os.makedirs(model_dir, exist_ok=True)
num_classes = len( num_classes = len(
[ [
d d
@ -84,6 +173,12 @@ class ClassificationTrainingProcess(FrigateProcess):
] ]
) )
if num_classes < 2:
logger.error(
f"Training failed for {self.model_name}: Need at least 2 classes, found {num_classes}"
)
return False
# Start with imagenet base model with 35% of channels in each layer # Start with imagenet base model with 35% of channels in each layer
base_model = MobileNetV2( base_model = MobileNetV2(
input_shape=(224, 224, 3), input_shape=(224, 224, 3),
@ -119,6 +214,11 @@ class ClassificationTrainingProcess(FrigateProcess):
subset="training", subset="training",
) )
total_images = train_gen.samples
logger.debug(
f"Training {self.model_name}: {total_images} images across {num_classes} classes"
)
# write labelmap # write labelmap
class_indices = train_gen.class_indices class_indices = train_gen.class_indices
index_to_class = {v: k for k, v in class_indices.items()} index_to_class = {v: k for k, v in class_indices.items()}
@ -128,7 +228,9 @@ class ClassificationTrainingProcess(FrigateProcess):
f.write(f"{class_name}\n") f.write(f"{class_name}\n")
# train the model # train the model
logger.debug(f"Training {self.model_name} for {EPOCHS} epochs...")
model.fit(train_gen, epochs=EPOCHS, verbose=0) model.fit(train_gen, epochs=EPOCHS, verbose=0)
logger.debug(f"Converting {self.model_name} to TFLite...")
# convert model to tflite # convert model to tflite
converter = tf.lite.TFLiteConverter.from_keras_model(model) converter = tf.lite.TFLiteConverter.from_keras_model(model)
@ -142,9 +244,28 @@ class ClassificationTrainingProcess(FrigateProcess):
tflite_model = converter.convert() tflite_model = converter.convert()
# write model # write model
with open(os.path.join(model_dir, "model.tflite"), "wb") as f: model_path = os.path.join(model_dir, "model.tflite")
with open(model_path, "wb") as f:
f.write(tflite_model) f.write(tflite_model)
# verify model file was written successfully
if not os.path.exists(model_path) or os.path.getsize(model_path) == 0:
logger.error(
f"Training failed for {self.model_name}: Model file was not created or is empty"
)
return False
# write training metadata with image count
dataset_image_count = get_dataset_image_count(self.model_name)
write_training_metadata(self.model_name, dataset_image_count)
logger.info(f"Finished training {self.model_name}")
return True
except Exception as e:
logger.error(f"Training failed for {self.model_name}: {e}", exc_info=True)
return False
def kickoff_model_training( def kickoff_model_training(
embeddingRequestor: EmbeddingsRequestor, model_name: str embeddingRequestor: EmbeddingsRequestor, model_name: str
@ -165,6 +286,10 @@ def kickoff_model_training(
training_process.start() training_process.start()
training_process.join() training_process.join()
# check if training succeeded by examining the exit code
training_success = training_process.exitcode == 0
if training_success:
# reload model and mark training as complete # reload model and mark training as complete
embeddingRequestor.send_data( embeddingRequestor.send_data(
EmbeddingsRequestEnum.reload_classification_model.value, EmbeddingsRequestEnum.reload_classification_model.value,
@ -177,6 +302,20 @@ def kickoff_model_training(
"state": ModelStatusTypesEnum.complete, "state": ModelStatusTypesEnum.complete,
}, },
) )
else:
logger.error(
f"Training subprocess failed for {model_name} (exit code: {training_process.exitcode})"
)
# mark training as failed so UI shows error state
# don't reload the model since it failed
requestor.send_data(
UPDATE_MODEL_STATE,
{
"model": model_name,
"state": ModelStatusTypesEnum.failed,
},
)
requestor.stop() requestor.stop()

View File

@ -72,7 +72,10 @@
"formattedTimestampFilename": { "formattedTimestampFilename": {
"12hour": "MM-dd-yy-h-mm-ss-a", "12hour": "MM-dd-yy-h-mm-ss-a",
"24hour": "MM-dd-yy-HH-mm-ss" "24hour": "MM-dd-yy-HH-mm-ss"
} },
"inProgress": "In progress",
"invalidStartTime": "Invalid start time",
"invalidEndTime": "Invalid end time"
}, },
"unit": { "unit": {
"speed": { "speed": {
@ -144,7 +147,8 @@
"unselect": "Unselect", "unselect": "Unselect",
"export": "Export", "export": "Export",
"deleteNow": "Delete Now", "deleteNow": "Delete Now",
"next": "Next" "next": "Next",
"continue": "Continue"
}, },
"menu": { "menu": {
"system": "System", "system": "System",
@ -237,6 +241,7 @@
"export": "Export", "export": "Export",
"uiPlayground": "UI Playground", "uiPlayground": "UI Playground",
"faceLibrary": "Face Library", "faceLibrary": "Face Library",
"classification": "Classification",
"user": { "user": {
"title": "User", "title": "User",
"account": "Account", "account": "Account",

View File

@ -13,6 +13,12 @@
"deleteModels": "Delete Models", "deleteModels": "Delete Models",
"editModel": "Edit Model" "editModel": "Edit Model"
}, },
"tooltip": {
"trainingInProgress": "Model is currently training",
"noNewImages": "No new images to train. Classify more images in the dataset first.",
"noChanges": "No changes to the dataset since last training.",
"modelNotReady": "Model is not ready for training"
},
"toast": { "toast": {
"success": { "success": {
"deletedCategory": "Deleted Class", "deletedCategory": "Deleted Class",
@ -30,14 +36,17 @@
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}", "deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
"deleteModelFailed": "Failed to delete model: {{errorMessage}}", "deleteModelFailed": "Failed to delete model: {{errorMessage}}",
"categorizeFailed": "Failed to categorize image: {{errorMessage}}", "categorizeFailed": "Failed to categorize image: {{errorMessage}}",
"trainingFailed": "Failed to start model training: {{errorMessage}}", "trainingFailed": "Model training failed. Check Frigate logs for details.",
"trainingFailedToStart": "Failed to start model training: {{errorMessage}}",
"updateModelFailed": "Failed to update model: {{errorMessage}}", "updateModelFailed": "Failed to update model: {{errorMessage}}",
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}" "renameCategoryFailed": "Failed to rename class: {{errorMessage}}"
} }
}, },
"deleteCategory": { "deleteCategory": {
"title": "Delete Class", "title": "Delete Class",
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model." "desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model.",
"minClassesTitle": "Cannot Delete Class",
"minClassesDesc": "A classification model must have at least 2 classes. Add another class before deleting this one."
}, },
"deleteModel": { "deleteModel": {
"title": "Delete Classification Model", "title": "Delete Classification Model",
@ -143,6 +152,8 @@
"step3": { "step3": {
"selectImagesPrompt": "Select all images with: {{className}}", "selectImagesPrompt": "Select all images with: {{className}}",
"selectImagesDescription": "Click on images to select them. Click Continue when you're done with this class.", "selectImagesDescription": "Click on images to select them. Click Continue when you're done with this class.",
"allImagesRequired_one": "Please classify all images. {{count}} image remaining.",
"allImagesRequired_other": "Please classify all images. {{count}} images remaining.",
"generating": { "generating": {
"title": "Generating Sample Images", "title": "Generating Sample Images",
"description": "Frigate is pulling representative images from your recordings. This may take a moment..." "description": "Frigate is pulling representative images from your recordings. This may take a moment..."

View File

@ -24,8 +24,8 @@
"label": "Detail", "label": "Detail",
"noDataFound": "No detail data to review", "noDataFound": "No detail data to review",
"aria": "Toggle detail view", "aria": "Toggle detail view",
"trackedObject_one": "object", "trackedObject_one": "{{count}} object",
"trackedObject_other": "objects", "trackedObject_other": "{{count}} objects",
"noObjectDetailData": "No object detail data available.", "noObjectDetailData": "No object detail data available.",
"settings": "Detail View Settings", "settings": "Detail View Settings",
"alwaysExpandActive": { "alwaysExpandActive": {

View File

@ -35,7 +35,7 @@
"snapshot": "snapshot", "snapshot": "snapshot",
"thumbnail": "thumbnail", "thumbnail": "thumbnail",
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "tracking_details": "tracking details"
}, },
"trackingDetails": { "trackingDetails": {
"title": "Tracking Details", "title": "Tracking Details",

View File

@ -8,7 +8,7 @@
"masksAndZones": "Mask and Zone Editor - Frigate", "masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate", "motionTuner": "Motion Tuner - Frigate",
"object": "Debug - Frigate", "object": "Debug - Frigate",
"general": "General Settings - Frigate", "general": "UI Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate", "frigatePlus": "Frigate+ Settings - Frigate",
"notifications": "Notification Settings - Frigate" "notifications": "Notification Settings - Frigate"
}, },
@ -37,7 +37,7 @@
"noCamera": "No Camera" "noCamera": "No Camera"
}, },
"general": { "general": {
"title": "General Settings", "title": "UI Settings",
"liveDashboard": { "liveDashboard": {
"title": "Live Dashboard", "title": "Live Dashboard",
"automaticLiveView": { "automaticLiveView": {
@ -51,6 +51,10 @@
"displayCameraNames": { "displayCameraNames": {
"label": "Always Show Camera Names", "label": "Always Show Camera Names",
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard." "desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
},
"liveFallbackTimeout": {
"label": "Live Player Fallback Timeout",
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
} }
}, },
"storedLayouts": { "storedLayouts": {
@ -154,6 +158,7 @@
"description": "Follow the steps below to add a new camera to your Frigate installation.", "description": "Follow the steps below to add a new camera to your Frigate installation.",
"steps": { "steps": {
"nameAndConnection": "Name & Connection", "nameAndConnection": "Name & Connection",
"probeOrSnapshot": "Probe or Snapshot",
"streamConfiguration": "Stream Configuration", "streamConfiguration": "Stream Configuration",
"validationAndTesting": "Validation & Testing" "validationAndTesting": "Validation & Testing"
}, },
@ -172,7 +177,7 @@
"testFailed": "Stream test failed: {{error}}" "testFailed": "Stream test failed: {{error}}"
}, },
"step1": { "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", "cameraName": "Camera Name",
"cameraNamePlaceholder": "e.g., front_door or Back Yard Overview", "cameraNamePlaceholder": "e.g., front_door or Back Yard Overview",
"host": "Host/IP Address", "host": "Host/IP Address",
@ -188,33 +193,65 @@
"brandInformation": "Brand information", "brandInformation": "Brand information",
"brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}", "brandUrlFormat": "For cameras with the RTSP URL format as: {{exampleUrl}}",
"customUrlPlaceholder": "rtsp://username:password@host:port/path", "customUrlPlaceholder": "rtsp://username:password@host:port/path",
"testConnection": "Test Connection", "connectionSettings": "Connection Settings",
"testSuccess": "Connection test successful!", "detectionMethod": "Stream Detection Method",
"testFailed": "Connection test failed. Please check your input and try again.", "onvifPort": "ONVIF Port",
"streamDetails": "Stream Details", "probeMode": "Probe camera",
"testing": { "manualMode": "Manual selection",
"probingMetadata": "Probing camera metadata...", "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\".",
"fetchingSnapshot": "Fetching camera snapshot..." "onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
}, "useDigestAuth": "Use digest authentication",
"warnings": { "useDigestAuthDescription": "Use HTTP digest authentication for ONVIF. Some cameras may require a dedicated ONVIF username/password instead of the standard admin user.",
"noSnapshot": "Unable to fetch a snapshot from the configured stream."
},
"errors": { "errors": {
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL", "brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
"nameRequired": "Camera name is required", "nameRequired": "Camera name is required",
"nameLength": "Camera name must be 64 characters or less", "nameLength": "Camera name must be 64 characters or less",
"invalidCharacters": "Camera name contains invalid characters", "invalidCharacters": "Camera name contains invalid characters",
"nameExists": "Camera name already exists", "nameExists": "Camera name already exists",
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\". Manual configuration is required for non-RTSP camera streams.", "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"
} }
}, },
"step2": { "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.", "description": "Configure stream roles and add additional streams for your camera.",
"streamsTitle": "Camera Streams", "streamsTitle": "Camera Streams",
"addStream": "Add Stream", "addStream": "Add Stream",
@ -222,6 +259,9 @@
"streamTitle": "Stream {{number}}", "streamTitle": "Stream {{number}}",
"streamUrl": "Stream URL", "streamUrl": "Stream URL",
"streamUrlPlaceholder": "rtsp://username:password@host:port/path", "streamUrlPlaceholder": "rtsp://username:password@host:port/path",
"selectStream": "Select a stream",
"searchCandidates": "Search candidates...",
"noStreamFound": "No stream found",
"url": "URL", "url": "URL",
"resolution": "Resolution", "resolution": "Resolution",
"selectResolution": "Select resolution", "selectResolution": "Select resolution",
@ -253,7 +293,7 @@
"description": "Use go2rtc restreaming to reduce connections to your camera." "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.", "description": "Final validation and analysis before saving your new camera. Connect each stream before saving.",
"validationTitle": "Stream Validation", "validationTitle": "Stream Validation",
"connectAllStreams": "Connect All Streams", "connectAllStreams": "Connect All Streams",
@ -289,6 +329,9 @@
"audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.", "audioCodecRecordError": "The AAC audio codec is required to support audio in recordings.",
"audioCodecRequired": "An audio stream is required to support audio detection.", "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.", "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": { "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." "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."
}, },

View File

@ -28,6 +28,7 @@ import {
CustomClassificationModelConfig, CustomClassificationModelConfig,
FrigateConfig, FrigateConfig,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import { ClassificationDatasetResponse } from "@/types/classification";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios"; import axios from "axios";
@ -140,16 +141,19 @@ export default function ClassificationModelEditDialog({
}); });
// Fetch dataset to get current classes for state models // Fetch dataset to get current classes for state models
const { data: dataset } = useSWR<{ const { data: dataset } = useSWR<ClassificationDatasetResponse>(
[id: string]: string[]; isStateModel ? `classification/${model.name}/dataset` : null,
}>(isStateModel ? `classification/${model.name}/dataset` : null, { {
revalidateOnFocus: false, revalidateOnFocus: false,
}); },
);
// Update form with classes from dataset when loaded // Update form with classes from dataset when loaded
useEffect(() => { useEffect(() => {
if (isStateModel && dataset) { if (isStateModel && dataset?.categories) {
const classes = Object.keys(dataset).filter((key) => key !== "none"); const classes = Object.keys(dataset.categories).filter(
(key) => key !== "none",
);
if (classes.length > 0) { if (classes.length > 0) {
(form as ReturnType<typeof useForm<StateFormData>>).setValue( (form as ReturnType<typeof useForm<StateFormData>>).setValue(
"classes", "classes",

View File

@ -15,6 +15,7 @@ import Step3ChooseExamples, {
} from "./wizard/Step3ChooseExamples"; } from "./wizard/Step3ChooseExamples";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import axios from "axios";
const OBJECT_STEPS = [ const OBJECT_STEPS = [
"wizard.steps.nameAndDefine", "wizard.steps.nameAndDefine",
@ -120,7 +121,18 @@ export default function ClassificationModelWizardDialog({
dispatch({ type: "PREVIOUS_STEP" }); dispatch({ type: "PREVIOUS_STEP" });
}; };
const handleCancel = () => { const handleCancel = async () => {
// Clean up any generated training images if we're cancelling from Step 3
if (wizardState.step1Data && wizardState.step3Data?.examplesGenerated) {
try {
await axios.delete(
`/classification/${wizardState.step1Data.modelName}`,
);
} catch (error) {
// Silently fail - user is already cancelling
}
}
dispatch({ type: "RESET" }); dispatch({ type: "RESET" });
onClose(); onClose();
}; };

View File

@ -10,6 +10,12 @@ import useSWR from "swr";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
export type Step3FormData = { export type Step3FormData = {
examplesGenerated: boolean; examplesGenerated: boolean;
@ -159,18 +165,15 @@ export default function Step3ChooseExamples({
const isLastClass = currentClassIndex === allClasses.length - 1; const isLastClass = currentClassIndex === allClasses.length - 1;
if (isLastClass) { if (isLastClass) {
// Assign remaining unclassified images // For object models, assign remaining unclassified images to "none"
// For state models, this should never happen since we require all images to be classified
if (step1Data.modelType !== "state") {
unknownImages.slice(0, 24).forEach((imageName) => { unknownImages.slice(0, 24).forEach((imageName) => {
if (!newClassifications[imageName]) { if (!newClassifications[imageName]) {
// For state models with 2 classes, assign to the last class
// For object models, assign to "none"
if (step1Data.modelType === "state" && allClasses.length === 2) {
newClassifications[imageName] = allClasses[allClasses.length - 1];
} else {
newClassifications[imageName] = "none"; newClassifications[imageName] = "none";
} }
}
}); });
}
// All done, trigger training immediately // All done, trigger training immediately
setImageClassifications(newClassifications); setImageClassifications(newClassifications);
@ -310,13 +313,44 @@ export default function Step3ChooseExamples({
return images; return images;
} }
return images.filter((img) => !imageClassifications[img]); // If we're viewing a previous class (going back), show images for that class
}, [unknownImages, imageClassifications]); // Otherwise show only unclassified images
const currentClassInView = allClasses[currentClassIndex];
return images.filter((img) => {
const imgClass = imageClassifications[img];
// Show if: unclassified OR classified with current class we're viewing
return !imgClass || imgClass === currentClassInView;
});
}, [unknownImages, imageClassifications, allClasses, currentClassIndex]);
const allImagesClassified = useMemo(() => { const allImagesClassified = useMemo(() => {
return unclassifiedImages.length === 0; return unclassifiedImages.length === 0;
}, [unclassifiedImages]); }, [unclassifiedImages]);
// For state models on the last class, require all images to be classified
const isLastClass = currentClassIndex === allClasses.length - 1;
const canProceed = useMemo(() => {
if (step1Data.modelType === "state" && isLastClass) {
// Check if all 24 images will be classified after current selections are applied
const totalImages = unknownImages.slice(0, 24).length;
// Count images that will be classified (either already classified or currently selected)
const allImages = unknownImages.slice(0, 24);
const willBeClassified = allImages.filter((img) => {
return imageClassifications[img] || selectedImages.has(img);
}).length;
return willBeClassified >= totalImages;
}
return true;
}, [
step1Data.modelType,
isLastClass,
unknownImages,
imageClassifications,
selectedImages,
]);
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (currentClassIndex > 0) { if (currentClassIndex > 0) {
const previousClass = allClasses[currentClassIndex - 1]; const previousClass = allClasses[currentClassIndex - 1];
@ -438,6 +472,8 @@ export default function Step3ChooseExamples({
<Button type="button" onClick={handleBack} className="sm:flex-1"> <Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })} {t("button.back", { ns: "common" })}
</Button> </Button>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
type="button" type="button"
onClick={ onClick={
@ -447,11 +483,24 @@ export default function Step3ChooseExamples({
} }
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
disabled={!hasGenerated || isGenerating || isProcessing} disabled={
!hasGenerated || isGenerating || isProcessing || !canProceed
}
> >
{isProcessing && <ActivityIndicator className="size-4" />} {isProcessing && <ActivityIndicator className="size-4" />}
{t("button.continue", { ns: "common" })} {t("button.continue", { ns: "common" })}
</Button> </Button>
</TooltipTrigger>
{!canProceed && (
<TooltipPortal>
<TooltipContent>
{t("wizard.step3.allImagesRequired", {
count: unclassifiedImages.length,
})}
</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
</div> </div>
)} )}
</div> </div>

View File

@ -454,6 +454,24 @@ export function GeneralFilterContent({
onClose, onClose,
}: GeneralFilterContentProps) { }: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter", "views/events"]); const { t } = useTranslation(["components/filter", "views/events"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allAudioListenLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return [...labels].sort();
}, [config]);
return ( return (
<> <>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
@ -504,7 +522,10 @@ export function GeneralFilterContent({
{allLabels.map((item) => ( {allLabels.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={getTranslatedLabel(item)} label={getTranslatedLabel(
item,
allAudioListenLabels.includes(item) ? "audio" : "object",
)}
isChecked={filter.labels?.includes(item) ?? false} isChecked={filter.labels?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -81,6 +81,43 @@ export default function InputWithTags({
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const allAudioListenLabels = useMemo<Set<string>>(() => {
if (!config) {
return new Set<string>();
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return labels;
}, [config]);
const translatedAudioLabelMap = useMemo<Map<string, string>>(() => {
const map = new Map<string, string>();
if (!config) return map;
allAudioListenLabels.forEach((label) => {
// getTranslatedLabel likely depends on i18n internally; including `lang`
// in deps ensures this map is rebuilt when language changes
map.set(label, getTranslatedLabel(label, "audio"));
});
return map;
}, [allAudioListenLabels, config]);
function resolveLabel(value: string) {
const mapped = translatedAudioLabelMap.get(value);
if (mapped) return mapped;
return getTranslatedLabel(
value,
allAudioListenLabels.has(value) ? "audio" : "object",
);
}
const [inputValue, setInputValue] = useState(search || ""); const [inputValue, setInputValue] = useState(search || "");
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>( const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
null, null,
@ -421,7 +458,8 @@ export default function InputWithTags({
? t("button.yes", { ns: "common" }) ? t("button.yes", { ns: "common" })
: t("button.no", { ns: "common" }); : t("button.no", { ns: "common" });
} else if (filterType === "labels") { } else if (filterType === "labels") {
return getTranslatedLabel(String(filterValues)); const value = String(filterValues);
return resolveLabel(value);
} else if (filterType === "search_type") { } else if (filterType === "search_type") {
return t("filter.searchType." + String(filterValues)); return t("filter.searchType." + String(filterValues));
} else { } else {
@ -828,7 +866,7 @@ export default function InputWithTags({
> >
{t("filter.label." + filterType)}:{" "} {t("filter.label." + filterType)}:{" "}
{filterType === "labels" ? ( {filterType === "labels" ? (
getTranslatedLabel(value) resolveLabel(value)
) : filterType === "cameras" ? ( ) : filterType === "cameras" ? (
<CameraNameLabel camera={value} /> <CameraNameLabel camera={value} />
) : filterType === "zones" ? ( ) : filterType === "zones" ? (

View File

@ -12,13 +12,13 @@ export function ImageShadowOverlay({
<> <>
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl", "pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
upperClassName, upperClassName,
)} )}
/> />
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl", "pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
lowerClassName, lowerClassName,
)} )}
/> />

View File

@ -1155,7 +1155,7 @@ function ObjectDetailsTab({
</div> </div>
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize"> <div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
{getIconForLabel(search.label, "size-4 text-primary")} {getIconForLabel(search.label, "size-4 text-primary")}
{getTranslatedLabel(search.label)} {getTranslatedLabel(search.label, search.data.type)}
{search.sub_label && ` (${search.sub_label})`} {search.sub_label && ` (${search.sub_label})`}
{isAdmin && search.end_time && ( {isAdmin && search.end_time && (
<Tooltip> <Tooltip>
@ -1394,7 +1394,9 @@ function ObjectDetailsTab({
{state == "submitted" && ( {state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" /> <FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")} {t("explore.plus.review.state.submitted", {
ns: "components/dialog",
})}
</div> </div>
)} )}
</div> </div>

View File

@ -343,6 +343,10 @@ export function TrackingDetails({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedRecordTime]); }, [displayedRecordTime]);
const onUploadFrameToPlus = useCallback(() => {
return axios.post(`/${event.camera}/plus/${currentTime}`);
}, [event.camera, currentTime]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -388,6 +392,7 @@ export function TrackingDetails({
frigateControls={true} frigateControls={true}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime} onSeekToTime={handleSeekToTime}
onUploadFrame={onUploadFrameToPlus}
isDetailMode={true} isDetailMode={true}
camera={event.camera} camera={event.camera}
currentTimeOverride={currentTime} currentTimeOverride={currentTime}

View File

@ -77,7 +77,10 @@ export default function BirdseyeLivePlayer({
)} )}
onClick={onClick} onClick={onClick}
> >
<ImageShadowOverlay /> <ImageShadowOverlay
upperClassName="md:rounded-2xl"
lowerClassName="md:rounded-2xl"
/>
<div className="size-full" ref={playerRef}> <div className="size-full" ref={playerRef}>
{player} {player}
</div> </div>

View File

@ -331,7 +331,10 @@ export default function LivePlayer({
> >
{cameraEnabled && {cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && ( ((showStillWithoutActivity && !liveReady) || liveReady) && (
<ImageShadowOverlay /> <ImageShadowOverlay
upperClassName="md:rounded-2xl"
lowerClassName="md:rounded-2xl"
/>
)} )}
{player} {player}
{cameraEnabled && {cameraEnabled &&

View File

@ -1,4 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { usePersistence } from "@/hooks/use-persistence";
import { import {
LivePlayerError, LivePlayerError,
PlayerStatsType, PlayerStatsType,
@ -71,6 +72,8 @@ function MSEPlayer({
const [errorCount, setErrorCount] = useState<number>(0); const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0); const totalBytesLoaded = useRef(0);
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTIDRef = useRef<number | null>(null); const reconnectTIDRef = useRef<number | null>(null);
@ -475,7 +478,10 @@ function MSEPlayer({
setBufferTimeout(undefined); setBufferTimeout(undefined);
} }
const timeoutDuration = bufferTime == 0 ? 5000 : 3000; const timeoutDuration =
bufferTime == 0
? (fallbackTimeout ?? 3) * 2 * 1000
: (fallbackTimeout ?? 3) * 1000;
setBufferTimeout( setBufferTimeout(
setTimeout(() => { setTimeout(() => {
if ( if (
@ -500,6 +506,7 @@ function MSEPlayer({
onError, onError,
onPlaying, onPlaying,
playbackEnabled, playbackEnabled,
fallbackTimeout,
]); ]);
useEffect(() => { useEffect(() => {

View File

@ -12,15 +12,15 @@ import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import axios from "axios"; import axios from "axios";
import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera"; import Step1NameCamera from "@/components/settings/wizard/Step1NameCamera";
import Step2StreamConfig from "@/components/settings/wizard/Step2StreamConfig"; import Step2ProbeOrSnapshot from "@/components/settings/wizard/Step2ProbeOrSnapshot";
import Step3Validation from "@/components/settings/wizard/Step3Validation"; import Step3StreamConfig from "@/components/settings/wizard/Step3StreamConfig";
import Step4Validation from "@/components/settings/wizard/Step4Validation";
import type { import type {
WizardFormData, WizardFormData,
CameraConfigData, CameraConfigData,
ConfigSetBody, ConfigSetBody,
} from "@/types/cameraWizard"; } from "@/types/cameraWizard";
import { processCameraName } from "@/utils/cameraUtil"; import { processCameraName } from "@/utils/cameraUtil";
import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type WizardState = { type WizardState = {
@ -57,6 +57,7 @@ const wizardReducer = (
const STEPS = [ const STEPS = [
"cameraWizard.steps.nameAndConnection", "cameraWizard.steps.nameAndConnection",
"cameraWizard.steps.probeOrSnapshot",
"cameraWizard.steps.streamConfiguration", "cameraWizard.steps.streamConfiguration",
"cameraWizard.steps.validationAndTesting", "cameraWizard.steps.validationAndTesting",
]; ];
@ -100,20 +101,20 @@ export default function CameraWizardDialog({
const canProceedToNext = useCallback((): boolean => { const canProceedToNext = useCallback((): boolean => {
switch (currentStep) { switch (currentStep) {
case 0: case 0:
// Can proceed if camera name is set and at least one stream exists // Step 1: Can proceed if camera name is set
return !!( return !!state.wizardData.cameraName;
state.wizardData.cameraName &&
(state.wizardData.streams?.length ?? 0) > 0
);
case 1: 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 !!( return !!(
state.wizardData.streams?.some((stream) => state.wizardData.streams?.some((stream) =>
stream.roles.includes("detect"), stream.roles.includes("detect"),
) ?? false ) ?? false
); );
case 2: case 3:
// Always can proceed from final step (save will be handled there) // Step 4: Always can proceed from final step (save will be handled there)
return true; return true;
default: default:
return false; return false;
@ -340,13 +341,7 @@ export default function CameraWizardDialog({
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent <DialogContent
className={cn( className={cn(
"max-h-[90dvh] max-w-xl overflow-y-auto", "scrollbar-container max-h-[90dvh] max-w-3xl overflow-y-auto",
isDesktop &&
currentStep == 0 &&
state.wizardData?.streams?.[0]?.testResult?.snapshot &&
"max-w-4xl",
isDesktop && currentStep == 1 && "max-w-2xl",
isDesktop && currentStep > 1 && "max-w-4xl",
)} )}
onInteractOutside={(e) => { onInteractOutside={(e) => {
e.preventDefault(); e.preventDefault();
@ -385,7 +380,16 @@ export default function CameraWizardDialog({
/> />
)} )}
{currentStep === 1 && ( {currentStep === 1 && (
<Step2StreamConfig <Step2ProbeOrSnapshot
wizardData={state.wizardData}
onUpdate={onUpdate}
onNext={handleNext}
onBack={handleBack}
probeMode={state.wizardData.probeMode ?? true}
/>
)}
{currentStep === 2 && (
<Step3StreamConfig
wizardData={state.wizardData} wizardData={state.wizardData}
onUpdate={onUpdate} onUpdate={onUpdate}
onBack={handleBack} onBack={handleBack}
@ -393,8 +397,8 @@ export default function CameraWizardDialog({
canProceed={canProceedToNext()} canProceed={canProceedToNext()}
/> />
)} )}
{currentStep === 2 && ( {currentStep === 3 && (
<Step3Validation <Step4Validation
wizardData={state.wizardData} wizardData={state.wizardData}
onUpdate={onUpdate} onUpdate={onUpdate}
onSave={handleSave} onSave={handleSave}

View File

@ -0,0 +1,363 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FaCopy, FaCheck } from "react-icons/fa";
import { LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useState } from "react";
import { toast } from "sonner";
import type {
OnvifProbeResponse,
OnvifRtspCandidate,
TestResult,
CandidateTestMap,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils";
import { maskUri } from "@/utils/cameraUtil";
type OnvifProbeResultsProps = {
isLoading: boolean;
isError: boolean;
error?: string;
probeResult?: OnvifProbeResponse;
onRetry: () => void;
selectedUris?: string[];
testCandidate?: (uri: string) => void;
candidateTests?: CandidateTestMap;
testingCandidates?: Record<string, boolean>;
};
export default function OnvifProbeResults({
isLoading,
isError,
error,
probeResult,
onRetry,
selectedUris,
testCandidate,
candidateTests,
testingCandidates,
}: OnvifProbeResultsProps) {
const { t } = useTranslation(["views/settings"]);
const [copiedUri, setCopiedUri] = useState<string | null>(null);
const handleCopyUri = (uri: string) => {
navigator.clipboard.writeText(uri);
setCopiedUri(uri);
toast.success(t("cameraWizard.step2.uriCopied"));
setTimeout(() => setCopiedUri(null), 2000);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<ActivityIndicator className="size-6" />
<p className="text-sm text-muted-foreground">
{t("cameraWizard.step2.probingDevice")}
</p>
</div>
);
}
if (isError) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.probeError")}</AlertTitle>
{error && <AlertDescription>{error}</AlertDescription>}
</Alert>
<Button onClick={onRetry} variant="outline" className="w-full">
{t("button.retry", { ns: "common" })}
</Button>
</div>
);
}
if (!probeResult?.success) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.probeNoSuccess")}</AlertTitle>
{probeResult?.message && (
<AlertDescription>{probeResult.message}</AlertDescription>
)}
</Alert>
<Button onClick={onRetry} variant="outline" className="w-full">
{t("button.retry", { ns: "common" })}
</Button>
</div>
);
}
const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
);
if (probeResult?.success && rtspCandidates.length === 0) {
return (
<div className="space-y-4">
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
</Alert>
</div>
);
}
return (
<>
<div className="space-y-2">
{probeResult?.success && (
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
<FaCircleCheck className="size-4" />
<span>{t("cameraWizard.step2.probeSuccessful")}</span>
</div>
)}
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
<Card>
<CardContent className="space-y-2 p-4 text-sm">
{probeResult.manufacturer && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.manufacturer")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.manufacturer}
</span>
</div>
)}
{probeResult.model && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.model")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.model}
</span>
</div>
)}
{probeResult.firmware_version && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.firmware")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.firmware_version}
</span>
</div>
)}
{probeResult.profiles_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.profiles")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.profiles_count}
</span>
</div>
)}
{probeResult.ptz_supported !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.ptzSupport")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.ptz_supported
? t("yes", { ns: "common" })
: t("no", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported && probeResult.autotrack_supported && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.autotrackingSupport")}:
</span>{" "}
<span className="text-primary-variant">
{t("yes", { ns: "common" })}
</span>
</div>
)}
{probeResult.ptz_supported &&
probeResult.presets_count !== undefined && (
<div>
<span className="text-muted-foreground">
{t("cameraWizard.step2.presets")}:
</span>{" "}
<span className="text-primary-variant">
{probeResult.presets_count}
</span>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-2">
{rtspCandidates.length > 0 && (
<div className="mt-5 space-y-2">
<div className="text-sm">
{t("cameraWizard.step2.rtspCandidates")}
</div>
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step2.rtspCandidatesDescription")}
</div>
<div className="space-y-2">
{rtspCandidates.map((candidate, idx) => {
const isSelected = !!selectedUris?.includes(candidate.uri);
const candidateTest = candidateTests?.[candidate.uri];
const isTesting = testingCandidates?.[candidate.uri];
return (
<CandidateItem
key={idx}
index={idx}
candidate={candidate}
copiedUri={copiedUri}
onCopy={() => handleCopyUri(candidate.uri)}
isSelected={isSelected}
testCandidate={testCandidate}
candidateTest={candidateTest}
isTesting={isTesting}
/>
);
})}
</div>
</div>
)}
</div>
</>
);
}
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);
return (
<Card
className={cn(
isSelected &&
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
)}
>
<CardContent className="p-4">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.candidateStreamTitle", {
number: (index ?? 0) + 1,
})}
</h4>
{candidateTest?.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
candidateTest.resolution,
candidateTest.fps
? `${candidateTest.fps} ${t(
"cameraWizard.testResultLabels.fps",
)}`
: null,
candidateTest.videoCodec,
candidateTest.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{candidateTest?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{candidateTest && !candidateTest.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
</div>
</div>
<div className="mt-1 flex items-start gap-2">
<p
className="flex-1 cursor-pointer break-all text-sm text-primary-variant hover:underline"
onClick={() => setShowFull((s) => !s)}
title={t("cameraWizard.step2.toggleUriView")}
>
{showFull ? candidate.uri : maskUri(candidate.uri)}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={onCopy}
className="mr-4 size-8 p-0"
title={t("cameraWizard.step2.uriCopy")}
>
{copiedUri === candidate.uri ? (
<FaCheck className="size-3" />
) : (
<FaCopy className="size-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={isTesting}
onClick={() => testCandidate?.(candidate.uri)}
className="h-8 px-3 text-sm"
>
{isTesting ? (
<>
<ActivityIndicator className="mr-2 size-4" />{" "}
{t("cameraWizard.step2.testConnection")}
</>
) : (
t("cameraWizard.step2.testConnection")
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -2,11 +2,13 @@ import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@ -15,15 +17,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu"; 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 useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { import {
@ -31,20 +31,13 @@ import {
CameraBrand, CameraBrand,
CAMERA_BRANDS, CAMERA_BRANDS,
CAMERA_BRAND_VALUES, CAMERA_BRAND_VALUES,
TestResult,
FfprobeStream,
StreamRole,
StreamConfig,
} from "@/types/cameraWizard"; } from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu"; import { LuInfo } from "react-icons/lu";
import { detectReolinkCamera } from "@/utils/cameraUtil";
type Step1NameCameraProps = { type Step1NameCameraProps = {
wizardData: Partial<WizardFormData>; wizardData: Partial<WizardFormData>;
@ -63,9 +56,9 @@ export default function Step1NameCamera({
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isTesting, setIsTesting] = useState(false); const [probeMode, setProbeMode] = useState<boolean>(
const [testStatus, setTestStatus] = useState<string>(""); wizardData.probeMode ?? true,
const [testResult, setTestResult] = useState<TestResult | null>(null); );
const existingCameraNames = useMemo(() => { const existingCameraNames = useMemo(() => {
if (!config?.cameras) { if (!config?.cameras) {
@ -88,6 +81,8 @@ export default function Step1NameCamera({
username: z.string().optional(), username: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(), brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
useDigestAuth: z.boolean().optional(),
customUrl: z customUrl: z
.string() .string()
.optional() .optional()
@ -124,6 +119,8 @@ export default function Step1NameCamera({
? (wizardData.brandTemplate as CameraBrand) ? (wizardData.brandTemplate as CameraBrand)
: "dahua", : "dahua",
customUrl: wizardData.customUrl || "", customUrl: wizardData.customUrl || "",
onvifPort: wizardData.onvifPort ?? 80,
useDigestAuth: wizardData.useDigestAuth ?? false,
}, },
mode: "onChange", mode: "onChange",
}); });
@ -132,243 +129,32 @@ export default function Step1NameCamera({
const watchedHost = form.watch("host"); const watchedHost = form.watch("host");
const watchedCustomUrl = form.watch("customUrl"); const watchedCustomUrl = form.watch("customUrl");
const isTestButtonEnabled = const hostPresent = !!(watchedHost && watchedHost.trim());
watchedBrand === "other" const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
? !!(watchedCustomUrl && watchedCustomUrl.trim()) const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
: !!(watchedHost && watchedHost.trim());
const generateDynamicStreamUrl = useCallback( const isContinueButtonEnabled =
async (data: z.infer<typeof step1FormData>): Promise<string | null> => { cameraNamePresent &&
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); (probeMode
if (!brand || !data.host) return null; ? hostPresent
: watchedBrand === "other"
let protocol = undefined; ? customPresent
if (data.brandTemplate === "reolink" && data.username && data.password) { : hostPresent);
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<string, string> = 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<typeof step1FormData>): Promise<string> => {
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<string>((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 onSubmit = (data: z.infer<typeof step1FormData>) => { const onSubmit = (data: z.infer<typeof step1FormData>) => {
onUpdate(data); onUpdate({ ...data, probeMode });
}; };
const handleContinue = useCallback(async () => { const handleContinue = useCallback(async () => {
const isValid = await form.trigger();
if (isValid) {
const data = form.getValues(); const data = form.getValues();
const streamUrl = await generateStreamUrl(data); onNext({ ...data, probeMode });
const streamId = `stream_${Date.now()}`; }
}, [form, probeMode, onNext]);
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{!testResult?.success && (
<>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("cameraWizard.step1.description")} {t("cameraWizard.step1.description")}
</div> </div>
@ -386,9 +172,7 @@ export default function Step1NameCamera({
<FormControl> <FormControl>
<Input <Input
className="text-md h-8" className="text-md h-8"
placeholder={t( placeholder={t("cameraWizard.step1.cameraNamePlaceholder")}
"cameraWizard.step1.cameraNamePlaceholder",
)}
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -397,6 +181,186 @@ export default function Step1NameCamera({
)} )}
/> />
<div className="space-y-4">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.host")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder="192.168.1.100"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.username")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder={t("cameraWizard.step1.usernamePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
className="text-md h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
)}
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-3 pt-4">
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.detectionMethod")}
</FormLabel>
<RadioGroup
value={probeMode ? "probe" : "manual"}
onValueChange={(value) => {
setProbeMode(value === "probe");
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="probe"
id="probe-mode"
className={
probeMode
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor="probe-mode" className="cursor-pointer text-sm">
{t("cameraWizard.step1.probeMode")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id="manual-mode"
className={
!probeMode
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor="manual-mode" className="cursor-pointer text-sm">
{t("cameraWizard.step1.manualMode")}
</label>
</div>
</RadioGroup>
<FormDescription>
{t("cameraWizard.step1.detectionMethodDescription")}
</FormDescription>
</div>
{probeMode && (
<FormField
control={form.control}
name="onvifPort"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.onvifPort")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
type="text"
{...field}
placeholder="80"
/>
</FormControl>
<FormDescription>
{t("cameraWizard.step1.onvifPortDescription")}
</FormDescription>
<FormMessage>
{fieldState.error ? fieldState.error.message : null}
</FormMessage>
</FormItem>
)}
/>
)}
{probeMode && (
<FormField
control={form.control}
name="useDigestAuth"
render={({ field }) => (
<FormItem className="flex items-start space-x-2">
<FormControl>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={!!field.value}
onCheckedChange={(val) => field.onChange(!!val)}
/>
</FormControl>
<div className="flex flex-1 flex-col space-y-1">
<FormLabel className="mb-0 text-primary-variant">
{t("cameraWizard.step1.useDigestAuth")}
</FormLabel>
<FormDescription className="mt-0">
{t("cameraWizard.step1.useDigestAuthDescription")}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
{!probeMode && (
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="brandTemplate" name="brandTemplate"
@ -463,90 +427,6 @@ export default function Step1NameCamera({
)} )}
/> />
{watchedBrand !== "other" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.host")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder="192.168.1.100"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.username")}
</FormLabel>
<FormControl>
<Input
className="text-md h-8"
placeholder={t(
"cameraWizard.step1.usernamePlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("cameraWizard.step1.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
className="text-md h-8 pr-10"
type={showPassword ? "text" : "password"}
placeholder={t(
"cameraWizard.step1.passwordPlaceholder",
)}
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBrand == "other" && ( {watchedBrand == "other" && (
<FormField <FormField
control={form.control} control={form.control}
@ -568,124 +448,25 @@ export default function Step1NameCamera({
)} )}
/> />
)} )}
</div>
)}
</form> </form>
</Form> </Form>
</>
)}
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<FaCircleCheck className="size-4" />
{t("cameraWizard.step1.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step1.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button <Button type="button" onClick={onCancel} className="sm:flex-1">
type="button" {t("button.cancel", { ns: "common" })}
onClick={testResult?.success ? () => setTestResult(null) : onCancel}
className="sm:flex-1"
>
{testResult?.success
? t("button.back", { ns: "common" })
: t("button.cancel", { ns: "common" })}
</Button> </Button>
{testResult?.success ? (
<Button <Button
type="button" type="button"
onClick={handleContinue} onClick={handleContinue}
disabled={!isContinueButtonEnabled}
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{t("button.continue", { ns: "common" })} {t("button.continue", { ns: "common" })}
</Button> </Button>
) : (
<Button
type="button"
onClick={testConnection}
disabled={isTesting || !isTestButtonEnabled}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step1.testConnection")}
</Button>
)}
</div> </div>
</div> </div>
); );
} }
function StreamDetails({ testResult }: { testResult: TestResult }) {
const { t } = useTranslation(["views/settings"]);
return (
<>
{testResult.resolution && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-white">{testResult.resolution}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-white">{testResult.fps}</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-white">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-white">{testResult.audioCodec}</span>
</div>
)}
</>
);
}

View File

@ -0,0 +1,725 @@
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<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onNext: (data?: Partial<WizardFormData>) => 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<string>("");
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [isProbing, setIsProbing] = useState(false);
const [probeError, setProbeError] = useState<string | null>(null);
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
null,
);
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} as CandidateTestMap,
);
const probeUri = useCallback(
async (
uri: string,
fetchSnapshot = false,
setStatus?: (s: string) => void,
): Promise<TestResult> => {
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<string>((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,
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
},
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<WizardFormData>): Promise<string | null> => {
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<string, string> = 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<WizardFormData>): Promise<string> => {
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 (
<div className="space-y-6">
{probeMode ? (
// Probe mode: show probe results directly
<>
{probeResult && (
<div className="space-y-4">
<OnvifProbeResults
isLoading={isProbing}
isError={!!probeError}
error={probeError || undefined}
probeResult={probeResult}
onRetry={probeCamera}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
/>
</div>
)}
<ProbeFooterButtons
isProbing={isProbing}
probeError={probeError}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
// disable next if either the overall testConnection is running or any candidate test is running
isTesting={
isTesting || Object.values(testingCandidates).some((v) => v)
}
candidateCount={
(probeResult?.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
).length
}
/>
</>
) : (
// Manual mode: show snapshot and stream details
<>
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<FaCircleCheck className="size-4" />
{t("cameraWizard.step2.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step2.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
{testResult && !testResult.success && (
<div className="space-y-4">
<div className="text-sm text-destructive">{testResult.error}</div>
</div>
)}
<ProbeFooterButtons
mode="manual"
isProbing={false}
probeError={null}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
isTesting={
isTesting || Object.values(testingCandidates).some((v) => v)
}
candidateCount={
(probeResult?.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
).length
}
manualTestSuccess={!!testResult?.success}
onContinue={handleContinue}
onManualTest={testConnection}
/>
</>
)}
</div>
);
}
function StreamDetails({ testResult }: { testResult: TestResult }) {
const { t } = useTranslation(["views/settings"]);
return (
<>
{testResult.resolution && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-white">{testResult.resolution}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-white">{testResult.fps}</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-white">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-white">{testResult.audioCodec}</span>
</div>
)}
</>
);
}
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 (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
disabled
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</Button>
</div>
</div>
);
}
// Error footer
if (probeError) {
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onRetry}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
);
}
// 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 (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
{manualTestSuccess ? (
<Button
type="button"
onClick={onContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={onManualTest}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting ? (
<>
<ActivityIndicator className="size-4" />{" "}
{t("button.continue", { ns: "common" })}
</>
) : (
t("cameraWizard.step2.retry")
)}
</Button>
)}
</div>
);
}
// Default probe footer
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onTestAll}
disabled={isTesting || (candidateCount ?? 0) === 0}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
);
}

View File

@ -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<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => 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<Set<string>>(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<StreamConfig>) => {
onUpdate({
streams: streams.map((s) =>
s.id === streamId ? { ...s, ...updates } : s,
),
});
},
[streams, onUpdate],
);
const getUsedRolesExcludingStream = useCallback(
(excludeStreamId: string) => {
const roles = new Set<StreamRole>();
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 (
<div className="space-y-6">
<div className="text-sm text-secondary-foreground">
{t("cameraWizard.step2.description")}
</div>
<div className="space-y-4">
{streams.map((stream, index) => (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step2.streamTitle", { number: index + 1 })}
</h4>
{stream.testResult && stream.testResult.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
stream.testResult.resolution,
stream.testResult.fps
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
stream.testResult.videoCodec,
stream.testResult.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{stream.testResult?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step2.connected")}
</span>
</div>
)}
{stream.testResult && !stream.testResult.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step2.notConnected")}
</span>
</div>
)}
{streams.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeStream(stream.id)}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step2.url")}
</label>
<div className="flex flex-row items-center gap-2">
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t("cameraWizard.step2.streamUrlPlaceholder")}
/>
<Button
type="button"
onClick={() => testStream(stream)}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("cameraWizard.step2.testStream")}
</Button>
</div>
</div>
</div>
{stream.testResult &&
!stream.testResult.success &&
stream.userTested && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step2.testFailedTitle")}
</div>
<div className="mt-1 text-xs">
{stream.testResult.error}
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step2.roles")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.rolesPopover.title")}
</div>
<div className="space-y-1 text-muted-foreground">
<div>
<strong>detect</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.detect")}
</div>
<div>
<strong>record</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.record")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step2.rolesPopover.audio")}
</div>
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/cameras")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() => toggleRole(stream.id, role)}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step2.featuresTitle")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step2.featuresPopover.title")}
</div>
<div className="text-muted-foreground">
{t("cameraWizard.step2.featuresPopover.description")}
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/restream#reduce-connections-to-camera",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("cameraWizard.step2.go2rtc")}
</span>
<Switch
checked={stream.restream || false}
onCheckedChange={() => setRestream(stream.id)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Button
type="button"
onClick={addStream}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraWizard.step2.addAnotherStream")}
</Button>
</div>
{!hasDetectRole && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step2.detectRoleWarning")}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
{onNext && (
<Button
type="button"
onClick={() => onNext?.()}
disabled={!canProceed}
variant="select"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
)}
</div>
</div>
);
}

View File

@ -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<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => 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<Set<string>>(new Set());
const [openCombobox, setOpenCombobox] = useState<string | null>(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<StreamConfig>) => {
onUpdate({
streams: streams.map((s) =>
s.id === streamId ? { ...s, ...updates } : s,
),
});
},
[streams, onUpdate],
);
const getUsedRolesExcludingStream = useCallback(
(excludeStreamId: string) => {
const roles = new Set<StreamRole>();
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<string>();
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 (
<div className="space-y-6">
<div className="text-sm text-secondary-foreground">
{t("cameraWizard.step3.description")}
</div>
<div className="space-y-4">
{streams.map((stream, index) => (
<Card key={stream.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">
{t("cameraWizard.step3.streamTitle", { number: index + 1 })}
</h4>
{stream.testResult && stream.testResult.success && (
<div className="mt-1 text-sm text-muted-foreground">
{[
stream.testResult.resolution,
stream.testResult.fps
? `${stream.testResult.fps} ${t("cameraWizard.testResultLabels.fps")}`
: null,
stream.testResult.videoCodec,
stream.testResult.audioCodec,
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="flex items-center gap-2">
{stream.testResult?.success && (
<div className="flex items-center gap-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span className="text-success">
{t("cameraWizard.step3.connected")}
</span>
</div>
)}
{stream.testResult && !stream.testResult.success && (
<div className="flex items-center gap-2 text-sm">
<LuX className="size-4 text-danger" />
<span className="text-danger">
{t("cameraWizard.step3.notConnected")}
</span>
</div>
)}
{streams.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeStream(stream.id)}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.url")}
</label>
<div className="flex flex-row items-center gap-2">
{isProbeMode && probeCandidates.length > 0 ? (
// Responsive: Popover on desktop, Drawer on mobile
!isMobile ? (
<Popover
open={openCombobox === stream.id}
onOpenChange={(isOpen) => {
setOpenCombobox(isOpen ? stream.id : null);
}}
>
<PopoverTrigger asChild>
<div className="min-w-0 flex-1">
<Button
variant="outline"
role="combobox"
aria-expanded={openCombobox === stream.id}
className="h-8 w-full justify-between overflow-hidden text-left"
>
<span className="truncate">
{stream.url
? stream.url
: t("cameraWizard.step3.selectStream")}
</span>
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
</Button>
</div>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-2"
disablePortal
>
<Command>
<CommandInput
placeholder={t(
"cameraWizard.step3.searchCandidates",
)}
className="h-9"
/>
<CommandList>
<CommandEmpty>
{t("cameraWizard.step3.noStreamFound")}
</CommandEmpty>
<CommandGroup>
{probeCandidates
.filter((c) => {
const used = getUsedUrlsExcludingStream(
stream.id,
);
return !used.has(c);
})
.map((candidate) => (
<CommandItem
key={candidate}
value={candidate}
onSelect={() => {
updateStream(stream.id, {
url: candidate,
testResult:
candidateTests[candidate] ||
undefined,
userTested:
!!candidateTests[candidate],
});
setOpenCombobox(null);
}}
>
<LuCheck
className={cn(
"mr-3 size-5",
stream.url === candidate
? "opacity-100"
: "opacity-0",
)}
/>
{candidate}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Drawer
open={openCombobox === stream.id}
onOpenChange={(isOpen) =>
setOpenCombobox(isOpen ? stream.id : null)
}
>
<DrawerTrigger asChild>
<div className="min-w-0 flex-1">
<Button
variant="outline"
role="combobox"
aria-expanded={openCombobox === stream.id}
className="h-8 w-full justify-between overflow-hidden text-left"
>
<span className="truncate">
{stream.url
? stream.url
: t("cameraWizard.step3.selectStream")}
</span>
<LuChevronsUpDown className="ml-2 size-6 opacity-50" />
</Button>
</div>
</DrawerTrigger>
<DrawerContent className="mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-2">
<div className="mt-2">
<Command>
<CommandInput
placeholder={t(
"cameraWizard.step3.searchCandidates",
)}
className="h-9"
/>
<CommandList>
<CommandEmpty>
{t("cameraWizard.step3.noStreamFound")}
</CommandEmpty>
<CommandGroup>
{probeCandidates
.filter((c) => {
const used = getUsedUrlsExcludingStream(
stream.id,
);
return !used.has(c);
})
.map((candidate) => (
<CommandItem
key={candidate}
value={candidate}
onSelect={() => {
updateStream(stream.id, {
url: candidate,
testResult:
candidateTests[candidate] ||
undefined,
userTested:
!!candidateTests[candidate],
});
setOpenCombobox(null);
}}
>
<LuCheck
className={cn(
"mr-3 size-5",
stream.url === candidate
? "opacity-100"
: "opacity-0",
)}
/>
{candidate}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
</DrawerContent>
</Drawer>
)
) : (
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t(
"cameraWizard.step3.streamUrlPlaceholder",
)}
/>
)}
<Button
type="button"
onClick={() => testStream(stream)}
disabled={
testingStreams.has(stream.id) || !stream.url.trim()
}
variant="outline"
size="sm"
>
{testingStreams.has(stream.id) && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("cameraWizard.step3.testStream")}
</Button>
</div>
</div>
</div>
{stream.testResult &&
!stream.testResult.success &&
stream.userTested && (
<div className="rounded-md border border-danger/20 bg-danger/10 p-3 text-sm text-danger">
<div className="font-medium">
{t("cameraWizard.step3.testFailedTitle")}
</div>
<div className="mt-1 text-xs">
{stream.testResult.error}
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.roles")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.rolesPopover.title")}
</div>
<div className="space-y-1 text-muted-foreground">
<div>
<strong>detect</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.detect")}
</div>
<div>
<strong>record</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.record")}
</div>
<div>
<strong>audio</strong> -{" "}
{t("cameraWizard.step3.rolesPopover.audio")}
</div>
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/cameras")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map((role) => {
const isUsedElsewhere = getUsedRolesExcludingStream(
stream.id,
).has(role);
const isChecked = stream.roles.includes(role);
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">{role}</span>
<Switch
checked={isChecked}
onCheckedChange={() => toggleRole(stream.id, role)}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.featuresTitle")}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.featuresPopover.title")}
</div>
<div className="text-muted-foreground">
{t("cameraWizard.step3.featuresPopover.description")}
</div>
<div className="mt-3 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/restream#reduce-connections-to-camera",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="rounded-lg bg-background p-3">
<div className="flex items-center justify-between">
<span className="text-sm">
{t("cameraWizard.step3.go2rtc")}
</span>
<Switch
checked={stream.restream || false}
onCheckedChange={() => setRestream(stream.id)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Button
type="button"
onClick={addStream}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraWizard.step3.addAnotherStream")}
</Button>
</div>
{!hasDetectRole && (
<div className="rounded-lg border border-danger/50 p-3 text-sm text-danger">
{t("cameraWizard.step3.detectRoleWarning")}
</div>
)}
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
{onBack && (
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
)}
{onNext && (
<Button
type="button"
onClick={() => onNext?.()}
disabled={!canProceed || testingStreams.size > 0}
variant="select"
className="sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
)}
</div>
</div>
);
}

View File

@ -18,8 +18,9 @@ import { PlayerStatsType } from "@/types/live";
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu"; import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card"; import { Card, CardContent } from "../../ui/card";
import { maskUri } from "@/utils/cameraUtil";
type Step3ValidationProps = { type Step4ValidationProps = {
wizardData: Partial<WizardFormData>; wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void; onUpdate: (data: Partial<WizardFormData>) => void;
onSave: (config: WizardFormData) => void; onSave: (config: WizardFormData) => void;
@ -27,13 +28,13 @@ type Step3ValidationProps = {
isLoading?: boolean; isLoading?: boolean;
}; };
export default function Step3Validation({ export default function Step4Validation({
wizardData, wizardData,
onUpdate, onUpdate,
onSave, onSave,
onBack, onBack,
isLoading = false, isLoading = false,
}: Step3ValidationProps) { }: Step4ValidationProps) {
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set()); const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
@ -143,13 +144,13 @@ export default function Step3Validation({
if (testResult.success) { if (testResult.success) {
toast.success( toast.success(
t("cameraWizard.step3.streamValidated", { t("cameraWizard.step4.streamValidated", {
number: streams.findIndex((s) => s.id === stream.id) + 1, number: streams.findIndex((s) => s.id === stream.id) + 1,
}), }),
); );
} else { } else {
toast.error( toast.error(
t("cameraWizard.step3.streamValidationFailed", { t("cameraWizard.step4.streamValidationFailed", {
number: streams.findIndex((s) => s.id === stream.id) + 1, number: streams.findIndex((s) => s.id === stream.id) + 1,
}), }),
); );
@ -200,16 +201,16 @@ export default function Step3Validation({
(r) => r.success, (r) => r.success,
).length; ).length;
if (successfulTests === results.size) { if (successfulTests === results.size) {
toast.success(t("cameraWizard.step3.reconnectionSuccess")); toast.success(t("cameraWizard.step4.reconnectionSuccess"));
} else { } else {
toast.warning(t("cameraWizard.step3.reconnectionPartial")); toast.warning(t("cameraWizard.step4.reconnectionPartial"));
} }
} }
}, [streams, onUpdate, t, performStreamValidation]); }, [streams, onUpdate, t, performStreamValidation]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
if (!wizardData.cameraName || !wizardData.streams?.length) { if (!wizardData.cameraName || !wizardData.streams?.length) {
toast.error(t("cameraWizard.step3.saveError")); toast.error(t("cameraWizard.step4.saveError"));
return; return;
} }
@ -239,13 +240,13 @@ export default function Step3Validation({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("cameraWizard.step3.description")} {t("cameraWizard.step4.description")}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
{t("cameraWizard.step3.validationTitle")} {t("cameraWizard.step4.validationTitle")}
</h3> </h3>
<Button <Button
onClick={validateAllStreams} onClick={validateAllStreams}
@ -254,8 +255,8 @@ export default function Step3Validation({
> >
{isValidating && <ActivityIndicator className="mr-2 size-4" />} {isValidating && <ActivityIndicator className="mr-2 size-4" />}
{isValidating {isValidating
? t("cameraWizard.step3.connecting") ? t("cameraWizard.step4.connecting")
: t("cameraWizard.step3.connectAllStreams")} : t("cameraWizard.step4.connectAllStreams")}
</Button> </Button>
</div> </div>
@ -270,7 +271,7 @@ export default function Step3Validation({
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<h4 className="mr-2 font-medium"> <h4 className="mr-2 font-medium">
{t("cameraWizard.step3.streamTitle", { {t("cameraWizard.step4.streamTitle", {
number: index + 1, number: index + 1,
})} })}
</h4> </h4>
@ -331,7 +332,7 @@ export default function Step3Validation({
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm"> <span className="text-sm">
{t("cameraWizard.step3.ffmpegModule")} {t("cameraWizard.step4.ffmpegModule")}
</span> </span>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -346,11 +347,11 @@ export default function Step3Validation({
<PopoverContent className="pointer-events-auto w-80 text-xs"> <PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium"> <div className="font-medium">
{t("cameraWizard.step3.ffmpegModule")} {t("cameraWizard.step4.ffmpegModule")}
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{t( {t(
"cameraWizard.step3.ffmpegModuleDescription", "cameraWizard.step4.ffmpegModuleDescription",
)} )}
</div> </div>
</div> </div>
@ -374,7 +375,7 @@ export default function Step3Validation({
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center"> <div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="break-all text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{stream.url} {maskUri(stream.url)}
</span> </span>
<Button <Button
onClick={() => { onClick={() => {
@ -402,17 +403,17 @@ export default function Step3Validation({
<ActivityIndicator className="mr-2 size-4" /> <ActivityIndicator className="mr-2 size-4" />
)} )}
{result?.success {result?.success
? t("cameraWizard.step3.disconnectStream") ? t("cameraWizard.step4.disconnectStream")
: testingStreams.has(stream.id) : testingStreams.has(stream.id)
? t("cameraWizard.step3.connectingStream") ? t("cameraWizard.step4.connectingStream")
: t("cameraWizard.step3.connectStream")} : t("cameraWizard.step4.connectStream")}
</Button> </Button>
</div> </div>
{result && ( {result && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs"> <div className="text-xs">
{t("cameraWizard.step3.issues.title")} {t("cameraWizard.step4.issues.title")}
</div> </div>
<div className="rounded-lg bg-background p-3"> <div className="rounded-lg bg-background p-3">
<StreamIssues <StreamIssues
@ -455,7 +456,7 @@ export default function Step3Validation({
{isLoading && <ActivityIndicator className="mr-2 size-4" />} {isLoading && <ActivityIndicator className="mr-2 size-4" />}
{isLoading {isLoading
? t("button.saving", { ns: "common" }) ? t("button.saving", { ns: "common" })
: t("cameraWizard.step3.saveAndApply")} : t("cameraWizard.step4.saveAndApply")}
</Button> </Button>
</div> </div>
</div> </div>
@ -486,7 +487,7 @@ function StreamIssues({
if (streamUrl.startsWith("rtsp://")) { if (streamUrl.startsWith("rtsp://")) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step1.errors.brands.reolink-rtsp"), message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
}); });
} }
} }
@ -497,7 +498,7 @@ function StreamIssues({
if (["h264", "h265", "hevc"].includes(videoCodec)) { if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({ result.push({
type: "good", type: "good",
message: t("cameraWizard.step3.issues.videoCodecGood", { message: t("cameraWizard.step4.issues.videoCodecGood", {
codec: stream.testResult.videoCodec, codec: stream.testResult.videoCodec,
}), }),
}); });
@ -511,20 +512,20 @@ function StreamIssues({
if (audioCodec === "aac") { if (audioCodec === "aac") {
result.push({ result.push({
type: "good", type: "good",
message: t("cameraWizard.step3.issues.audioCodecGood", { message: t("cameraWizard.step4.issues.audioCodecGood", {
codec: stream.testResult.audioCodec, codec: stream.testResult.audioCodec,
}), }),
}); });
} else { } else {
result.push({ result.push({
type: "error", type: "error",
message: t("cameraWizard.step3.issues.audioCodecRecordError"), message: t("cameraWizard.step4.issues.audioCodecRecordError"),
}); });
} }
} else { } else {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.noAudioWarning"), message: t("cameraWizard.step4.issues.noAudioWarning"),
}); });
} }
} }
@ -534,7 +535,7 @@ function StreamIssues({
if (!stream.testResult?.audioCodec) { if (!stream.testResult?.audioCodec) {
result.push({ result.push({
type: "error", type: "error",
message: t("cameraWizard.step3.issues.audioCodecRequired"), message: t("cameraWizard.step4.issues.audioCodecRequired"),
}); });
} }
} }
@ -544,7 +545,7 @@ function StreamIssues({
if (stream.restream) { if (stream.restream) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"), message: t("cameraWizard.step4.issues.restreamingWarning"),
}); });
} }
} }
@ -557,14 +558,14 @@ function StreamIssues({
if (minDimension > 1080) { if (minDimension > 1080) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.resolutionHigh", { message: t("cameraWizard.step4.issues.resolutionHigh", {
resolution: stream.resolution, resolution: stream.resolution,
}), }),
}); });
} else if (maxDimension < 640) { } else if (maxDimension < 640) {
result.push({ result.push({
type: "error", type: "error",
message: t("cameraWizard.step3.issues.resolutionLow", { message: t("cameraWizard.step4.issues.resolutionLow", {
resolution: stream.resolution, resolution: stream.resolution,
}), }),
}); });
@ -580,7 +581,7 @@ function StreamIssues({
) { ) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.dahua.substreamWarning"), message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
}); });
} }
if ( if (
@ -590,7 +591,7 @@ function StreamIssues({
) { ) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.hikvision.substreamWarning"), message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
}); });
} }
@ -662,7 +663,7 @@ function BandwidthDisplay({
return ( return (
<div className="mb-2 text-sm"> <div className="mb-2 text-sm">
<span className="font-medium text-muted-foreground"> <span className="font-medium text-muted-foreground">
{t("cameraWizard.step3.estimatedBandwidth")}: {t("cameraWizard.step4.estimatedBandwidth")}:
</span>{" "} </span>{" "}
<span className="text-secondary-foreground"> <span className="text-secondary-foreground">
{streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })} {streamBandwidth.toFixed(1)} {t("unit.data.kbps", { ns: "common" })}
@ -748,7 +749,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
style={{ aspectRatio }} style={{ aspectRatio }}
> >
<span className="text-sm text-danger"> <span className="text-sm text-danger">
{t("cameraWizard.step3.streamUnavailable")} {t("cameraWizard.step4.streamUnavailable")}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -757,7 +758,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<LuRotateCcw className="size-4" /> <LuRotateCcw className="size-4" />
{t("cameraWizard.step3.reload")} {t("cameraWizard.step4.reload")}
</Button> </Button>
</div> </div>
); );
@ -771,7 +772,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
> >
<ActivityIndicator className="size-4" /> <ActivityIndicator className="size-4" />
<span className="ml-2 text-sm"> <span className="ml-2 text-sm">
{t("cameraWizard.step3.connecting")} {t("cameraWizard.step4.connecting")}
</span> </span>
</div> </div>
); );

View File

@ -349,7 +349,7 @@ function ReviewGroup({
? fetchedEvents.length ? fetchedEvents.length
: (review.data.objects ?? []).length; : (review.data.objects ?? []).length;
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`; return `${t("detail.trackedObject", { count: objectCount })}`;
}, [review, t, fetchedEvents]); }, [review, t, fetchedEvents]);
const reviewDuration = useMemo( const reviewDuration = useMemo(
@ -478,7 +478,7 @@ function ReviewGroup({
<div className="rounded-full bg-muted-foreground p-1"> <div className="rounded-full bg-muted-foreground p-1">
{getIconForLabel(audioLabel, "size-3 text-white")} {getIconForLabel(audioLabel, "size-3 text-white")}
</div> </div>
<span>{getTranslatedLabel(audioLabel)}</span> <span>{getTranslatedLabel(audioLabel, "audio")}</span>
</div> </div>
</div> </div>
))} ))}
@ -513,7 +513,8 @@ function EventList({
const isSelected = selectedObjectIds.includes(event.id); const isSelected = selectedObjectIds.includes(event.id);
const label = event.sub_label || getTranslatedLabel(event.label); const label =
event.sub_label || getTranslatedLabel(event.label, event.data.type);
const handleObjectSelect = (event: Event | undefined) => { const handleObjectSelect = (event: Event | undefined) => {
if (event) { if (event) {

View File

@ -1,4 +1,10 @@
import React, { createContext, useContext, useState, useEffect } from "react"; import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
} from "react";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
@ -36,6 +42,23 @@ export function DetailStreamProvider({
() => initialSelectedObjectIds ?? [], () => initialSelectedObjectIds ?? [],
); );
// When the parent provides a new initialSelectedObjectIds (for example
// when navigating between search results) update the selection so children
// like `ObjectTrackOverlay` receive the new ids immediately. We only
// perform this update when the incoming value actually changes.
useEffect(() => {
if (
initialSelectedObjectIds &&
(initialSelectedObjectIds.length !== selectedObjectIds.length ||
initialSelectedObjectIds.some((v, i) => selectedObjectIds[i] !== v))
) {
setSelectedObjectIds(initialSelectedObjectIds);
}
// Intentionally include selectedObjectIds to compare previous value and
// avoid overwriting user interactions unless the incoming prop changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSelectedObjectIds]);
const toggleObjectSelection = (id: string | undefined) => { const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) { if (id === undefined) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
@ -63,10 +86,33 @@ export function DetailStreamProvider({
setAnnotationOffset(cfgOffset); setAnnotationOffset(cfgOffset);
}, [config, camera]); }, [config, camera]);
// Clear selected objects when exiting detail mode or changing cameras // Clear selected objects when exiting detail mode or when the camera
// changes for providers that are not initialized with an explicit
// `initialSelectedObjectIds` (e.g., the RecordingView). For providers
// that receive `initialSelectedObjectIds` (like SearchDetailDialog) we
// avoid clearing on camera change to prevent a race with children that
// immediately set selection when mounting.
const prevCameraRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
// Always clear when leaving detail mode
if (!isDetailMode) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
}, [isDetailMode, camera]); prevCameraRef.current = camera;
return;
}
// If camera changed and the parent did not provide initialSelectedObjectIds,
// clear selection to preserve previous behavior.
if (
prevCameraRef.current !== undefined &&
prevCameraRef.current !== camera &&
initialSelectedObjectIds === undefined
) {
setSelectedObjectIds([]);
}
prevCameraRef.current = camera;
}, [isDetailMode, camera, initialSelectedObjectIds]);
const value: DetailStreamContextType = { const value: DetailStreamContextType = {
selectedObjectIds, selectedObjectIds,

View File

@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
export default function useCameraLiveMode( export default function useCameraLiveMode(
cameras: CameraConfig[], cameras: CameraConfig[],
windowVisible: boolean, windowVisible: boolean,
activeStreams?: { [cameraName: string]: string },
) { ) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -20,16 +21,20 @@ export default function useCameraLiveMode(
); );
if (isRestreamed) { if (isRestreamed) {
if (activeStreams && activeStreams[camera.name]) {
streamNames.add(activeStreams[camera.name]);
} else {
Object.values(camera.live.streams).forEach((streamName) => { Object.values(camera.live.streams).forEach((streamName) => {
streamNames.add(streamName); streamNames.add(streamName);
}); });
} }
}
}); });
return streamNames.size > 0 return streamNames.size > 0
? Array.from(streamNames).sort().join(",") ? Array.from(streamNames).sort().join(",")
: null; : null;
}, [cameras, config]); }, [cameras, config, activeStreams]);
const streamsFetcher = useCallback(async (key: string) => { const streamsFetcher = useCallback(async (key: string) => {
const streamNames = key.split(","); const streamNames = key.split(",");
@ -68,7 +73,9 @@ export default function useCameraLiveMode(
[key: string]: LiveStreamMetadata; [key: string]: LiveStreamMetadata;
}>(restreamedStreamsKey, streamsFetcher, { }>(restreamedStreamsKey, streamsFetcher, {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 10000, revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
}); });
const [preferredLiveModes, setPreferredLiveModes] = useState<{ const [preferredLiveModes, setPreferredLiveModes] = useState<{

View File

@ -99,6 +99,11 @@ export type TestResult = {
error?: string; error?: string;
}; };
export type CandidateTestMap = Record<
string,
TestResult | { success: false; error: string }
>;
export type WizardFormData = { export type WizardFormData = {
cameraName?: string; cameraName?: string;
host?: string; host?: string;
@ -107,12 +112,18 @@ export type WizardFormData = {
brandTemplate?: CameraBrand; brandTemplate?: CameraBrand;
customUrl?: string; customUrl?: string;
streams?: StreamConfig[]; streams?: StreamConfig[];
probeMode?: boolean; // true for probe, false for manual
onvifPort?: number;
useDigestAuth?: boolean;
probeResult?: OnvifProbeResponse;
probeCandidates?: string[]; // candidate URLs from probe
candidateTests?: CandidateTestMap; // test results for candidates
}; };
// API Response Types // API Response Types
export type FfprobeResponse = { export type FfprobeResponse = {
return_code: number; return_code: number;
stderr: string; stderr: string | string[];
stdout: FfprobeData | string; stdout: FfprobeData | string;
}; };
@ -167,3 +178,26 @@ export type ConfigSetBody = {
config_data: CameraConfigData; config_data: CameraConfigData;
update_topic?: string; 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;
};

View File

@ -20,3 +20,17 @@ export type ClassificationThreshold = {
recognition: number; recognition: number;
unknown: number; unknown: number;
}; };
export type ClassificationDatasetResponse = {
categories: {
[id: string]: string[];
};
training_metadata: {
has_trained: boolean;
last_training_date: string | null;
last_training_image_count: number;
current_image_count: number;
new_images_count: number;
dataset_changed: boolean;
} | null;
};

View File

@ -87,7 +87,8 @@ export type ModelState =
| "downloaded" | "downloaded"
| "error" | "error"
| "training" | "training"
| "complete"; | "complete"
| "failed";
export type EmbeddingsReindexProgressType = { export type EmbeddingsReindexProgressType = {
thumbnails: number; thumbnails: number;

View File

@ -71,3 +71,26 @@ export async function detectReolinkCamera(
return null; return null;
} }
} }
/**
* Mask credentials in RTSP URIs for display
*/
export function maskUri(uri: string): string {
try {
// Handle RTSP URLs with user:pass@host format
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
if (rtspMatch) {
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
}
// Handle HTTP/HTTPS URLs with password query parameter
const urlObj = new URL(uri);
if (urlObj.searchParams.has("password")) {
urlObj.searchParams.set("password", "*".repeat(4));
return urlObj.toString();
}
} catch (e) {
// ignore
}
return uri;
}

View File

@ -244,12 +244,12 @@ export const getDurationFromTimestamps = (
abbreviated: boolean = false, abbreviated: boolean = false,
): string => { ): string => {
if (isNaN(start_time)) { if (isNaN(start_time)) {
return "Invalid start time"; return i18n.t("time.invalidStartTime", { ns: "common" });
} }
let duration = "In Progress"; let duration = i18n.t("time.inProgress", { ns: "common" });
if (end_time !== null) { if (end_time !== null) {
if (isNaN(end_time)) { if (isNaN(end_time)) {
return "Invalid end time"; return i18n.t("time.invalidEndTime", { ns: "common" });
} }
const start = fromUnixTime(start_time); const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time); const end = fromUnixTime(end_time);

View File

@ -11,6 +11,7 @@ import {
CustomClassificationModelConfig, CustomClassificationModelConfig,
FrigateConfig, FrigateConfig,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import { ClassificationDatasetResponse } from "@/types/classification";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa"; import { FaFolderPlus } from "react-icons/fa";
@ -209,9 +210,10 @@ type ModelCardProps = {
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) { function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
const { t } = useTranslation(["views/classificationModel"]); const { t } = useTranslation(["views/classificationModel"]);
const { data: dataset } = useSWR<{ const { data: dataset } = useSWR<ClassificationDatasetResponse>(
[id: string]: string[]; `classification/${config.name}/dataset`,
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false }); { revalidateOnFocus: false },
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
@ -260,20 +262,25 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
}, []); }, []);
const coverImage = useMemo(() => { const coverImage = useMemo(() => {
if (!dataset) { if (!dataset || !dataset.categories) {
return undefined; return undefined;
} }
const keys = Object.keys(dataset).filter((key) => key != "none"); const keys = Object.keys(dataset.categories).filter((key) => key != "none");
const selectedKey = keys[0]; if (keys.length === 0) {
return undefined;
}
if (!dataset[selectedKey]) { const selectedKey = keys[0];
const images = dataset.categories[selectedKey];
if (!images || images.length === 0) {
return undefined; return undefined;
} }
return { return {
name: selectedKey, name: selectedKey,
img: dataset[selectedKey][0], img: images[0],
}; };
}, [dataset]); }, [dataset]);
@ -317,11 +324,19 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
)} )}
onClick={onClick} onClick={onClick}
> >
{coverImage ? (
<>
<img <img
className="size-full" className="size-full"
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`} src={`${baseUrl}clips/${config.name}/dataset/${coverImage.name}/${coverImage.img}`}
/> />
<ImageShadowOverlay lowerClassName="h-[30%] z-0" /> <ImageShadowOverlay lowerClassName="h-[30%] z-0" />
</>
) : (
<div className="flex size-full items-center justify-center bg-background_alt">
<MdModelTraining className="size-16 text-muted-foreground" />
</div>
)}
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize"> <div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
{config.name} {config.name}
</div> </div>

View File

@ -59,7 +59,11 @@ import { useNavigate } from "react-router-dom";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import TrainFilterDialog from "@/components/overlay/dialog/TrainFilterDialog"; import TrainFilterDialog from "@/components/overlay/dialog/TrainFilterDialog";
import useApiFilter from "@/hooks/use-api-filter"; import useApiFilter from "@/hooks/use-api-filter";
import { ClassificationItemData, TrainFilter } from "@/types/classification"; import {
ClassificationDatasetResponse,
ClassificationItemData,
TrainFilter,
} from "@/types/classification";
import { import {
ClassificationCard, ClassificationCard,
GroupedClassificationCard, GroupedClassificationCard,
@ -102,6 +106,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
position: "top-center", position: "top-center",
}); });
setWasTraining(false); setWasTraining(false);
refreshDataset();
} else if (modelState == "failed") {
toast.error(t("toast.error.trainingFailed"), {
position: "top-center",
});
setWasTraining(false);
} }
// only refresh when modelState changes // only refresh when modelState changes
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -112,9 +122,13 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>( const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
`classification/${model.name}/train`, `classification/${model.name}/train`,
); );
const { data: dataset, mutate: refreshDataset } = useSWR<{ const { data: datasetResponse, mutate: refreshDataset } =
[id: string]: string[]; useSWR<ClassificationDatasetResponse>(
}>(`classification/${model.name}/dataset`); `classification/${model.name}/dataset`,
);
const dataset = datasetResponse?.categories || {};
const trainingMetadata = datasetResponse?.training_metadata;
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>(); const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
@ -177,7 +191,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
toast.error(t("toast.error.trainingFailed", { errorMessage }), { toast.error(t("toast.error.trainingFailedToStart", { errorMessage }), {
position: "top-center", position: "top-center",
}); });
}); });
@ -248,10 +262,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
); );
} }
// Always refresh dataset to update the categories list
refreshDataset();
if (pageToggle == "train") { if (pageToggle == "train") {
refreshTrain(); refreshTrain();
} else {
refreshDataset();
} }
} }
}) })
@ -421,19 +436,48 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
filterValues={{ classes: Object.keys(dataset || {}) }} filterValues={{ classes: Object.keys(dataset || {}) }}
onUpdateFilter={setTrainFilter} onUpdateFilter={setTrainFilter}
/> />
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
className="flex justify-center gap-2" className="flex justify-center gap-2"
onClick={trainModel} onClick={trainModel}
variant="select" variant={modelState == "failed" ? "destructive" : "select"}
disabled={modelState != "complete"} disabled={
(modelState != "complete" && modelState != "failed") ||
!trainingMetadata?.dataset_changed
}
> >
{modelState == "training" ? ( {modelState == "training" ? (
<ActivityIndicator size={20} /> <ActivityIndicator size={20} />
) : ( ) : (
<HiSparkles className="text-white" /> <HiSparkles className="text-white" />
)} )}
{isDesktop && t("button.trainModel")} {isDesktop && (
<>
{t("button.trainModel")}
{trainingMetadata?.new_images_count !== undefined &&
trainingMetadata.new_images_count > 0 && (
<span className="text-sm text-selected-foreground">
({trainingMetadata.new_images_count})
</span>
)}
</>
)}
</Button> </Button>
</TooltipTrigger>
{(!trainingMetadata?.dataset_changed ||
(modelState != "complete" && modelState != "failed")) && (
<TooltipPortal>
<TooltipContent>
{modelState == "training"
? t("tooltip.trainingInProgress")
: !trainingMetadata?.dataset_changed
? t("tooltip.noChanges")
: t("tooltip.modelNotReady")}
</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
</div> </div>
)} )}
</div> </div>
@ -526,13 +570,28 @@ function LibrarySelector({
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("deleteCategory.title")}</DialogTitle> <DialogTitle>
{Object.keys(dataset).length <= 2
? t("deleteCategory.minClassesTitle")
: t("deleteCategory.title")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t("deleteCategory.desc", { name: confirmDelete })} {Object.keys(dataset).length <= 2
? t("deleteCategory.minClassesDesc")
: t("deleteCategory.desc", { name: confirmDelete })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{Object.keys(dataset).length <= 2 ? (
<Button variant="outline" onClick={() => setConfirmDelete(null)}> <Button variant="outline" onClick={() => setConfirmDelete(null)}>
{t("button.ok", { ns: "common" })}
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setConfirmDelete(null)}
>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
@ -547,6 +606,8 @@ function LibrarySelector({
> >
{t("button.delete", { ns: "common" })} {t("button.delete", { ns: "common" })}
</Button> </Button>
</>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -86,14 +86,6 @@ export default function DraggableGridLayout({
// preferred live modes per camera // preferred live modes per camera
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
} }
}, [allGroupsStreamingSettings, cameraGroup]); }, [allGroupsStreamingSettings, cameraGroup]);
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
// grid layout // grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);

View File

@ -162,6 +162,9 @@ export default function LiveCameraView({
isRestreamed ? `go2rtc/streams/${streamName}` : null, isRestreamed ? `go2rtc/streams/${streamName}` : null,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
}, },
); );
@ -1027,6 +1030,7 @@ function FrigateCameraFeatures({
disabled={!cameraEnabled || debug || isSnapshotLoading} disabled={!cameraEnabled || debug || isSnapshotLoading}
loading={isSnapshotLoading} loading={isSnapshotLoading}
/> />
{!fullscreen && (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div <div
@ -1155,7 +1159,9 @@ function FrigateCameraFeatures({
{t("stream.audio.tips.title")} {t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary"> <div className="mt-2 flex items-center text-primary">
<Link <Link
to={getLocaleDocUrl("configuration/live")} to={getLocaleDocUrl(
"configuration/live",
)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline" className="inline"
@ -1315,6 +1321,7 @@ function FrigateCameraFeatures({
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</> </>
); );
} }

View File

@ -202,14 +202,6 @@ export default function LiveDashboardView({
}; };
}, []); }, []);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -239,6 +231,33 @@ export default function LiveDashboardView({
[visibleCameraObserver.current], [visibleCameraObserver.current],
); );
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const handleError = useCallback( const handleError = useCallback(

View File

@ -649,7 +649,7 @@ export function RecordingView({
value="detail" value="detail"
aria-label="Detail Stream" aria-label="Detail Stream"
> >
<div className="">Detail</div> <div className="">{t("detail.label")}</div>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
) : ( ) : (

View File

@ -99,6 +99,10 @@ export default function UiSettingsView() {
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true); const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
"liveFallbackTimeout",
3,
);
return ( return (
<> <>
@ -161,6 +165,48 @@ export default function UiSettingsView() {
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p> <p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
</div> </div>
</div> </div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Label
className="cursor-pointer"
htmlFor="live-fallback-timeout"
>
{t("general.liveDashboard.liveFallbackTimeout.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.liveFallbackTimeout.desc")}</p>
</div>
<Select
value={fallbackTimeout?.toString()}
onValueChange={(value) => setFallbackTimeout(parseInt(value))}
>
<SelectTrigger className="w-36">
{t("time.second", {
ns: "common",
time: fallbackTimeout,
count: fallbackTimeout,
})}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => (
<SelectItem
key={timeout}
className="cursor-pointer"
value={timeout.toString()}
>
{t("time.second", {
ns: "common",
time: timeout,
count: timeout,
})}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="my-3 flex w-full flex-col space-y-6"> <div className="my-3 flex w-full flex-col space-y-6">