toposync-ext-streaming 0.1.1__py3-none-any.whl
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/__init__.py +2 -0
- toposync_ext_streaming/api/__init__.py +2 -0
- toposync_ext_streaming/api/models.py +715 -0
- toposync_ext_streaming/api/routes.py +1612 -0
- toposync_ext_streaming/bin/ffmpeg/LICENSE +5 -0
- toposync_ext_streaming/bin/mediamtx/LICENSE +21 -0
- toposync_ext_streaming/extension.json +14 -0
- toposync_ext_streaming/pipelines/__init__.py +11 -0
- toposync_ext_streaming/pipelines/operators.py +182 -0
- toposync_ext_streaming/plugin.py +234 -0
- toposync_ext_streaming/static/326.js +2 -0
- toposync_ext_streaming/static/326.js.LICENSE.txt +9 -0
- toposync_ext_streaming/static/4.js +2 -0
- toposync_ext_streaming/static/4.js.LICENSE.txt +19 -0
- toposync_ext_streaming/static/623.js +2 -0
- toposync_ext_streaming/static/623.js.LICENSE.txt +19 -0
- toposync_ext_streaming/static/703.js +2 -0
- toposync_ext_streaming/static/703.js.LICENSE.txt +9 -0
- toposync_ext_streaming/static/main.js +2 -0
- toposync_ext_streaming/static/main.js.LICENSE.txt +9 -0
- toposync_ext_streaming/static/remoteEntry.js +1 -0
- toposync_ext_streaming/streaming/__init__.py +4 -0
- toposync_ext_streaming/streaming/arbitration.py +149 -0
- toposync_ext_streaming/streaming/camera_ingest.py +192 -0
- toposync_ext_streaming/streaming/distributed_sync.py +172 -0
- toposync_ext_streaming/streaming/engine_manager.py +891 -0
- toposync_ext_streaming/streaming/ffmpeg_binary.py +152 -0
- toposync_ext_streaming/streaming/mediamtx_api_client.py +109 -0
- toposync_ext_streaming/streaming/mediamtx_binary.py +271 -0
- toposync_ext_streaming/streaming/mediamtx_config.py +240 -0
- toposync_ext_streaming/streaming/mediamtx_processes.py +115 -0
- toposync_ext_streaming/streaming/placeholder.py +39 -0
- toposync_ext_streaming/streaming/platform.py +50 -0
- toposync_ext_streaming/streaming/publisher_manager.py +729 -0
- toposync_ext_streaming/streaming/resize.py +52 -0
- toposync_ext_streaming/streaming/runtime_state.py +485 -0
- toposync_ext_streaming/streaming/writer_bridge.py +1095 -0
- toposync_ext_streaming/wizard/__init__.py +10 -0
- toposync_ext_streaming/wizard/pipeline_builder.py +259 -0
- toposync_ext_streaming-0.1.1.dist-info/METADATA +732 -0
- toposync_ext_streaming-0.1.1.dist-info/RECORD +44 -0
- toposync_ext_streaming-0.1.1.dist-info/WHEEL +4 -0
- toposync_ext_streaming-0.1.1.dist-info/entry_points.txt +2 -0
- toposync_ext_streaming-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from ..streaming import MEDIAMTX_VERSION
|
|
11
|
+
from ..streaming.mediamtx_config import normalize_path_slug
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
EXTENSION_ID = "com.toposync.streaming"
|
|
15
|
+
TEST_PATH = "test"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _now_utc() -> datetime:
|
|
19
|
+
return datetime.now(timezone.utc)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_ice_servers_value(value: Any) -> list[str]:
|
|
23
|
+
raw_items: list[Any]
|
|
24
|
+
if value is None:
|
|
25
|
+
return []
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
raw_items = [item.strip() for item in value.replace("\n", ",").split(",")]
|
|
28
|
+
elif isinstance(value, list):
|
|
29
|
+
raw_items = value
|
|
30
|
+
else:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
normalized: list[str] = []
|
|
34
|
+
seen: set[str] = set()
|
|
35
|
+
for item in raw_items:
|
|
36
|
+
text = str(item or "").strip()
|
|
37
|
+
if not text:
|
|
38
|
+
continue
|
|
39
|
+
lowered = text.lower()
|
|
40
|
+
if not (lowered.startswith("stun:") or lowered.startswith("turn:") or lowered.startswith("turns:")):
|
|
41
|
+
continue
|
|
42
|
+
if lowered in seen:
|
|
43
|
+
continue
|
|
44
|
+
seen.add(lowered)
|
|
45
|
+
normalized.append(text)
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Resolution(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
width: int = Field(ge=1, le=7680)
|
|
53
|
+
height: int = Field(ge=1, le=4320)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StreamAuthentication(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
|
|
59
|
+
enabled: bool = False
|
|
60
|
+
username: str | None = None
|
|
61
|
+
password: str | None = None
|
|
62
|
+
|
|
63
|
+
@field_validator("username", "password", mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _trim_credentials(cls, value: Any) -> str | None:
|
|
66
|
+
normalized = str(value or "").strip()
|
|
67
|
+
return normalized or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TransmissionOutput(BaseModel):
|
|
71
|
+
model_config = ConfigDict(extra="allow")
|
|
72
|
+
|
|
73
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
74
|
+
protocol: Literal["hls", "rtsp", "webrtc"]
|
|
75
|
+
enabled: bool = True
|
|
76
|
+
resolution: Resolution | None = None
|
|
77
|
+
fps_limit: int | None = Field(default=None, ge=1, le=120)
|
|
78
|
+
bitrate_kbps: int | None = Field(default=None, ge=64, le=250000)
|
|
79
|
+
latency_profile: Literal["normal", "low", "ultra_low"] = "normal"
|
|
80
|
+
authentication: StreamAuthentication | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TransmissionCameraControls(BaseModel):
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
|
|
86
|
+
enabled: bool = False
|
|
87
|
+
camera_id: str | None = None
|
|
88
|
+
|
|
89
|
+
@field_validator("camera_id", mode="before")
|
|
90
|
+
@classmethod
|
|
91
|
+
def _trim_camera_id(cls, value: Any) -> str | None:
|
|
92
|
+
normalized = str(value or "").strip()
|
|
93
|
+
return normalized or None
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def _validate_enabled_camera_id(self) -> "TransmissionCameraControls":
|
|
97
|
+
if bool(self.enabled) and not str(self.camera_id or "").strip():
|
|
98
|
+
raise ValueError("camera_id is required when camera_controls.enabled is true")
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Transmission(BaseModel):
|
|
103
|
+
model_config = ConfigDict(extra="allow")
|
|
104
|
+
|
|
105
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
106
|
+
name: str = ""
|
|
107
|
+
enabled: bool = True
|
|
108
|
+
host_server_id: str = "local"
|
|
109
|
+
path: str = ""
|
|
110
|
+
placeholder: Literal["gray", "black"] = "gray"
|
|
111
|
+
arbitration: Literal["latest", "priority_latest"] = "priority_latest"
|
|
112
|
+
camera_controls: TransmissionCameraControls | None = None
|
|
113
|
+
outputs: list[TransmissionOutput] = Field(default_factory=list)
|
|
114
|
+
created_at: datetime = Field(default_factory=_now_utc)
|
|
115
|
+
updated_at: datetime = Field(default_factory=_now_utc)
|
|
116
|
+
|
|
117
|
+
@field_validator("name", mode="before")
|
|
118
|
+
@classmethod
|
|
119
|
+
def _trim_name(cls, value: Any) -> str:
|
|
120
|
+
return str(value or "").strip()
|
|
121
|
+
|
|
122
|
+
@field_validator("host_server_id", mode="before")
|
|
123
|
+
@classmethod
|
|
124
|
+
def _normalize_host_server_id(cls, value: Any) -> str:
|
|
125
|
+
normalized = str(value or "").strip().lower()
|
|
126
|
+
return normalized or "local"
|
|
127
|
+
|
|
128
|
+
@field_validator("path", mode="before")
|
|
129
|
+
@classmethod
|
|
130
|
+
def validate_path_slug(cls, value: Any, info) -> str: # noqa: ANN001
|
|
131
|
+
# Keep a safe slug for URLs and for the engine.
|
|
132
|
+
fallback = str(getattr(info, "data", {}).get("id") or "test")
|
|
133
|
+
return normalize_path_slug(str(value or ""), fallback=fallback)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_output_engine_path(transmission: Transmission, output: TransmissionOutput) -> str:
|
|
137
|
+
enabled_outputs = [item for item in transmission.outputs if bool(getattr(item, "enabled", True))]
|
|
138
|
+
output_count = len(enabled_outputs) if enabled_outputs else 1
|
|
139
|
+
|
|
140
|
+
extra = output.model_dump(mode="python")
|
|
141
|
+
direct = normalize_path_slug(str(extra.get("path") or ""), fallback="")
|
|
142
|
+
if direct:
|
|
143
|
+
return direct
|
|
144
|
+
if output_count <= 1:
|
|
145
|
+
return transmission.path
|
|
146
|
+
if _outputs_can_share_engine_path(enabled_outputs):
|
|
147
|
+
return transmission.path
|
|
148
|
+
return normalize_path_slug(f"{transmission.path}-{output.id}", fallback=transmission.path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _outputs_can_share_engine_path(outputs: list[TransmissionOutput]) -> bool:
|
|
152
|
+
if len(outputs) <= 1:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
encoding_keys: set[tuple[Any, ...]] = set()
|
|
156
|
+
auth_keys: set[tuple[Any, ...]] = set()
|
|
157
|
+
for output in outputs:
|
|
158
|
+
payload = output.model_dump(mode="python")
|
|
159
|
+
direct = normalize_path_slug(str(payload.get("path") or ""), fallback="")
|
|
160
|
+
if direct:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
encoding_keys.add(_normalize_output_encoding_key(payload))
|
|
164
|
+
auth_keys.add(_normalize_output_auth_key(output))
|
|
165
|
+
|
|
166
|
+
return len(encoding_keys) == 1 and len(auth_keys) == 1
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalize_output_encoding_key(payload: dict[str, Any]) -> tuple[Any, ...]:
|
|
170
|
+
width = payload.get("width")
|
|
171
|
+
height = payload.get("height")
|
|
172
|
+
resolution = payload.get("resolution") if isinstance(payload.get("resolution"), dict) else {}
|
|
173
|
+
if width is None:
|
|
174
|
+
width = resolution.get("width")
|
|
175
|
+
if height is None:
|
|
176
|
+
height = resolution.get("height")
|
|
177
|
+
|
|
178
|
+
resolved_width = max(16, int(_int_like(width) or 0)) if _int_like(width) else 1280
|
|
179
|
+
resolved_height = max(16, int(_int_like(height) or 0)) if _int_like(height) else 720
|
|
180
|
+
|
|
181
|
+
fps_raw = payload.get("fps_limit")
|
|
182
|
+
if fps_raw is None:
|
|
183
|
+
fps_raw = payload.get("fps")
|
|
184
|
+
resolved_fps = max(1, int(_int_like(fps_raw) or 0)) if _int_like(fps_raw) else 12
|
|
185
|
+
|
|
186
|
+
bitrate_raw = payload.get("bitrate_kbps")
|
|
187
|
+
resolved_bitrate = int(_int_like(bitrate_raw) or 0) if _int_like(bitrate_raw) else None
|
|
188
|
+
|
|
189
|
+
latency_profile = str(payload.get("latency_profile") or "normal").strip().lower()
|
|
190
|
+
if latency_profile not in {"normal", "low", "ultra_low"}:
|
|
191
|
+
latency_profile = "normal"
|
|
192
|
+
|
|
193
|
+
resize_mode = str(payload.get("resize_mode") or "contain").strip().lower()
|
|
194
|
+
if resize_mode not in {"contain", "none"}:
|
|
195
|
+
resize_mode = "contain"
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
resolved_width,
|
|
199
|
+
resolved_height,
|
|
200
|
+
resolved_fps,
|
|
201
|
+
resolved_bitrate,
|
|
202
|
+
latency_profile,
|
|
203
|
+
resize_mode,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _normalize_output_auth_key(output: TransmissionOutput) -> tuple[Any, ...]:
|
|
208
|
+
auth = output.authentication
|
|
209
|
+
if auth is None or not bool(getattr(auth, "enabled", False)):
|
|
210
|
+
return (False, "", "")
|
|
211
|
+
|
|
212
|
+
username = str(getattr(auth, "username", "") or "").strip()
|
|
213
|
+
password = str(getattr(auth, "password", "") or "").strip()
|
|
214
|
+
return (True, username, password)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _int_like(value: Any) -> int | None:
|
|
218
|
+
if value is None:
|
|
219
|
+
return None
|
|
220
|
+
if isinstance(value, bool):
|
|
221
|
+
return None
|
|
222
|
+
if isinstance(value, int):
|
|
223
|
+
return value
|
|
224
|
+
try:
|
|
225
|
+
return int(value)
|
|
226
|
+
except Exception:
|
|
227
|
+
try:
|
|
228
|
+
return int(float(str(value).strip()))
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class StreamingPreferredPorts(BaseModel):
|
|
234
|
+
model_config = ConfigDict(extra="forbid")
|
|
235
|
+
|
|
236
|
+
rtsp: int = Field(default=8554, ge=1, le=65535)
|
|
237
|
+
hls: int = Field(default=8888, ge=1, le=65535)
|
|
238
|
+
webrtc: int = Field(default=8889, ge=1, le=65535)
|
|
239
|
+
api: int = Field(default=9997, ge=1, le=65535)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class StreamingPreferredPortsPatch(BaseModel):
|
|
243
|
+
model_config = ConfigDict(extra="forbid")
|
|
244
|
+
|
|
245
|
+
rtsp: int | None = Field(default=None, ge=1, le=65535)
|
|
246
|
+
hls: int | None = Field(default=None, ge=1, le=65535)
|
|
247
|
+
webrtc: int | None = Field(default=None, ge=1, le=65535)
|
|
248
|
+
api: int | None = Field(default=None, ge=1, le=65535)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class StreamingEngineSettings(BaseModel):
|
|
252
|
+
model_config = ConfigDict(extra="forbid")
|
|
253
|
+
|
|
254
|
+
enabled: bool = False
|
|
255
|
+
expose_to_lan: bool = False
|
|
256
|
+
preferred_ports: StreamingPreferredPorts = Field(default_factory=StreamingPreferredPorts)
|
|
257
|
+
mediamtx_version: str = MEDIAMTX_VERSION
|
|
258
|
+
webrtc_ice_servers: list[str] = Field(default_factory=list)
|
|
259
|
+
|
|
260
|
+
@field_validator("webrtc_ice_servers", mode="before")
|
|
261
|
+
@classmethod
|
|
262
|
+
def _normalize_webrtc_ice_servers(cls, value: Any) -> list[str]:
|
|
263
|
+
return _normalize_ice_servers_value(value)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class StreamingCameraIngestSettings(BaseModel):
|
|
267
|
+
model_config = ConfigDict(extra="forbid")
|
|
268
|
+
|
|
269
|
+
enabled: bool = True
|
|
270
|
+
path_prefix: str = "ingest"
|
|
271
|
+
|
|
272
|
+
@field_validator("path_prefix", mode="before")
|
|
273
|
+
@classmethod
|
|
274
|
+
def _normalize_path_prefix(cls, value: Any) -> str:
|
|
275
|
+
return normalize_path_slug(str(value or ""), fallback="ingest")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class StreamingEngineSettingsPatch(BaseModel):
|
|
279
|
+
model_config = ConfigDict(extra="forbid")
|
|
280
|
+
|
|
281
|
+
enabled: bool | None = None
|
|
282
|
+
expose_to_lan: bool | None = None
|
|
283
|
+
preferred_ports: StreamingPreferredPortsPatch | None = None
|
|
284
|
+
mediamtx_version: str | None = None
|
|
285
|
+
webrtc_ice_servers: list[str] | None = None
|
|
286
|
+
|
|
287
|
+
@field_validator("webrtc_ice_servers", mode="before")
|
|
288
|
+
@classmethod
|
|
289
|
+
def _normalize_webrtc_ice_servers(cls, value: Any) -> list[str]:
|
|
290
|
+
return _normalize_ice_servers_value(value)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class StreamingExtensionSettings(BaseModel):
|
|
294
|
+
model_config = ConfigDict(extra="allow")
|
|
295
|
+
|
|
296
|
+
transmissions: list[Transmission] = Field(default_factory=list)
|
|
297
|
+
engine: StreamingEngineSettings = Field(default_factory=StreamingEngineSettings)
|
|
298
|
+
camera_ingest: StreamingCameraIngestSettings = Field(default_factory=StreamingCameraIngestSettings)
|
|
299
|
+
|
|
300
|
+
@model_validator(mode="after")
|
|
301
|
+
def _validate_uniqueness(self) -> "StreamingExtensionSettings":
|
|
302
|
+
seen_transmission_ids: set[str] = set()
|
|
303
|
+
seen_paths: set[tuple[str, str]] = set()
|
|
304
|
+
|
|
305
|
+
for transmission in self.transmissions:
|
|
306
|
+
if transmission.id in seen_transmission_ids:
|
|
307
|
+
raise ValueError(f"Duplicate transmission id: {transmission.id}")
|
|
308
|
+
seen_transmission_ids.add(transmission.id)
|
|
309
|
+
|
|
310
|
+
host_server_id = normalize_server_id(transmission.host_server_id, fallback="local")
|
|
311
|
+
path_key = (host_server_id, transmission.path)
|
|
312
|
+
if path_key in seen_paths:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"Duplicate transmission path for host_server_id='{host_server_id}': {transmission.path}"
|
|
315
|
+
)
|
|
316
|
+
seen_paths.add(path_key)
|
|
317
|
+
|
|
318
|
+
seen_output_ids: set[str] = set()
|
|
319
|
+
for output in transmission.outputs:
|
|
320
|
+
if output.id in seen_output_ids:
|
|
321
|
+
raise ValueError(f"Duplicate output id in transmission '{transmission.id}': {output.id}")
|
|
322
|
+
seen_output_ids.add(output.id)
|
|
323
|
+
|
|
324
|
+
return self
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class StreamingSettingsPatchRequest(BaseModel):
|
|
328
|
+
model_config = ConfigDict(extra="forbid")
|
|
329
|
+
|
|
330
|
+
transmissions: list[Transmission] | None = None
|
|
331
|
+
engine: StreamingEngineSettingsPatch | None = None
|
|
332
|
+
camera_ingest: StreamingCameraIngestSettings | None = None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class StreamingHealthResponse(BaseModel):
|
|
336
|
+
status: str
|
|
337
|
+
extension: str
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class StreamingEngineActivePorts(BaseModel):
|
|
341
|
+
model_config = ConfigDict(extra="forbid")
|
|
342
|
+
|
|
343
|
+
rtsp: int = Field(ge=1, le=65535)
|
|
344
|
+
hls: int = Field(ge=1, le=65535)
|
|
345
|
+
webrtc: int = Field(ge=1, le=65535)
|
|
346
|
+
api: int = Field(ge=1, le=65535)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class StreamingEngineUrls(BaseModel):
|
|
350
|
+
model_config = ConfigDict(extra="forbid")
|
|
351
|
+
|
|
352
|
+
rtsp_url: str
|
|
353
|
+
hls_url: str
|
|
354
|
+
webrtc_url: str
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class StreamingEngineStatusResponse(BaseModel):
|
|
358
|
+
model_config = ConfigDict(extra="forbid")
|
|
359
|
+
|
|
360
|
+
running: bool
|
|
361
|
+
pid: int | None = None
|
|
362
|
+
uptime_seconds: float | None = None
|
|
363
|
+
started_at_unix: float | None = None
|
|
364
|
+
bind_host: str
|
|
365
|
+
ports: StreamingEngineActivePorts
|
|
366
|
+
last_error: str | None = None
|
|
367
|
+
mediamtx_version: str
|
|
368
|
+
platform: str | None = None
|
|
369
|
+
binary_path: str | None = None
|
|
370
|
+
config_path: str | None = None
|
|
371
|
+
log_path: str | None = None
|
|
372
|
+
test_path: str = "test"
|
|
373
|
+
urls: StreamingEngineUrls
|
|
374
|
+
warnings: list[str] = Field(default_factory=list)
|
|
375
|
+
restart_count: int = Field(default=0, ge=0)
|
|
376
|
+
orphan_pids: list[int] = Field(default_factory=list)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class TransmissionCreateRequest(BaseModel):
|
|
380
|
+
model_config = ConfigDict(extra="forbid")
|
|
381
|
+
|
|
382
|
+
name: str
|
|
383
|
+
enabled: bool = True
|
|
384
|
+
host_server_id: str = "local"
|
|
385
|
+
path: str
|
|
386
|
+
placeholder: Literal["gray", "black"] = "gray"
|
|
387
|
+
arbitration: Literal["latest", "priority_latest"] = "priority_latest"
|
|
388
|
+
camera_controls: TransmissionCameraControls | None = None
|
|
389
|
+
outputs: list[TransmissionOutput] = Field(default_factory=list)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TransmissionOutputUrl(BaseModel):
|
|
393
|
+
model_config = ConfigDict(extra="forbid")
|
|
394
|
+
|
|
395
|
+
output_id: str
|
|
396
|
+
protocol: Literal["hls", "rtsp", "webrtc"]
|
|
397
|
+
resolved_engine_path: str
|
|
398
|
+
url: str
|
|
399
|
+
requires_auth: bool = False
|
|
400
|
+
auth_username: str | None = None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class TransmissionUrlsResponse(BaseModel):
|
|
404
|
+
model_config = ConfigDict(extra="forbid")
|
|
405
|
+
|
|
406
|
+
transmission_id: str
|
|
407
|
+
engine_running: bool
|
|
408
|
+
outputs: list[TransmissionOutputUrl]
|
|
409
|
+
warnings: list[str] = Field(default_factory=list)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class CameraPtzPreset(BaseModel):
|
|
413
|
+
model_config = ConfigDict(extra="forbid")
|
|
414
|
+
|
|
415
|
+
token: str
|
|
416
|
+
name: str = ""
|
|
417
|
+
pan: float | None = None
|
|
418
|
+
tilt: float | None = None
|
|
419
|
+
zoom: float | None = None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class CameraPtzStatus(BaseModel):
|
|
423
|
+
model_config = ConfigDict(extra="forbid")
|
|
424
|
+
|
|
425
|
+
pan: float | None = None
|
|
426
|
+
tilt: float | None = None
|
|
427
|
+
zoom: float | None = None
|
|
428
|
+
move_status: str = ""
|
|
429
|
+
error: str = ""
|
|
430
|
+
utc_time: str = ""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class TransmissionCameraPresetsResponse(BaseModel):
|
|
434
|
+
model_config = ConfigDict(extra="forbid")
|
|
435
|
+
|
|
436
|
+
transmission_id: str
|
|
437
|
+
camera_id: str
|
|
438
|
+
presets: list[CameraPtzPreset] = Field(default_factory=list)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class TransmissionCameraGotoPresetRequest(BaseModel):
|
|
442
|
+
model_config = ConfigDict(extra="forbid")
|
|
443
|
+
|
|
444
|
+
preset_token: str
|
|
445
|
+
|
|
446
|
+
@field_validator("preset_token", mode="before")
|
|
447
|
+
@classmethod
|
|
448
|
+
def _trim_preset_token(cls, value: Any) -> str:
|
|
449
|
+
normalized = str(value or "").strip()
|
|
450
|
+
if not normalized:
|
|
451
|
+
raise ValueError("Field is required")
|
|
452
|
+
return normalized
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class TransmissionCameraMoveRequest(BaseModel):
|
|
456
|
+
model_config = ConfigDict(extra="forbid")
|
|
457
|
+
|
|
458
|
+
pan: float = Field(default=0.0, ge=-1.0, le=1.0)
|
|
459
|
+
tilt: float = Field(default=0.0, ge=-1.0, le=1.0)
|
|
460
|
+
zoom: float = Field(default=0.0, ge=-1.0, le=1.0)
|
|
461
|
+
timeout_s: float | None = Field(default=None, ge=0.0, le=30.0)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class TransmissionCameraStopRequest(BaseModel):
|
|
465
|
+
model_config = ConfigDict(extra="forbid")
|
|
466
|
+
|
|
467
|
+
pan_tilt: bool = True
|
|
468
|
+
zoom: bool = True
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class TransmissionCameraStatusResponse(BaseModel):
|
|
472
|
+
model_config = ConfigDict(extra="forbid")
|
|
473
|
+
|
|
474
|
+
transmission_id: str
|
|
475
|
+
camera_id: str
|
|
476
|
+
status: CameraPtzStatus
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class TransmissionCameraActionResponse(BaseModel):
|
|
480
|
+
model_config = ConfigDict(extra="forbid")
|
|
481
|
+
|
|
482
|
+
ok: bool = True
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class TransmissionDemandOutputStatus(BaseModel):
|
|
486
|
+
model_config = ConfigDict(extra="forbid")
|
|
487
|
+
|
|
488
|
+
output_id: str
|
|
489
|
+
output_key: str
|
|
490
|
+
viewer_count: int = Field(ge=0)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class TransmissionDemandResponse(BaseModel):
|
|
494
|
+
model_config = ConfigDict(extra="forbid")
|
|
495
|
+
|
|
496
|
+
transmission_id: str
|
|
497
|
+
demand_signal: bool
|
|
498
|
+
viewer_count_total: int = Field(ge=0)
|
|
499
|
+
outputs: list[TransmissionDemandOutputStatus] = Field(default_factory=list)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class StreamingOutputRuntimeStatus(BaseModel):
|
|
503
|
+
model_config = ConfigDict(extra="forbid")
|
|
504
|
+
|
|
505
|
+
output_key: str
|
|
506
|
+
output_id: str
|
|
507
|
+
transmission_id: str
|
|
508
|
+
protocol: Literal["hls", "rtsp", "webrtc"]
|
|
509
|
+
resolved_engine_path: str
|
|
510
|
+
viewer_count: int = Field(ge=0)
|
|
511
|
+
demand_signal: bool
|
|
512
|
+
publisher_running: bool
|
|
513
|
+
publisher_pid: int | None = None
|
|
514
|
+
publisher_frames_sent: int = Field(ge=0)
|
|
515
|
+
publisher_last_error: str | None = None
|
|
516
|
+
publisher_active_codec: str | None = None
|
|
517
|
+
publisher_hardware_accelerated: bool = False
|
|
518
|
+
publisher_restart_count: int = Field(default=0, ge=0)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class StreamingOutputsRuntimeResponse(BaseModel):
|
|
522
|
+
model_config = ConfigDict(extra="forbid")
|
|
523
|
+
|
|
524
|
+
updated_at_unix: float
|
|
525
|
+
outputs: list[StreamingOutputRuntimeStatus] = Field(default_factory=list)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
StreamingWizardPresetId = Literal[
|
|
529
|
+
"simple_stream",
|
|
530
|
+
"motion_gate_stream",
|
|
531
|
+
"detection_stream",
|
|
532
|
+
"tracking_stream",
|
|
533
|
+
"segmentation_stream",
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class StreamingWizardOptionalParameters(BaseModel):
|
|
538
|
+
model_config = ConfigDict(extra="allow")
|
|
539
|
+
|
|
540
|
+
pipeline_name: str | None = None
|
|
541
|
+
enabled: bool = True
|
|
542
|
+
processing_server_id: str | None = None
|
|
543
|
+
source_backend: Literal["auto", "opencv", "ffmpeg"] = "auto"
|
|
544
|
+
use_fps_reducer: bool | None = None
|
|
545
|
+
fps_limit: float | None = Field(default=None, ge=0.5, le=60.0)
|
|
546
|
+
motion_sensitivity: float | None = Field(default=None, gt=0.0, le=1.0)
|
|
547
|
+
motion_hold_seconds: float | None = Field(default=None, ge=0.0, le=120.0)
|
|
548
|
+
resize_mode: Literal["contain", "none"] | None = None
|
|
549
|
+
writer_priority: int | None = None
|
|
550
|
+
bypass_mode: Literal["auto", "force_on", "force_off"] | None = None
|
|
551
|
+
yolo_confidence_threshold: float | None = Field(default=None, gt=0.0, le=1.0)
|
|
552
|
+
yolo_filter_enabled: bool | None = None
|
|
553
|
+
detection_categories: list[str] | None = None
|
|
554
|
+
tracking_categories: list[str] | None = None
|
|
555
|
+
|
|
556
|
+
@field_validator("pipeline_name", "processing_server_id", mode="before")
|
|
557
|
+
@classmethod
|
|
558
|
+
def _trim_optional_names(cls, value: Any) -> str | None:
|
|
559
|
+
normalized = str(value or "").strip()
|
|
560
|
+
return normalized or None
|
|
561
|
+
|
|
562
|
+
@field_validator("detection_categories", "tracking_categories", mode="before")
|
|
563
|
+
@classmethod
|
|
564
|
+
def _normalize_categories(cls, value: Any) -> list[str] | None:
|
|
565
|
+
if value is None:
|
|
566
|
+
return None
|
|
567
|
+
if not isinstance(value, list):
|
|
568
|
+
return None
|
|
569
|
+
normalized: list[str] = []
|
|
570
|
+
seen: set[str] = set()
|
|
571
|
+
for item in value:
|
|
572
|
+
category = str(item or "").strip().lower()
|
|
573
|
+
if not category or category in seen:
|
|
574
|
+
continue
|
|
575
|
+
seen.add(category)
|
|
576
|
+
normalized.append(category)
|
|
577
|
+
return normalized or None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class StreamingWizardCreatePipelineRequest(BaseModel):
|
|
581
|
+
model_config = ConfigDict(extra="forbid")
|
|
582
|
+
|
|
583
|
+
transmission_id: str
|
|
584
|
+
camera_id: str
|
|
585
|
+
preset_id: StreamingWizardPresetId
|
|
586
|
+
optional_parameters: StreamingWizardOptionalParameters | None = None
|
|
587
|
+
|
|
588
|
+
@field_validator("transmission_id", "camera_id", mode="before")
|
|
589
|
+
@classmethod
|
|
590
|
+
def _trim_required_ids(cls, value: Any) -> str:
|
|
591
|
+
normalized = str(value or "").strip()
|
|
592
|
+
if not normalized:
|
|
593
|
+
raise ValueError("Field is required")
|
|
594
|
+
return normalized
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class StreamingWizardCreatePipelineResponse(BaseModel):
|
|
598
|
+
model_config = ConfigDict(extra="forbid")
|
|
599
|
+
|
|
600
|
+
pipeline_name: str
|
|
601
|
+
transmission_id: str
|
|
602
|
+
camera_id: str
|
|
603
|
+
preset_id: StreamingWizardPresetId
|
|
604
|
+
engine_running: bool
|
|
605
|
+
warnings: list[str] = Field(default_factory=list)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def list_engine_paths(settings: StreamingExtensionSettings) -> list[str]:
|
|
609
|
+
return list_engine_paths_for_host(settings, host_server_id="local")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def normalize_server_id(value: Any, *, fallback: str = "local") -> str:
|
|
613
|
+
normalized = str(value or "").strip().lower()
|
|
614
|
+
if normalized:
|
|
615
|
+
return normalized
|
|
616
|
+
fallback_value = str(fallback or "").strip().lower()
|
|
617
|
+
return fallback_value or "local"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def list_engine_paths_for_host(
|
|
621
|
+
settings: StreamingExtensionSettings,
|
|
622
|
+
*,
|
|
623
|
+
host_server_id: str = "local",
|
|
624
|
+
) -> list[str]:
|
|
625
|
+
host_id = normalize_server_id(host_server_id)
|
|
626
|
+
paths: list[str] = [TEST_PATH]
|
|
627
|
+
for transmission in settings.transmissions:
|
|
628
|
+
if not transmission.enabled:
|
|
629
|
+
continue
|
|
630
|
+
if normalize_server_id(transmission.host_server_id) != host_id:
|
|
631
|
+
continue
|
|
632
|
+
enabled_outputs = [output for output in transmission.outputs if output.enabled]
|
|
633
|
+
if not enabled_outputs:
|
|
634
|
+
paths.append(transmission.path)
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
for output in enabled_outputs:
|
|
638
|
+
paths.append(resolve_output_engine_path(transmission, output))
|
|
639
|
+
return paths
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def list_path_read_auth_for_host(
|
|
643
|
+
settings: StreamingExtensionSettings,
|
|
644
|
+
*,
|
|
645
|
+
host_server_id: str = "local",
|
|
646
|
+
) -> dict[str, tuple[str, str]]:
|
|
647
|
+
host_id = normalize_server_id(host_server_id)
|
|
648
|
+
auth_by_path: dict[str, tuple[str, str]] = {}
|
|
649
|
+
for transmission in settings.transmissions:
|
|
650
|
+
if not transmission.enabled:
|
|
651
|
+
continue
|
|
652
|
+
if normalize_server_id(transmission.host_server_id) != host_id:
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
enabled_outputs = [output for output in transmission.outputs if output.enabled]
|
|
656
|
+
if not enabled_outputs:
|
|
657
|
+
continue
|
|
658
|
+
|
|
659
|
+
for output in enabled_outputs:
|
|
660
|
+
authentication = output.authentication
|
|
661
|
+
if authentication is None or not authentication.enabled:
|
|
662
|
+
continue
|
|
663
|
+
username = str(authentication.username or "").strip()
|
|
664
|
+
password = str(authentication.password or "").strip()
|
|
665
|
+
if not username or not password:
|
|
666
|
+
continue
|
|
667
|
+
path = resolve_output_engine_path(transmission, output)
|
|
668
|
+
if path not in auth_by_path:
|
|
669
|
+
auth_by_path[path] = (username, password)
|
|
670
|
+
continue
|
|
671
|
+
if auth_by_path[path] != (username, password):
|
|
672
|
+
# Avoid breaking the entire config; the first output wins.
|
|
673
|
+
continue
|
|
674
|
+
return auth_by_path
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def build_transmission_output_key(*, transmission_id: str, output_id: str) -> str:
|
|
678
|
+
transmission_key = str(transmission_id or "").strip()
|
|
679
|
+
output_key = str(output_id or "").strip()
|
|
680
|
+
return f"{transmission_key}:{output_key}" if transmission_key and output_key else ""
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def default_streaming_settings_dict() -> dict[str, Any]:
|
|
684
|
+
return StreamingExtensionSettings().model_dump(mode="json")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def normalize_streaming_settings(value: Any) -> dict[str, Any]:
|
|
688
|
+
if isinstance(value, StreamingExtensionSettings):
|
|
689
|
+
return value.model_dump(mode="json")
|
|
690
|
+
if isinstance(value, dict):
|
|
691
|
+
try:
|
|
692
|
+
return StreamingExtensionSettings.model_validate(value).model_dump(mode="json")
|
|
693
|
+
except Exception:
|
|
694
|
+
return default_streaming_settings_dict()
|
|
695
|
+
return default_streaming_settings_dict()
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
699
|
+
merged = dict(base)
|
|
700
|
+
for key, patch_value in patch.items():
|
|
701
|
+
base_value = merged.get(key)
|
|
702
|
+
if isinstance(base_value, dict) and isinstance(patch_value, dict):
|
|
703
|
+
merged[key] = _merge_dict(base_value, patch_value)
|
|
704
|
+
continue
|
|
705
|
+
merged[key] = patch_value
|
|
706
|
+
return merged
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def apply_streaming_settings_patch(current_value: Any, patch: StreamingSettingsPatchRequest) -> dict[str, Any]:
|
|
710
|
+
current = normalize_streaming_settings(current_value)
|
|
711
|
+
patch_data = patch.model_dump(mode="json", exclude_none=True)
|
|
712
|
+
if not patch_data:
|
|
713
|
+
return current
|
|
714
|
+
merged = _merge_dict(current, patch_data)
|
|
715
|
+
return normalize_streaming_settings(merged)
|