toposync-ext-streaming 0.4.2__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.2 → toposync_ext_streaming-0.4.3}/PKG-INFO +1 -1
  2. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/pyproject.toml +1 -1
  3. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/routes.py +331 -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.2 → toposync_ext_streaming-0.4.3}/ui/src/activate.tsx +2 -2
  9. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/settings/StreamingSettingsPanel.tsx +4 -3
  10. toposync_ext_streaming-0.4.2/src/toposync_ext_streaming/static/703.js +0 -2
  11. toposync_ext_streaming-0.4.2/src/toposync_ext_streaming/static/main.js +0 -2
  12. toposync_ext_streaming-0.4.2/src/toposync_ext_streaming/static/remoteEntry.js +0 -1
  13. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/.gitignore +0 -0
  14. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/LICENSE +0 -0
  15. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/LICENSE.ffmpeg +0 -0
  16. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/LICENSE.mediamtx +0 -0
  17. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/README.md +0 -0
  18. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/__init__.py +0 -0
  19. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/__init__.py +0 -0
  20. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/models.py +0 -0
  21. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/bin/ffmpeg/LICENSE +0 -0
  22. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/bin/mediamtx/LICENSE +0 -0
  23. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/extension.json +0 -0
  24. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/pipelines/__init__.py +0 -0
  25. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/pipelines/operators.py +0 -0
  26. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/plugin.py +0 -0
  27. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/326.js +0 -0
  28. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/326.js.LICENSE.txt +0 -0
  29. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/4.js +0 -0
  30. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/4.js.LICENSE.txt +0 -0
  31. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/623.js +0 -0
  32. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/623.js.LICENSE.txt +0 -0
  33. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/703.js.LICENSE.txt +0 -0
  34. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/main.js.LICENSE.txt +0 -0
  35. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/__init__.py +0 -0
  36. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/arbitration.py +0 -0
  37. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/camera_ingest.py +0 -0
  38. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/distributed_sync.py +0 -0
  39. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/encoder_state.py +0 -0
  40. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/engine_manager.py +0 -0
  41. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ffmpeg_binary.py +0 -0
  42. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_binary.py +0 -0
  43. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_config.py +0 -0
  44. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_manager.py +0 -0
  45. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ingest_auth.py +0 -0
  46. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ingest_resolver.py +0 -0
  47. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/jsmpeg_manager.py +0 -0
  48. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_api_client.py +0 -0
  49. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_binary.py +0 -0
  50. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_config.py +0 -0
  51. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_processes.py +0 -0
  52. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/placeholder.py +0 -0
  53. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/platform.py +0 -0
  54. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/playback_events.py +0 -0
  55. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/publisher_manager.py +0 -0
  56. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/resize.py +0 -0
  57. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/runtime_state.py +0 -0
  58. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/writer_bridge.py +0 -0
  59. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/wizard/__init__.py +0 -0
  60. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/wizard/pipeline_builder.py +0 -0
  61. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/package.json +0 -0
  62. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/api/streamingApi.ts +0 -0
  63. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/constants.ts +0 -0
  64. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/entry.ts +0 -0
  65. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/settings/SubModal.tsx +0 -0
  66. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/settings/WizardCreatePipelineFromTransmission.tsx +0 -0
  67. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/translations.ts +0 -0
  68. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/src/types.ts +0 -0
  69. {toposync_ext_streaming-0.4.2 → toposync_ext_streaming-0.4.3}/ui/tsconfig.json +0 -0
  70. {toposync_ext_streaming-0.4.2 → 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.2
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.2"
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,7 +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 = 1800.0
521
+ MAX_MEDIA_TOKEN_TTL_OVERRIDE_SECONDS = 21600.0
522
522
 
523
523
 
524
524
  def _hls_proxy_url(
@@ -1079,6 +1079,70 @@ async def _fetch_json(
1079
1079
  return await asyncio.to_thread(_do_request)
1080
1080
 
1081
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
+
1082
1146
  def _read_http_error(exc: urllib_error.HTTPError) -> str:
1083
1147
  try:
1084
1148
  body = exc.read().decode("utf-8", errors="replace").strip()
@@ -2390,6 +2454,139 @@ def _redact_url_credentials(url: str | None) -> str | None:
2390
2454
  return urllib_parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
2391
2455
 
2392
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
+
2393
2590
  def _home_assistant_still_url_path(
2394
2591
  *,
2395
2592
  transmission_id: str,
@@ -2545,7 +2742,35 @@ async def _build_home_assistant_camera_item(
2545
2742
  if output is None:
2546
2743
  blocking_errors.append("No enabled output is available for Home Assistant camera playback.")
2547
2744
  elif transmission_host != current_server_id:
2548
- 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}")
2549
2774
  else:
2550
2775
  engine_path = resolve_output_engine_path(transmission, output)
2551
2776
  rtsp_url = await _engine_manager(request).get_read_url_for_path(
@@ -7410,7 +7635,36 @@ def create_streaming_router() -> APIRouter:
7410
7635
  raise HTTPException(status_code=404, detail="Transmission not found")
7411
7636
 
7412
7637
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(request):
7413
- 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
+ )
7414
7668
 
7415
7669
  output = _best_transmission_output_for_home_assistant(
7416
7670
  transmission,
@@ -7576,7 +7830,34 @@ def create_streaming_router() -> APIRouter:
7576
7830
  if transmission is None:
7577
7831
  raise HTTPException(status_code=404, detail="Transmission not found")
7578
7832
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(request):
7579
- 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
7580
7861
 
7581
7862
  selected_output_id = str(body.output_id or output_id or "").strip() or None
7582
7863
  selected_profile_id = body.quality_profile_id or quality_profile_id
@@ -8161,11 +8442,31 @@ def create_streaming_router() -> APIRouter:
8161
8442
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(
8162
8443
  request
8163
8444
  ):
8164
- return {
8165
- "transmission_id": transmission_id,
8166
- "primed": False,
8167
- "primed_outputs": 0,
8168
- }
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
8169
8470
 
8170
8471
  bridge = _writer_bridge(request)
8171
8472
  prime_demand = getattr(bridge, "prime_transmission_demand", None)
@@ -8219,13 +8520,28 @@ def create_streaming_router() -> APIRouter:
8219
8520
  default_lease_seconds = 90.0 if payload.source == "home_assistant_entity" else 45.0
8220
8521
  lease_seconds = float(payload.ttl_seconds or default_lease_seconds)
8221
8522
  if normalize_server_id(transmission.host_server_id, fallback="local") != _current_server_id(request):
8222
- return TransmissionDemandHeartbeatResponse(
8223
- transmission_id=transmission_id,
8224
- playback_session_id=payload.playback_session_id,
8225
- renewed=False,
8226
- renewed_outputs=0,
8227
- 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",
8228
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
8229
8545
 
8230
8546
  bridge = _writer_bridge(request)
8231
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)}}]);