Compare commits

...

11 Commits

Author SHA1 Message Date
Daniel
9bf89993d7
Merge f68ff53bd9 into de066d0062 2025-11-14 16:59:57 +08: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
Daniel
f68ff53bd9 fix MQTT disconnect reason code comparison bug
The mypy fixes introduced a bug where disconnect callback was comparing
reason_code == 0 instead of reason_value == 0. Since reason_code is a
ReasonCode object, it never equals integer 0, causing clean disconnects
to never be detected and infinite retry loops to occur.

Fixed by using reason_value (extracted from reason_code.value) for
numeric comparison, consistent with how we extract the value for logging.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 20:07:22 -04:00
Daniel
b0f189056a fix MQTT retry loop race condition with cleanup disconnect
Previously, the reconnection loop was calling client.disconnect() during cleanup,
which triggered the disconnect callback with code 0 ("Normal disconnection").
The callback would then exit early, preventing further reconnection attempts.

This creates a cleanup flag that prevents the disconnect callback from
stopping reconnection when we're intentionally cleaning up the old client
during retry attempts.

Fixes issue where MQTT would get stuck in retry loop without actually
attempting fresh connections.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 19:03:34 -04:00
Dan
91d3a7b245 format MQTT code with ruff 2025-09-01 18:12:51 -04:00
Dan
44c07aac12 fix mypy errors with improved MQTT reliability implementation
- Add _reason_info helper method to extract reason names safely
- Update connect/disconnect callbacks to use string comparison instead of numeric
- Fix type annotations with proper Optional[Client] typing
- Resolve unreachable code warnings with type ignore comments for threading
- Improve error handling with robust try/finally blocks in stop method
- Maintain fresh client creation approach for reliable reconnection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:08:01 -04:00
Dan
3952035579 format MQTT code with ruff 2025-09-01 15:54:48 -04:00
Dan
a45915517f improve MQTT reconnection with fresh client creation approach
- Replace client.reconnect() with fresh client creation for each retry attempt
- Add proper cleanup of old client before creating new one
- Enhanced logging with detailed debugging info for disconnect reasons
- Use proven aiomqtt-style retry pattern for better reliability
- Each reconnection attempt now creates completely new MQTT client instance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 15:32:04 -04:00
Dan
f95a49b81d implement automatic MQTT reconnection with 10s retry interval
- Fix connection state management: only set connected=True on successful connection
- Add automatic reconnection loop that retries every 10 seconds indefinitely
- Proper cleanup of reconnection thread on stop
- Enhanced disconnect logging with reason codes
- Thread-safe reconnection handling to avoid blocking main MQTT thread

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 12:57:02 -04:00
26 changed files with 768 additions and 333 deletions

View File

@ -7,11 +7,13 @@ from importlib.util import find_spec
from pathlib import Path
from urllib.parse import quote_plus
import httpx
import requests
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse
from onvif import ONVIFCamera, ONVIFError
from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
@ -464,7 +466,8 @@ def _extract_fps(r_frame_rate: str) -> float | None:
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)."
"Query params: host (required), port (default 80), username, password, test (boolean), "
"auth_type (basic or digest, default basic)."
),
)
async def onvif_probe(
@ -474,6 +477,7 @@ async def onvif_probe(
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.
@ -491,6 +495,7 @@ async def onvif_probe(
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
@ -508,10 +513,20 @@ async def onvif_probe(
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}")
logger.debug(f"Probing ONVIF device at {host}:{port} with {auth_type} auth")
try:
wsdl_base = None
@ -525,6 +540,28 @@ async def onvif_probe(
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
@ -535,6 +572,14 @@ async def onvif_probe(
}
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")
@ -558,8 +603,8 @@ async def onvif_probe(
"firmware_version": firmware or "Unknown",
}
)
except Exception:
logger.debug("Failed to get device info")
except Exception as e:
logger.debug(f"Failed to get device info: {e}")
# Get media profiles
profiles = []
@ -568,6 +613,14 @@ async def onvif_probe(
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:
@ -585,8 +638,8 @@ async def onvif_probe(
if isinstance(ptz_configuration, dict)
else None
)
except Exception:
logger.debug("Failed to get media profiles")
except Exception as e:
logger.debug(f"Failed to get media profiles: {e}")
# Check PTZ support and capabilities
ptz_supported = False
@ -596,6 +649,13 @@ async def onvif_probe(
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()
@ -744,6 +804,14 @@ async def onvif_probe(
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 (

View File

@ -1,6 +1,7 @@
import logging
import threading
from typing import Any, Callable
import time
from typing import Any, Callable, Optional
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
@ -18,15 +19,20 @@ class MqttClient(Communicator):
self.config = config
self.mqtt_config = config.mqtt
self.connected = False
self.client: Optional[mqtt.Client] = None
self._dispatcher: Callable[[str, str], None] = lambda *_: None
self._reconnect_thread: Optional[threading.Thread] = None
self._reconnect_delay = 10 # Retry every 10 seconds
self._stop_reconnect: bool = False
def subscribe(self, receiver: Callable) -> None:
def subscribe(self, receiver: Callable[[str, str], None]) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver
self._start()
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
if not self.connected:
if not self.connected or self.client is None:
logger.debug(f"Unable to publish to {topic}: client is not connected")
return
@ -38,7 +44,17 @@ class MqttClient(Communicator):
)
def stop(self) -> None:
self._stop_reconnect = True
if self._reconnect_thread is not None and self._reconnect_thread.is_alive():
self._reconnect_thread.join(timeout=5)
if self.client is not None:
try:
self.client.disconnect()
finally:
try:
self.client.loop_stop()
except Exception:
pass
def _set_initial_topics(self) -> None:
"""Set initial state topics."""
@ -142,6 +158,22 @@ class MqttClient(Communicator):
self.publish("available", "online", retain=True)
@staticmethod
def _reason_info(reason_code: object) -> str:
"""Return human_readable_name for a Paho reason code."""
# Name string
if hasattr(reason_code, "getName") and callable(
getattr(reason_code, "getName")
):
try:
name = str(getattr(reason_code, "getName")())
except Exception:
name = str(reason_code)
else:
name = str(reason_code)
return name
def on_mqtt_command(
self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage
) -> None:
@ -155,27 +187,31 @@ class MqttClient(Communicator):
client: mqtt.Client,
userdata: Any,
flags: Any,
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
reason_code: object,
properties: Any,
) -> None:
"""Mqtt connection callback."""
threading.current_thread().name = "mqtt"
if reason_code != 0:
if reason_code == "Server unavailable":
reason_name = self._reason_info(reason_code)
# Check for connection failure by comparing reason name
if reason_name != "Success":
if reason_name == "Server unavailable":
logger.error(
"Unable to connect to MQTT server: MQTT Server unavailable"
)
elif reason_code == "Bad user name or password":
elif reason_name == "Bad user name or password":
logger.error(
"Unable to connect to MQTT server: MQTT Bad username or password"
)
elif reason_code == "Not authorized":
elif reason_name == "Not authorized":
logger.error("Unable to connect to MQTT server: MQTT Not authorized")
else:
logger.error(
"Unable to connect to MQTT server: Connection refused. Error code: "
+ reason_code.getName()
f"Unable to connect to MQTT server: Connection refused. Error: {reason_name}"
)
# Don't set connected = True on connection failure
return
self.connected = True
logger.debug("MQTT connected")
@ -192,7 +228,34 @@ class MqttClient(Communicator):
) -> None:
"""Mqtt disconnection callback."""
self.connected = False
logger.error("MQTT disconnected")
# Debug reason code thoroughly
reason_name = (
reason_code.getName()
if hasattr(reason_code, "getName")
else str(reason_code)
)
reason_value = getattr(reason_code, "value", reason_code)
logger.error(
f"MQTT disconnected - reason: '{reason_name}', code: {reason_value}, type: {type(reason_code)}"
)
# Don't attempt reconnection if we're stopping or if it was a clean disconnect
if self._stop_reconnect:
logger.error("MQTT not reconnecting - stop flag set")
return
if reason_value == 0:
logger.error("MQTT not reconnecting - clean disconnect (code 0)")
return
logger.error("MQTT will attempt reconnection...")
# Start reconnection in a separate thread to avoid blocking
if self._reconnect_thread is None or not self._reconnect_thread.is_alive():
self._reconnect_thread = threading.Thread(
target=self._reconnect_loop, name="mqtt-reconnect", daemon=True
)
self._reconnect_thread.start()
def _start(self) -> None:
"""Start mqtt client."""
@ -281,3 +344,66 @@ class MqttClient(Communicator):
except Exception as e:
logger.error(f"Unable to connect to MQTT server: {e}")
return
def _reconnect_loop(self) -> None:
"""Handle MQTT reconnection using fresh client creation, retrying every 10 seconds indefinitely."""
logger.error("MQTT reconnection loop started")
attempt = 0
while not self._stop_reconnect and not self.connected:
attempt += 1
logger.error(
f"Will attempt MQTT reconnection in {self._reconnect_delay} seconds (attempt {attempt})"
)
# Wait with ability to exit early if stopping
delay_count = 0
while delay_count < self._reconnect_delay:
if self._stop_reconnect:
logger.error("MQTT reconnection stopped during delay") # type: ignore[unreachable]
return
time.sleep(1)
delay_count += 1
# Double-check stop flag after delay
if self._stop_reconnect:
logger.error("MQTT reconnection stopped after delay") # type: ignore[unreachable]
return
try:
logger.error(
f"Creating fresh MQTT client for reconnection attempt {attempt}..."
)
# Clean up old client if it exists
if self.client is not None:
try:
self.client.disconnect()
self.client.loop_stop()
except Exception:
pass # Ignore cleanup errors
# Create completely fresh client and attempt connection
self._start()
# Give the connection attempt some time to complete
wait_count = 0
while wait_count < 5: # Wait up to 5 seconds for connection
if self.connected:
logger.error( # type: ignore[unreachable]
f"MQTT fresh connection successful on attempt {attempt}!"
)
return
time.sleep(1)
wait_count += 1
logger.error(
f"MQTT fresh connection attempt {attempt} timed out, will retry"
)
# Continue the outer while loop to retry
except Exception as e:
logger.error(f"MQTT fresh connection attempt {attempt} failed: {e}")
# Continue the outer while loop to retry
logger.error("MQTT reconnection loop finished")

View File

@ -255,6 +255,7 @@ class OpenVINOModelRunner(BaseModelRunner):
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path
self.device = device
self.model_type = model_type
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
model_type
@ -341,6 +342,13 @@ class OpenVINOModelRunner(BaseModelRunner):
# Lock prevents concurrent access to infer_request
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
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
if (
len(inputs) == 1

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
"masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate",
"object": "Debug - Frigate",
"general": "General Settings - Frigate",
"general": "UI Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate",
"notifications": "Notification Settings - Frigate"
},
@ -37,7 +37,7 @@
"noCamera": "No Camera"
},
"general": {
"title": "General Settings",
"title": "UI Settings",
"liveDashboard": {
"title": "Live Dashboard",
"automaticLiveView": {
@ -51,6 +51,10 @@
"displayCameraNames": {
"label": "Always Show Camera Names",
"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": {
@ -196,6 +200,8 @@
"manualMode": "Manual selection",
"detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".",
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
"useDigestAuth": "Use digest authentication",
"useDigestAuthDescription": "Use HTTP digest authentication for ONVIF. Some cameras may require a dedicated ONVIF username/password instead of the standard admin user.",
"errors": {
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
"nameRequired": "Camera name is required",

View File

@ -454,6 +454,24 @@ export function GeneralFilterContent({
onClose,
}: GeneralFilterContentProps) {
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 (
<>
<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) => (
<FilterSwitch
key={item}
label={getTranslatedLabel(item)}
label={getTranslatedLabel(
item,
allAudioListenLabels.includes(item) ? "audio" : "object",
)}
isChecked={filter.labels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {

View File

@ -81,6 +81,43 @@ export default function InputWithTags({
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 [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
null,
@ -421,7 +458,8 @@ export default function InputWithTags({
? t("button.yes", { ns: "common" })
: t("button.no", { ns: "common" });
} else if (filterType === "labels") {
return getTranslatedLabel(String(filterValues));
const value = String(filterValues);
return resolveLabel(value);
} else if (filterType === "search_type") {
return t("filter.searchType." + String(filterValues));
} else {
@ -828,7 +866,7 @@ export default function InputWithTags({
>
{t("filter.label." + filterType)}:{" "}
{filterType === "labels" ? (
getTranslatedLabel(value)
resolveLabel(value)
) : filterType === "cameras" ? (
<CameraNameLabel camera={value} />
) : filterType === "zones" ? (

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import type {
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils";
import { maskUri } from "@/utils/cameraUtil";
type OnvifProbeResultsProps = {
isLoading: boolean;
@ -258,12 +259,6 @@ function CandidateItem({
const { t } = useTranslation(["views/settings"]);
const [showFull, setShowFull] = useState(false);
const maskUri = (uri: string) => {
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
if (match) return `rtsp://${match[1]}:••••@${match[3]}`;
return uri;
};
return (
<Card
className={cn(

View File

@ -8,6 +8,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Select,
@ -81,6 +82,7 @@ export default function Step1NameCamera({
password: z.string().optional(),
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
useDigestAuth: z.boolean().optional(),
customUrl: z
.string()
.optional()
@ -118,6 +120,7 @@ export default function Step1NameCamera({
: "dahua",
customUrl: wizardData.customUrl || "",
onvifPort: wizardData.onvifPort ?? 80,
useDigestAuth: wizardData.useDigestAuth ?? false,
},
mode: "onChange",
});
@ -330,6 +333,32 @@ export default function Step1NameCamera({
/>
)}
{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

View File

@ -191,6 +191,7 @@ export default function Step2ProbeOrSnapshot({
username: wizardData.username || "",
password: wizardData.password || "",
test: false,
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
},
timeout: 30000,
});

View File

@ -18,6 +18,7 @@ import { PlayerStatsType } from "@/types/live";
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
import { maskUri } from "@/utils/cameraUtil";
type Step4ValidationProps = {
wizardData: Partial<WizardFormData>;
@ -374,7 +375,7 @@ export default function Step4Validation({
<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">
{stream.url}
{maskUri(stream.url)}
</span>
<Button
onClick={() => {

View File

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

View File

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

View File

@ -114,6 +114,7 @@ export type WizardFormData = {
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

View File

@ -71,3 +71,26 @@ export async function detectReolinkCamera(
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,
): string => {
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 (isNaN(end_time)) {
return "Invalid end time";
return i18n.t("time.invalidEndTime", { ns: "common" });
}
const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time);

View File

@ -86,14 +86,6 @@ export default function DraggableGridLayout({
// preferred live modes per camera
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
}
}, [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
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);

View File

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

View File

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

View File

@ -99,6 +99,10 @@ export default function UiSettingsView() {
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
"liveFallbackTimeout",
3,
);
return (
<>
@ -161,6 +165,48 @@ export default function UiSettingsView() {
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
</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 className="my-3 flex w-full flex-col space-y-6">