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.
Files changed (44) hide show
  1. toposync_ext_streaming/__init__.py +2 -0
  2. toposync_ext_streaming/api/__init__.py +2 -0
  3. toposync_ext_streaming/api/models.py +715 -0
  4. toposync_ext_streaming/api/routes.py +1612 -0
  5. toposync_ext_streaming/bin/ffmpeg/LICENSE +5 -0
  6. toposync_ext_streaming/bin/mediamtx/LICENSE +21 -0
  7. toposync_ext_streaming/extension.json +14 -0
  8. toposync_ext_streaming/pipelines/__init__.py +11 -0
  9. toposync_ext_streaming/pipelines/operators.py +182 -0
  10. toposync_ext_streaming/plugin.py +234 -0
  11. toposync_ext_streaming/static/326.js +2 -0
  12. toposync_ext_streaming/static/326.js.LICENSE.txt +9 -0
  13. toposync_ext_streaming/static/4.js +2 -0
  14. toposync_ext_streaming/static/4.js.LICENSE.txt +19 -0
  15. toposync_ext_streaming/static/623.js +2 -0
  16. toposync_ext_streaming/static/623.js.LICENSE.txt +19 -0
  17. toposync_ext_streaming/static/703.js +2 -0
  18. toposync_ext_streaming/static/703.js.LICENSE.txt +9 -0
  19. toposync_ext_streaming/static/main.js +2 -0
  20. toposync_ext_streaming/static/main.js.LICENSE.txt +9 -0
  21. toposync_ext_streaming/static/remoteEntry.js +1 -0
  22. toposync_ext_streaming/streaming/__init__.py +4 -0
  23. toposync_ext_streaming/streaming/arbitration.py +149 -0
  24. toposync_ext_streaming/streaming/camera_ingest.py +192 -0
  25. toposync_ext_streaming/streaming/distributed_sync.py +172 -0
  26. toposync_ext_streaming/streaming/engine_manager.py +891 -0
  27. toposync_ext_streaming/streaming/ffmpeg_binary.py +152 -0
  28. toposync_ext_streaming/streaming/mediamtx_api_client.py +109 -0
  29. toposync_ext_streaming/streaming/mediamtx_binary.py +271 -0
  30. toposync_ext_streaming/streaming/mediamtx_config.py +240 -0
  31. toposync_ext_streaming/streaming/mediamtx_processes.py +115 -0
  32. toposync_ext_streaming/streaming/placeholder.py +39 -0
  33. toposync_ext_streaming/streaming/platform.py +50 -0
  34. toposync_ext_streaming/streaming/publisher_manager.py +729 -0
  35. toposync_ext_streaming/streaming/resize.py +52 -0
  36. toposync_ext_streaming/streaming/runtime_state.py +485 -0
  37. toposync_ext_streaming/streaming/writer_bridge.py +1095 -0
  38. toposync_ext_streaming/wizard/__init__.py +10 -0
  39. toposync_ext_streaming/wizard/pipeline_builder.py +259 -0
  40. toposync_ext_streaming-0.1.1.dist-info/METADATA +732 -0
  41. toposync_ext_streaming-0.1.1.dist-info/RECORD +44 -0
  42. toposync_ext_streaming-0.1.1.dist-info/WHEEL +4 -0
  43. toposync_ext_streaming-0.1.1.dist-info/entry_points.txt +2 -0
  44. 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)