mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-10 00:32:48 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c89e77b332 | ||
|
|
71445231f6 | ||
|
|
d4a7504d67 | ||
|
|
53b0dff949 | ||
|
|
6eebd4234c | ||
|
|
945de037a5 |
@@ -2,9 +2,9 @@ aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
starlette == 1.7.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.142.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.52.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.6.*
|
||||
|
||||
@@ -12,6 +12,6 @@ nvidia-cusolver-cu12==11.7.3.90; platform_machine == 'x86_64'
|
||||
nvidia-cusparse-cu12==12.5.8.93; platform_machine == 'x86_64'
|
||||
nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
|
||||
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
|
||||
onnx==1.23.*; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
onnx == 1.23.2; platform_machine == 'aarch64'
|
||||
onnx == 1.14.0; platform_machine == 'aarch64'
|
||||
protobuf == 3.20.3; platform_machine == 'aarch64'
|
||||
|
||||
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
|
||||
|
||||
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
|
||||
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
|
||||
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
|
||||
4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
|
||||
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
|
||||
4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
|
||||
|
||||
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
|
||||
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
|
||||
|
||||
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA
|
||||
|
||||
#### Minimum Hardware Support
|
||||
|
||||
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. The oldest supported GPU generation for object detection is Pascal (Compute Capability 6.0 or greater). Older generations such as Maxwell are not supported but can still be used by following a [community workaround](https://github.com/blakeblackshear/frigate/discussions/20088). For other GPUs, check the NVIDIA GPU Compute Capability table linked below.
|
||||
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below.
|
||||
|
||||
Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU.
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ go2rtc:
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### Can I add a privacy mask to hide part of my camera's view?
|
||||
|
||||
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
|
||||
|
||||
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
|
||||
Generated
+15
-24
@@ -7030,9 +7030,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -7698,15 +7698,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
@@ -7714,10 +7713,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/bytes": {
|
||||
@@ -17453,9 +17448,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -17463,10 +17458,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr/node_modules/ipaddr.js": {
|
||||
@@ -19238,9 +19229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
|
||||
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -19522,9 +19513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
||||
Vendored
+2
-7
@@ -8017,7 +8017,7 @@ components:
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
contentMediaType: application/octet-stream
|
||||
format: binary
|
||||
title: File
|
||||
type: object
|
||||
required:
|
||||
@@ -8027,7 +8027,7 @@ components:
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
contentMediaType: application/octet-stream
|
||||
format: binary
|
||||
title: File
|
||||
type: object
|
||||
required:
|
||||
@@ -9601,11 +9601,6 @@ components:
|
||||
type:
|
||||
type: string
|
||||
title: Error Type
|
||||
input:
|
||||
title: Input
|
||||
ctx:
|
||||
type: object
|
||||
title: Context
|
||||
type: object
|
||||
required:
|
||||
- loc
|
||||
|
||||
+6
-29
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
import queue
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -58,11 +57,6 @@ class MqttClient(Communicator):
|
||||
self._next_connect_time = 0.0
|
||||
self._last_on_connect_dispatch = 0.0
|
||||
|
||||
# lets other threads interrupt the worker's socket wait
|
||||
self._wake_recv, self._wake_send = socket.socketpair()
|
||||
self._wake_recv.setblocking(False)
|
||||
self._wake_send.setblocking(False)
|
||||
|
||||
def subscribe(self, receiver: Callable) -> None:
|
||||
"""Wrapper for allowing dispatcher to subscribe."""
|
||||
self._dispatcher = receiver
|
||||
@@ -92,7 +86,6 @@ class MqttClient(Communicator):
|
||||
return
|
||||
|
||||
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
|
||||
self._wake_worker()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._worker is None:
|
||||
@@ -108,11 +101,9 @@ class MqttClient(Communicator):
|
||||
publish_done,
|
||||
)
|
||||
)
|
||||
self._wake_worker()
|
||||
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
|
||||
|
||||
self._stop_event.set()
|
||||
self._wake_worker()
|
||||
|
||||
if self.client is not None:
|
||||
try:
|
||||
@@ -378,17 +369,11 @@ class MqttClient(Communicator):
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _wake_worker(self) -> None:
|
||||
try:
|
||||
self._wake_send.send(b"\0")
|
||||
except BlockingIOError:
|
||||
# the buffer is full, so a wake is already pending
|
||||
pass
|
||||
|
||||
def _loop_client(self, timeout: float) -> int:
|
||||
"""Drive Paho without select()'s limit on socket file descriptors."""
|
||||
assert self.client is not None
|
||||
client = self.client
|
||||
if client is None:
|
||||
return mqtt.MQTT_ERR_NO_CONN
|
||||
sock = client.socket()
|
||||
if sock is None:
|
||||
return mqtt.MQTT_ERR_NO_CONN
|
||||
@@ -400,19 +385,11 @@ class MqttClient(Communicator):
|
||||
pending = hasattr(sock, "pending") and sock.pending() > 0
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(sock, events)
|
||||
selector.register(self._wake_recv, selectors.EVENT_READ)
|
||||
ready = {
|
||||
key.fileobj: mask
|
||||
for key, mask in selector.select(0.0 if pending else timeout)
|
||||
}
|
||||
ready = selector.select(0.0 if pending else timeout)
|
||||
|
||||
if self._wake_recv in ready:
|
||||
try:
|
||||
self._wake_recv.recv(4096)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
|
||||
ready_events = ready.get(sock, 0)
|
||||
ready_events = 0
|
||||
for _, mask in ready:
|
||||
ready_events |= mask
|
||||
if pending or ready_events & selectors.EVENT_READ:
|
||||
result = client.loop_read()
|
||||
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
|
||||
|
||||
@@ -15,7 +15,7 @@ from peewee import DoesNotExist
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.const import EXPORT_DIR, UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import (
|
||||
@@ -415,6 +415,10 @@ def reap_stale_exports() -> None:
|
||||
this in a try/except. A failure on a single row will not stop the rest
|
||||
of the sweep, and a failure in the top-level query will log and return.
|
||||
"""
|
||||
# staged stream runs live on disk, so a killed export leaves them behind
|
||||
for staged in Path(EXPORT_DIR).glob("export_stage_*"):
|
||||
staged.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
|
||||
except Exception:
|
||||
|
||||
@@ -559,7 +559,7 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
def _staged_run_path(self, index: int) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
return os.path.join(EXPORT_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
|
||||
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
|
||||
"""Probe one recording from a run for its resolution.
|
||||
|
||||
@@ -453,6 +453,21 @@ class TestHttpExport(BaseTestHttp):
|
||||
assert unchanged.name == "front door export"
|
||||
assert unchanged.video_path == video
|
||||
|
||||
def test_reap_stale_exports_removes_staged_runs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
staged = os.path.join(tmpdir, "export_stage_front_door_abc_0.mp4")
|
||||
finished = os.path.join(tmpdir, "front_door_export.mp4")
|
||||
|
||||
for path in (staged, finished):
|
||||
with open(path, "w") as handle:
|
||||
handle.write("video")
|
||||
|
||||
with patch("frigate.jobs.export.EXPORT_DIR", tmpdir):
|
||||
reap_stale_exports()
|
||||
|
||||
assert not os.path.exists(staged)
|
||||
assert os.path.exists(finished)
|
||||
|
||||
def test_reap_stale_exports_deletes_rows_with_no_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
stale_video = os.path.join(tmpdir, "stale.mp4")
|
||||
|
||||
@@ -126,18 +126,12 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
os.makedirs(MODEL_CACHE_DIR)
|
||||
|
||||
self.config = build_config()
|
||||
self.client = self._build_client()
|
||||
self.client = MqttClient(self.config)
|
||||
self.receiver = RuntimeSnapshotReceiver()
|
||||
self.client.attach_dispatcher(build_dispatcher(self.config, []))
|
||||
|
||||
def _build_client(self) -> MqttClient:
|
||||
client = MqttClient(self.config)
|
||||
self.addCleanup(client._wake_recv.close)
|
||||
self.addCleanup(client._wake_send.close)
|
||||
return client
|
||||
|
||||
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
|
||||
client = self._build_client()
|
||||
client = MqttClient(self.config)
|
||||
|
||||
with patch.object(client, "_start_worker") as mock_start_worker:
|
||||
client.subscribe(self.receiver._receive)
|
||||
@@ -148,7 +142,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
mock_start_worker.assert_not_called()
|
||||
|
||||
def test_attach_dispatcher_supplies_command_surface(self) -> None:
|
||||
client = self._build_client()
|
||||
client = MqttClient(self.config)
|
||||
|
||||
self.assertFalse(client._is_supported_command_topic("front/detect/set"))
|
||||
|
||||
@@ -301,13 +295,6 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
self.assertEqual(self.client._subscription_mid, 42)
|
||||
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
|
||||
|
||||
def test_publish_wakes_worker(self) -> None:
|
||||
self.client.connected = True
|
||||
|
||||
self.client.publish("events", "payload")
|
||||
|
||||
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
|
||||
|
||||
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
@@ -2,7 +2,6 @@ import fcntl
|
||||
import resource
|
||||
import selectors
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -24,11 +23,6 @@ class TestMqttNetworkLoop(unittest.TestCase):
|
||||
self.sock, self.peer = socket.socketpair()
|
||||
self.addCleanup(self.sock.close)
|
||||
self.addCleanup(self.peer.close)
|
||||
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
|
||||
self.transport._wake_recv.setblocking(False)
|
||||
self.transport._wake_send.setblocking(False)
|
||||
self.addCleanup(self.transport._wake_recv.close)
|
||||
self.addCleanup(self.transport._wake_send.close)
|
||||
self.client.socket.return_value = self.sock
|
||||
|
||||
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
|
||||
@@ -129,18 +123,8 @@ class TestMqttNetworkLoop(unittest.TestCase):
|
||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
|
||||
self.client.loop_misc.assert_not_called()
|
||||
|
||||
def test_missing_socket_reports_no_connection(self) -> None:
|
||||
def test_missing_client_or_socket_reports_no_connection(self) -> None:
|
||||
self.client.socket.return_value = None
|
||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
|
||||
|
||||
def test_wake_interrupts_wait_and_is_consumed(self) -> None:
|
||||
self.transport._wake_worker()
|
||||
|
||||
start = time.monotonic()
|
||||
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
|
||||
self.assertLess(time.monotonic() - start, 1)
|
||||
self.client.loop_read.assert_not_called()
|
||||
|
||||
start = time.monotonic()
|
||||
self.transport._loop_client(0.2)
|
||||
self.assertGreater(time.monotonic() - start, 0.15)
|
||||
self.transport.client = None
|
||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
|
||||
|
||||
@@ -13,6 +13,7 @@ from frigate.models import Recordings
|
||||
from frigate.util.recording_coverage import (
|
||||
_rows_query,
|
||||
coverage_spans,
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
@@ -166,6 +167,22 @@ class TestRecordingCoverage(CoverageDbTestCase):
|
||||
expected = int(5 * 1024 * 1024 * 8 / 10)
|
||||
assert summary["main"]["bitrate"] == expected
|
||||
|
||||
def test_unknown_audio_row_keeps_video_only_stream(self):
|
||||
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
|
||||
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
|
||||
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
|
||||
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
|
||||
self.assertEqual(
|
||||
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
|
||||
)
|
||||
|
||||
def test_video_only_glitch_dropped_on_audio_stream(self):
|
||||
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
|
||||
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
|
||||
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
|
||||
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
|
||||
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
|
||||
|
||||
def test_other_camera_rows_excluded(self):
|
||||
self._insert("m1", 1000.0, 1010.0, "main")
|
||||
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
|
||||
|
||||
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
|
||||
selected_events = _select_balanced_events(events, target_count=100)
|
||||
logger.debug(f"Selected {len(selected_events)} events")
|
||||
|
||||
# Step 3: Extract thumbnails from events
|
||||
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
|
||||
# Step 3: Extract thumbnails from events, falling back to the remaining
|
||||
# events when the selected ones have no image on disk
|
||||
selected_ids = {e.id for e in selected_events}
|
||||
remaining_events = [e for e in events if e.id not in selected_ids]
|
||||
random.shuffle(remaining_events)
|
||||
thumbnails = _extract_event_thumbnails(
|
||||
selected_events + remaining_events, temp_dir, target_count=100
|
||||
)
|
||||
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
|
||||
|
||||
# Step 4: Select 24 most visually distinct thumbnails
|
||||
@@ -833,10 +839,16 @@ def _select_balanced_events(
|
||||
else:
|
||||
selected.extend(remaining)
|
||||
|
||||
# groups are ordered oldest first, so truncating unshuffled keeps only the
|
||||
# oldest events, which are the least likely to still have images on disk
|
||||
random.shuffle(selected)
|
||||
|
||||
return selected[:target_count]
|
||||
|
||||
|
||||
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
||||
def _extract_event_thumbnails(
|
||||
events: list[Event], output_dir: str, target_count: int = 100
|
||||
) -> list[str]:
|
||||
"""
|
||||
Extract a training image for each event.
|
||||
|
||||
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
||||
using a step ladder sized from the box/region area ratio.
|
||||
|
||||
Args:
|
||||
events: List of Event objects
|
||||
events: List of Event objects, in order of preference
|
||||
output_dir: Directory to save crops
|
||||
target_count: Number of images to extract before stopping
|
||||
|
||||
Returns:
|
||||
List of paths to successfully extracted images
|
||||
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
||||
image_paths = []
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
if len(image_paths) >= target_count:
|
||||
break
|
||||
|
||||
try:
|
||||
img = _load_event_classification_crop(event)
|
||||
if img is None:
|
||||
|
||||
@@ -204,11 +204,12 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
|
||||
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
||||
"""Whether a stream is audio-bearing over a coverage window.
|
||||
|
||||
A stream counts as audio-bearing unless EVERY one of its rows reports
|
||||
has_audio False; NULL (legacy or undetermined) counts as audio.
|
||||
A stream counts as audio-bearing only when one of its rows is known
|
||||
to carry audio. A NULL row (legacy, or a segment ffprobe could not
|
||||
read) proves nothing either way.
|
||||
"""
|
||||
return any(
|
||||
row is not None and row.has_audio is not False
|
||||
row is not None and row.has_audio is True
|
||||
for row in ((interval.main if main else interval.sub) for interval in intervals)
|
||||
)
|
||||
|
||||
|
||||
+25
-32
@@ -136,36 +136,33 @@ ACCESS_NOTES = {
|
||||
}
|
||||
|
||||
|
||||
# Mirrors the router set wired up in frigate.api.fastapi_app.
|
||||
ROUTERS = [
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
classification.router,
|
||||
review.router,
|
||||
main_app.router,
|
||||
preview.router,
|
||||
notification.router,
|
||||
export.router,
|
||||
hardware.router,
|
||||
notices.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
record.router,
|
||||
debug_replay.router,
|
||||
]
|
||||
|
||||
|
||||
def build_app() -> FastAPI:
|
||||
"""Build a bare app with every router mounted.
|
||||
|
||||
It omits the global admin dependency and all runtime state; the OpenAPI
|
||||
route table and the per-route dependencies are all we need to export and
|
||||
classify.
|
||||
This mirrors the router set wired up in frigate.api.fastapi_app. It omits
|
||||
the global admin dependency and all runtime state; the OpenAPI route table
|
||||
and the per-route dependencies are all we need to export and classify.
|
||||
"""
|
||||
app = FastAPI()
|
||||
for router in ROUTERS:
|
||||
routers = [
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
classification.router,
|
||||
review.router,
|
||||
main_app.router,
|
||||
preview.router,
|
||||
notification.router,
|
||||
export.router,
|
||||
hardware.router,
|
||||
notices.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
record.router,
|
||||
debug_replay.router,
|
||||
]
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@@ -321,17 +318,13 @@ def _classify_base(
|
||||
|
||||
|
||||
def build_access_map(
|
||||
app: FastAPI,
|
||||
exempt_paths: set[str],
|
||||
exempt_prefixes: tuple[str, ...],
|
||||
) -> dict[tuple[str, str], dict]:
|
||||
"""Map (path, lowercase method) -> classification details."""
|
||||
access_map: dict[tuple[str, str], dict] = {}
|
||||
|
||||
# app.routes holds opaque wrappers for included routers on newer FastAPI.
|
||||
# The routers mount without a prefix, so their own routes carry final paths.
|
||||
routes = [route for router in ROUTERS for route in router.routes]
|
||||
|
||||
for route in routes:
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
|
||||
@@ -522,7 +515,7 @@ def render(spec: dict) -> str:
|
||||
def build_spec() -> tuple[dict, dict, list, list, list]:
|
||||
app = build_app()
|
||||
exempt_paths, exempt_prefixes = read_exempt_rules()
|
||||
access_map = build_access_map(exempt_paths, exempt_prefixes)
|
||||
access_map = build_access_map(app, exempt_paths, exempt_prefixes)
|
||||
|
||||
spec = base_document(app.openapi())
|
||||
normalized = strip_volatile_defaults(spec)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Add-camera wizard - Reolink stream selection with the brand template.
|
||||
*
|
||||
* The wizard asks the camera for its resolution, then probes http-flv first
|
||||
* above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
|
||||
* cameras that should be on http-flv. An http-flv stream the wizard selects
|
||||
* is registered with go2rtc through the ffmpeg module.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const FLV_PATH = "channel0_main.bcs";
|
||||
const RTSP_PATH = "Preview_01_main";
|
||||
const RTSP_WARNING = "Reolink RTSP is not recommended";
|
||||
const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
|
||||
|
||||
const FFPROBE_OK = [
|
||||
{
|
||||
return_code: 0,
|
||||
stderr: [],
|
||||
stdout: {
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "hevc",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
avg_frame_rate: "15/1",
|
||||
},
|
||||
{ codec_type: "audio", codec_name: "aac" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const FFPROBE_FAILED = [
|
||||
{ return_code: 1, stderr: ["probe failed"], stdout: "" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mock the camera's answers and drive the wizard to Step 3. Returns the
|
||||
* dialog and the stream paths the wizard probed, in order.
|
||||
*/
|
||||
async function gotoStep3(
|
||||
page: Page,
|
||||
{ protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
|
||||
) {
|
||||
const probed: string[] = [];
|
||||
|
||||
await page.route("**/api/reolink/detect**", (route) =>
|
||||
route.fulfill({ json: { success: protocol !== null, protocol } }),
|
||||
);
|
||||
await page.route("**/api/ffprobe**", (route) => {
|
||||
const paths = new URL(route.request().url()).searchParams.get("paths");
|
||||
const isFlv = !!paths?.includes(FLV_PATH);
|
||||
probed.push(isFlv ? FLV_PATH : RTSP_PATH);
|
||||
return route.fulfill({
|
||||
json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ffprobe/snapshot**", (route) =>
|
||||
route.fulfill({ status: 500 }),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
|
||||
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
||||
await dialog.getByPlaceholder("Optional").first().fill("admin");
|
||||
await dialog.getByPlaceholder("Optional").last().fill("pw");
|
||||
await dialog.getByText("Manual selection").click();
|
||||
await dialog.getByRole("combobox").click();
|
||||
await page.getByRole("option", { name: "Reolink" }).click();
|
||||
await dialog.getByRole("button", { name: /^Continue$/i }).click();
|
||||
|
||||
// Step 2 tests the connection on its own, then offers Continue
|
||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
||||
await expect(next).toBeEnabled({ timeout: 10_000 });
|
||||
await next.click();
|
||||
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Add Another Stream/i }),
|
||||
).toBeVisible();
|
||||
return { dialog, probed };
|
||||
}
|
||||
|
||||
test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
// not in the default mock; unmocked it 500s and trips the error collector
|
||||
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraManagement");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
|
||||
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||
protocol: "rtsp",
|
||||
flvProbes: true,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([FLV_PATH]);
|
||||
await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
|
||||
|
||||
const registered: string[] = [];
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
|
||||
const src = new URL(route.request().url()).searchParams.get("src");
|
||||
if (src) registered.push(src);
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
|
||||
await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("above 5MP falls back to RTSP without a warning", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||
protocol: "rtsp",
|
||||
flvProbes: false,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
|
||||
await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
|
||||
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||
protocol: null,
|
||||
flvProbes: true,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([RTSP_PATH]);
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
|
||||
// the fixture detector runs at 75.5 ms, above the live warning threshold
|
||||
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
|
||||
|
||||
// the fixture has no go2rtc streams, which gives every camera a live view hint
|
||||
const RESTREAMED = {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
front_door: ["rtsp://x"],
|
||||
backyard: ["rtsp://x"],
|
||||
garage: ["rtsp://x"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ERROR_NOTICE = {
|
||||
id: "model_download_failed:yolo/model.onnx",
|
||||
kind: "model_download_failed",
|
||||
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -84,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
for (const action of ["acknowledge", "mute"] as const) {
|
||||
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
@@ -829,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("a camera without a go2rtc stream gets a live view hint", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
await expect(row).toContainText("lower frame rate and no audio");
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a global config problem is not repeated per camera", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -1130,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
Generated
+3
-3
@@ -9910,9 +9910,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
||||
@@ -1997,6 +1997,9 @@
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
|
||||
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
|
||||
},
|
||||
"live": {
|
||||
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
},
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
messages: [
|
||||
{
|
||||
key: "no-go2rtc-stream",
|
||||
health: true,
|
||||
messageKey: "configMessages.live.noGo2rtcStream",
|
||||
severity: "info",
|
||||
docLink: "/configuration/live",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
return !Object.values(ctx.fullCameraConfig.live.streams).some(
|
||||
(name) => isRestreamedStream(ctx.fullConfig, name),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldOrder: ["streams", "transcode", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hardwareForDevices,
|
||||
MAX_DETECTORS,
|
||||
recommendedDetectorCount,
|
||||
resolveUnitDevice,
|
||||
} from "@/utils/detectionHardware";
|
||||
|
||||
type HardwarePickerProps = {
|
||||
@@ -60,9 +61,13 @@ export function HardwarePicker({
|
||||
return [];
|
||||
}
|
||||
|
||||
const assigned = devices.map((device) =>
|
||||
resolveUnitDevice(selected, device),
|
||||
);
|
||||
|
||||
return selected.units
|
||||
.map((unit) => unit.device)
|
||||
.filter((device) => devices.includes(device));
|
||||
.filter((device) => assigned.includes(device));
|
||||
}, [selected, devices]);
|
||||
|
||||
/** Spread `count` detectors round robin over the selected units. */
|
||||
@@ -200,7 +205,7 @@ export function HardwarePicker({
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${unit.device}`}
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={devices.includes(unit.device)}
|
||||
checked={selectedUnits.includes(unit.device)}
|
||||
disabled={disabled || Boolean(claimedBy)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUnitToggle(unit.device, checked === true)
|
||||
|
||||
@@ -222,7 +222,6 @@ export default function Step2ProbeOrSnapshot({
|
||||
wizardData.username,
|
||||
wizardData.password,
|
||||
);
|
||||
update.reolinkProtocol = protocol;
|
||||
if (protocol === "http-flv") {
|
||||
update.brandTemplate = "reolink";
|
||||
}
|
||||
@@ -295,72 +294,73 @@ export default function Step2ProbeOrSnapshot({
|
||||
[probeUri],
|
||||
);
|
||||
|
||||
const generateDynamicStreamUrls = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
const host = data.host;
|
||||
if (!brand || !host) return [];
|
||||
if (!brand || !data.host) return null;
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
host,
|
||||
data.host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
onUpdate({ reolinkProtocol: protocol });
|
||||
}
|
||||
|
||||
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
|
||||
// fallback when the http-flv stream does not probe.
|
||||
const protocolKeys =
|
||||
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
|
||||
const protocolKey = protocol || "rtsp";
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
return protocolKeys
|
||||
.filter((key) => key in templates)
|
||||
.map((key) =>
|
||||
templates[key]
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", host),
|
||||
);
|
||||
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;
|
||||
},
|
||||
[onUpdate],
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStreamUrls = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl ? [data.customUrl] : [];
|
||||
return data.customUrl || "";
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return [];
|
||||
if (!brand || !data.host) return "";
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
return generateDynamicStreamUrls(data);
|
||||
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),
|
||||
];
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
},
|
||||
[generateDynamicStreamUrls],
|
||||
[generateDynamicStreamUrl],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (showToast = true) => {
|
||||
const streamUrls = await generateStreamUrls(wizardData);
|
||||
const streamUrl = await generateStreamUrl(wizardData);
|
||||
|
||||
if (streamUrls.length === 0) {
|
||||
if (!streamUrl) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
@@ -370,18 +370,8 @@ export default function Step2ProbeOrSnapshot({
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
let streamUrl = streamUrls[0];
|
||||
let result: TestResult | undefined;
|
||||
|
||||
for (const url of streamUrls) {
|
||||
streamUrl = url;
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
result = await probeUri(url, true, setTestStatus);
|
||||
|
||||
if (result.success && result.resolution) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||
|
||||
if (result && result.success) {
|
||||
setTestResult(result);
|
||||
@@ -393,9 +383,6 @@ export default function Step2ProbeOrSnapshot({
|
||||
url: streamUrl,
|
||||
roles: ["detect"] as StreamRole[],
|
||||
testResult: result,
|
||||
useFfmpeg:
|
||||
wizardData.brandTemplate === "reolink" &&
|
||||
streamUrl.startsWith("http://"),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -447,7 +434,7 @@ export default function Step2ProbeOrSnapshot({
|
||||
setTestStatus("");
|
||||
}
|
||||
},
|
||||
[wizardData, generateStreamUrls, t, onUpdate, probeUri],
|
||||
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
|
||||
);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
|
||||
@@ -499,7 +499,6 @@ function StreamIssues({
|
||||
url: stream.url,
|
||||
roles: stream.roles,
|
||||
brand: wizardData.brandTemplate,
|
||||
reolinkProtocol: wizardData.reolinkProtocol,
|
||||
useFfmpeg: stream.useFfmpeg,
|
||||
restream: stream.restream,
|
||||
testResult: stream.testResult,
|
||||
|
||||
@@ -110,7 +110,6 @@ export type WizardFormData = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
brandTemplate?: CameraBrand;
|
||||
reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
|
||||
customUrl?: string;
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
|
||||
@@ -21,10 +21,31 @@ export function hardwareForDevices(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hardware.find((entry) => {
|
||||
const known = new Set(entry.units.map((unit) => unit.device));
|
||||
return devices.every((device) => known.has(device));
|
||||
});
|
||||
return hardware.find((entry) =>
|
||||
devices.every((device) => resolveUnitDevice(entry, device)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit device string a configured device refers to.
|
||||
*
|
||||
* A config may leave the index off, such as "edgetpu:usb", which the detector
|
||||
* resolves to the first unit of that kind.
|
||||
*/
|
||||
export function resolveUnitDevice(
|
||||
entry: DetectionHardware,
|
||||
device: string,
|
||||
): string | undefined {
|
||||
const unitDevices = entry.units.map((unit) => unit.device);
|
||||
|
||||
return (
|
||||
unitDevices.find((candidate) => candidate === device) ??
|
||||
unitDevices.find(
|
||||
(candidate) =>
|
||||
candidate.startsWith(`${device}:`) ||
|
||||
candidate.startsWith(`${device}.`),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,6 @@ export type StreamIssueInput = {
|
||||
url: string;
|
||||
roles: StreamRole[];
|
||||
brand?: CameraBrand;
|
||||
reolinkProtocol?: "http-flv" | "rtsp" | null;
|
||||
useFfmpeg?: boolean;
|
||||
restream?: boolean;
|
||||
testResult?: TestResult;
|
||||
@@ -97,7 +96,7 @@ export function getStreamIssues(
|
||||
|
||||
if (input.brand === "reolink") {
|
||||
const streamUrl = input.url.toLowerCase();
|
||||
if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") {
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "reolink-rtsp",
|
||||
|
||||
Reference in New Issue
Block a user