toposync-ext-streaming 0.4.1__tar.gz → 0.4.3__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 (70) hide show
  1. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/PKG-INFO +1 -1
  2. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/pyproject.toml +1 -1
  3. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/routes.py +362 -15
  4. toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/387.js +1 -0
  5. toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/703.js +2 -0
  6. toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/main.js +2 -0
  7. toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/remoteEntry.js +1 -0
  8. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/activate.tsx +2 -2
  9. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/settings/StreamingSettingsPanel.tsx +4 -3
  10. toposync_ext_streaming-0.4.1/src/toposync_ext_streaming/static/703.js +0 -2
  11. toposync_ext_streaming-0.4.1/src/toposync_ext_streaming/static/main.js +0 -2
  12. toposync_ext_streaming-0.4.1/src/toposync_ext_streaming/static/remoteEntry.js +0 -1
  13. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/.gitignore +0 -0
  14. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/LICENSE +0 -0
  15. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/LICENSE.ffmpeg +0 -0
  16. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/LICENSE.mediamtx +0 -0
  17. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/README.md +0 -0
  18. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/__init__.py +0 -0
  19. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/__init__.py +0 -0
  20. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/models.py +0 -0
  21. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/bin/ffmpeg/LICENSE +0 -0
  22. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/bin/mediamtx/LICENSE +0 -0
  23. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/extension.json +0 -0
  24. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/pipelines/__init__.py +0 -0
  25. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/pipelines/operators.py +0 -0
  26. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/plugin.py +0 -0
  27. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/326.js +0 -0
  28. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/326.js.LICENSE.txt +0 -0
  29. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/4.js +0 -0
  30. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/4.js.LICENSE.txt +0 -0
  31. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/623.js +0 -0
  32. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/623.js.LICENSE.txt +0 -0
  33. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/703.js.LICENSE.txt +0 -0
  34. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/main.js.LICENSE.txt +0 -0
  35. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/__init__.py +0 -0
  36. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/arbitration.py +0 -0
  37. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/camera_ingest.py +0 -0
  38. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/distributed_sync.py +0 -0
  39. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/encoder_state.py +0 -0
  40. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/engine_manager.py +0 -0
  41. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ffmpeg_binary.py +0 -0
  42. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_binary.py +0 -0
  43. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_config.py +0 -0
  44. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_manager.py +0 -0
  45. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ingest_auth.py +0 -0
  46. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ingest_resolver.py +0 -0
  47. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/jsmpeg_manager.py +0 -0
  48. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_api_client.py +0 -0
  49. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_binary.py +0 -0
  50. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_config.py +0 -0
  51. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_processes.py +0 -0
  52. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/placeholder.py +0 -0
  53. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/platform.py +0 -0
  54. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/playback_events.py +0 -0
  55. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/publisher_manager.py +0 -0
  56. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/resize.py +0 -0
  57. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/runtime_state.py +0 -0
  58. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/writer_bridge.py +0 -0
  59. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/wizard/__init__.py +0 -0
  60. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/wizard/pipeline_builder.py +0 -0
  61. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/package.json +0 -0
  62. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/api/streamingApi.ts +0 -0
  63. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/constants.ts +0 -0
  64. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/entry.ts +0 -0
  65. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/settings/SubModal.tsx +0 -0
  66. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/settings/WizardCreatePipelineFromTransmission.tsx +0 -0
  67. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/translations.ts +0 -0
  68. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/types.ts +0 -0
  69. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/tsconfig.json +0 -0
  70. {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/webpack.config.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toposync-ext-streaming
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "toposync-ext-streaming"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Toposync first-party extension: streaming settings, API surface, and pipeline sink bootstrap."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -518,6 +518,7 @@ def _hls_proxy_public_base_path(request: Request) -> str:
518
518
 
519
519
  MEDIA_TOKEN_SCOPE = "stream:media:read"
520
520
  LEGACY_HLS_MEDIA_TOKEN_SCOPE = "stream:hls:read"
521
+ MAX_MEDIA_TOKEN_TTL_OVERRIDE_SECONDS = 21600.0
521
522
 
522
523
 
523
524
  def _hls_proxy_url(
@@ -651,9 +652,18 @@ def _issue_media_token(
651
652
  output: TransmissionOutput,
652
653
  engine_path: str,
653
654
  transport: Literal["hls", "mse", "jsmpeg"],
655
+ ttl_seconds: float | None = None,
654
656
  ) -> tuple[str, float, float]:
655
657
  now = time.time()
656
- ttl_s = max(30.0, float(settings.engine.media_auth.token_ttl_seconds))
658
+ configured_ttl_s = max(30.0, float(settings.engine.media_auth.token_ttl_seconds))
659
+ if ttl_seconds is not None:
660
+ requested_ttl_s = max(
661
+ 30.0,
662
+ min(float(ttl_seconds), MAX_MEDIA_TOKEN_TTL_OVERRIDE_SECONDS),
663
+ )
664
+ ttl_s = max(configured_ttl_s, requested_ttl_s)
665
+ else:
666
+ ttl_s = configured_ttl_s
657
667
  renew_margin_s = max(1.0, float(settings.engine.media_auth.renew_margin_seconds))
658
668
  expires_at = now + ttl_s
659
669
  renew_after = max(now, expires_at - min(renew_margin_s, ttl_s - 1.0))
@@ -680,6 +690,7 @@ def _issue_hls_media_token(
680
690
  transmission: Transmission,
681
691
  output: TransmissionOutput,
682
692
  engine_path: str,
693
+ ttl_seconds: float | None = None,
683
694
  ) -> tuple[str, float, float]:
684
695
  return _issue_media_token(
685
696
  config_store=config_store,
@@ -688,6 +699,7 @@ def _issue_hls_media_token(
688
699
  output=output,
689
700
  engine_path=engine_path,
690
701
  transport="hls",
702
+ ttl_seconds=ttl_seconds,
691
703
  )
692
704
 
693
705
 
@@ -1067,6 +1079,70 @@ async def _fetch_json(
1067
1079
  return await asyncio.to_thread(_do_request)
1068
1080
 
1069
1081
 
1082
+ async def _fetch_bytes(
1083
+ *,
1084
+ url: str,
1085
+ timeout_s: float = 6.0,
1086
+ username: str = "",
1087
+ password: str = "",
1088
+ accept: str = "*/*",
1089
+ ) -> tuple[bytes, str | None]:
1090
+ def _do_request() -> tuple[bytes, str | None]:
1091
+ headers = {"accept": accept}
1092
+ if username or password:
1093
+ headers["authorization"] = _build_basic_authorization(username, password)
1094
+ req = urllib_request.Request(url=url, headers=headers, method="GET")
1095
+ try:
1096
+ with urllib_request.urlopen(req, timeout=max(1.0, float(timeout_s))) as response:
1097
+ return response.read(), response.headers.get("content-type")
1098
+ except urllib_error.HTTPError as exc:
1099
+ body = _read_http_error(exc)
1100
+ raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
1101
+ except urllib_error.URLError as exc:
1102
+ reason = str(getattr(exc, "reason", "") or exc)
1103
+ raise RuntimeError(f"Connection failed: {reason}") from exc
1104
+
1105
+ return await asyncio.to_thread(_do_request)
1106
+
1107
+
1108
+ async def _post_json(
1109
+ *,
1110
+ url: str,
1111
+ body: dict[str, Any],
1112
+ timeout_s: float = 6.0,
1113
+ username: str = "",
1114
+ password: str = "",
1115
+ ) -> dict[str, Any]:
1116
+ def _do_request() -> dict[str, Any]:
1117
+ headers = {"accept": "application/json", "content-type": "application/json"}
1118
+ if username or password:
1119
+ headers["authorization"] = _build_basic_authorization(username, password)
1120
+ req = urllib_request.Request(
1121
+ url=url,
1122
+ data=json.dumps(body).encode("utf-8"),
1123
+ headers=headers,
1124
+ method="POST",
1125
+ )
1126
+ try:
1127
+ with urllib_request.urlopen(req, timeout=max(1.0, float(timeout_s))) as response:
1128
+ payload = response.read().decode("utf-8", errors="replace")
1129
+ except urllib_error.HTTPError as exc:
1130
+ detail = _read_http_error(exc)
1131
+ raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
1132
+ except urllib_error.URLError as exc:
1133
+ reason = str(getattr(exc, "reason", "") or exc)
1134
+ raise RuntimeError(f"Connection failed: {reason}") from exc
1135
+ try:
1136
+ parsed_payload = json.loads(payload)
1137
+ except Exception as exc:
1138
+ raise RuntimeError("Invalid JSON response") from exc
1139
+ if not isinstance(parsed_payload, dict):
1140
+ raise RuntimeError("Invalid JSON payload")
1141
+ return parsed_payload
1142
+
1143
+ return await asyncio.to_thread(_do_request)
1144
+
1145
+
1070
1146
  def _read_http_error(exc: urllib_error.HTTPError) -> str:
1071
1147
  try:
1072
1148
  body = exc.read().decode("utf-8", errors="replace").strip()
@@ -1535,6 +1611,7 @@ async def _resolve_local_transmission_urls(
1535
1611
  transmission: Transmission,
1536
1612
  output_id: str | None = None,
1537
1613
  quality_profile_id: str | None = None,
1614
+ media_token_ttl_seconds: float | None = None,
1538
1615
  ) -> TransmissionUrlsResponse:
1539
1616
  bridge = _writer_bridge(request)
1540
1617
  prime_demand = getattr(bridge, "prime_transmission_demand", None)
@@ -1672,6 +1749,7 @@ async def _resolve_local_transmission_urls(
1672
1749
  transmission=transmission,
1673
1750
  output=output,
1674
1751
  engine_path=engine_path,
1752
+ ttl_seconds=media_token_ttl_seconds,
1675
1753
  )
1676
1754
  url = _hls_proxy_url(request, engine_path, media_token=media_token) or ""
1677
1755
  media_auth_type = "signed_url"
@@ -1701,6 +1779,7 @@ async def _resolve_local_transmission_urls(
1701
1779
  output=output,
1702
1780
  engine_path=engine_path,
1703
1781
  transport="mse",
1782
+ ttl_seconds=media_token_ttl_seconds,
1704
1783
  )
1705
1784
  outputs.append(
1706
1785
  TransmissionOutputUrl(
@@ -1724,6 +1803,7 @@ async def _resolve_local_transmission_urls(
1724
1803
  output=output,
1725
1804
  engine_path=engine_path,
1726
1805
  transport="jsmpeg",
1806
+ ttl_seconds=media_token_ttl_seconds,
1727
1807
  )
1728
1808
  outputs.append(
1729
1809
  TransmissionOutputUrl(
@@ -1776,6 +1856,7 @@ async def _resolve_local_transmission_urls(
1776
1856
  output=output,
1777
1857
  engine_path=engine_path,
1778
1858
  transport="mse",
1859
+ ttl_seconds=media_token_ttl_seconds,
1779
1860
  )
1780
1861
  outputs.append(
1781
1862
  TransmissionOutputUrl(
@@ -1799,6 +1880,7 @@ async def _resolve_local_transmission_urls(
1799
1880
  output=output,
1800
1881
  engine_path=engine_path,
1801
1882
  transport="jsmpeg",
1883
+ ttl_seconds=media_token_ttl_seconds,
1802
1884
  )
1803
1885
  outputs.append(
1804
1886
  TransmissionOutputUrl(
@@ -1846,6 +1928,7 @@ async def _resolve_remote_transmission_urls(
1846
1928
  transmission: Transmission,
1847
1929
  output_id: str | None = None,
1848
1930
  quality_profile_id: str | None = None,
1931
+ media_token_ttl_seconds: float | None = None,
1849
1932
  ) -> TransmissionUrlsResponse:
1850
1933
  servers_by_id = await _processing_servers_by_id(config_store)
1851
1934
  host_server_id = normalize_server_id(transmission.host_server_id, fallback="local")
@@ -1872,6 +1955,7 @@ async def _resolve_remote_transmission_urls(
1872
1955
  for key, value in {
1873
1956
  "output_id": str(output_id or "").strip(),
1874
1957
  "quality_profile_id": str(quality_profile_id or "").strip(),
1958
+ "media_token_ttl_seconds": str(media_token_ttl_seconds or "").strip(),
1875
1959
  }.items()
1876
1960
  if value
1877
1961
  }
@@ -2370,6 +2454,139 @@ def _redact_url_credentials(url: str | None) -> str | None:
2370
2454
  return urllib_parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
2371
2455
 
2372
2456
 
2457
+ def _url_has_loopback_host(url: str | None) -> bool:
2458
+ try:
2459
+ host = str(urllib_parse.urlsplit(str(url or "")).hostname or "").strip().lower()
2460
+ except Exception:
2461
+ return False
2462
+ return host in {"localhost", "::1"} or host.startswith("127.")
2463
+
2464
+
2465
+ def _url_host_for_rtsp(url: str | None) -> str:
2466
+ try:
2467
+ parsed = urllib_parse.urlsplit(str(url or ""))
2468
+ except Exception:
2469
+ return ""
2470
+ host = str(parsed.hostname or "").strip()
2471
+ if not host:
2472
+ return ""
2473
+ return f"[{host}]" if ":" in host and not host.startswith("[") else host
2474
+
2475
+
2476
+ def _rtsp_port_from_urls_response(urls: TransmissionUrlsResponse) -> int | None:
2477
+ contract = urls.network_contract
2478
+ if contract is None:
2479
+ return None
2480
+ for ports in (contract.actual_ports, contract.expected_ports):
2481
+ value = getattr(ports, "rtsp", None)
2482
+ if value is not None:
2483
+ return int(value)
2484
+ return None
2485
+
2486
+
2487
+ def _rtsp_url_with_output_auth(url: str, output: TransmissionOutput | None) -> str:
2488
+ raw = str(url or "").strip()
2489
+ if not raw or output is None:
2490
+ return raw
2491
+ auth = output.authentication
2492
+ if auth is None or not auth.enabled:
2493
+ return raw
2494
+ username = str(auth.username or "").strip()
2495
+ password = str(auth.password or "").strip()
2496
+ if not username or not password:
2497
+ return raw
2498
+ try:
2499
+ parsed = urllib_parse.urlsplit(raw)
2500
+ except Exception:
2501
+ return raw
2502
+ if parsed.username or parsed.password:
2503
+ return raw
2504
+ host = _url_host_for_rtsp(raw)
2505
+ if not host:
2506
+ return raw
2507
+ port = f":{parsed.port}" if parsed.port is not None else ""
2508
+ user = urllib_parse.quote(username, safe="")
2509
+ pwd = urllib_parse.quote(password, safe="")
2510
+ return urllib_parse.urlunsplit(
2511
+ (
2512
+ parsed.scheme,
2513
+ f"{user}:{pwd}@{host}{port}",
2514
+ parsed.path,
2515
+ parsed.query,
2516
+ parsed.fragment,
2517
+ )
2518
+ )
2519
+
2520
+
2521
+ def _remote_rtsp_url_for_home_assistant(
2522
+ *,
2523
+ urls: TransmissionUrlsResponse,
2524
+ transmission: Transmission,
2525
+ output: TransmissionOutput,
2526
+ ) -> str | None:
2527
+ selected = next(
2528
+ (
2529
+ item
2530
+ for item in urls.outputs
2531
+ if item.protocol == "rtsp" and item.output_id == output.id
2532
+ ),
2533
+ None,
2534
+ ) or next((item for item in urls.outputs if item.protocol == "rtsp"), None)
2535
+ if selected is not None:
2536
+ return _rtsp_url_with_output_auth(selected.url, output)
2537
+
2538
+ host = ""
2539
+ for candidate in urls.outputs:
2540
+ host = _url_host_for_rtsp(candidate.url)
2541
+ if host:
2542
+ break
2543
+ port = _rtsp_port_from_urls_response(urls)
2544
+ if not host or port is None:
2545
+ return None
2546
+ engine_path = resolve_output_engine_path(transmission, output)
2547
+ return _rtsp_url_with_output_auth(_rtsp_url(host, port, engine_path), output)
2548
+
2549
+
2550
+ async def _remote_transmission_server(
2551
+ *,
2552
+ config_store: ConfigStore,
2553
+ transmission: Transmission,
2554
+ ) -> ProcessingServer:
2555
+ servers_by_id = await _processing_servers_by_id(config_store)
2556
+ host_server_id = normalize_server_id(transmission.host_server_id, fallback="local")
2557
+ server = servers_by_id.get(host_server_id)
2558
+ if server is None:
2559
+ raise HTTPException(status_code=400, detail=f"Unknown host_server_id: {host_server_id}")
2560
+ if str(server.kind) != "http":
2561
+ raise HTTPException(
2562
+ status_code=400,
2563
+ detail=f"host_server_id '{host_server_id}' does not support remote HTTP access.",
2564
+ )
2565
+ if not str(server.url or "").strip():
2566
+ raise HTTPException(status_code=400, detail=f"host_server_id '{host_server_id}' has an empty URL.")
2567
+ return server
2568
+
2569
+
2570
+ def _remote_transmission_endpoint(
2571
+ server: ProcessingServer,
2572
+ *,
2573
+ transmission_id: str,
2574
+ suffix: str,
2575
+ query: dict[str, str | None] | None = None,
2576
+ ) -> str:
2577
+ base_url = str(server.url or "").strip().rstrip("/")
2578
+ encoded_id = urllib_parse.quote(str(transmission_id or ""), safe="")
2579
+ url = f"{base_url}/api/streams/transmissions/{encoded_id}/{suffix.lstrip('/')}"
2580
+ query_params = {
2581
+ key: value
2582
+ for key, value in (query or {}).items()
2583
+ if value is not None and str(value).strip()
2584
+ }
2585
+ if query_params:
2586
+ url = f"{url}?{urllib_parse.urlencode(query_params)}"
2587
+ return url
2588
+
2589
+
2373
2590
  def _home_assistant_still_url_path(
2374
2591
  *,
2375
2592
  transmission_id: str,
@@ -2525,7 +2742,35 @@ async def _build_home_assistant_camera_item(
2525
2742
  if output is None:
2526
2743
  blocking_errors.append("No enabled output is available for Home Assistant camera playback.")
2527
2744
  elif transmission_host != current_server_id:
2528
- blocking_errors.append("Transmission is hosted on another processing server; Home Assistant entity export is local-only in this version.")
2745
+ try:
2746
+ remote_urls = await _resolve_remote_transmission_urls(
2747
+ config_store=_config_store(request),
2748
+ transmission=transmission,
2749
+ output_id=output_id,
2750
+ quality_profile_id=quality_profile_id,
2751
+ )
2752
+ warnings.extend(remote_urls.warnings)
2753
+ blocking_errors.extend(remote_urls.blocking_errors)
2754
+ rtsp_url = _remote_rtsp_url_for_home_assistant(
2755
+ urls=remote_urls,
2756
+ transmission=transmission,
2757
+ output=output,
2758
+ )
2759
+ if not rtsp_url:
2760
+ blocking_errors.append(
2761
+ "Remote processing server did not expose an RTSP URL for Home Assistant camera playback."
2762
+ )
2763
+ elif _url_has_loopback_host(rtsp_url):
2764
+ blocking_errors.append(
2765
+ "Remote processing server returned a loopback RTSP URL; configure it with a LAN-reachable URL or expose the RTSP port."
2766
+ )
2767
+ rtsp_url = None
2768
+ redacted_rtsp_url = _redact_url_credentials(rtsp_url)
2769
+ except HTTPException as exc:
2770
+ detail = str(exc.detail or exc)
2771
+ blocking_errors.append(detail)
2772
+ except Exception as exc:
2773
+ blocking_errors.append(f"Failed to resolve remote Home Assistant camera playback: {exc}")
2529
2774
  else:
2530
2775
  engine_path = resolve_output_engine_path(transmission, output)
2531
2776
  rtsp_url = await _engine_manager(request).get_read_url_for_path(
@@ -6285,6 +6530,7 @@ def create_streaming_router() -> APIRouter:
6285
6530
  live_view_id: str,
6286
6531
  context: StreamingCameraLiveContext = "thumbnail",
6287
6532
  variant_id: str | None = None,
6533
+ media_token_ttl_seconds: float | None = None,
6288
6534
  ) -> CameraLiveViewPlaybackResponse:
6289
6535
  _require_auth(request, action="core:settings:read")
6290
6536
  config_store = _config_store(request)
@@ -6338,12 +6584,14 @@ def create_streaming_router() -> APIRouter:
6338
6584
  settings=settings,
6339
6585
  transmission=transmission,
6340
6586
  quality_profile_id=variant.quality_profile_id,
6587
+ media_token_ttl_seconds=media_token_ttl_seconds,
6341
6588
  )
6342
6589
  else:
6343
6590
  urls = await _resolve_remote_transmission_urls(
6344
6591
  config_store=config_store,
6345
6592
  transmission=transmission,
6346
6593
  quality_profile_id=variant.quality_profile_id,
6594
+ media_token_ttl_seconds=media_token_ttl_seconds,
6347
6595
  )
6348
6596
 
6349
6597
  selected_output = _select_live_playback_output(urls=urls, variant=variant)
@@ -7387,7 +7635,36 @@ def create_streaming_router() -> APIRouter:
7387
7635
  raise HTTPException(status_code=404, detail="Transmission not found")
7388
7636
 
7389
7637
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(request):
7390
- raise HTTPException(status_code=409, detail="Still image is only available on the transmission host server.")
7638
+ server = await _remote_transmission_server(
7639
+ config_store=config_store,
7640
+ transmission=transmission,
7641
+ )
7642
+ remote_url = _remote_transmission_endpoint(
7643
+ server,
7644
+ transmission_id=transmission.id,
7645
+ suffix="still.jpg",
7646
+ query={
7647
+ "output_id": output_id,
7648
+ "quality_profile_id": quality_profile_id,
7649
+ },
7650
+ )
7651
+ try:
7652
+ body, media_type = await _fetch_bytes(
7653
+ url=remote_url,
7654
+ username=str(server.username or "").strip(),
7655
+ password=str(server.password or "").strip(),
7656
+ accept="image/jpeg,*/*",
7657
+ )
7658
+ except Exception as exc:
7659
+ raise HTTPException(
7660
+ status_code=502,
7661
+ detail=f"Failed to resolve still image from processing server '{transmission.host_server_id}': {exc}",
7662
+ ) from exc
7663
+ return Response(
7664
+ content=body,
7665
+ media_type=media_type or "image/jpeg",
7666
+ headers={"cache-control": "no-store, max-age=0", "pragma": "no-cache"},
7667
+ )
7391
7668
 
7392
7669
  output = _best_transmission_output_for_home_assistant(
7393
7670
  transmission,
@@ -7435,6 +7712,7 @@ def create_streaming_router() -> APIRouter:
7435
7712
  transmission_id: str,
7436
7713
  output_id: str | None = None,
7437
7714
  quality_profile_id: str | None = None,
7715
+ media_token_ttl_seconds: float | None = None,
7438
7716
  ) -> TransmissionUrlsResponse:
7439
7717
  _require_auth(request, action="core:settings:read")
7440
7718
  config_store = _config_store(request)
@@ -7456,12 +7734,14 @@ def create_streaming_router() -> APIRouter:
7456
7734
  transmission=transmission,
7457
7735
  output_id=output_id,
7458
7736
  quality_profile_id=quality_profile_id,
7737
+ media_token_ttl_seconds=media_token_ttl_seconds,
7459
7738
  )
7460
7739
  return await _resolve_remote_transmission_urls(
7461
7740
  config_store=config_store,
7462
7741
  transmission=transmission,
7463
7742
  output_id=output_id,
7464
7743
  quality_profile_id=quality_profile_id,
7744
+ media_token_ttl_seconds=media_token_ttl_seconds,
7465
7745
  )
7466
7746
 
7467
7747
  @router.get(
@@ -7476,6 +7756,7 @@ def create_streaming_router() -> APIRouter:
7476
7756
  quality_profile_id: str | None = None,
7477
7757
  context: StreamingCameraLiveContext | None = None,
7478
7758
  low_latency: bool = False,
7759
+ media_token_ttl_seconds: float | None = None,
7479
7760
  ) -> StreamingPlaybackPlanResponse:
7480
7761
  _require_auth(request, action="core:settings:read")
7481
7762
  config_store = _config_store(request)
@@ -7493,6 +7774,7 @@ def create_streaming_router() -> APIRouter:
7493
7774
  transmission=transmission,
7494
7775
  output_id=output_id,
7495
7776
  quality_profile_id=quality_profile_id,
7777
+ media_token_ttl_seconds=media_token_ttl_seconds,
7496
7778
  )
7497
7779
  runtime_health: StreamingRuntimeTransmissionHealth | None = None
7498
7780
  try:
@@ -7509,6 +7791,7 @@ def create_streaming_router() -> APIRouter:
7509
7791
  transmission=transmission,
7510
7792
  output_id=output_id,
7511
7793
  quality_profile_id=quality_profile_id,
7794
+ media_token_ttl_seconds=media_token_ttl_seconds,
7512
7795
  )
7513
7796
  runtime_health = None
7514
7797
 
@@ -7547,7 +7830,34 @@ def create_streaming_router() -> APIRouter:
7547
7830
  if transmission is None:
7548
7831
  raise HTTPException(status_code=404, detail="Transmission not found")
7549
7832
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(request):
7550
- raise HTTPException(status_code=409, detail="WebRTC offer handling is only available on the transmission host server.")
7833
+ server = await _remote_transmission_server(
7834
+ config_store=config_store,
7835
+ transmission=transmission,
7836
+ )
7837
+ remote_url = _remote_transmission_endpoint(
7838
+ server,
7839
+ transmission_id=transmission.id,
7840
+ suffix="webrtc/offer",
7841
+ query={
7842
+ "output_id": body.output_id or output_id,
7843
+ "quality_profile_id": body.quality_profile_id or quality_profile_id,
7844
+ },
7845
+ )
7846
+ try:
7847
+ payload = await _post_json(
7848
+ url=remote_url,
7849
+ body=body.model_dump(mode="json"),
7850
+ username=str(server.username or "").strip(),
7851
+ password=str(server.password or "").strip(),
7852
+ )
7853
+ return StreamingHomeAssistantWebRtcOfferResponse.model_validate(payload)
7854
+ except HTTPException:
7855
+ raise
7856
+ except Exception as exc:
7857
+ raise HTTPException(
7858
+ status_code=502,
7859
+ detail=f"Failed to negotiate WebRTC offer with processing server '{transmission.host_server_id}': {exc}",
7860
+ ) from exc
7551
7861
 
7552
7862
  selected_output_id = str(body.output_id or output_id or "").strip() or None
7553
7863
  selected_profile_id = body.quality_profile_id or quality_profile_id
@@ -7688,6 +7998,7 @@ def create_streaming_router() -> APIRouter:
7688
7998
  transmission_id: str,
7689
7999
  output_id: str | None = None,
7690
8000
  quality_profile_id: str | None = None,
8001
+ media_token_ttl_seconds: float | None = None,
7691
8002
  ) -> TransmissionUrlsResponse:
7692
8003
  _require_auth(request, action="core:settings:read")
7693
8004
  config_store = _config_store(request)
@@ -7716,6 +8027,7 @@ def create_streaming_router() -> APIRouter:
7716
8027
  transmission=transmission,
7717
8028
  output_id=output_id,
7718
8029
  quality_profile_id=quality_profile_id,
8030
+ media_token_ttl_seconds=media_token_ttl_seconds,
7719
8031
  )
7720
8032
 
7721
8033
  @router.get("/distributed/settings/{server_id}", response_model=StreamingExtensionSettings)
@@ -8130,11 +8442,31 @@ def create_streaming_router() -> APIRouter:
8130
8442
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(
8131
8443
  request
8132
8444
  ):
8133
- return {
8134
- "transmission_id": transmission_id,
8135
- "primed": False,
8136
- "primed_outputs": 0,
8137
- }
8445
+ server = await _remote_transmission_server(
8446
+ config_store=config_store,
8447
+ transmission=transmission,
8448
+ )
8449
+ remote_url = _remote_transmission_endpoint(
8450
+ server,
8451
+ transmission_id=transmission.id,
8452
+ suffix="demand/prime",
8453
+ query={
8454
+ "output_id": output_id,
8455
+ "quality_profile_id": quality_profile_id,
8456
+ },
8457
+ )
8458
+ try:
8459
+ return await _post_json(
8460
+ url=remote_url,
8461
+ body={},
8462
+ username=str(server.username or "").strip(),
8463
+ password=str(server.password or "").strip(),
8464
+ )
8465
+ except Exception as exc:
8466
+ raise HTTPException(
8467
+ status_code=502,
8468
+ detail=f"Failed to prime demand on processing server '{transmission.host_server_id}': {exc}",
8469
+ ) from exc
8138
8470
 
8139
8471
  bridge = _writer_bridge(request)
8140
8472
  prime_demand = getattr(bridge, "prime_transmission_demand", None)
@@ -8188,13 +8520,28 @@ def create_streaming_router() -> APIRouter:
8188
8520
  default_lease_seconds = 90.0 if payload.source == "home_assistant_entity" else 45.0
8189
8521
  lease_seconds = float(payload.ttl_seconds or default_lease_seconds)
8190
8522
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(request):
8191
- return TransmissionDemandHeartbeatResponse(
8192
- transmission_id=transmission_id,
8193
- playback_session_id=payload.playback_session_id,
8194
- renewed=False,
8195
- renewed_outputs=0,
8196
- lease_seconds=lease_seconds,
8523
+ server = await _remote_transmission_server(
8524
+ config_store=config_store,
8525
+ transmission=transmission,
8526
+ )
8527
+ remote_url = _remote_transmission_endpoint(
8528
+ server,
8529
+ transmission_id=transmission.id,
8530
+ suffix="demand/heartbeat",
8197
8531
  )
8532
+ try:
8533
+ remote_payload = await _post_json(
8534
+ url=remote_url,
8535
+ body=payload.model_dump(mode="json"),
8536
+ username=str(server.username or "").strip(),
8537
+ password=str(server.password or "").strip(),
8538
+ )
8539
+ return TransmissionDemandHeartbeatResponse.model_validate(remote_payload)
8540
+ except Exception as exc:
8541
+ raise HTTPException(
8542
+ status_code=502,
8543
+ detail=f"Failed to renew demand on processing server '{transmission.host_server_id}': {exc}",
8544
+ ) from exc
8198
8545
 
8199
8546
  bridge = _writer_bridge(request)
8200
8547
  prime_demand = getattr(bridge, "prime_transmission_demand", None)
@@ -0,0 +1 @@
1
+ "use strict";(self.webpackChunk_toposync_extension_streaming_ui=self.webpackChunk_toposync_extension_streaming_ui||[]).push([[387],{89(t){function n(){return"undefined"==typeof window?"/":function(t){const n=String(t||"").trim();return n&&(n.startsWith("/")?n:`/${n}`).replace(/\/+$/,"")||"/"}(window.__TOPOSYNC_PUBLIC_BASE_PATH__)}function e(t){const e=n();return t.startsWith("/")?"/"===e||t===e||t.startsWith(`${e}/`)?t:`${e}${t}`:t}t.exports={getToposyncBasePath:n,resolveToposyncUrl:function(t){const n=String(t||"");if(!n)return n;if("undefined"==typeof window)return n;if(/^(data:|blob:|mailto:|tel:|#)/i.test(n))return n;if(n.startsWith("//"))return n;if(/^[a-z][a-z0-9+.-]*:/i.test(n))try{const t=new URL(n,window.location.href);return t.origin!==window.location.origin?n:e(`${t.pathname}${t.search}${t.hash}`)}catch{return n}return n.startsWith("/")?e(n):n}}},387(t,n,e){t.exports=e(89)}}]);