toposync-ext-streaming 0.7.9__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/.gitignore +1 -0
- toposync_ext_streaming-0.7.9/README.md → toposync_ext_streaming-0.8.0/PKG-INFO +26 -0
- toposync_ext_streaming-0.7.9/PKG-INFO → toposync_ext_streaming-0.8.0/README.md +13 -13
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/pyproject.toml +1 -1
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/api/models.py +3 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/api/routes.py +191 -11
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/pipelines/operators.py +35 -7
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/engine_manager.py +498 -32
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/wizard/pipeline_builder.py +10 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/LICENSE +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/LICENSE.ffmpeg +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/LICENSE.mediamtx +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/__init__.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/api/__init__.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/bin/ffmpeg/LICENSE +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/bin/mediamtx/LICENSE +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/extension.json +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/pipelines/__init__.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/plugin.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/326.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/326.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/387.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/4.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/4.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/623.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/623.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/703.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/703.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/main.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/main.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/remoteEntry.js +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/__init__.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/arbitration.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/camera_ingest.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/distributed_sync.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/encoder_state.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/ffmpeg_binary.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/go2rtc_binary.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/go2rtc_config.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/go2rtc_manager.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/ingest_auth.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/ingest_resolver.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/jsmpeg_manager.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_api_client.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_binary.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_config.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_processes.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/placeholder.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/platform.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/playback_events.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/publisher_manager.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/resize.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/runtime_state.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/writer_bridge.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/wizard/__init__.py +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/package.json +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/activate.tsx +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/api/streamingApi.ts +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/constants.ts +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/entry.ts +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/settings/StreamingSettingsPanel.tsx +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/settings/SubModal.tsx +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/settings/WizardCreatePipelineFromTransmission.tsx +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/translations.ts +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/types.ts +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/tsconfig.json +0 -0
- {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/webpack.config.js +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toposync-ext-streaming
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: numpy>=1.26
|
|
9
|
+
Requires-Dist: opencv-python-headless>=4.10
|
|
10
|
+
Requires-Dist: toposync-core>=0.3.3
|
|
11
|
+
Requires-Dist: websockets>=12.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# Toposync Streaming Extension
|
|
2
15
|
|
|
3
16
|
Extension ID: `com.toposync.streaming`
|
|
@@ -729,6 +742,15 @@ All endpoints are under `/api/streams`.
|
|
|
729
742
|
### Home Assistant
|
|
730
743
|
- `GET /api/streams/home-assistant/cameras`
|
|
731
744
|
|
|
745
|
+
For Home Assistant running in Docker Compose, the RTSP URL in this manifest must
|
|
746
|
+
use a host reachable from the Home Assistant container. Enable
|
|
747
|
+
`engine.expose_to_lan`, restart the streaming engine, and set
|
|
748
|
+
`TOPOSYNC_HOME_ASSISTANT_RTSP_HOST` to the Compose service name or another
|
|
749
|
+
container-reachable hostname, for example `toposync`. If the endpoint would
|
|
750
|
+
otherwise return a loopback RTSP URL for a non-loopback request host, the
|
|
751
|
+
manifest omits `rtsp_url` and reports a blocking error instead of publishing an
|
|
752
|
+
unusable `rtsp://127.0.0.1:...` URL.
|
|
753
|
+
|
|
732
754
|
### Distributed settings
|
|
733
755
|
- `GET /api/streams/distributed/settings/{server_id}`
|
|
734
756
|
|
|
@@ -968,6 +990,10 @@ To enable LAN viewers:
|
|
|
968
990
|
|
|
969
991
|
Notes:
|
|
970
992
|
- `GET /api/streams/transmissions/{id}/urls` chooses the host component based on the current request host when `expose_to_lan` is enabled.
|
|
993
|
+
- `GET /api/streams/home-assistant/cameras` follows the same rule, but a
|
|
994
|
+
Compose-based Home Assistant often needs `TOPOSYNC_HOME_ASSISTANT_RTSP_HOST`
|
|
995
|
+
so the advertised RTSP host is the Toposync service name visible from the Home
|
|
996
|
+
Assistant container.
|
|
971
997
|
- If MediaMTX runs behind Docker/NAT, advertise the reachable LAN host with `TOPOSYNC_STREAMING_WEBRTC_ADDITIONAL_HOSTS` and publish the static ICE UDP port configured by `TOPOSYNC_STREAMING_WEBRTC_LOCAL_UDP_ADDRESS`.
|
|
972
998
|
- MediaMTX still restricts publishing and API access to localhost IPs; LAN exposure is for viewer playback only.
|
|
973
999
|
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: toposync-ext-streaming
|
|
3
|
-
Version: 0.7.9
|
|
4
|
-
Summary: Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap.
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
License-File: LICENSE
|
|
7
|
-
Requires-Python: >=3.11
|
|
8
|
-
Requires-Dist: numpy>=1.26
|
|
9
|
-
Requires-Dist: opencv-python-headless>=4.10
|
|
10
|
-
Requires-Dist: toposync-core>=0.3.3
|
|
11
|
-
Requires-Dist: websockets>=12.0
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
1
|
# Toposync Streaming Extension
|
|
15
2
|
|
|
16
3
|
Extension ID: `com.toposync.streaming`
|
|
@@ -742,6 +729,15 @@ All endpoints are under `/api/streams`.
|
|
|
742
729
|
### Home Assistant
|
|
743
730
|
- `GET /api/streams/home-assistant/cameras`
|
|
744
731
|
|
|
732
|
+
For Home Assistant running in Docker Compose, the RTSP URL in this manifest must
|
|
733
|
+
use a host reachable from the Home Assistant container. Enable
|
|
734
|
+
`engine.expose_to_lan`, restart the streaming engine, and set
|
|
735
|
+
`TOPOSYNC_HOME_ASSISTANT_RTSP_HOST` to the Compose service name or another
|
|
736
|
+
container-reachable hostname, for example `toposync`. If the endpoint would
|
|
737
|
+
otherwise return a loopback RTSP URL for a non-loopback request host, the
|
|
738
|
+
manifest omits `rtsp_url` and reports a blocking error instead of publishing an
|
|
739
|
+
unusable `rtsp://127.0.0.1:...` URL.
|
|
740
|
+
|
|
745
741
|
### Distributed settings
|
|
746
742
|
- `GET /api/streams/distributed/settings/{server_id}`
|
|
747
743
|
|
|
@@ -981,6 +977,10 @@ To enable LAN viewers:
|
|
|
981
977
|
|
|
982
978
|
Notes:
|
|
983
979
|
- `GET /api/streams/transmissions/{id}/urls` chooses the host component based on the current request host when `expose_to_lan` is enabled.
|
|
980
|
+
- `GET /api/streams/home-assistant/cameras` follows the same rule, but a
|
|
981
|
+
Compose-based Home Assistant often needs `TOPOSYNC_HOME_ASSISTANT_RTSP_HOST`
|
|
982
|
+
so the advertised RTSP host is the Toposync service name visible from the Home
|
|
983
|
+
Assistant container.
|
|
984
984
|
- If MediaMTX runs behind Docker/NAT, advertise the reachable LAN host with `TOPOSYNC_STREAMING_WEBRTC_ADDITIONAL_HOSTS` and publish the static ICE UDP port configured by `TOPOSYNC_STREAMING_WEBRTC_LOCAL_UDP_ADDRESS`.
|
|
985
985
|
- MediaMTX still restricts publishing and API access to localhost IPs; LAN exposure is for viewer playback only.
|
|
986
986
|
|
|
@@ -1034,6 +1034,9 @@ class StreamingEngineStatusResponse(BaseModel):
|
|
|
1034
1034
|
warnings: list[str] = Field(default_factory=list)
|
|
1035
1035
|
restart_count: int = Field(default=0, ge=0)
|
|
1036
1036
|
orphan_pids: list[int] = Field(default_factory=list)
|
|
1037
|
+
port_policy: Literal["stable", "flexible"] = "flexible"
|
|
1038
|
+
port_resolution: str = "preferred"
|
|
1039
|
+
port_blocking_errors: list[str] = Field(default_factory=list)
|
|
1037
1040
|
|
|
1038
1041
|
|
|
1039
1042
|
class StreamingMseSidecarStatusResponse(BaseModel):
|
|
@@ -33,7 +33,7 @@ from toposync.runtime.pipelines.operators_sinks import _encode_image_bytes
|
|
|
33
33
|
from toposync.runtime.pipelines.templates import camera_names_by_id, safe_pipeline_name
|
|
34
34
|
from toposync.runtime.services import ServiceRegistry
|
|
35
35
|
|
|
36
|
-
from ..streaming.engine_manager import MediaMtxEngineManager
|
|
36
|
+
from ..streaming.engine_manager import MediaMtxEngineManager, MediaMtxPortResolutionError
|
|
37
37
|
from ..streaming.go2rtc_binary import extract_go2rtc_binary, find_installed_go2rtc_binary
|
|
38
38
|
from ..streaming.go2rtc_manager import Go2RtcSidecarManager, Go2RtcSidecarStatus
|
|
39
39
|
from ..streaming.camera_ingest import (
|
|
@@ -852,7 +852,19 @@ def _build_network_contract(
|
|
|
852
852
|
media_url_origin=media_url_origin,
|
|
853
853
|
)
|
|
854
854
|
|
|
855
|
-
|
|
855
|
+
explicit_expected_ports = any(
|
|
856
|
+
value is not None
|
|
857
|
+
for value in (
|
|
858
|
+
expected_ports.direct_api,
|
|
859
|
+
expected_ports.rtsp,
|
|
860
|
+
expected_ports.hls,
|
|
861
|
+
expected_ports.webrtc,
|
|
862
|
+
expected_ports.webrtc_udp,
|
|
863
|
+
expected_ports.api,
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
strict_contract = environment == "home_assistant_addon" or explicit_expected_ports
|
|
867
|
+
fail_on_mismatch = _env_bool("TOPOSYNC_FAIL_STREAM_URLS_ON_PORT_MISMATCH") or strict_contract
|
|
856
868
|
status: Literal["ok", "port_mismatch", "proxy_required", "proxy_unavailable"] = "ok"
|
|
857
869
|
|
|
858
870
|
def compare_port(
|
|
@@ -869,11 +881,16 @@ def _build_network_contract(
|
|
|
869
881
|
actual = getattr(actual_ports, key)
|
|
870
882
|
if expected is None or actual is None or int(expected) == int(actual):
|
|
871
883
|
return
|
|
872
|
-
|
|
884
|
+
expected_label = (
|
|
885
|
+
"expected add-on port"
|
|
886
|
+
if environment == "home_assistant_addon"
|
|
887
|
+
else "expected streaming port"
|
|
888
|
+
)
|
|
889
|
+
message = f"{label} active port {actual} does not match {expected_label} {expected}."
|
|
873
890
|
warnings.append(message)
|
|
874
891
|
if status == "ok":
|
|
875
892
|
status = "port_mismatch"
|
|
876
|
-
if blocking_when_failed and fail_on_mismatch:
|
|
893
|
+
if (blocking_when_failed and fail_on_mismatch) or strict_contract:
|
|
877
894
|
blocking_errors.append(message)
|
|
878
895
|
|
|
879
896
|
compare_port(
|
|
@@ -2626,7 +2643,12 @@ def _url_has_loopback_host(url: str | None) -> bool:
|
|
|
2626
2643
|
host = str(urllib_parse.urlsplit(str(url or "")).hostname or "").strip().lower()
|
|
2627
2644
|
except Exception:
|
|
2628
2645
|
return False
|
|
2629
|
-
return host
|
|
2646
|
+
return _host_is_loopback(host)
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
def _host_is_loopback(host: str | None) -> bool:
|
|
2650
|
+
normalized = str(host or "").strip().lower()
|
|
2651
|
+
return normalized in {"localhost", "::1"} or normalized.startswith("127.")
|
|
2630
2652
|
|
|
2631
2653
|
|
|
2632
2654
|
def _url_host_for_rtsp(url: str | None) -> str:
|
|
@@ -2940,10 +2962,17 @@ async def _build_home_assistant_camera_item(
|
|
|
2940
2962
|
blocking_errors.append(f"Failed to resolve remote Home Assistant camera playback: {exc}")
|
|
2941
2963
|
else:
|
|
2942
2964
|
engine_path = resolve_output_engine_path(transmission, output)
|
|
2965
|
+
home_assistant_rtsp_host = _home_assistant_rtsp_host(request)
|
|
2943
2966
|
rtsp_url = await _engine_manager(request).get_read_url_for_path(
|
|
2944
2967
|
engine_path,
|
|
2945
|
-
host=
|
|
2968
|
+
host=home_assistant_rtsp_host,
|
|
2946
2969
|
)
|
|
2970
|
+
if _url_has_loopback_host(rtsp_url) and not _host_is_loopback(home_assistant_rtsp_host):
|
|
2971
|
+
blocking_errors.append(
|
|
2972
|
+
"Local streaming engine returned a loopback RTSP URL for Home Assistant camera playback; "
|
|
2973
|
+
"enable engine.expose_to_lan and set TOPOSYNC_HOME_ASSISTANT_RTSP_HOST to a host reachable by Home Assistant."
|
|
2974
|
+
)
|
|
2975
|
+
rtsp_url = None
|
|
2947
2976
|
redacted_rtsp_url = _redact_url_credentials(rtsp_url)
|
|
2948
2977
|
|
|
2949
2978
|
role = variant.role if variant is not None else None
|
|
@@ -5874,6 +5903,7 @@ async def _sync_pipeline_output_publication_nodes(
|
|
|
5874
5903
|
graph = pipeline.graph if isinstance(pipeline.graph, dict) else {}
|
|
5875
5904
|
nodes = graph.get("nodes") if isinstance(graph.get("nodes"), list) else []
|
|
5876
5905
|
changed = False
|
|
5906
|
+
publication_targets: dict[str, tuple[str, str, str]] = {}
|
|
5877
5907
|
for node in nodes:
|
|
5878
5908
|
if not isinstance(node, dict):
|
|
5879
5909
|
continue
|
|
@@ -5885,11 +5915,25 @@ async def _sync_pipeline_output_publication_nodes(
|
|
|
5885
5915
|
continue
|
|
5886
5916
|
cfg = node.get("config") if isinstance(node.get("config"), dict) else {}
|
|
5887
5917
|
expected_transmission_id = _publication_transmission_id(publication)
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5918
|
+
expected_output_id = f"hls_{_publication_quality_profile(publication)}"
|
|
5919
|
+
expected_quality_profile_id = _publication_quality_profile(publication)
|
|
5920
|
+
publication_targets[node_id] = (
|
|
5921
|
+
expected_transmission_id,
|
|
5922
|
+
expected_output_id,
|
|
5923
|
+
expected_quality_profile_id,
|
|
5924
|
+
)
|
|
5925
|
+
if str(cfg.get("transmission_id") or "").strip() != expected_transmission_id:
|
|
5926
|
+
next_cfg = {**cfg, "transmission_id": expected_transmission_id}
|
|
5927
|
+
node["config"] = next_cfg
|
|
5928
|
+
changed = True
|
|
5929
|
+
if publication_targets:
|
|
5930
|
+
updated_gate_ids = _sync_demand_gates_for_publication_targets(
|
|
5931
|
+
graph=graph,
|
|
5932
|
+
nodes=nodes,
|
|
5933
|
+
publication_targets=publication_targets,
|
|
5934
|
+
)
|
|
5935
|
+
if updated_gate_ids:
|
|
5936
|
+
changed = True
|
|
5893
5937
|
if not changed:
|
|
5894
5938
|
continue
|
|
5895
5939
|
updated = pipeline.model_copy(update={"graph": graph})
|
|
@@ -5908,6 +5952,87 @@ async def _sync_pipeline_output_publication_nodes(
|
|
|
5908
5952
|
return changed_pipelines
|
|
5909
5953
|
|
|
5910
5954
|
|
|
5955
|
+
def _sync_demand_gates_for_publication_targets(
|
|
5956
|
+
*,
|
|
5957
|
+
graph: dict[str, Any],
|
|
5958
|
+
nodes: list[Any],
|
|
5959
|
+
publication_targets: dict[str, tuple[str, str, str]],
|
|
5960
|
+
) -> list[str]:
|
|
5961
|
+
if not publication_targets:
|
|
5962
|
+
return []
|
|
5963
|
+
edges = graph.get("edges") if isinstance(graph.get("edges"), list) else []
|
|
5964
|
+
downstream_by_node: dict[str, set[str]] = {}
|
|
5965
|
+
for edge in edges:
|
|
5966
|
+
if not isinstance(edge, dict):
|
|
5967
|
+
continue
|
|
5968
|
+
source = edge.get("from")
|
|
5969
|
+
target = edge.get("to")
|
|
5970
|
+
if not isinstance(source, dict) or not isinstance(target, dict):
|
|
5971
|
+
continue
|
|
5972
|
+
source_node = str(source.get("node") or "").strip()
|
|
5973
|
+
target_node = str(target.get("node") or "").strip()
|
|
5974
|
+
if not source_node or not target_node:
|
|
5975
|
+
continue
|
|
5976
|
+
downstream_by_node.setdefault(source_node, set()).add(target_node)
|
|
5977
|
+
|
|
5978
|
+
changed_gate_ids: list[str] = []
|
|
5979
|
+
for node in nodes:
|
|
5980
|
+
if not isinstance(node, dict):
|
|
5981
|
+
continue
|
|
5982
|
+
if str(node.get("operator") or "").strip() != "stream.demand_gate":
|
|
5983
|
+
continue
|
|
5984
|
+
gate_id = str(node.get("id") or "").strip()
|
|
5985
|
+
if not gate_id:
|
|
5986
|
+
continue
|
|
5987
|
+
reachable_publications = [
|
|
5988
|
+
publish_node_id
|
|
5989
|
+
for publish_node_id in publication_targets
|
|
5990
|
+
if _graph_node_reaches(gate_id, publish_node_id, downstream_by_node)
|
|
5991
|
+
]
|
|
5992
|
+
if len(reachable_publications) != 1:
|
|
5993
|
+
continue
|
|
5994
|
+
transmission_id, output_id, quality_profile_id = publication_targets[reachable_publications[0]]
|
|
5995
|
+
cfg = node.get("config") if isinstance(node.get("config"), dict) else {}
|
|
5996
|
+
demand_scope = str(cfg.get("demand_scope") or "").strip().lower()
|
|
5997
|
+
use_specific_output = demand_scope == "output"
|
|
5998
|
+
next_cfg = {**cfg, "transmission_id": transmission_id}
|
|
5999
|
+
if use_specific_output:
|
|
6000
|
+
next_cfg["demand_scope"] = "output"
|
|
6001
|
+
next_cfg["output_id"] = str(cfg.get("output_id") or "").strip() or output_id
|
|
6002
|
+
next_cfg["quality_profile_id"] = str(cfg.get("quality_profile_id") or "").strip() or quality_profile_id
|
|
6003
|
+
else:
|
|
6004
|
+
next_cfg["demand_scope"] = "transmission"
|
|
6005
|
+
next_cfg["output_id"] = ""
|
|
6006
|
+
next_cfg["quality_profile_id"] = ""
|
|
6007
|
+
if next_cfg == cfg:
|
|
6008
|
+
continue
|
|
6009
|
+
node["config"] = next_cfg
|
|
6010
|
+
changed_gate_ids.append(gate_id)
|
|
6011
|
+
return changed_gate_ids
|
|
6012
|
+
|
|
6013
|
+
|
|
6014
|
+
def _graph_node_reaches(
|
|
6015
|
+
source_node_id: str,
|
|
6016
|
+
target_node_id: str,
|
|
6017
|
+
downstream_by_node: dict[str, set[str]],
|
|
6018
|
+
) -> bool:
|
|
6019
|
+
if source_node_id == target_node_id:
|
|
6020
|
+
return True
|
|
6021
|
+
seen: set[str] = set()
|
|
6022
|
+
stack = [source_node_id]
|
|
6023
|
+
while stack:
|
|
6024
|
+
current = stack.pop()
|
|
6025
|
+
if current in seen:
|
|
6026
|
+
continue
|
|
6027
|
+
seen.add(current)
|
|
6028
|
+
for child in downstream_by_node.get(current, set()):
|
|
6029
|
+
if child == target_node_id:
|
|
6030
|
+
return True
|
|
6031
|
+
if child not in seen:
|
|
6032
|
+
stack.append(child)
|
|
6033
|
+
return False
|
|
6034
|
+
|
|
6035
|
+
|
|
5911
6036
|
async def _delete_camera_live_pipelines(
|
|
5912
6037
|
*,
|
|
5913
6038
|
request: Request,
|
|
@@ -6153,6 +6278,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
6153
6278
|
path_auth=path_auth,
|
|
6154
6279
|
path_configs=path_configs,
|
|
6155
6280
|
)
|
|
6281
|
+
except MediaMtxPortResolutionError as exc:
|
|
6282
|
+
raise HTTPException(
|
|
6283
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
6284
|
+
) from exc
|
|
6156
6285
|
except Exception as exc:
|
|
6157
6286
|
raise HTTPException(
|
|
6158
6287
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -6277,6 +6406,7 @@ def create_streaming_router() -> APIRouter:
|
|
|
6277
6406
|
|
|
6278
6407
|
warnings: list[str] = []
|
|
6279
6408
|
warnings.extend(list(status.warnings))
|
|
6409
|
+
warnings.extend(list(status.port_blocking_errors))
|
|
6280
6410
|
if not settings.engine.enabled:
|
|
6281
6411
|
warnings.append("Engine is disabled in settings.")
|
|
6282
6412
|
if settings.engine.enabled and not status.running:
|
|
@@ -6354,6 +6484,9 @@ def create_streaming_router() -> APIRouter:
|
|
|
6354
6484
|
warnings=warnings,
|
|
6355
6485
|
restart_count=status.restart_count,
|
|
6356
6486
|
orphan_pids=orphan_pids,
|
|
6487
|
+
port_policy=status.port_policy if status.port_policy in {"stable", "flexible"} else "flexible",
|
|
6488
|
+
port_resolution=status.port_resolution,
|
|
6489
|
+
port_blocking_errors=list(status.port_blocking_errors),
|
|
6357
6490
|
)
|
|
6358
6491
|
|
|
6359
6492
|
@router.post("/engine/download", response_model=StreamingEngineStatusResponse)
|
|
@@ -6416,6 +6549,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
6416
6549
|
),
|
|
6417
6550
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
6418
6551
|
)
|
|
6552
|
+
except MediaMtxPortResolutionError as exc:
|
|
6553
|
+
raise HTTPException(
|
|
6554
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
6555
|
+
) from exc
|
|
6419
6556
|
except Exception as exc:
|
|
6420
6557
|
raise HTTPException(
|
|
6421
6558
|
status_code=500, detail=f"Failed to start streaming engine: {exc}"
|
|
@@ -6477,6 +6614,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
6477
6614
|
),
|
|
6478
6615
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
6479
6616
|
)
|
|
6617
|
+
except MediaMtxPortResolutionError as exc:
|
|
6618
|
+
raise HTTPException(
|
|
6619
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
6620
|
+
) from exc
|
|
6480
6621
|
except Exception as exc:
|
|
6481
6622
|
raise HTTPException(
|
|
6482
6623
|
status_code=500, detail=f"Failed to restart streaming engine: {exc}"
|
|
@@ -6532,6 +6673,12 @@ def create_streaming_router() -> APIRouter:
|
|
|
6532
6673
|
),
|
|
6533
6674
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
6534
6675
|
)
|
|
6676
|
+
except MediaMtxPortResolutionError as exc:
|
|
6677
|
+
suffix = f" (killed {len(killed_pids)} stale process(es))" if killed_pids else ""
|
|
6678
|
+
raise HTTPException(
|
|
6679
|
+
status_code=409,
|
|
6680
|
+
detail=f"Streaming engine port contract is blocked: {exc}{suffix}",
|
|
6681
|
+
) from exc
|
|
6535
6682
|
except Exception as exc:
|
|
6536
6683
|
suffix = f" (killed {len(killed_pids)} stale process(es))" if killed_pids else ""
|
|
6537
6684
|
raise HTTPException(
|
|
@@ -6864,6 +7011,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
6864
7011
|
await _apply_streaming_engine_state(request, settings=saved)
|
|
6865
7012
|
except HTTPException:
|
|
6866
7013
|
raise
|
|
7014
|
+
except MediaMtxPortResolutionError as exc:
|
|
7015
|
+
raise HTTPException(
|
|
7016
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7017
|
+
) from exc
|
|
6867
7018
|
except Exception as exc:
|
|
6868
7019
|
raise HTTPException(
|
|
6869
7020
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -6911,6 +7062,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
6911
7062
|
await _apply_streaming_engine_state(request, settings=saved)
|
|
6912
7063
|
except HTTPException:
|
|
6913
7064
|
raise
|
|
7065
|
+
except MediaMtxPortResolutionError as exc:
|
|
7066
|
+
raise HTTPException(
|
|
7067
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7068
|
+
) from exc
|
|
6914
7069
|
except Exception as exc:
|
|
6915
7070
|
raise HTTPException(
|
|
6916
7071
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -7111,6 +7266,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
7111
7266
|
),
|
|
7112
7267
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
7113
7268
|
)
|
|
7269
|
+
except MediaMtxPortResolutionError as exc:
|
|
7270
|
+
raise HTTPException(
|
|
7271
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7272
|
+
) from exc
|
|
7114
7273
|
except Exception as exc:
|
|
7115
7274
|
raise HTTPException(
|
|
7116
7275
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -7202,6 +7361,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
7202
7361
|
),
|
|
7203
7362
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
7204
7363
|
)
|
|
7364
|
+
except MediaMtxPortResolutionError as exc:
|
|
7365
|
+
raise HTTPException(
|
|
7366
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7367
|
+
) from exc
|
|
7205
7368
|
except Exception as exc:
|
|
7206
7369
|
raise HTTPException(
|
|
7207
7370
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -7271,6 +7434,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
7271
7434
|
),
|
|
7272
7435
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
7273
7436
|
)
|
|
7437
|
+
except MediaMtxPortResolutionError as exc:
|
|
7438
|
+
raise HTTPException(
|
|
7439
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7440
|
+
) from exc
|
|
7274
7441
|
except Exception as exc:
|
|
7275
7442
|
raise HTTPException(
|
|
7276
7443
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -7342,6 +7509,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
7342
7509
|
),
|
|
7343
7510
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
7344
7511
|
)
|
|
7512
|
+
except MediaMtxPortResolutionError as exc:
|
|
7513
|
+
raise HTTPException(
|
|
7514
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7515
|
+
) from exc
|
|
7345
7516
|
except Exception as exc:
|
|
7346
7517
|
raise HTTPException(
|
|
7347
7518
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -7390,6 +7561,10 @@ def create_streaming_router() -> APIRouter:
|
|
|
7390
7561
|
),
|
|
7391
7562
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
7392
7563
|
)
|
|
7564
|
+
except MediaMtxPortResolutionError as exc:
|
|
7565
|
+
raise HTTPException(
|
|
7566
|
+
status_code=409, detail=f"Streaming engine port contract is blocked: {exc}"
|
|
7567
|
+
) from exc
|
|
7393
7568
|
except Exception as exc:
|
|
7394
7569
|
raise HTTPException(
|
|
7395
7570
|
status_code=500, detail=f"Failed to apply streaming settings: {exc}"
|
|
@@ -8646,6 +8821,11 @@ def create_streaming_router() -> APIRouter:
|
|
|
8646
8821
|
),
|
|
8647
8822
|
path_configs=build_camera_ingest_path_configs(camera_ingest_by_id),
|
|
8648
8823
|
)
|
|
8824
|
+
except MediaMtxPortResolutionError as exc:
|
|
8825
|
+
raise HTTPException(
|
|
8826
|
+
status_code=409,
|
|
8827
|
+
detail=f"Streaming engine port contract is blocked: {exc}",
|
|
8828
|
+
) from exc
|
|
8649
8829
|
except Exception as exc:
|
|
8650
8830
|
raise HTTPException(
|
|
8651
8831
|
status_code=500, detail=f"Failed to rotate ingest credentials: {exc}"
|
|
@@ -8,7 +8,7 @@ import numpy
|
|
|
8
8
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
9
|
|
|
10
10
|
from toposync.runtime.pipelines.execution import PipelineRuntimeDependencies, SinkRuntime, SourceOperatorRuntime
|
|
11
|
-
from toposync.runtime.pipelines.images import normalize_artifact_name
|
|
11
|
+
from toposync.runtime.pipelines.images import MAIN_ARTIFACT_NAME, normalize_artifact_name
|
|
12
12
|
from toposync.runtime.pipelines.operator_registry import OperatorDiagnostic, OperatorRegistry
|
|
13
13
|
from toposync.runtime.pipelines.packet_contract import resolve_media_ts
|
|
14
14
|
from toposync.runtime.pipelines.runtime import Lifecycle, Packet
|
|
@@ -85,11 +85,30 @@ class DemandGateConfig(BaseModel):
|
|
|
85
85
|
model_config = ConfigDict(extra="forbid")
|
|
86
86
|
|
|
87
87
|
transmission_id: str = ""
|
|
88
|
+
demand_scope: Literal["transmission", "output"] = "transmission"
|
|
88
89
|
output_id: str = ""
|
|
89
90
|
quality_profile_id: str = ""
|
|
90
91
|
poll_interval_ms: int = Field(default=500, ge=100, le=10_000)
|
|
91
92
|
fail_open: bool = True
|
|
92
93
|
|
|
94
|
+
@model_validator(mode="before")
|
|
95
|
+
@classmethod
|
|
96
|
+
def _normalize_legacy_scope(cls, value: Any) -> Any:
|
|
97
|
+
if not isinstance(value, dict):
|
|
98
|
+
return value
|
|
99
|
+
data = dict(value)
|
|
100
|
+
scope = str(data.get("demand_scope") or "").strip().lower()
|
|
101
|
+
if scope == "auto":
|
|
102
|
+
scope = "transmission"
|
|
103
|
+
if scope not in {"transmission", "output"}:
|
|
104
|
+
has_specific_output = bool(
|
|
105
|
+
str(data.get("output_id") or "").strip()
|
|
106
|
+
or str(data.get("quality_profile_id") or "").strip()
|
|
107
|
+
)
|
|
108
|
+
scope = "output" if has_specific_output else "transmission"
|
|
109
|
+
data["demand_scope"] = scope
|
|
110
|
+
return data
|
|
111
|
+
|
|
93
112
|
@field_validator("transmission_id", "output_id", "quality_profile_id", mode="after")
|
|
94
113
|
@classmethod
|
|
95
114
|
def _trim(cls, value: str) -> str:
|
|
@@ -147,10 +166,11 @@ class DemandGateRuntime(SourceOperatorRuntime):
|
|
|
147
166
|
self._last_open = is_open
|
|
148
167
|
self._last_payload = payload
|
|
149
168
|
lifecycle = Lifecycle.OPEN if is_open else Lifecycle.CLOSE
|
|
169
|
+
output_id, quality_profile_id = self._selected_demand_target()
|
|
150
170
|
stream_id = _demand_gate_stream_id(
|
|
151
171
|
self._config.transmission_id,
|
|
152
|
-
output_id=
|
|
153
|
-
quality_profile_id=
|
|
172
|
+
output_id=output_id,
|
|
173
|
+
quality_profile_id=quality_profile_id,
|
|
154
174
|
)
|
|
155
175
|
return Packet.create(
|
|
156
176
|
stream_id=stream_id,
|
|
@@ -158,8 +178,9 @@ class DemandGateRuntime(SourceOperatorRuntime):
|
|
|
158
178
|
payload={
|
|
159
179
|
"gate_open": bool(is_open),
|
|
160
180
|
"transmission_id": self._config.transmission_id,
|
|
161
|
-
"
|
|
162
|
-
"
|
|
181
|
+
"demand_scope": self._config.demand_scope,
|
|
182
|
+
"output_id": output_id,
|
|
183
|
+
"quality_profile_id": quality_profile_id,
|
|
163
184
|
**payload,
|
|
164
185
|
},
|
|
165
186
|
)
|
|
@@ -175,11 +196,12 @@ class DemandGateRuntime(SourceOperatorRuntime):
|
|
|
175
196
|
"reason": "services_unavailable",
|
|
176
197
|
}
|
|
177
198
|
try:
|
|
199
|
+
output_id, quality_profile_id = self._selected_demand_target()
|
|
178
200
|
raw = await services.call(
|
|
179
201
|
"streaming.demand.snapshot",
|
|
180
202
|
transmission_id=self._config.transmission_id,
|
|
181
|
-
output_id=
|
|
182
|
-
quality_profile_id=
|
|
203
|
+
output_id=output_id,
|
|
204
|
+
quality_profile_id=quality_profile_id,
|
|
183
205
|
)
|
|
184
206
|
except Exception as exc: # noqa: BLE001
|
|
185
207
|
return bool(self._config.fail_open), {
|
|
@@ -199,6 +221,11 @@ class DemandGateRuntime(SourceOperatorRuntime):
|
|
|
199
221
|
"matched_outputs": int(snapshot.get("matched_outputs") or 0),
|
|
200
222
|
}
|
|
201
223
|
|
|
224
|
+
def _selected_demand_target(self) -> tuple[str, str]:
|
|
225
|
+
if self._config.demand_scope != "output":
|
|
226
|
+
return "", ""
|
|
227
|
+
return self._config.output_id, self._config.quality_profile_id
|
|
228
|
+
|
|
202
229
|
|
|
203
230
|
def register_streaming_pipeline_operators(registry: OperatorRegistry) -> None:
|
|
204
231
|
if registry.get("stream.demand_gate") is None:
|
|
@@ -227,6 +254,7 @@ def register_streaming_pipeline_operators(registry: OperatorRegistry) -> None:
|
|
|
227
254
|
capabilities=["streaming", "sink", "realtime"],
|
|
228
255
|
defaults=PublishVideoConfig(publication_enabled=True).model_dump(mode="json"),
|
|
229
256
|
share_strategy="never",
|
|
257
|
+
requires_artifacts=[MAIN_ARTIFACT_NAME],
|
|
230
258
|
requires_media_fields=["ts", "width", "height"],
|
|
231
259
|
input_modalities=["video"],
|
|
232
260
|
owner=EXTENSION_ID,
|