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.
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/PKG-INFO +1 -1
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/pyproject.toml +1 -1
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/routes.py +362 -15
- toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/387.js +1 -0
- toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/703.js +2 -0
- toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/main.js +2 -0
- toposync_ext_streaming-0.4.3/src/toposync_ext_streaming/static/remoteEntry.js +1 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/activate.tsx +2 -2
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/settings/StreamingSettingsPanel.tsx +4 -3
- toposync_ext_streaming-0.4.1/src/toposync_ext_streaming/static/703.js +0 -2
- toposync_ext_streaming-0.4.1/src/toposync_ext_streaming/static/main.js +0 -2
- toposync_ext_streaming-0.4.1/src/toposync_ext_streaming/static/remoteEntry.js +0 -1
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/.gitignore +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/LICENSE +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/LICENSE.ffmpeg +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/LICENSE.mediamtx +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/README.md +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/__init__.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/__init__.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/api/models.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/bin/ffmpeg/LICENSE +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/bin/mediamtx/LICENSE +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/extension.json +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/pipelines/__init__.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/pipelines/operators.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/plugin.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/326.js +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/326.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/4.js +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/4.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/623.js +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/623.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/703.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/static/main.js.LICENSE.txt +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/__init__.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/arbitration.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/camera_ingest.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/distributed_sync.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/encoder_state.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/engine_manager.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ffmpeg_binary.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_binary.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_config.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/go2rtc_manager.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ingest_auth.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/ingest_resolver.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/jsmpeg_manager.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_api_client.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_binary.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_config.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/mediamtx_processes.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/placeholder.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/platform.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/playback_events.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/publisher_manager.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/resize.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/runtime_state.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/streaming/writer_bridge.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/wizard/__init__.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/src/toposync_ext_streaming/wizard/pipeline_builder.py +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/package.json +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/api/streamingApi.ts +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/constants.ts +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/entry.ts +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/settings/SubModal.tsx +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/settings/WizardCreatePipelineFromTransmission.tsx +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/translations.ts +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/src/types.ts +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/tsconfig.json +0 -0
- {toposync_ext_streaming-0.4.1 → toposync_ext_streaming-0.4.3}/ui/webpack.config.js +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
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
|
-
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8196
|
-
|
|
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)}}]);
|