mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:56:52 +03:00
add transcoded live streams and stream ordering for auto mode
Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.
The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.
Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.
Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.
This commit is contained in:
@@ -15,6 +15,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
from frigate.util.live_streams import raw_transcode_streams
|
||||
from frigate.util.services import (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
@@ -174,6 +175,17 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
del go2rtc_config["streams"][name]
|
||||
continue
|
||||
|
||||
# add transcoded live streams; a user stream with the same name wins here and
|
||||
# fails Frigate's config validation
|
||||
transcoded_streams = raw_transcode_streams(config)
|
||||
|
||||
if transcoded_streams:
|
||||
if go2rtc_config.get("streams") is None:
|
||||
go2rtc_config["streams"] = {}
|
||||
|
||||
for name, source in transcoded_streams.items():
|
||||
go2rtc_config["streams"].setdefault(name, source)
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
@@ -900,6 +900,21 @@ live:
|
||||
streams:
|
||||
main_stream: main_stream_name
|
||||
sub_stream: sub_stream_name
|
||||
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
|
||||
# NOTE: Set at the camera level only.
|
||||
transcode:
|
||||
# Optional: Enable transcoded streams (default: shown below)
|
||||
enabled: False
|
||||
# Optional: go2rtc stream to transcode (default: the first live stream)
|
||||
source: main_stream_name
|
||||
# Optional: One transcoded stream per quality (default: shown below)
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
- height: 360
|
||||
bitrate: 250
|
||||
# Optional: Set the height of the jsmpeg stream. (default: 720)
|
||||
# This must be less than or equal to the height of the detect stream. Lower resolutions
|
||||
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
|
||||
|
||||
@@ -92,7 +92,7 @@ go2rtc:
|
||||
|
||||
### Setting Streams For Live UI
|
||||
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others.
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
|
||||
|
||||
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
|
||||
|
||||
@@ -158,6 +158,26 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Transcoded streams
|
||||
|
||||
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
test_cam:
|
||||
live:
|
||||
transcode:
|
||||
enabled: true
|
||||
source: test_cam # optional, defaults to the first live stream
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200 # kbps
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
```
|
||||
|
||||
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
|
||||
|
||||
### WebRTC extra configuration:
|
||||
|
||||
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
||||
|
||||
Vendored
+38
@@ -412,6 +412,39 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/go2rtc/streams/{stream_name}/bitrate:
|
||||
get:
|
||||
tags:
|
||||
- Camera
|
||||
summary: Go2Rtc Stream Bitrate
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Measure a go2rtc stream's bitrate over a few seconds.
|
||||
operationId:
|
||||
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
|
||||
parameters:
|
||||
- name: stream_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Stream Name
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/ffprobe:
|
||||
get:
|
||||
tags:
|
||||
@@ -7731,6 +7764,11 @@ components:
|
||||
type: boolean
|
||||
title: Skip Save
|
||||
default: false
|
||||
replace_paths:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Replace Paths
|
||||
type: object
|
||||
title: AppConfigSetBody
|
||||
AppPostLoginBody:
|
||||
|
||||
@@ -63,6 +63,7 @@ from frigate.util.builtin import (
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import (
|
||||
@@ -70,6 +71,10 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
@@ -810,6 +815,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
def _config_path_exists(data: Any, key_path: str) -> bool:
|
||||
"""Return whether a dotted config path is present in parsed yaml."""
|
||||
for key in split_config_key_path(key_path):
|
||||
if not isinstance(data, dict) or key not in data:
|
||||
return False
|
||||
|
||||
data = data[key]
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
@@ -864,6 +880,19 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# delete replaced paths first so their maps are rewritten in
|
||||
# the order sent; update_yaml would otherwise keep old order
|
||||
if body.replace_paths:
|
||||
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
|
||||
updates = {
|
||||
**{
|
||||
path: ""
|
||||
for path in body.replace_paths
|
||||
if _config_path_exists(old_yaml, path)
|
||||
},
|
||||
**updates,
|
||||
}
|
||||
|
||||
# apply all updates in a single operation
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
|
||||
@@ -927,9 +956,15 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
go2rtc_synced = True
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
swap_runtime_config(request.app, config)
|
||||
go2rtc_synced = sync_transcode_streams(
|
||||
generated_transcode_streams(old_config),
|
||||
generated_transcode_streams(config),
|
||||
)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@@ -989,6 +1024,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if body.requires_restart == 0
|
||||
else "Config successfully updated, restart to apply"
|
||||
),
|
||||
"go2rtc_synced": go2rtc_synced,
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
|
||||
@@ -39,6 +39,11 @@ from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
measure_stream_bitrate,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
@@ -248,6 +253,34 @@ def go2rtc_delete_stream(stream_name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{stream_name}/bitrate",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
|
||||
"""Measure a go2rtc stream's bitrate over a few seconds."""
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
|
||||
generated_transcode_streams(config)
|
||||
)
|
||||
|
||||
if stream_name not in known:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown stream"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
|
||||
|
||||
if kbps is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Stream sent no data"},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "kbps": round(kbps)})
|
||||
|
||||
|
||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
@@ -1342,6 +1375,12 @@ async def delete_camera(
|
||||
except Exception:
|
||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||
|
||||
await asyncio.to_thread(
|
||||
sync_transcode_streams,
|
||||
generated_transcode_streams(frigate_config),
|
||||
generated_transcode_streams(request.app.frigate_config),
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
|
||||
@@ -10,6 +10,8 @@ class AppConfigSetBody(BaseModel):
|
||||
update_topic: str | None = None
|
||||
config_data: dict[str, Any] | None = None
|
||||
skip_save: bool = False
|
||||
# paths rewritten whole, so a map saves in the order sent
|
||||
replace_paths: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["CameraLiveConfig"]
|
||||
__all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
|
||||
|
||||
|
||||
class LiveTranscodeQualityConfig(FrigateBaseModel):
|
||||
height: int = Field(
|
||||
ge=144,
|
||||
le=2160,
|
||||
title="Height",
|
||||
description="Output height in pixels; width follows the source aspect ratio.",
|
||||
)
|
||||
bitrate: int = Field(
|
||||
ge=64,
|
||||
title="Bitrate",
|
||||
description="Target and maximum video bitrate in kbps.",
|
||||
)
|
||||
|
||||
|
||||
class LiveTranscodeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable transcoded streams",
|
||||
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
|
||||
)
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
title="Source stream",
|
||||
description="go2rtc stream to transcode. Defaults to the first live stream.",
|
||||
)
|
||||
qualities: list[LiveTranscodeQualityConfig] = Field(
|
||||
default_factory=lambda: [
|
||||
LiveTranscodeQualityConfig(**quality)
|
||||
for quality in DEFAULT_TRANSCODE_QUALITIES
|
||||
],
|
||||
title="Qualities",
|
||||
description="One transcoded stream is added per quality.",
|
||||
)
|
||||
|
||||
@field_validator("qualities")
|
||||
@classmethod
|
||||
def validate_unique_heights(
|
||||
cls, qualities: list[LiveTranscodeQualityConfig]
|
||||
) -> list[LiveTranscodeQualityConfig]:
|
||||
heights = [quality.height for quality in qualities]
|
||||
|
||||
if len(heights) != len(set(heights)):
|
||||
raise ValueError("Transcoded stream heights must be unique.")
|
||||
|
||||
return qualities
|
||||
|
||||
|
||||
class CameraLiveConfig(FrigateBaseModel):
|
||||
@@ -11,6 +60,11 @@ class CameraLiveConfig(FrigateBaseModel):
|
||||
title="Live stream names",
|
||||
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
||||
)
|
||||
transcode: LiveTranscodeConfig = Field(
|
||||
default_factory=LiveTranscodeConfig,
|
||||
title="Transcoded streams",
|
||||
description="Lower-quality live streams transcoded on demand by go2rtc.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=720,
|
||||
title="Live height",
|
||||
|
||||
@@ -35,6 +35,12 @@ from frigate.util.config import (
|
||||
migrate_frigate_config,
|
||||
)
|
||||
from frigate.util.image import create_mask
|
||||
from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
is_transcode_stream_name,
|
||||
transcode_stream_name,
|
||||
transcode_streams,
|
||||
)
|
||||
from frigate.util.services import auto_detect_hwaccel
|
||||
|
||||
from .auth import AuthConfig
|
||||
@@ -282,11 +288,78 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
|
||||
)
|
||||
|
||||
|
||||
def apply_live_transcode_streams(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> None:
|
||||
"""Fold a camera's transcoded streams into its live stream list.
|
||||
|
||||
Enabled qualities missing from live.streams are appended, and entries the
|
||||
user placed keep their position. Transcoded names that are no longer
|
||||
generated are dropped unless they name a real go2rtc stream.
|
||||
"""
|
||||
live = camera_config.live
|
||||
transcode = live.transcode
|
||||
go2rtc_streams = frigate_config.go2rtc.model_dump().get("streams") or {}
|
||||
generated: dict[str, str] = {}
|
||||
|
||||
if transcode.enabled:
|
||||
if transcode.source is None:
|
||||
transcode.source = default_transcode_source(
|
||||
camera_config.name, live.streams
|
||||
)
|
||||
|
||||
if transcode.source not in go2rtc_streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
||||
)
|
||||
|
||||
generated = transcode_streams(
|
||||
camera_config.name,
|
||||
transcode.source,
|
||||
[quality.model_dump() for quality in transcode.qualities],
|
||||
)
|
||||
|
||||
for name in generated:
|
||||
if name in go2rtc_streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} generates transcoded stream {name}, which collides with a go2rtc stream of the same name."
|
||||
)
|
||||
|
||||
streams = {
|
||||
label: name
|
||||
for label, name in live.streams.items()
|
||||
if name in generated
|
||||
or name in go2rtc_streams
|
||||
or not is_transcode_stream_name(camera_config.name, name)
|
||||
}
|
||||
placed = set(streams.values())
|
||||
|
||||
for quality in transcode.qualities if transcode.enabled else []:
|
||||
name = transcode_stream_name(camera_config.name, quality.height)
|
||||
|
||||
if name in placed:
|
||||
continue
|
||||
|
||||
label = f"{quality.height}p"
|
||||
|
||||
if label in streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} already has a live stream named {label}; rename it or place the transcoded stream under another name."
|
||||
)
|
||||
|
||||
streams[label] = name
|
||||
|
||||
live.streams = streams
|
||||
|
||||
|
||||
def verify_valid_live_stream_names(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> ValueError | None:
|
||||
"""Verify that a restream exists to use for live view."""
|
||||
for _, stream_name in camera_config.live.streams.items():
|
||||
if is_transcode_stream_name(camera_config.name, stream_name):
|
||||
continue
|
||||
|
||||
if (
|
||||
stream_name
|
||||
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
|
||||
@@ -978,6 +1051,8 @@ class FrigateConfig(FrigateBaseModel):
|
||||
"face_recognition": ["enabled", "min_area"],
|
||||
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
|
||||
"audio_transcription": ["enabled", "live_enabled"],
|
||||
# transcode is camera-level only
|
||||
"live": ["streams", "height", "quality"],
|
||||
}
|
||||
|
||||
for section in allowed_fields_map:
|
||||
@@ -1206,6 +1281,8 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if not camera_config.live.streams:
|
||||
camera_config.live.streams = {name: name}
|
||||
|
||||
apply_live_transcode_streams(self, camera_config)
|
||||
|
||||
# generate the ffmpeg commands
|
||||
camera_config.create_ffmpeg_cmds()
|
||||
self.cameras[name] = camera_config
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for config_set live stream ordering and go2rtc transcode sync."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
from frigate.util.live_streams import transcode_stream_source
|
||||
|
||||
STREAMS_PATH = "cameras.front_door.live.streams"
|
||||
|
||||
|
||||
class TestConfigSetLiveStreams(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"streams": {
|
||||
"front_main": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||
}
|
||||
}
|
||||
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||
"streams": {"Main": "front_main", "Sub": "front_sub"}
|
||||
}
|
||||
|
||||
def _write_config_file(self) -> str:
|
||||
yaml = ruamel.yaml.YAML()
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||
yaml.dump(self.minimal_config, f)
|
||||
f.close()
|
||||
self.addCleanup(os.unlink, f.name)
|
||||
return f.name
|
||||
|
||||
def _read_config_file(self, path: str) -> dict:
|
||||
with open(path) as f:
|
||||
return ruamel.yaml.YAML(typ="safe").load(f)
|
||||
|
||||
def _app(self):
|
||||
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
publisher.publisher = MagicMock()
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
return app
|
||||
|
||||
def _save(self, app, live: dict, replace_paths: list[str] | None = None):
|
||||
body = {
|
||||
"config_data": {"cameras": {"front_door": {"live": live}}},
|
||||
"requires_restart": 0,
|
||||
"update_topic": "config/cameras/front_door/live",
|
||||
}
|
||||
|
||||
if replace_paths is not None:
|
||||
body["replace_paths"] = replace_paths
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
return client.put("/config/set", json=body)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_replace_paths_saves_map_in_sent_order(self, mock_find_config):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(
|
||||
self._app(),
|
||||
{"streams": {"Sub": "front_sub", "Main": "front_main"}},
|
||||
[STREAMS_PATH],
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
|
||||
"streams"
|
||||
]
|
||||
self.assertEqual(list(streams), ["Sub", "Main"])
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_without_replace_paths_order_is_kept(self, mock_find_config):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
self._save(self._app(), {"streams": {"Sub": "front_sub", "Main": "front_main"}})
|
||||
|
||||
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
|
||||
"streams"
|
||||
]
|
||||
self.assertEqual(list(streams), ["Main", "Sub"])
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_replace_path_missing_from_yaml_is_skipped(self, mock_find_config):
|
||||
del self.minimal_config["cameras"]["front_door"]["live"]
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(
|
||||
self._app(), {"streams": {"Main": "front_main"}}, [STREAMS_PATH]
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(
|
||||
self._read_config_file(path)["cameras"]["front_door"]["live"]["streams"],
|
||||
{"Main": "front_main"},
|
||||
)
|
||||
|
||||
@patch("frigate.api.app.sync_transcode_streams", return_value=True)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_enabling_transcode_syncs_go2rtc(self, mock_find_config, mock_sync):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(
|
||||
self._app(),
|
||||
{
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 480, "bitrate": 500}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json()["go2rtc_synced"])
|
||||
mock_sync.assert_called_once_with(
|
||||
{},
|
||||
{
|
||||
"front_door_transcode_480p": transcode_stream_source(
|
||||
"front_main", 480, 500
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@patch("frigate.api.app.sync_transcode_streams", return_value=False)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_sync_failure_is_reported(self, mock_find_config, mock_sync):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(self._app(), {"transcode": {"enabled": True}})
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json()["success"])
|
||||
self.assertFalse(resp.json()["go2rtc_synced"])
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the go2rtc stream bitrate endpoint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpGo2rtcBitrate(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([])
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"streams": {"front_main": ["rtsp://10.0.0.1:554/main"]}
|
||||
}
|
||||
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||
"streams": {"Main": "front_main"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 480, "bitrate": 500}],
|
||||
},
|
||||
}
|
||||
self.app = self.create_app()
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate", return_value=812.4)
|
||||
def test_returns_rounded_kbps(self, mock_measure):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/front_main/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["kbps"], 812)
|
||||
mock_measure.assert_called_once_with("front_main")
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate", return_value=480.0)
|
||||
def test_generated_streams_are_known(self, _):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/front_door_transcode_480p/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate")
|
||||
def test_unknown_stream_is_404(self, mock_measure):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/rtsp_somewhere/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
mock_measure.assert_not_called()
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate", return_value=None)
|
||||
def test_no_data_is_502(self, _):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/front_main/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 502)
|
||||
|
||||
def test_viewer_is_forbidden(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get(
|
||||
"/go2rtc/streams/front_main/bitrate",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Tests for Frigate-managed go2rtc live streams."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import requests
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.util.live_streams import (
|
||||
DEFAULT_TRANSCODE_QUALITIES,
|
||||
default_transcode_source,
|
||||
generated_transcode_streams,
|
||||
is_transcode_stream_name,
|
||||
measure_stream_bitrate,
|
||||
raw_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
transcode_stream_name,
|
||||
transcode_stream_source,
|
||||
)
|
||||
|
||||
|
||||
class TestTranscodeNaming(unittest.TestCase):
|
||||
def test_stream_name(self):
|
||||
self.assertEqual(transcode_stream_name("front", 720), "front_transcode_720p")
|
||||
|
||||
def test_is_transcode_stream_name(self):
|
||||
self.assertTrue(is_transcode_stream_name("front", "front_transcode_480p"))
|
||||
self.assertFalse(is_transcode_stream_name("front", "front_sub"))
|
||||
self.assertFalse(is_transcode_stream_name("front", "back_transcode_480p"))
|
||||
self.assertFalse(is_transcode_stream_name("front", "front_transcode_480"))
|
||||
|
||||
def test_source_gives_each_ffmpeg_token_its_own_raw_param(self):
|
||||
self.assertEqual(
|
||||
transcode_stream_source("front", 480, 500),
|
||||
"ffmpeg:front#video=h264#height=480#hardware#audio=copy"
|
||||
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
|
||||
)
|
||||
|
||||
def test_default_source_skips_transcoded_streams(self):
|
||||
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
|
||||
self.assertEqual(default_transcode_source("front", streams), "front_main")
|
||||
self.assertIsNone(default_transcode_source("front", {}))
|
||||
|
||||
|
||||
class TestRawTranscodeStreams(unittest.TestCase):
|
||||
def test_defaults_to_camera_stream_and_default_qualities(self):
|
||||
config = {"cameras": {"front": {"live": {"transcode": {"enabled": True}}}}}
|
||||
streams = raw_transcode_streams(config)
|
||||
|
||||
self.assertEqual(
|
||||
list(streams),
|
||||
[
|
||||
transcode_stream_name("front", q["height"])
|
||||
for q in DEFAULT_TRANSCODE_QUALITIES
|
||||
],
|
||||
)
|
||||
self.assertTrue(streams["front_transcode_720p"].startswith("ffmpeg:front#"))
|
||||
|
||||
def test_uses_first_live_stream_and_explicit_qualities(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"streams": {"Main": "front_main", "Sub": "front_sub"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 540, "bitrate": 800}],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(config),
|
||||
{"front_transcode_540p": transcode_stream_source("front_main", 540, 800)},
|
||||
)
|
||||
|
||||
def test_explicit_source_wins(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"streams": {"Main": "front_main"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"source": "front_sub",
|
||||
"qualities": [{"height": 360, "bitrate": 250}],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(config)["front_transcode_360p"],
|
||||
transcode_stream_source("front_sub", 360, 250),
|
||||
)
|
||||
|
||||
def test_malformed_qualities_are_skipped(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [
|
||||
{"height": 720},
|
||||
"480",
|
||||
{"height": 360, "bitrate": 250},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"live": {"transcode": {"enabled": True, "qualities": {"a": 1}}}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
|
||||
|
||||
def test_disabled_or_missing_generates_nothing(self):
|
||||
self.assertEqual(raw_transcode_streams({}), {})
|
||||
self.assertEqual(
|
||||
raw_transcode_streams({"cameras": {"front": {"live": {}}}}), {}
|
||||
)
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(
|
||||
{"cameras": {"front": {"live": {"transcode": {"enabled": False}}}}}
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def camera_config(live: dict | None = None, go2rtc: dict | None = None) -> dict:
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"go2rtc": {
|
||||
"streams": go2rtc
|
||||
or {
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/main", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if live is not None:
|
||||
config["cameras"]["front"]["live"] = live
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class TestLiveTranscodeConfig(unittest.TestCase):
|
||||
def streams(self, live: dict, go2rtc: dict | None = None) -> dict[str, str]:
|
||||
config = FrigateConfig(**camera_config(live, go2rtc))
|
||||
return config.cameras["front"].live.streams
|
||||
|
||||
def test_global_transcode_is_ignored(self):
|
||||
config = camera_config()
|
||||
config["live"] = {"transcode": {"enabled": True}}
|
||||
|
||||
streams = FrigateConfig(**config).cameras["front"].live.streams
|
||||
self.assertEqual(streams, {"front": "front"})
|
||||
|
||||
def test_disabled_leaves_streams_alone(self):
|
||||
self.assertEqual(self.streams({}), {"front": "front"})
|
||||
|
||||
def test_enabled_appends_default_qualities(self):
|
||||
streams = self.streams({"transcode": {"enabled": True}})
|
||||
|
||||
self.assertEqual(
|
||||
list(streams.items()),
|
||||
[
|
||||
("front", "front"),
|
||||
("720p", "front_transcode_720p"),
|
||||
("480p", "front_transcode_480p"),
|
||||
("360p", "front_transcode_360p"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_source_resolves_to_first_live_stream(self):
|
||||
config = FrigateConfig(
|
||||
**camera_config(
|
||||
{
|
||||
"streams": {"Sub": "front_sub", "Main": "front"},
|
||||
"transcode": {"enabled": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(config.cameras["front"].live.transcode.source, "front_sub")
|
||||
|
||||
def test_placed_entries_keep_position_and_label(self):
|
||||
streams = self.streams(
|
||||
{
|
||||
"streams": {
|
||||
"Main": "front",
|
||||
"Low": "front_transcode_480p",
|
||||
"Sub": "front_sub",
|
||||
},
|
||||
"transcode": {"enabled": True},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
list(streams.items()),
|
||||
[
|
||||
("Main", "front"),
|
||||
("Low", "front_transcode_480p"),
|
||||
("Sub", "front_sub"),
|
||||
("720p", "front_transcode_720p"),
|
||||
("360p", "front_transcode_360p"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_disabling_drops_transcoded_entries(self):
|
||||
streams = self.streams(
|
||||
{
|
||||
"streams": {"Main": "front", "720p": "front_transcode_720p"},
|
||||
"transcode": {"enabled": False},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(streams, {"Main": "front"})
|
||||
|
||||
def test_changed_height_drops_the_old_entry(self):
|
||||
streams = self.streams(
|
||||
{
|
||||
"streams": {"Main": "front", "720p": "front_transcode_720p"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 540, "bitrate": 800}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(streams, {"Main": "front", "540p": "front_transcode_540p"})
|
||||
|
||||
def test_real_go2rtc_stream_with_transcode_name_is_kept(self):
|
||||
streams = self.streams(
|
||||
{"streams": {"Main": "front", "Odd": "front_transcode_720p"}},
|
||||
go2rtc={
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(streams, {"Main": "front", "Odd": "front_transcode_720p"})
|
||||
|
||||
def test_generated_name_colliding_with_go2rtc_stream_fails(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
{"transcode": {"enabled": True}},
|
||||
go2rtc={
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_source_must_be_a_go2rtc_stream(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams({"transcode": {"enabled": True, "source": "missing"}})
|
||||
|
||||
def test_duplicate_heights_fail(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
{
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [
|
||||
{"height": 480, "bitrate": 500},
|
||||
{"height": 480, "bitrate": 600},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def test_label_collision_fails(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
{
|
||||
"streams": {"Main": "front", "720p": "front_sub"},
|
||||
"transcode": {"enabled": True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestGeneratedTranscodeStreams(unittest.TestCase):
|
||||
def test_collects_enabled_cameras(self):
|
||||
config = FrigateConfig(
|
||||
**camera_config(
|
||||
{
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 480, "bitrate": 500}],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
generated_transcode_streams(config),
|
||||
{"front_transcode_480p": transcode_stream_source("front", 480, 500)},
|
||||
)
|
||||
|
||||
def test_disabled_is_empty(self):
|
||||
self.assertEqual(
|
||||
generated_transcode_streams(FrigateConfig(**camera_config())), {}
|
||||
)
|
||||
|
||||
|
||||
class TestSyncTranscodeStreams(unittest.TestCase):
|
||||
@patch("frigate.util.live_streams.requests.request")
|
||||
def test_puts_added_and_changed_and_deletes_removed(self, mock_request):
|
||||
mock_request.return_value = MagicMock(ok=True)
|
||||
|
||||
ok = sync_transcode_streams(
|
||||
{"a": "src-a", "b": "src-b", "c": "src-c"},
|
||||
{"a": "src-a", "b": "src-b2", "d": "src-d"},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
url = "http://127.0.0.1:1984/api/streams"
|
||||
self.assertCountEqual(
|
||||
mock_request.call_args_list,
|
||||
[
|
||||
call("put", url, params={"name": "b", "src": "src-b2"}, timeout=5),
|
||||
call("put", url, params={"name": "d", "src": "src-d"}, timeout=5),
|
||||
call("delete", url, params={"src": "c"}, timeout=5),
|
||||
],
|
||||
)
|
||||
|
||||
@patch("frigate.util.live_streams.requests.request")
|
||||
def test_unchanged_makes_no_calls(self, mock_request):
|
||||
self.assertTrue(sync_transcode_streams({"a": "x"}, {"a": "x"}))
|
||||
mock_request.assert_not_called()
|
||||
|
||||
@patch("frigate.util.live_streams.requests.request")
|
||||
def test_failure_returns_false_and_keeps_going(self, mock_request):
|
||||
mock_request.side_effect = [
|
||||
requests.ConnectionError("down"),
|
||||
MagicMock(ok=True),
|
||||
]
|
||||
|
||||
self.assertFalse(sync_transcode_streams({}, {"a": "x", "b": "y"}))
|
||||
self.assertEqual(mock_request.call_count, 2)
|
||||
|
||||
|
||||
class TestMeasureStreamBitrate(unittest.TestCase):
|
||||
@patch("frigate.util.live_streams.time.monotonic")
|
||||
@patch("frigate.util.live_streams.requests.get")
|
||||
def test_skips_warmup_and_averages(self, mock_get, mock_monotonic):
|
||||
response = MagicMock(ok=True)
|
||||
response.iter_content.return_value = [b"x" * 1000] * 6
|
||||
mock_get.return_value.__enter__.return_value = response
|
||||
# first byte, warmup, window start, then counted chunks up to 6 s
|
||||
mock_monotonic.side_effect = [0.0, 0.5, 1.0, 2.0, 4.0, 7.0]
|
||||
|
||||
kbps = measure_stream_bitrate("front", duration=6.0, warmup=1.0)
|
||||
|
||||
self.assertAlmostEqual(kbps, 3000 * 8 / 1000 / 6.0)
|
||||
mock_get.assert_called_once_with(
|
||||
"http://127.0.0.1:1984/api/stream.mp4",
|
||||
params={"src": "front"},
|
||||
stream=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@patch("frigate.util.live_streams.requests.get")
|
||||
def test_no_data_returns_none(self, mock_get):
|
||||
response = MagicMock(ok=True)
|
||||
response.iter_content.return_value = []
|
||||
mock_get.return_value.__enter__.return_value = response
|
||||
|
||||
self.assertIsNone(measure_stream_bitrate("front"))
|
||||
|
||||
@patch("frigate.util.live_streams.requests.get")
|
||||
def test_request_error_returns_none(self, mock_get):
|
||||
mock_get.side_effect = requests.ReadTimeout("slow")
|
||||
|
||||
self.assertIsNone(measure_stream_bitrate("front"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Frigate-managed go2rtc live streams: transcoded rungs and bitrate probes."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GO2RTC_API = "http://127.0.0.1:1984/api"
|
||||
|
||||
DEFAULT_TRANSCODE_QUALITIES: list[dict[str, int]] = [
|
||||
{"height": 720, "bitrate": 1200},
|
||||
{"height": 480, "bitrate": 500},
|
||||
{"height": 360, "bitrate": 250},
|
||||
]
|
||||
|
||||
|
||||
def transcode_stream_name(camera: str, height: int) -> str:
|
||||
"""Return the go2rtc name of a camera's transcoded stream at one height."""
|
||||
return f"{camera}_transcode_{height}p"
|
||||
|
||||
|
||||
def is_transcode_stream_name(camera: str, name: str) -> bool:
|
||||
"""Return whether a stream name follows this camera's transcoded naming."""
|
||||
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
|
||||
|
||||
|
||||
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
|
||||
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
|
||||
|
||||
go2rtc rejects API-registered sources containing spaces, so each ffmpeg
|
||||
token gets its own #raw= parameter. #audio=copy keeps audio, which any
|
||||
#video= option would otherwise drop.
|
||||
"""
|
||||
tokens = (
|
||||
"-b:v",
|
||||
f"{bitrate}k",
|
||||
"-maxrate",
|
||||
f"{bitrate}k",
|
||||
"-bufsize",
|
||||
f"{2 * bitrate}k",
|
||||
)
|
||||
raw = "".join(f"#raw={token}" for token in tokens)
|
||||
return f"ffmpeg:{source}#video=h264#height={height}#hardware#audio=copy{raw}"
|
||||
|
||||
|
||||
def transcode_streams(
|
||||
camera: str, source: str, qualities: list[dict[str, int]]
|
||||
) -> dict[str, str]:
|
||||
"""Map each quality's go2rtc stream name to its source."""
|
||||
return {
|
||||
transcode_stream_name(camera, quality["height"]): transcode_stream_source(
|
||||
source, quality["height"], quality["bitrate"]
|
||||
)
|
||||
for quality in qualities
|
||||
}
|
||||
|
||||
|
||||
def default_transcode_source(camera: str, streams: dict[str, str]) -> str | None:
|
||||
"""Return the first live stream that is not itself transcoded."""
|
||||
return next(
|
||||
(
|
||||
name
|
||||
for name in streams.values()
|
||||
if not is_transcode_stream_name(camera, name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]:
|
||||
"""Build transcoded go2rtc streams for every enabled camera in a raw config.
|
||||
|
||||
go2rtc's config is written before FrigateConfig validation runs, so this
|
||||
resolves defaults the same way the validator does.
|
||||
"""
|
||||
streams: dict[str, str] = {}
|
||||
|
||||
for camera, camera_config in (config.get("cameras") or {}).items():
|
||||
live = (camera_config or {}).get("live") or {}
|
||||
transcode = live.get("transcode") or {}
|
||||
|
||||
if not transcode.get("enabled"):
|
||||
continue
|
||||
|
||||
source = transcode.get("source") or default_transcode_source(
|
||||
camera, live.get("streams") or {camera: camera}
|
||||
)
|
||||
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
qualities = transcode.get("qualities")
|
||||
|
||||
if qualities is None:
|
||||
qualities = DEFAULT_TRANSCODE_QUALITIES
|
||||
|
||||
# malformed entries fail Frigate's validation; skipping them here keeps
|
||||
# go2rtc from crash-looping alongside it
|
||||
valid = [
|
||||
quality
|
||||
for quality in (qualities if isinstance(qualities, list) else [])
|
||||
if isinstance(quality, dict)
|
||||
and "height" in quality
|
||||
and "bitrate" in quality
|
||||
]
|
||||
streams.update(transcode_streams(camera, source, valid))
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def generated_transcode_streams(config: "FrigateConfig") -> dict[str, str]:
|
||||
"""Build transcoded go2rtc streams for every enabled camera."""
|
||||
streams: dict[str, str] = {}
|
||||
|
||||
for camera in config.cameras.values():
|
||||
transcode = camera.live.transcode
|
||||
|
||||
if transcode.enabled and transcode.source:
|
||||
streams.update(
|
||||
transcode_streams(
|
||||
camera.name,
|
||||
transcode.source,
|
||||
[quality.model_dump() for quality in transcode.qualities],
|
||||
)
|
||||
)
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def _go2rtc_streams_call(method: str, name: str, params: dict[str, str]) -> bool:
|
||||
try:
|
||||
response = requests.request(
|
||||
method, f"{GO2RTC_API}/streams", params=params, timeout=5
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error("Failed to sync go2rtc stream %s: %s", name, e)
|
||||
return False
|
||||
|
||||
if not response.ok:
|
||||
logger.error("Failed to sync go2rtc stream %s: %s", name, response.text)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sync_transcode_streams(old: dict[str, str], new: dict[str, str]) -> bool:
|
||||
"""Register added or changed streams with go2rtc and remove dropped ones.
|
||||
|
||||
Returns False when any go2rtc call failed.
|
||||
"""
|
||||
ok = True
|
||||
|
||||
for name, source in new.items():
|
||||
if old.get(name) != source:
|
||||
ok = _go2rtc_streams_call("put", name, {"name": name, "src": source}) and ok
|
||||
|
||||
for name in old.keys() - new.keys():
|
||||
ok = _go2rtc_streams_call("delete", name, {"src": name}) and ok
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def measure_stream_bitrate(
|
||||
name: str, duration: float = 6.0, warmup: float = 1.0
|
||||
) -> float | None:
|
||||
"""Measure a go2rtc stream's average kbps from its local MP4 output.
|
||||
|
||||
The first warmup seconds after the first byte are skipped so the startup
|
||||
keyframe burst does not inflate the result. Returns None when the stream
|
||||
sends nothing.
|
||||
"""
|
||||
first = start = now = None
|
||||
counted = 0
|
||||
|
||||
try:
|
||||
with requests.get(
|
||||
f"{GO2RTC_API}/stream.mp4",
|
||||
params={"src": name},
|
||||
stream=True,
|
||||
timeout=5,
|
||||
) as response:
|
||||
if not response.ok:
|
||||
return None
|
||||
|
||||
for chunk in response.iter_content(chunk_size=16384):
|
||||
now = time.monotonic()
|
||||
|
||||
if first is None:
|
||||
first = now
|
||||
|
||||
if now - first < warmup:
|
||||
continue
|
||||
|
||||
if start is None:
|
||||
start = now
|
||||
continue
|
||||
|
||||
counted += len(chunk)
|
||||
|
||||
if now - start >= duration:
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to measure go2rtc stream %s: %s", name, e)
|
||||
return None
|
||||
|
||||
if start is None or now is None or now <= start or counted == 0:
|
||||
return None
|
||||
|
||||
return counted * 8 / 1000 / (now - start)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -134,6 +134,43 @@ test.describe("Auto live stream @mobile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Transcoded live stream @mobile", () => {
|
||||
test("a pinned transcoded stream plays over MSE", async ({ frigateApp }) => {
|
||||
// go2rtc.streams lists only yaml streams; transcoded ones are generated
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
...CONFIG,
|
||||
cameras: {
|
||||
front_door: {
|
||||
live: {
|
||||
streams: {
|
||||
Sub: "front_door_sub",
|
||||
"720p": "front_door_transcode_720p",
|
||||
},
|
||||
transcode: {
|
||||
enabled: true,
|
||||
source: "front_door",
|
||||
qualities: [{ height: 720, bitrate: 1200 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const opened = await mockGo2rtc(frigateApp.page);
|
||||
|
||||
await frigateApp.goto("/");
|
||||
await writeIdb(frigateApp.page, {
|
||||
[STREAM_KEY]: "front_door_transcode_720p",
|
||||
});
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
await expect
|
||||
.poll(() => opened, { timeout: 10_000 })
|
||||
.toContain("front_door_transcode_720p");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Auto live stream selector", () => {
|
||||
test.beforeEach(({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
@@ -168,6 +205,26 @@ test.describe("Auto live stream selector", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("trying the highest quality retries the top stream", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ config: CONFIG });
|
||||
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
await expect
|
||||
.poll(() => opened, { timeout: 10_000 })
|
||||
.toContain("front_door_sub");
|
||||
const menu = await openDesktopSettings(frigateApp.page);
|
||||
const beforeRetry = opened.length;
|
||||
await menu.getByRole("button", { name: "Try highest quality" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => opened.slice(beforeRetry), { timeout: 10_000 })
|
||||
.toContain("front_door");
|
||||
});
|
||||
|
||||
test("a pinned main takes the legacy fallback instead of stepping down", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Live playback stream order and transcoded streams -- MEDIUM tier.
|
||||
*
|
||||
* The stream list order is the Auto ladder, so the form saves it with
|
||||
* replace_paths. Transcoded streams are generated from live.transcode and
|
||||
* appear in the list as soon as the switch flips.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const GO2RTC_STREAMS = {
|
||||
front_door_main: ["rtsp://user:pass@192.168.0.20:554/main"],
|
||||
front_door_sub: ["rtsp://user:pass@192.168.0.20:554/sub"],
|
||||
};
|
||||
|
||||
const TRANSCODE = {
|
||||
enabled: false,
|
||||
qualities: [
|
||||
{ height: 720, bitrate: 1200 },
|
||||
{ height: 480, bitrate: 500 },
|
||||
],
|
||||
};
|
||||
|
||||
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
|
||||
|
||||
type RouteOptions = {
|
||||
go2rtcStreams?: Record<string, string[]>;
|
||||
streams?: Record<string, string>;
|
||||
bitrates?: Record<string, number>;
|
||||
};
|
||||
|
||||
async function installRoutes(page: Page, options: RouteOptions = {}) {
|
||||
const go2rtcStreams = options.go2rtcStreams ?? GO2RTC_STREAMS;
|
||||
const streams = options.streams ?? {
|
||||
Sub: "front_door_sub",
|
||||
Main: "front_door_main",
|
||||
};
|
||||
const live = { streams, transcode: TRANSCODE };
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: go2rtcStreams },
|
||||
cameras: { front_door: { live } },
|
||||
});
|
||||
// configFactory's deepMerge adds override keys onto the base fixture's
|
||||
// streams map instead of replacing it (they share no key names here), so
|
||||
// pin the streams map to exactly what this test wants.
|
||||
config.cameras.front_door.live.streams = streams;
|
||||
|
||||
let lastSaved: Record<string, unknown> | null = null;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
go2rtc: { streams: go2rtcStreams },
|
||||
cameras: { front_door: { live } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
lastSaved = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true, go2rtc_synced: true } });
|
||||
});
|
||||
await page.route("**/api/go2rtc/streams/*/bitrate", (route) => {
|
||||
const name = decodeURIComponent(
|
||||
new URL(route.request().url()).pathname.split("/").slice(-2)[0],
|
||||
);
|
||||
const kbps = options.bitrates?.[name];
|
||||
return kbps === undefined
|
||||
? route.fulfill({ status: 502, json: { success: false } })
|
||||
: route.fulfill({ json: { success: true, kbps } });
|
||||
});
|
||||
|
||||
return { saved: () => lastSaved };
|
||||
}
|
||||
|
||||
function streamNames(page: Page) {
|
||||
return page
|
||||
.getByRole("textbox", { name: "Stream name" })
|
||||
.evaluateAll((inputs) =>
|
||||
inputs.map((input) => (input as HTMLInputElement).value),
|
||||
);
|
||||
}
|
||||
|
||||
async function dragRow(page: Page, from: number, to: number) {
|
||||
const handles = page.getByRole("button", { name: "Drag to reorder" });
|
||||
const start = await handles.nth(from).boundingBox();
|
||||
const end = await handles.nth(to).boundingBox();
|
||||
|
||||
if (!start || !end) throw new Error("drag handle not visible");
|
||||
|
||||
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
end.x + end.width / 2,
|
||||
end.y + end.height / 2 + (to > from ? 12 : -12),
|
||||
{ steps: 15 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("live stream order and transcoded streams @medium", () => {
|
||||
test.skip(({ frigateApp }) => frigateApp.isMobile, "desktop drag");
|
||||
|
||||
test("dragging a stream saves the new order", async ({ frigateApp }) => {
|
||||
const capture = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await expect
|
||||
.poll(() => streamNames(frigateApp.page))
|
||||
.toEqual(["Sub", "Main"]);
|
||||
|
||||
await dragRow(frigateApp.page, 1, 0);
|
||||
await expect
|
||||
.poll(() => streamNames(frigateApp.page))
|
||||
.toEqual(["Main", "Sub"]);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => capture.saved()).not.toBeNull();
|
||||
const saved = capture.saved() as {
|
||||
replace_paths: string[];
|
||||
config_data: {
|
||||
cameras: { front_door: { live: { streams: Record<string, string> } } };
|
||||
};
|
||||
};
|
||||
expect(saved.replace_paths).toEqual(["cameras.front_door.live.streams"]);
|
||||
expect(
|
||||
Object.keys(saved.config_data.cameras.front_door.live.streams),
|
||||
).toEqual(["Main", "Sub"]);
|
||||
});
|
||||
|
||||
test("enabling transcoding saves the shown source", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Enable transcoded streams" })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => capture.saved()).not.toBeNull();
|
||||
const saved = capture.saved() as {
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: { live: { transcode: Record<string, unknown> } };
|
||||
};
|
||||
};
|
||||
};
|
||||
// pinned, so dragging another stream to the top can't change it
|
||||
expect(saved.config_data.cameras.front_door.live.transcode).toMatchObject({
|
||||
enabled: true,
|
||||
source: "front_door_sub",
|
||||
});
|
||||
});
|
||||
|
||||
test("the transcode switch adds and removes transcoded rows", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const toggle = frigateApp.page.getByRole("switch", {
|
||||
name: "Enable transcoded streams",
|
||||
});
|
||||
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
||||
|
||||
await toggle.click();
|
||||
await expect
|
||||
.poll(() => streamNames(frigateApp.page))
|
||||
.toEqual(["Sub", "Main", "720p", "480p"]);
|
||||
await expect(
|
||||
frigateApp.page.getByText("Transcoded", { exact: true }),
|
||||
).toHaveCount(2);
|
||||
await expect(save).toBeEnabled();
|
||||
|
||||
await toggle.click();
|
||||
await expect
|
||||
.poll(() => streamNames(frigateApp.page))
|
||||
.toEqual(["Sub", "Main"]);
|
||||
await expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
test("auto order sorts measured and set bitrates", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
bitrates: { front_door_main: 4000, front_door_sub: 800 },
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Enable transcoded streams" })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => streamNames(frigateApp.page))
|
||||
.toEqual(["Main", "720p", "Sub", "480p"]);
|
||||
await expect(frigateApp.page.getByText("4000 kbps")).toBeVisible();
|
||||
await expect(frigateApp.page.getByText("1200 kbps (set)")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unmeasurable stream sorts last", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
bitrates: { front_door_main: 4000 },
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => streamNames(frigateApp.page))
|
||||
.toEqual(["Main", "Sub"]);
|
||||
await expect(frigateApp.page.getByText("Couldn't measure")).toBeVisible();
|
||||
});
|
||||
|
||||
test("transcoding needs a go2rtc stream", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
go2rtcStreams: {},
|
||||
streams: { front_door: "front_door" },
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", {
|
||||
name: "Enable transcoded streams",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
frigateApp.page.getByText(/need a go2rtc stream to read from/),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -238,6 +238,30 @@
|
||||
"label": "Live stream names",
|
||||
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
|
||||
},
|
||||
"transcode": {
|
||||
"label": "Transcoded streams",
|
||||
"description": "Lower-quality live streams transcoded on demand by go2rtc.",
|
||||
"enabled": {
|
||||
"label": "Enable transcoded streams",
|
||||
"description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching."
|
||||
},
|
||||
"source": {
|
||||
"label": "Source stream",
|
||||
"description": "go2rtc stream to transcode. Defaults to the first live stream."
|
||||
},
|
||||
"qualities": {
|
||||
"label": "Qualities",
|
||||
"description": "One transcoded stream is added per quality.",
|
||||
"height": {
|
||||
"label": "Height",
|
||||
"description": "Output height in pixels; width follows the source aspect ratio."
|
||||
},
|
||||
"bitrate": {
|
||||
"label": "Bitrate",
|
||||
"description": "Target and maximum video bitrate in kbps."
|
||||
}
|
||||
}
|
||||
},
|
||||
"height": {
|
||||
"label": "Live height",
|
||||
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
||||
|
||||
@@ -594,6 +594,30 @@
|
||||
"label": "Live stream names",
|
||||
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
|
||||
},
|
||||
"transcode": {
|
||||
"label": "Transcoded streams",
|
||||
"description": "Lower-quality live streams transcoded on demand by go2rtc.",
|
||||
"enabled": {
|
||||
"label": "Enable transcoded streams",
|
||||
"description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching."
|
||||
},
|
||||
"source": {
|
||||
"label": "Source stream",
|
||||
"description": "go2rtc stream to transcode. Defaults to the first live stream."
|
||||
},
|
||||
"qualities": {
|
||||
"label": "Qualities",
|
||||
"description": "One transcoded stream is added per quality.",
|
||||
"height": {
|
||||
"label": "Height",
|
||||
"description": "Output height in pixels; width follows the source aspect ratio."
|
||||
},
|
||||
"bitrate": {
|
||||
"label": "Bitrate",
|
||||
"description": "Target and maximum video bitrate in kbps."
|
||||
}
|
||||
}
|
||||
},
|
||||
"height": {
|
||||
"label": "Live height",
|
||||
"description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height."
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
"tips": "Switches streams to match your connection",
|
||||
"selected": "Auto · {{stream}}",
|
||||
"floorLabel": "Low bandwidth",
|
||||
"retry": "Try highest quality",
|
||||
"reason": {
|
||||
"bandwidth": "Playing {{stream}} because the connection couldn't keep up with a higher-quality stream",
|
||||
"codec": "Playing {{stream}} because this browser can't play a higher-quality stream",
|
||||
|
||||
@@ -1543,7 +1543,27 @@
|
||||
"noGo2rtcStreams": "No go2rtc streams configured",
|
||||
"availableStreams": "Available streams",
|
||||
"useCustom": "Use \"{{value}}\"",
|
||||
"addStream": "Add stream"
|
||||
"addStream": "Add stream",
|
||||
"reorderHandle": "Drag to reorder",
|
||||
"transcodedBadge": "Transcoded",
|
||||
"autoOrder": "Auto order",
|
||||
"autoOrderTips": "Measure each stream's bitrate and sort highest first",
|
||||
"measuring": "Measuring…",
|
||||
"rate": "{{kbps}} kbps",
|
||||
"rateSet": "{{kbps}} kbps (set)",
|
||||
"rateFailed": "Couldn't measure"
|
||||
},
|
||||
"liveTranscode": {
|
||||
"title": "Transcoded streams",
|
||||
"description": "Adds lower-quality streams that go2rtc transcodes in real time while someone is watching.",
|
||||
"unavailable": "Transcoded streams need a go2rtc stream to read from. Add one under go2rtc streams first.",
|
||||
"enable": "Enable transcoded streams",
|
||||
"source": "Source stream",
|
||||
"height": "Height",
|
||||
"heightOption": "{{height}}p",
|
||||
"bitrate": "Bitrate (kbps)",
|
||||
"addQuality": "Add quality",
|
||||
"removeQuality": "Remove quality"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
@@ -1778,6 +1798,7 @@
|
||||
"toast": {
|
||||
"success": "Settings saved successfully",
|
||||
"applied": "Settings applied successfully",
|
||||
"go2rtcSyncFailed": "Saved, but go2rtc couldn't be updated. Restart Frigate to apply.",
|
||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||
"error": "Failed to save settings",
|
||||
"validationError": "Validation failed: {{message}}",
|
||||
|
||||
@@ -4,17 +4,18 @@ const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["streams", "height", "quality"],
|
||||
fieldOrder: ["streams", "transcode", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["streams", "height", "quality"],
|
||||
hiddenFields: ["streams"],
|
||||
hiddenFields: ["streams", "transcode"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["height", "quality"],
|
||||
orderedMaps: ["streams"],
|
||||
uiSchema: {
|
||||
streams: {
|
||||
"ui:field": "LiveStreamsField",
|
||||
@@ -23,6 +24,13 @@ const live: SectionConfigOverrides = {
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
transcode: {
|
||||
"ui:field": "LiveTranscodeField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -66,6 +66,8 @@ import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
applyOrderedMaps,
|
||||
changedOrderedMapPaths,
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
getBaseCameraSectionValue,
|
||||
@@ -121,6 +123,8 @@ export interface SectionConfig {
|
||||
messages?: ConditionalMessage[];
|
||||
/** Conditional messages displayed inline with specific fields */
|
||||
fieldMessages?: FieldConditionalMessage[];
|
||||
/** Maps whose key order is saved (e.g. live stream ladder order) */
|
||||
orderedMaps?: string[];
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
@@ -488,6 +492,18 @@ export function ConfigSection({
|
||||
[rawFormData, sanitizeSectionData],
|
||||
);
|
||||
|
||||
const orderedMaps = sectionConfig.orderedMaps;
|
||||
const buildSectionOverrides = useCallback(
|
||||
(current: unknown, base: unknown, defaults: unknown) =>
|
||||
applyOrderedMaps(
|
||||
buildOverrides(current, base, defaults),
|
||||
current,
|
||||
base,
|
||||
orderedMaps ?? [],
|
||||
),
|
||||
[orderedMaps],
|
||||
);
|
||||
|
||||
// Clear pendingData whenever the section/camera key changes (e.g., switching
|
||||
// cameras) or when there is no pending data yet (initialization).
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit.
|
||||
@@ -528,10 +544,12 @@ export function ConfigSection({
|
||||
// Track if there are unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
const pendingChanged = pendingData
|
||||
? !isEqual(formData, pendingData)
|
||||
? !isEqual(formData, pendingData) ||
|
||||
changedOrderedMapPaths(pendingData, formData, orderedMaps ?? [])
|
||||
.length > 0
|
||||
: false;
|
||||
return pendingChanged || extraHasChanges;
|
||||
}, [formData, pendingData, extraHasChanges]);
|
||||
}, [formData, pendingData, extraHasChanges, orderedMaps]);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.({
|
||||
@@ -564,7 +582,7 @@ export function ConfigSection({
|
||||
}
|
||||
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
||||
const nextBaselineFormData = baselineSnapshot;
|
||||
const overrides = buildOverrides(
|
||||
const overrides = buildSectionOverrides(
|
||||
sanitizedData,
|
||||
compareBaseData,
|
||||
effectiveSchemaDefaults,
|
||||
@@ -579,7 +597,7 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
}
|
||||
const dirty = buildOverrides(
|
||||
const dirty = buildSectionOverrides(
|
||||
sanitizedData,
|
||||
nextBaselineFormData,
|
||||
undefined,
|
||||
@@ -602,12 +620,24 @@ export function ConfigSection({
|
||||
setPendingOverrides,
|
||||
setDirtyOverrides,
|
||||
baselineSnapshot,
|
||||
buildSectionOverrides,
|
||||
],
|
||||
);
|
||||
|
||||
const currentFormData = pendingData || formData;
|
||||
const effectiveBaselineFormData = baselineSnapshot;
|
||||
|
||||
// RJSF memoizes fields with an order-blind deep compare, so a pure reorder
|
||||
// of an ordered map would not re-render; the key order breaks the tie
|
||||
const orderedMapsKeyOrder = useMemo(
|
||||
() =>
|
||||
(orderedMaps ?? []).map((path) => {
|
||||
const value = get(currentFormData, path);
|
||||
return value && typeof value === "object" ? Object.keys(value) : [];
|
||||
}),
|
||||
[currentFormData, orderedMaps],
|
||||
);
|
||||
|
||||
// Build context for conditional messages
|
||||
const messageContext = useMemo<MessageConditionContext | undefined>(() => {
|
||||
if (!config || !currentFormData) return undefined;
|
||||
@@ -636,7 +666,7 @@ export function ConfigSection({
|
||||
const sanitizedData = sanitizeSectionData(
|
||||
currentFormData as ConfigSectionData,
|
||||
);
|
||||
return buildOverrides(
|
||||
return buildSectionOverrides(
|
||||
sanitizedData,
|
||||
compareBaseData,
|
||||
effectiveSchemaDefaults,
|
||||
@@ -646,6 +676,7 @@ export function ConfigSection({
|
||||
sanitizeSectionData,
|
||||
compareBaseData,
|
||||
effectiveSchemaDefaults,
|
||||
buildSectionOverrides,
|
||||
]);
|
||||
|
||||
const effectiveOverrides = pendingData
|
||||
@@ -684,7 +715,12 @@ export function ConfigSection({
|
||||
? `cameras.${cameraName}.${effectiveSectionPath}`
|
||||
: effectiveSectionPath;
|
||||
const rawData = sanitizeSectionData(rawFormData);
|
||||
const overrides = buildOverrides(
|
||||
const replacePaths = changedOrderedMapPaths(
|
||||
pendingData,
|
||||
rawData,
|
||||
orderedMaps ?? [],
|
||||
).map((path) => `${basePath}.${path}`);
|
||||
const overrides = buildSectionOverrides(
|
||||
pendingData,
|
||||
rawData,
|
||||
effectiveSchemaDefaults,
|
||||
@@ -711,10 +747,11 @@ export function ConfigSection({
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||
await axios.put("config/set", {
|
||||
const response = await axios.put("config/set", {
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
config_data: configData,
|
||||
...(replacePaths.length > 0 ? { replace_paths: replacePaths } : {}),
|
||||
...(skipSave ? { skip_save: true } : {}),
|
||||
});
|
||||
|
||||
@@ -752,6 +789,8 @@ export function ConfigSection({
|
||||
),
|
||||
},
|
||||
);
|
||||
} else if (response.data?.go2rtc_synced === false) {
|
||||
toast.warning(t("toast.go2rtcSyncFailed", { ns: "views/settings" }));
|
||||
} else {
|
||||
toast.success(
|
||||
t("toast.success", {
|
||||
@@ -823,6 +862,8 @@ export function ConfigSection({
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
onSavingChange,
|
||||
orderedMaps,
|
||||
buildSectionOverrides,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
@@ -1030,6 +1071,7 @@ export function ConfigSection({
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
orderedMapsKeyOrder,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Command,
|
||||
@@ -16,16 +20,35 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
LuArrowDownWideNarrow,
|
||||
LuGripVertical,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { MapKeyInput } from "../components";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { LiveTranscodeConfig } from "@/types/frigateConfig";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
import {
|
||||
isTranscodeStreamName,
|
||||
transcodeBitrateFor,
|
||||
} from "@/utils/liveTranscode";
|
||||
|
||||
type LiveStreamsData = Record<string, string>;
|
||||
|
||||
// kbps measured by the server, or configured for a transcoded stream
|
||||
type StreamRate = { kbps: number; set: boolean } | { failed: true };
|
||||
|
||||
function rateValue(rate: StreamRate | undefined): number {
|
||||
return rate && "kbps" in rate ? rate.kbps : -1;
|
||||
}
|
||||
|
||||
type StreamValueComboboxProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -156,6 +179,154 @@ function StreamValueCombobox({
|
||||
);
|
||||
}
|
||||
|
||||
type StreamRowProps = {
|
||||
entryKey: string;
|
||||
value: string;
|
||||
entryId: string;
|
||||
data: LiveStreamsData;
|
||||
generated: boolean;
|
||||
rate?: StreamRate;
|
||||
options: string[];
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
onRename: (oldKey: string, newKey: string) => void;
|
||||
onUpdateValue: (key: string, value: string) => void;
|
||||
onRemove: (key: string) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
function StreamRow({
|
||||
entryKey,
|
||||
value,
|
||||
entryId,
|
||||
data,
|
||||
generated,
|
||||
rate,
|
||||
options,
|
||||
disabled,
|
||||
readonly,
|
||||
onRename,
|
||||
onUpdateValue,
|
||||
onRemove,
|
||||
onDragEnd,
|
||||
}: StreamRowProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const controls = useDragControls();
|
||||
const locked = disabled || readonly;
|
||||
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
|
||||
let rateLabel: string | undefined;
|
||||
if (rate && "failed" in rate) {
|
||||
rateLabel = t("configForm.liveStreams.rateFailed", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
} else if (rate?.set) {
|
||||
rateLabel = t("configForm.liveStreams.rateSet", {
|
||||
ns: "views/settings",
|
||||
kbps: rate.kbps,
|
||||
});
|
||||
} else if (rate) {
|
||||
rateLabel = t("configForm.liveStreams.rate", {
|
||||
ns: "views/settings",
|
||||
kbps: rate.kbps,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={entryKey}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
onDragEnd={onDragEnd}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="col-span-12 flex items-end gap-1 md:col-span-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="mb-2 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing disabled:cursor-not-allowed"
|
||||
aria-label={t("configForm.liveStreams.reorderHandle", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
>
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor={`${entryId}-key`}>
|
||||
{t("configForm.liveStreams.streamNameLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Label>
|
||||
<MapKeyInput
|
||||
id={`${entryId}-key`}
|
||||
value={entryKey}
|
||||
placeholder={t("configForm.liveStreams.streamNamePlaceholder", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
disabled={locked}
|
||||
onCommit={(next) => onRename(entryKey, next)}
|
||||
isKeyTaken={(next) =>
|
||||
next !== entryKey &&
|
||||
Object.prototype.hasOwnProperty.call(data, next)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
<Label htmlFor={`${entryId}-value`} className="flex items-center gap-2">
|
||||
{t("configForm.liveStreams.go2rtcStreamLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
{generated && (
|
||||
<Badge variant="secondary">
|
||||
{t("configForm.liveStreams.transcodedBadge", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{generated ? (
|
||||
<Input id={`${entryId}-value`} value={value} disabled readOnly />
|
||||
) : (
|
||||
<StreamValueCombobox
|
||||
id={`${entryId}-value`}
|
||||
value={value}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onChange={(next) => onUpdateValue(entryKey, next)}
|
||||
/>
|
||||
)}
|
||||
{rateLabel && (
|
||||
<p className="text-xs text-muted-foreground">{rateLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end md:col-span-1">
|
||||
{!generated && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(entryKey)}
|
||||
disabled={locked}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
@@ -166,6 +337,7 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
const { t: tSettings } = useTranslation(["views/settings"]);
|
||||
|
||||
const data: LiveStreamsData = useMemo(() => {
|
||||
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
|
||||
@@ -175,6 +347,7 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
}, [formData]);
|
||||
|
||||
const entries = useMemo(() => Object.entries(data), [data]);
|
||||
const keys = useMemo(() => entries.map(([key]) => key), [entries]);
|
||||
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
@@ -185,12 +358,22 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
t(`${sectionPrefix}.${id}.description`) ??
|
||||
(schema as RJSFSchema).description;
|
||||
|
||||
const camera = formContext?.cameraName ?? "";
|
||||
const transcode = formContext?.formData?.transcode as
|
||||
LiveTranscodeConfig | undefined;
|
||||
|
||||
const go2rtcStreamNames = useMemo<string[]>(() => {
|
||||
const streams = formContext?.fullConfig?.go2rtc?.streams;
|
||||
if (!streams || typeof streams !== "object") return [];
|
||||
return Object.keys(streams).sort();
|
||||
}, [formContext?.fullConfig?.go2rtc?.streams]);
|
||||
|
||||
const isGenerated = useCallback(
|
||||
(name: string) =>
|
||||
isTranscodeStreamName(camera, name) && !go2rtcStreamNames.includes(name),
|
||||
[camera, go2rtcStreamNames],
|
||||
);
|
||||
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
@@ -210,6 +393,51 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
);
|
||||
}, [fieldPath, formContext, data]);
|
||||
|
||||
const commitOrder = useCallback(
|
||||
(order: string[]) => {
|
||||
const next: LiveStreamsData = {};
|
||||
order.forEach((key) => {
|
||||
next[key] = data[key];
|
||||
});
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
// rows keep one React key through renames, so the name input stays mounted
|
||||
const rowIdsRef = useRef(new Map<string, number>());
|
||||
const nextRowIdRef = useRef(0);
|
||||
|
||||
const rowIdFor = (key: string) => {
|
||||
let id = rowIdsRef.current.get(key);
|
||||
|
||||
if (id === undefined) {
|
||||
id = nextRowIdRef.current++;
|
||||
rowIdsRef.current.set(key, id);
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
// local order while dragging; committed to the form on drop
|
||||
const [dragOrder, setDragOrder] = useState<string[] | null>(null);
|
||||
const dragOrderRef = useRef<string[] | null>(null);
|
||||
|
||||
const handleReorder = useCallback((next: string[]) => {
|
||||
dragOrderRef.current = next;
|
||||
setDragOrder(next);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
const next = dragOrderRef.current;
|
||||
dragOrderRef.current = null;
|
||||
setDragOrder(null);
|
||||
|
||||
if (next && !isEqual(next, keys)) {
|
||||
commitOrder(next);
|
||||
}
|
||||
}, [commitOrder, keys]);
|
||||
|
||||
const handleAddEntry = useCallback(() => {
|
||||
const next = { ...data, "": "" };
|
||||
onChange(next, fieldPath);
|
||||
@@ -227,6 +455,13 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
const handleRenameKey = useCallback(
|
||||
(oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
|
||||
const id = rowIdsRef.current.get(oldKey);
|
||||
if (id !== undefined) {
|
||||
rowIdsRef.current.delete(oldKey);
|
||||
rowIdsRef.current.set(newKey, id);
|
||||
}
|
||||
|
||||
const next: LiveStreamsData = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === oldKey) {
|
||||
@@ -248,24 +483,76 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
|
||||
// Auto order commits after measuring, so edits made meanwhile must survive
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const [measurement, setMeasurement] = useState<{
|
||||
data: LiveStreamsData;
|
||||
transcode: LiveTranscodeConfig | undefined;
|
||||
rates: Record<string, StreamRate>;
|
||||
} | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
// rates describe the streams as measured; a later edit hides them, while a
|
||||
// reorder alone keeps them since isEqual ignores key order
|
||||
const rates =
|
||||
measurement &&
|
||||
isEqual(measurement.data, data) &&
|
||||
isEqual(measurement.transcode, transcode)
|
||||
? measurement.rates
|
||||
: {};
|
||||
|
||||
const handleAutoOrder = useCallback(async () => {
|
||||
setMeasuring(true);
|
||||
|
||||
const measured = await Promise.all(
|
||||
entries.map(async ([, name]): Promise<[string, StreamRate]> => {
|
||||
const setRate = transcodeBitrateFor(camera, name, transcode);
|
||||
|
||||
if (setRate !== undefined) {
|
||||
return [name, { kbps: setRate, set: true }];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`go2rtc/streams/${encodeURIComponent(name)}/bitrate`,
|
||||
);
|
||||
return [name, { kbps: response.data.kbps, set: false }];
|
||||
} catch {
|
||||
return [name, { failed: true }];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const next = Object.fromEntries(measured);
|
||||
const latest = dataRef.current;
|
||||
setMeasurement({ data: latest, transcode, rates: next });
|
||||
setMeasuring(false);
|
||||
|
||||
// stable sort, so ties and unmeasured rows keep their order; rows added
|
||||
// or renamed while measuring go last
|
||||
const sorted = [...entries]
|
||||
.sort(([, a], [, b]) => rateValue(next[b]) - rateValue(next[a]))
|
||||
.map(([key]) => key)
|
||||
.filter((key) => key in latest);
|
||||
const order = [
|
||||
...sorted,
|
||||
...Object.keys(latest).filter((key) => !sorted.includes(key)),
|
||||
];
|
||||
|
||||
if (!isEqual(order, Object.keys(latest))) {
|
||||
onChange(
|
||||
Object.fromEntries(order.map((key) => [key, latest[key]])),
|
||||
fieldPath,
|
||||
);
|
||||
}
|
||||
}, [camera, entries, fieldPath, onChange, transcode]);
|
||||
|
||||
const baseId = idSchema?.$id || "live_streams";
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const streamNamePlaceholder = t(
|
||||
"configForm.liveStreams.streamNamePlaceholder",
|
||||
{ ns: "views/settings" },
|
||||
);
|
||||
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const addStreamLabel = t("configForm.liveStreams.addStream", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const order = dragOrder ?? keys;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
@@ -278,57 +565,34 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{entries.map(([key, value], entryIndex) => {
|
||||
const entryId = `${baseId}-${entryIndex}`;
|
||||
return (
|
||||
<div
|
||||
key={entryIndex}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
|
||||
<MapKeyInput
|
||||
id={`${entryId}-key`}
|
||||
value={key}
|
||||
placeholder={streamNamePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onCommit={(next) => handleRenameKey(key, next)}
|
||||
isKeyTaken={(next) =>
|
||||
next !== key &&
|
||||
Object.prototype.hasOwnProperty.call(data, next)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-10 space-y-2 md:col-span-6">
|
||||
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
|
||||
<StreamValueCombobox
|
||||
id={`${entryId}-value`}
|
||||
value={value}
|
||||
options={go2rtcStreamNames}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onChange={(next) => handleUpdateValue(key, next)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end md:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveEntry(key)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="shrink-0"
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={order}
|
||||
onReorder={handleReorder}
|
||||
className="space-y-3"
|
||||
>
|
||||
{order.map((key, entryIndex) => (
|
||||
<StreamRow
|
||||
key={rowIdFor(key)}
|
||||
entryKey={key}
|
||||
value={data[key] ?? ""}
|
||||
entryId={`${baseId}-${entryIndex}`}
|
||||
data={data}
|
||||
generated={isGenerated(data[key] ?? "")}
|
||||
rate={rates[data[key] ?? ""]}
|
||||
options={go2rtcStreamNames}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
onRename={handleRenameKey}
|
||||
onUpdateValue={handleUpdateValue}
|
||||
onRemove={handleRemoveEntry}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -338,7 +602,25 @@ export function LiveStreamsField(props: FieldProps) {
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{addStreamLabel}
|
||||
{tSettings("configForm.liveStreams.addStream")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoOrder}
|
||||
disabled={disabled || readonly || measuring || entries.length < 2}
|
||||
title={tSettings("configForm.liveStreams.autoOrderTips")}
|
||||
className="gap-2"
|
||||
>
|
||||
{measuring ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
<LuArrowDownWideNarrow className="h-4 w-4" />
|
||||
)}
|
||||
{measuring
|
||||
? tSettings("configForm.liveStreams.measuring")
|
||||
: tSettings("configForm.liveStreams.autoOrder")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { FieldPathList, FieldProps } from "@rjsf/utils";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import get from "lodash/get";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type {
|
||||
LiveTranscodeConfig,
|
||||
LiveTranscodeQuality,
|
||||
} from "@/types/frigateConfig";
|
||||
import {
|
||||
TRANSCODE_HEIGHTS,
|
||||
defaultTranscodeBitrate,
|
||||
reconcileTranscodeStreams,
|
||||
} from "@/utils/liveTranscode";
|
||||
|
||||
const EMPTY_TRANSCODE: LiveTranscodeConfig = {
|
||||
enabled: false,
|
||||
source: null,
|
||||
qualities: [],
|
||||
};
|
||||
|
||||
// presets plus a height set in yaml, minus heights other rows use
|
||||
function heightOptions(current: number, used: number[]): number[] {
|
||||
return [...new Set([...TRANSCODE_HEIGHTS, current])]
|
||||
.filter((height) => height === current || !used.includes(height))
|
||||
.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
export function LiveTranscodeField(props: FieldProps) {
|
||||
const { formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
ConfigFormContext | undefined;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const transcode =
|
||||
(formData as LiveTranscodeConfig | undefined) ?? EMPTY_TRANSCODE;
|
||||
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
emptyPath;
|
||||
|
||||
const camera = formContext?.cameraName ?? "";
|
||||
const streams = useMemo(
|
||||
() => (formContext?.formData?.streams ?? {}) as Record<string, string>,
|
||||
[formContext?.formData?.streams],
|
||||
);
|
||||
const go2rtcStreams = useMemo(
|
||||
() => Object.keys(formContext?.fullConfig?.go2rtc?.streams ?? {}),
|
||||
[formContext?.fullConfig?.go2rtc?.streams],
|
||||
);
|
||||
const sources = useMemo(
|
||||
() =>
|
||||
[...new Set(Object.values(streams))].filter((name) =>
|
||||
go2rtcStreams.includes(name),
|
||||
),
|
||||
[streams, go2rtcStreams],
|
||||
);
|
||||
|
||||
const savedSource = get(formContext?.baselineFormData, [
|
||||
...fieldPath,
|
||||
"source",
|
||||
]) as string | null | undefined;
|
||||
|
||||
const available = sources.length > 0;
|
||||
const locked = disabled || readonly || !available;
|
||||
const baseId = idSchema?.$id || "live_transcode";
|
||||
|
||||
// the stream list follows the transcode config in the same form change
|
||||
const update = useCallback(
|
||||
(next: LiveTranscodeConfig) => {
|
||||
onChange(next, fieldPath);
|
||||
onChange(
|
||||
reconcileTranscodeStreams(camera, streams, next, go2rtcStreams),
|
||||
[...fieldPath.slice(0, -1), "streams"],
|
||||
);
|
||||
},
|
||||
[camera, fieldPath, go2rtcStreams, onChange, streams],
|
||||
);
|
||||
|
||||
const updateQuality = useCallback(
|
||||
(index: number, quality: LiveTranscodeQuality) => {
|
||||
const qualities = [...transcode.qualities];
|
||||
qualities[index] = quality;
|
||||
update({ ...transcode, qualities });
|
||||
},
|
||||
[transcode, update],
|
||||
);
|
||||
|
||||
const addQuality = useCallback(() => {
|
||||
const used = new Set(transcode.qualities.map((q) => q.height));
|
||||
const height = TRANSCODE_HEIGHTS.find((h) => !used.has(h));
|
||||
|
||||
if (height === undefined) return;
|
||||
|
||||
update({
|
||||
...transcode,
|
||||
qualities: [
|
||||
...transcode.qualities,
|
||||
{ height, bitrate: defaultTranscodeBitrate(height) },
|
||||
],
|
||||
});
|
||||
}, [transcode, update]);
|
||||
|
||||
const removeQuality = useCallback(
|
||||
(index: number) =>
|
||||
update({
|
||||
...transcode,
|
||||
qualities: transcode.qualities.filter((_, i) => i !== index),
|
||||
}),
|
||||
[transcode, update],
|
||||
);
|
||||
|
||||
const usedHeights = transcode.qualities.map((q) => q.height);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm">
|
||||
{t("configForm.liveTranscode.title")}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{available
|
||||
? t("configForm.liveTranscode.description")
|
||||
: t("configForm.liveTranscode.unavailable")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id={`${baseId}-enabled`}
|
||||
checked={transcode.enabled}
|
||||
disabled={locked}
|
||||
onCheckedChange={(enabled) =>
|
||||
update({
|
||||
...transcode,
|
||||
enabled,
|
||||
// pin the shown source so reordering streams can't change it;
|
||||
// turning off drops the pin
|
||||
source: enabled
|
||||
? (transcode.source ?? sources[0])
|
||||
: savedSource,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${baseId}-enabled`}>
|
||||
{t("configForm.liveTranscode.enable")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{transcode.enabled && available && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${baseId}-source`}>
|
||||
{t("configForm.liveTranscode.source")}
|
||||
</Label>
|
||||
<Select
|
||||
value={transcode.source ?? sources[0]}
|
||||
disabled={locked}
|
||||
onValueChange={(source) => update({ ...transcode, source })}
|
||||
>
|
||||
<SelectTrigger id={`${baseId}-source`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{transcode.qualities.map((quality, index) => (
|
||||
<div
|
||||
key={quality.height}
|
||||
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="col-span-5 space-y-2">
|
||||
<Label htmlFor={`${baseId}-${index}-height`}>
|
||||
{t("configForm.liveTranscode.height")}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(quality.height)}
|
||||
disabled={locked}
|
||||
onValueChange={(value) =>
|
||||
updateQuality(index, {
|
||||
height: Number(value),
|
||||
bitrate: defaultTranscodeBitrate(Number(value)),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${baseId}-${index}-height`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{heightOptions(quality.height, usedHeights).map(
|
||||
(height) => (
|
||||
<SelectItem key={height} value={String(height)}>
|
||||
{t("configForm.liveTranscode.heightOption", {
|
||||
height,
|
||||
})}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-5 space-y-2">
|
||||
<Label htmlFor={`${baseId}-${index}-bitrate`}>
|
||||
{t("configForm.liveTranscode.bitrate")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${baseId}-${index}-bitrate`}
|
||||
type="number"
|
||||
min={64}
|
||||
value={quality.bitrate}
|
||||
disabled={locked}
|
||||
onChange={(e) => {
|
||||
// an empty input would save 0, which the backend rejects
|
||||
if (e.target.value === "") return;
|
||||
|
||||
updateQuality(index, {
|
||||
...quality,
|
||||
bitrate: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={locked}
|
||||
onClick={() => removeQuality(index)}
|
||||
aria-label={t("configForm.liveTranscode.removeQuality")}
|
||||
title={t("configForm.liveTranscode.removeQuality")}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={
|
||||
locked || usedHeights.length >= TRANSCODE_HEIGHTS.length
|
||||
}
|
||||
onClick={addQuality}
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("configForm.liveTranscode.addQuality")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveTranscodeField;
|
||||
@@ -3,3 +3,4 @@ export { LayoutGridField } from "./LayoutGridField";
|
||||
export { ModelsField } from "./ModelsField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
export { LiveStreamsField } from "./LiveStreamsField";
|
||||
export { LiveTranscodeField } from "./LiveTranscodeField";
|
||||
|
||||
@@ -57,6 +57,7 @@ import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
import { DictAsYamlField } from "./fields/DictAsYamlField";
|
||||
import { KnownPlatesField } from "./fields/KnownPlatesField";
|
||||
import { LiveStreamsField } from "./fields/LiveStreamsField";
|
||||
import { LiveTranscodeField } from "./fields/LiveTranscodeField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
@@ -121,5 +122,6 @@ export const frigateTheme: FrigateTheme = {
|
||||
DictAsYamlField: DictAsYamlField,
|
||||
KnownPlatesField: KnownPlatesField,
|
||||
LiveStreamsField: LiveStreamsField,
|
||||
LiveTranscodeField: LiveTranscodeField,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ import type {
|
||||
|
||||
// go2rtc bursts its GOP cache when a consumer attaches
|
||||
const WARMUP_MS = 3000;
|
||||
// bytes this recent mean a stall is congestion rather than a dead source
|
||||
const BYTES_RECENT_MS = 2000;
|
||||
// media per wall second below this is a low sample. Low samples reset
|
||||
// the upswitch clock and feed the chronic window
|
||||
const LOW_RATE = 0.85;
|
||||
@@ -63,7 +61,6 @@ export class LiveStreamGovernor {
|
||||
|
||||
private rungStartTs: number;
|
||||
private cleanSinceTs: number;
|
||||
private lastBytesTs = Number.NEGATIVE_INFINITY;
|
||||
// every sample, signed, for the sustained window
|
||||
private shortfalls: Shortfall[] = [];
|
||||
// low samples only, for the chronic window
|
||||
@@ -99,7 +96,6 @@ export class LiveStreamGovernor {
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
this.lastBytesTs = now;
|
||||
|
||||
if (now - this.rungStartTs < WARMUP_MS) {
|
||||
return;
|
||||
@@ -133,7 +129,7 @@ export class LiveStreamGovernor {
|
||||
* A player error. Returns true when auto handled it by stepping down,
|
||||
* false to hand it to the error fallback.
|
||||
*/
|
||||
playerError(error: LivePlayerError): boolean {
|
||||
playerError(error: LivePlayerError, sourceOnline: boolean): boolean {
|
||||
const { rung } = this.snapshot;
|
||||
if (rung === this.floor) {
|
||||
return false;
|
||||
@@ -150,9 +146,9 @@ export class LiveStreamGovernor {
|
||||
return this.stepDown();
|
||||
}
|
||||
|
||||
// without recent bytes the source is dead rather than congested,
|
||||
// and a lower stream from the same camera would fail the same way
|
||||
if (this.now() - this.lastBytesTs > BYTES_RECENT_MS) {
|
||||
// an offline camera fails every stream alike; a stall on a live camera
|
||||
// is the viewer's link, even when congestion stops delivery outright
|
||||
if (!sourceOnline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -204,7 +200,6 @@ export class LiveStreamGovernor {
|
||||
const now = this.now();
|
||||
this.rungStartTs = now;
|
||||
this.cleanSinceTs = now;
|
||||
this.lastBytesTs = Number.NEGATIVE_INFINITY;
|
||||
this.shortfalls = [];
|
||||
this.deficits = [];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { LiveAutoReason } from "@/types/live";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { MdOutlineRestartAlt } from "react-icons/md";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Radix Select needs a non-empty value. Auto itself is stored as the
|
||||
// absence of a pinned stream
|
||||
@@ -24,6 +26,8 @@ type LiveStreamSelectProps = {
|
||||
autoReason?: LiveAutoReason;
|
||||
// undefined selects auto
|
||||
onSelect: (stream: string | undefined) => void;
|
||||
// sends auto back to the top of the ladder
|
||||
onRetry: () => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
@@ -34,6 +38,7 @@ export default function LiveStreamSelect({
|
||||
autoAvailable,
|
||||
autoReason,
|
||||
onSelect,
|
||||
onRetry,
|
||||
disabled,
|
||||
}: LiveStreamSelectProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
@@ -91,13 +96,31 @@ export default function LiveStreamSelect({
|
||||
</Select>
|
||||
|
||||
{auto && autoReason && !disabled && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuInfo className="size-4 shrink-0" />
|
||||
<div>
|
||||
{t(`stream.auto.reason.${autoReason}`, {
|
||||
stream: labelFor(playingStream),
|
||||
})}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuInfo className="size-4 shrink-0" />
|
||||
<div>
|
||||
{t(`stream.auto.reason.${autoReason}`, {
|
||||
stream: labelFor(playingStream),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* data saver pins the lowest stream, so there is no higher one to try */}
|
||||
{autoReason !== "saveData" && (
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("stream.auto.retry")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.auto.retry")}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
||||
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
|
||||
@@ -82,9 +83,7 @@ export function CameraStreamingDialog({
|
||||
// metadata
|
||||
|
||||
const isRestreamed = useMemo(
|
||||
() =>
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
||||
() => isRestreamedStream(config, streamName),
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { LiveStreamGovernor } from "@/components/player/LiveStreamGovernor";
|
||||
import { isCameraOffline } from "@/hooks/use-camera-activity";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import {
|
||||
LiveAutoReason,
|
||||
LiveHealthSample,
|
||||
@@ -57,6 +59,9 @@ export function useAutoLiveStream({
|
||||
);
|
||||
const active = selected && !paused;
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
const offline = isCameraOffline(stats, cameraName);
|
||||
|
||||
// evidence from before a pin, a pause, or a remount does not count
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
@@ -102,8 +107,8 @@ export function useAutoLiveStream({
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: LivePlayerError) => active && governor.playerError(error),
|
||||
[active, governor],
|
||||
(error: LivePlayerError) => active && governor.playerError(error, !offline),
|
||||
[active, governor, offline],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => governor.reset(), [governor]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTimelineUtils } from "./use-timeline-utils";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { isEqual } from "lodash";
|
||||
import { useAutoFrigateStats } from "./use-stats";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import useSWR from "swr";
|
||||
import { getAttributeLabels } from "@/utils/iconUtil";
|
||||
|
||||
@@ -30,6 +31,19 @@ type useCameraActivityReturn = {
|
||||
offline: boolean;
|
||||
};
|
||||
|
||||
export function isCameraOffline(
|
||||
stats: FrigateStats | undefined,
|
||||
cameraName: string | undefined,
|
||||
): boolean {
|
||||
if (!stats?.cameras || !cameraName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
stats.cameras[cameraName]?.camera_fps == 0 && stats.service.uptime > 60
|
||||
);
|
||||
}
|
||||
|
||||
export function useCameraActivity(
|
||||
camera: CameraConfig | undefined,
|
||||
revalidateOnFocus: boolean = true,
|
||||
@@ -167,25 +181,10 @@ export function useCameraActivity(
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
const offline = useMemo(() => {
|
||||
if (!stats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cameras = stats["cameras"];
|
||||
|
||||
if (!cameras) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!camera?.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
|
||||
);
|
||||
}, [camera, stats]);
|
||||
const offline = useMemo(
|
||||
() => isCameraOffline(stats, camera?.name),
|
||||
[camera, stats],
|
||||
);
|
||||
|
||||
const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : true;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import useSWR from "swr";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import {
|
||||
evaluateStreamWebRTCAvailability,
|
||||
useWebRTCGloballyAvailable,
|
||||
@@ -51,18 +52,14 @@ export default function useCameraLiveMode(
|
||||
cameras.forEach((camera) => {
|
||||
if (activeStreams && activeStreams[camera.name]) {
|
||||
const selectedStreamName = activeStreams[camera.name];
|
||||
const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes(
|
||||
selectedStreamName,
|
||||
);
|
||||
const isRestreamed = isRestreamedStream(config, selectedStreamName);
|
||||
|
||||
if (isRestreamed) {
|
||||
streamNames.add(selectedStreamName);
|
||||
}
|
||||
} else {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
const isRestreamed = Object.keys(
|
||||
config.go2rtc.streams || {},
|
||||
).includes(streamName);
|
||||
const isRestreamed = isRestreamedStream(config, streamName);
|
||||
|
||||
if (isRestreamed) {
|
||||
streamNames.add(streamName);
|
||||
@@ -125,9 +122,7 @@ export default function useCameraLiveMode(
|
||||
cameras.forEach((camera) => {
|
||||
const selectedStreamName =
|
||||
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
const isRestreamed = isRestreamedStream(config, selectedStreamName);
|
||||
|
||||
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
|
||||
|
||||
@@ -181,9 +176,7 @@ export default function useCameraLiveMode(
|
||||
(cameraConfig
|
||||
? Object.values(cameraConfig.live.streams)[0]
|
||||
: cameraName);
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
const isRestreamed = isRestreamedStream(config, selectedStreamName);
|
||||
|
||||
setPreferredLiveModes((prevModes) => ({
|
||||
...prevModes,
|
||||
|
||||
@@ -39,6 +39,8 @@ export type ConfigFormContext = {
|
||||
extraHasChanges?: boolean;
|
||||
setExtraHasChanges?: (hasChanges: boolean) => void;
|
||||
formData?: JsonObject;
|
||||
/** Key order of each ordered map, so reorders re-render fields */
|
||||
orderedMapsKeyOrder?: string[][];
|
||||
pendingDataBySection?: Record<string, ConfigSectionData>;
|
||||
onPendingDataChange?: (
|
||||
sectionKey: string,
|
||||
|
||||
@@ -36,6 +36,17 @@ export interface FaceRecognitionConfig {
|
||||
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable<unknown>);
|
||||
export type SearchModelSize = "small" | "large";
|
||||
|
||||
export type LiveTranscodeQuality = {
|
||||
height: number;
|
||||
bitrate: number;
|
||||
};
|
||||
|
||||
export type LiveTranscodeConfig = {
|
||||
enabled: boolean;
|
||||
source?: string | null;
|
||||
qualities: LiveTranscodeQuality[];
|
||||
};
|
||||
|
||||
export interface CameraConfig {
|
||||
friendly_name: string;
|
||||
audio: {
|
||||
@@ -109,6 +120,7 @@ export interface CameraConfig {
|
||||
height: number;
|
||||
quality: number;
|
||||
streams: { [key: string]: string };
|
||||
transcode: LiveTranscodeConfig;
|
||||
};
|
||||
lpr: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -306,6 +306,49 @@ export function buildOverrides(
|
||||
return current;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordered maps: config maps whose key order is meaningful
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// lodash isEqual ignores key order, so ordered maps compare entry lists.
|
||||
export function changedOrderedMapPaths(
|
||||
current: unknown,
|
||||
base: unknown,
|
||||
paths: string[],
|
||||
): string[] {
|
||||
return paths.filter((path) => {
|
||||
const value = get(current, path);
|
||||
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseValue = get(base, path);
|
||||
return !isEqual(
|
||||
Object.entries(value),
|
||||
isJsonObject(baseValue) ? Object.entries(baseValue) : undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Send a changed ordered map whole; per-key overrides cannot carry order.
|
||||
export function applyOrderedMaps(
|
||||
overrides: unknown,
|
||||
current: unknown,
|
||||
base: unknown,
|
||||
paths: string[],
|
||||
): unknown {
|
||||
const changed = changedOrderedMapPaths(current, base, paths);
|
||||
|
||||
if (changed.length === 0) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
const result = isJsonObject(overrides) ? cloneDeep(overrides) : {};
|
||||
changed.forEach((path) => set(result, path, cloneDeep(get(current, path))));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// flattenOverrides — turn an overrides object into a list of leaf paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FrigateConfig, LiveTranscodeConfig } from "@/types/frigateConfig";
|
||||
|
||||
// Mirrors frigate/util/live_streams.py and apply_live_transcode_streams.
|
||||
|
||||
export const TRANSCODE_HEIGHTS = [1080, 720, 480, 360, 240];
|
||||
|
||||
const DEFAULT_BITRATES: Record<number, number> = {
|
||||
1080: 2500,
|
||||
720: 1200,
|
||||
480: 500,
|
||||
360: 250,
|
||||
240: 150,
|
||||
};
|
||||
|
||||
export function defaultTranscodeBitrate(height: number): number {
|
||||
return DEFAULT_BITRATES[height] ?? 500;
|
||||
}
|
||||
|
||||
export function transcodeStreamName(camera: string, height: number): string {
|
||||
return `${camera}_transcode_${height}p`;
|
||||
}
|
||||
|
||||
export function isTranscodeStreamName(camera: string, name: string): boolean {
|
||||
const prefix = `${camera}_transcode_`;
|
||||
return name.startsWith(prefix) && /^\d+p$/.test(name.slice(prefix.length));
|
||||
}
|
||||
|
||||
/** A generated stream's configured bitrate, or undefined for other streams. */
|
||||
export function transcodeBitrateFor(
|
||||
camera: string,
|
||||
name: string,
|
||||
transcode: LiveTranscodeConfig | undefined,
|
||||
): number | undefined {
|
||||
if (!transcode?.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return transcode.qualities.find(
|
||||
(quality) => transcodeStreamName(camera, quality.height) === name,
|
||||
)?.bitrate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop transcoded entries that are no longer generated and append generated
|
||||
* streams that are missing. Placed entries keep their position and label.
|
||||
*/
|
||||
export function reconcileTranscodeStreams(
|
||||
camera: string,
|
||||
streams: Record<string, string>,
|
||||
transcode: LiveTranscodeConfig,
|
||||
go2rtcStreams: string[],
|
||||
): Record<string, string> {
|
||||
const generated = transcode.enabled
|
||||
? transcode.qualities.map((quality) => ({
|
||||
label: `${quality.height}p`,
|
||||
name: transcodeStreamName(camera, quality.height),
|
||||
}))
|
||||
: [];
|
||||
const generatedNames = new Set(generated.map(({ name }) => name));
|
||||
|
||||
const kept = Object.entries(streams).filter(
|
||||
([, name]) =>
|
||||
generatedNames.has(name) ||
|
||||
go2rtcStreams.includes(name) ||
|
||||
!isTranscodeStreamName(camera, name),
|
||||
);
|
||||
const placed = new Set(kept.map(([, name]) => name));
|
||||
const labels = new Set(kept.map(([label]) => label));
|
||||
|
||||
generated.forEach(({ label, name }) => {
|
||||
// a taken label is left for the backend to reject on save
|
||||
if (!placed.has(name) && !labels.has(label)) {
|
||||
kept.push([label, name]);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.fromEntries(kept);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether go2rtc serves a live stream. config.go2rtc.streams lists only the
|
||||
* yaml streams, so transcoded streams of enabled cameras count too.
|
||||
*/
|
||||
export function isRestreamedStream(
|
||||
config: FrigateConfig | undefined,
|
||||
name: string | undefined,
|
||||
): boolean {
|
||||
if (!config || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.keys(config.go2rtc.streams || {}).includes(name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(config.cameras).some(
|
||||
(camera) =>
|
||||
camera.live.transcode?.enabled &&
|
||||
isTranscodeStreamName(camera.name, name),
|
||||
);
|
||||
}
|
||||
@@ -108,6 +108,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
|
||||
import ObjectSettingsView from "../settings/ObjectSettingsView";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
@@ -218,9 +219,7 @@ export default function LiveCameraView({
|
||||
);
|
||||
|
||||
const isRestreamed = useMemo(
|
||||
() =>
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
||||
() => isRestreamedStream(config, streamName),
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
@@ -1404,6 +1403,7 @@ function FrigateCameraFeatures({
|
||||
autoAvailable={autoAvailable}
|
||||
autoReason={autoReason}
|
||||
onSelect={onSelectStream}
|
||||
onRetry={onResetStream}
|
||||
disabled={debug || forceLowBandwidth}
|
||||
/>
|
||||
|
||||
@@ -1795,6 +1795,7 @@ function FrigateCameraFeatures({
|
||||
autoAvailable={autoAvailable}
|
||||
autoReason={autoReason}
|
||||
onSelect={onSelectStream}
|
||||
onRetry={onResetStream}
|
||||
disabled={debug || forceLowBandwidth}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user