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.
Files changed (67) hide show
  1. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/.gitignore +1 -0
  2. toposync_ext_streaming-0.7.9/README.md → toposync_ext_streaming-0.8.0/PKG-INFO +26 -0
  3. toposync_ext_streaming-0.7.9/PKG-INFO → toposync_ext_streaming-0.8.0/README.md +13 -13
  4. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/pyproject.toml +1 -1
  5. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/api/models.py +3 -0
  6. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/api/routes.py +191 -11
  7. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/pipelines/operators.py +35 -7
  8. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/engine_manager.py +498 -32
  9. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/wizard/pipeline_builder.py +10 -0
  10. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/LICENSE +0 -0
  11. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/LICENSE.ffmpeg +0 -0
  12. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/LICENSE.mediamtx +0 -0
  13. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/__init__.py +0 -0
  14. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/api/__init__.py +0 -0
  15. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/bin/ffmpeg/LICENSE +0 -0
  16. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/bin/mediamtx/LICENSE +0 -0
  17. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/extension.json +0 -0
  18. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/pipelines/__init__.py +0 -0
  19. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/plugin.py +0 -0
  20. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/326.js +0 -0
  21. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/326.js.LICENSE.txt +0 -0
  22. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/387.js +0 -0
  23. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/4.js +0 -0
  24. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/4.js.LICENSE.txt +0 -0
  25. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/623.js +0 -0
  26. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/623.js.LICENSE.txt +0 -0
  27. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/703.js +0 -0
  28. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/703.js.LICENSE.txt +0 -0
  29. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/main.js +0 -0
  30. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/main.js.LICENSE.txt +0 -0
  31. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/static/remoteEntry.js +0 -0
  32. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/__init__.py +0 -0
  33. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/arbitration.py +0 -0
  34. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/camera_ingest.py +0 -0
  35. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/distributed_sync.py +0 -0
  36. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/encoder_state.py +0 -0
  37. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/ffmpeg_binary.py +0 -0
  38. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/go2rtc_binary.py +0 -0
  39. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/go2rtc_config.py +0 -0
  40. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/go2rtc_manager.py +0 -0
  41. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/ingest_auth.py +0 -0
  42. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/ingest_resolver.py +0 -0
  43. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/jsmpeg_manager.py +0 -0
  44. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_api_client.py +0 -0
  45. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_binary.py +0 -0
  46. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_config.py +0 -0
  47. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/mediamtx_processes.py +0 -0
  48. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/placeholder.py +0 -0
  49. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/platform.py +0 -0
  50. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/playback_events.py +0 -0
  51. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/publisher_manager.py +0 -0
  52. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/resize.py +0 -0
  53. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/runtime_state.py +0 -0
  54. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/streaming/writer_bridge.py +0 -0
  55. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/src/toposync_ext_streaming/wizard/__init__.py +0 -0
  56. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/package.json +0 -0
  57. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/activate.tsx +0 -0
  58. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/api/streamingApi.ts +0 -0
  59. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/constants.ts +0 -0
  60. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/entry.ts +0 -0
  61. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/settings/StreamingSettingsPanel.tsx +0 -0
  62. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/settings/SubModal.tsx +0 -0
  63. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/settings/WizardCreatePipelineFromTransmission.tsx +0 -0
  64. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/translations.ts +0 -0
  65. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/src/types.ts +0 -0
  66. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/tsconfig.json +0 -0
  67. {toposync_ext_streaming-0.7.9 → toposync_ext_streaming-0.8.0}/ui/webpack.config.js +0 -0
@@ -38,3 +38,4 @@ extensions/streaming/src/toposync_ext_streaming/bin/ffmpeg/**/ffmpeg*
38
38
  extensions/vision/models/
39
39
 
40
40
  *.temporary.*
41
+ .playwright-mcp/*
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "toposync-ext-streaming"
3
- version = "0.7.9"
3
+ version = "0.8.0"
4
4
  description = "Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- fail_on_mismatch = _env_bool("TOPOSYNC_FAIL_STREAM_URLS_ON_PORT_MISMATCH")
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
- message = f"{label} active port {actual} does not match expected add-on port {expected}."
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 in {"localhost", "::1"} or host.startswith("127.")
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=_home_assistant_rtsp_host(request),
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
- if str(cfg.get("transmission_id") or "").strip() == expected_transmission_id:
5889
- continue
5890
- next_cfg = {**cfg, "transmission_id": expected_transmission_id}
5891
- node["config"] = next_cfg
5892
- changed = True
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=self._config.output_id,
153
- quality_profile_id=self._config.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
- "output_id": self._config.output_id,
162
- "quality_profile_id": self._config.quality_profile_id,
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=self._config.output_id,
182
- quality_profile_id=self._config.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,