janus-api-streaming 0.2.0__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.
@@ -0,0 +1,638 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import builtins
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any, Literal, TypeVar, Unpack
7
+
8
+ from janus_api.lib import Plugin
9
+ from janus_api.models import JanusResponse
10
+ from janus_api.models.base import Jsep
11
+ from janus_api.models.request import TrickleCandidate
12
+
13
+ from .errors import (
14
+ JanusCommandError,
15
+ StreamingBackendError,
16
+ StreamingProtocolError,
17
+ StreamingTimeout,
18
+ )
19
+ from .models import (
20
+ ConfigureStream,
21
+ DataTrackSpec,
22
+ FileMountpointSpec,
23
+ IceCandidate,
24
+ RtpMountpointSpec,
25
+ RtspMountpointSpec,
26
+ SessionDescription,
27
+ )
28
+ from .requests import (
29
+ ConfigureRequest,
30
+ CreateAudioMedia,
31
+ CreateDataMedia,
32
+ CreateMedia,
33
+ CreateRequest,
34
+ CreateVideoMedia,
35
+ DestroyRequest,
36
+ DisableRequest,
37
+ EditRequest,
38
+ EnableRequest,
39
+ InfoRequest,
40
+ KickAllRequest,
41
+ ListRequest,
42
+ MountPoint,
43
+ PauseRequest,
44
+ RecordingMedia,
45
+ RecordingRequest,
46
+ StartRequest,
47
+ StopRequest,
48
+ SwitchRequest,
49
+ WatchRequest,
50
+ )
51
+ from .requests import ConfigureStream as ProtocolConfigureStream
52
+
53
+ T = TypeVar("T")
54
+
55
+
56
+ class StreamingPlugin(Plugin):
57
+ """Concrete Streaming handle and typed convenience API.
58
+
59
+ ``mountpoint`` and ``admin_key`` are optional convenience defaults. They
60
+ are package-owned state and never leak into core plugin construction.
61
+ """
62
+
63
+ identifier = "streaming"
64
+ name = "janus.plugin.streaming"
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ session: Any,
70
+ mountpoint: str | int | None = None,
71
+ admin_key: str | None = None,
72
+ plugin_id: str | int | None = None,
73
+ **kwargs: Any,
74
+ ) -> None:
75
+ super().__init__(session=session, plugin_id=plugin_id, **kwargs)
76
+ self._mountpoint = mountpoint
77
+ self._admin_key = admin_key
78
+
79
+ @property
80
+ def mountpoint(self) -> str | int | None:
81
+ return self._mountpoint
82
+
83
+ def _resolve_mountpoint_id(self, mountpoint_id: int | str | None = None) -> int:
84
+ target = mountpoint_id if mountpoint_id is not None else self._mountpoint
85
+ if target is None:
86
+ raise ValueError("mountpoint ID is required when the plugin is not bound")
87
+ return int(target)
88
+
89
+ async def list(self) -> JanusResponse:
90
+ return await self.send(ListRequest())
91
+
92
+ async def info(
93
+ self,
94
+ secret: str | None = None,
95
+ *,
96
+ mountpoint_id: int | str | None = None,
97
+ ) -> JanusResponse:
98
+ return await self.send(
99
+ InfoRequest(id=self._resolve_mountpoint_id(mountpoint_id), secret=secret)
100
+ )
101
+
102
+ async def create(
103
+ self,
104
+ *,
105
+ admin_key: str | None = None,
106
+ **kwargs: Unpack[MountPoint],
107
+ ) -> JanusResponse:
108
+ effective_admin_key = self._admin_key if admin_key is None else admin_key
109
+ payload = CreateRequest.model_validate({"admin_key": effective_admin_key, **kwargs})
110
+ return await self.send(payload)
111
+
112
+ async def destroy(
113
+ self,
114
+ *,
115
+ mountpoint_id: int | str | None = None,
116
+ secret: str | None = None,
117
+ permanent: bool = False,
118
+ ) -> JanusResponse:
119
+ return await self.send(
120
+ DestroyRequest(
121
+ id=self._resolve_mountpoint_id(mountpoint_id),
122
+ permanent=permanent,
123
+ secret=secret,
124
+ )
125
+ )
126
+
127
+ async def recording(
128
+ self,
129
+ action: Literal["start", "stop"],
130
+ media: builtins.list[RecordingMedia],
131
+ *,
132
+ mountpoint_id: int | str | None = None,
133
+ ) -> JanusResponse:
134
+ return await self.send(
135
+ RecordingRequest(
136
+ action=action,
137
+ id=self._resolve_mountpoint_id(mountpoint_id),
138
+ media=media,
139
+ )
140
+ )
141
+
142
+ async def edit(
143
+ self,
144
+ *,
145
+ mountpoint_id: int | str | None = None,
146
+ secret: str | None = None,
147
+ new_description: str | None = None,
148
+ new_metadata: str | None = None,
149
+ new_secret: str | None = None,
150
+ new_pin: str | None = None,
151
+ new_is_private: bool | None = None,
152
+ edited_event: bool = False,
153
+ permanent: bool = False,
154
+ ) -> JanusResponse:
155
+ return await self.send(
156
+ EditRequest(
157
+ id=self._resolve_mountpoint_id(mountpoint_id),
158
+ secret=secret,
159
+ new_description=new_description,
160
+ new_metadata=new_metadata,
161
+ new_secret=new_secret,
162
+ new_pin=new_pin,
163
+ new_is_private=new_is_private,
164
+ edited_event=edited_event,
165
+ permanent=permanent,
166
+ )
167
+ )
168
+
169
+ async def enable(
170
+ self,
171
+ *,
172
+ secret: str | None = None,
173
+ mountpoint_id: int | str | None = None,
174
+ ) -> JanusResponse:
175
+ return await self.send(
176
+ EnableRequest(id=self._resolve_mountpoint_id(mountpoint_id), secret=secret)
177
+ )
178
+
179
+ async def disable(
180
+ self,
181
+ *,
182
+ mountpoint_id: int | str | None = None,
183
+ secret: str | None = None,
184
+ stop_recording: bool = True,
185
+ ) -> JanusResponse:
186
+ return await self.send(
187
+ DisableRequest(
188
+ id=self._resolve_mountpoint_id(mountpoint_id),
189
+ stop_recording=stop_recording,
190
+ secret=secret,
191
+ )
192
+ )
193
+
194
+ async def kick_all(
195
+ self,
196
+ *,
197
+ secret: str | None = None,
198
+ mountpoint_id: int | str | None = None,
199
+ ) -> JanusResponse:
200
+ return await self.send(
201
+ KickAllRequest(id=self._resolve_mountpoint_id(mountpoint_id), secret=secret)
202
+ )
203
+
204
+ async def watch(
205
+ self,
206
+ *,
207
+ mountpoint_id: int | str | None = None,
208
+ pin: str | None = None,
209
+ media: builtins.list[str] | None = None,
210
+ offer_audio: bool | None = None,
211
+ offer_video: bool | None = None,
212
+ offer_data: bool | None = None,
213
+ ) -> JanusResponse:
214
+ return await self.send(
215
+ WatchRequest(
216
+ id=self._resolve_mountpoint_id(mountpoint_id),
217
+ pin=pin,
218
+ media=list(dict.fromkeys(media or ())),
219
+ offer_audio=offer_audio,
220
+ offer_video=offer_video,
221
+ offer_data=offer_data,
222
+ )
223
+ )
224
+
225
+ async def start_streaming(self, jsep: Jsep | None = None) -> JanusResponse:
226
+ return await self.send(StartRequest(), jsep=jsep)
227
+
228
+ async def pause(self) -> JanusResponse:
229
+ return await self.send(PauseRequest())
230
+
231
+ async def configure(self, streams: builtins.list[ProtocolConfigureStream]) -> JanusResponse:
232
+ return await self.send(ConfigureRequest(streams=streams))
233
+
234
+ async def switch(self, mountpoint_id: int | str) -> JanusResponse:
235
+ return await self.send(SwitchRequest(id=int(mountpoint_id)))
236
+
237
+ async def stop_streaming(self) -> JanusResponse:
238
+ return await self.send(StopRequest())
239
+
240
+ async def subscribe(
241
+ self,
242
+ *,
243
+ mountpoint_id: int | str | None = None,
244
+ pin: str | None = None,
245
+ media: builtins.list[str] | None = None,
246
+ offer_audio: bool | None = None,
247
+ offer_video: bool | None = None,
248
+ offer_data: bool | None = None,
249
+ ) -> JanusResponse:
250
+ """Alias for :meth:`watch` retained for existing applications."""
251
+
252
+ return await self.watch(
253
+ mountpoint_id=mountpoint_id,
254
+ pin=pin,
255
+ media=media,
256
+ offer_audio=offer_audio,
257
+ offer_video=offer_video,
258
+ offer_data=offer_data,
259
+ )
260
+
261
+
262
+ def to_jsonable(value: Any, *, by_alias: bool = True) -> Any:
263
+ model_dump = getattr(value, "model_dump", None)
264
+ if callable(model_dump):
265
+ return model_dump(mode="json", by_alias=by_alias, exclude_none=True)
266
+ if isinstance(value, dict):
267
+ return value
268
+ attributes = getattr(value, "__dict__", None)
269
+ if isinstance(attributes, dict):
270
+ return {key: item for key, item in attributes.items() if not key.startswith("_")}
271
+ return value
272
+
273
+
274
+ def plugin_payload(response: Any) -> dict[str, Any]:
275
+ if getattr(response, "janus", None) == "error":
276
+ error = getattr(response, "error", None)
277
+ raise JanusCommandError(
278
+ code=getattr(error, "code", None),
279
+ reason=getattr(error, "reason", "unknown Janus error"),
280
+ )
281
+ plugindata = getattr(response, "plugindata", None)
282
+ if plugindata is None:
283
+ raise StreamingProtocolError("expected a Janus plugin response containing plugindata")
284
+ payload = to_jsonable(plugindata.data)
285
+ if not isinstance(payload, dict):
286
+ raise StreamingProtocolError("Janus plugin payload is not an object")
287
+ if "error_code" in payload or ("error" in payload and "status" not in payload):
288
+ raise JanusCommandError(
289
+ code=int(payload["error_code"]) if payload.get("error_code") is not None else None,
290
+ reason=str(payload.get("error") or "Janus Streaming command failed"),
291
+ )
292
+ return payload
293
+
294
+
295
+ def response_jsep(response: Any) -> SessionDescription:
296
+ jsep = getattr(response, "jsep", None)
297
+ if jsep is None:
298
+ payload = plugin_payload(response)
299
+ raw = payload.get("jsep")
300
+ if isinstance(raw, dict):
301
+ return SessionDescription.model_validate(raw)
302
+ raise StreamingProtocolError("Janus watch response did not contain JSEP")
303
+ raw = to_jsonable(jsep)
304
+ return SessionDescription.model_validate(raw)
305
+
306
+
307
+ def list_request() -> ListRequest:
308
+ return ListRequest()
309
+
310
+
311
+ def info_request(mountpoint_id: int, secret: str | None) -> InfoRequest:
312
+ return InfoRequest(id=mountpoint_id, secret=secret)
313
+
314
+
315
+ def rtp_create_request(spec: RtpMountpointSpec, admin_key: str | None) -> CreateRequest:
316
+ media: list[CreateMedia] = []
317
+ for item in spec.media:
318
+ if item.type == "audio":
319
+ media.append(
320
+ CreateAudioMedia(
321
+ mid=item.mid,
322
+ msid=item.msid,
323
+ label=item.label,
324
+ mcast=item.multicast,
325
+ iface=item.bind_interface,
326
+ port=item.port,
327
+ rtcpport=item.rtcp_port,
328
+ pt=item.payload_type,
329
+ codec=item.codec,
330
+ fmtp=item.fmtp,
331
+ skew=item.skew,
332
+ )
333
+ )
334
+ elif item.type == "video":
335
+ media.append(
336
+ CreateVideoMedia(
337
+ mid=item.mid,
338
+ msid=item.msid,
339
+ label=item.label,
340
+ mcast=item.multicast,
341
+ iface=item.bind_interface,
342
+ port=item.port,
343
+ rtcpport=item.rtcp_port,
344
+ pt=item.payload_type,
345
+ codec=item.codec,
346
+ fmtp=item.fmtp,
347
+ skew=item.skew,
348
+ simulcast=item.simulcast,
349
+ port2=item.port2,
350
+ port3=item.port3,
351
+ svc=item.svc,
352
+ h264sps=item.h264_sps,
353
+ collision=item.collision_ms,
354
+ )
355
+ )
356
+ else:
357
+ data: DataTrackSpec = item
358
+ media.append(
359
+ CreateDataMedia(
360
+ mid=data.mid,
361
+ msid=data.msid,
362
+ label=data.label,
363
+ mcast=data.multicast,
364
+ iface=data.bind_interface,
365
+ port=data.port,
366
+ datatype=data.data_type,
367
+ databuffermsg=data.buffer_latest_message,
368
+ )
369
+ )
370
+ return CreateRequest(
371
+ admin_key=admin_key,
372
+ type="rtp",
373
+ id=spec.id,
374
+ name=spec.name,
375
+ description=spec.description,
376
+ metadata=spec.metadata,
377
+ secret=spec.secret,
378
+ pin=spec.pin,
379
+ is_private=spec.private,
380
+ permanent=spec.permanent,
381
+ enabled=spec.enabled,
382
+ media=media,
383
+ threads=spec.threads,
384
+ bufferkf_ms=spec.buffer_keyframes_ms,
385
+ bufferkf_bytes=spec.buffer_keyframes_bytes,
386
+ srtpsuite=spec.srtp_suite,
387
+ srtpcrypto=spec.srtp_crypto,
388
+ e2ee=spec.e2ee,
389
+ )
390
+
391
+
392
+ def rtsp_create_request(spec: RtspMountpointSpec, admin_key: str | None) -> CreateRequest:
393
+ return CreateRequest(
394
+ admin_key=admin_key,
395
+ type="rtsp",
396
+ id=spec.id,
397
+ name=spec.name,
398
+ description=spec.description,
399
+ metadata=spec.metadata,
400
+ secret=spec.secret,
401
+ pin=spec.pin,
402
+ is_private=spec.private,
403
+ permanent=spec.permanent,
404
+ enabled=spec.enabled,
405
+ url=spec.url,
406
+ rtsp_user=spec.username,
407
+ rtsp_pwd=spec.password,
408
+ rtsp_reconnect_delay=spec.reconnect_delay_seconds,
409
+ rtsp_session_timeout=spec.session_timeout_seconds,
410
+ rtsp_timeout=spec.media_timeout_seconds,
411
+ rtsp_conn_timeout=spec.connection_timeout_seconds,
412
+ rtspiface=spec.bind_interface,
413
+ rtsp_notify_changes=spec.notify_changes,
414
+ rtsp_failcheck=spec.fail_check,
415
+ rtsp_quirk=spec.quirk,
416
+ )
417
+
418
+
419
+ def file_create_request(spec: FileMountpointSpec, admin_key: str | None) -> CreateRequest:
420
+ return CreateRequest(
421
+ admin_key=admin_key,
422
+ type=spec.type,
423
+ id=spec.id,
424
+ name=spec.name,
425
+ description=spec.description,
426
+ metadata=spec.metadata,
427
+ secret=spec.secret,
428
+ pin=spec.pin,
429
+ is_private=spec.private,
430
+ permanent=spec.permanent,
431
+ enabled=spec.enabled,
432
+ filename=spec.filename,
433
+ audio=spec.audio,
434
+ video=spec.video,
435
+ )
436
+
437
+
438
+ def destroy_request(mountpoint_id: int, secret: str | None, permanent: bool) -> DestroyRequest:
439
+ return DestroyRequest(id=mountpoint_id, secret=secret, permanent=permanent)
440
+
441
+
442
+ def enable_request(mountpoint_id: int, secret: str | None) -> EnableRequest:
443
+ return EnableRequest(id=mountpoint_id, secret=secret)
444
+
445
+
446
+ def disable_request(mountpoint_id: int, secret: str | None, stop_recording: bool) -> DisableRequest:
447
+ return DisableRequest(id=mountpoint_id, secret=secret, stop_recording=stop_recording)
448
+
449
+
450
+ def kick_all_request(mountpoint_id: int, secret: str | None) -> KickAllRequest:
451
+ return KickAllRequest(id=mountpoint_id, secret=secret)
452
+
453
+
454
+ def edit_request(mountpoint_id: int, **kwargs: Any) -> EditRequest:
455
+ return EditRequest(id=mountpoint_id, **kwargs)
456
+
457
+
458
+ def watch_request(
459
+ mountpoint_id: int,
460
+ *,
461
+ pin: str | None,
462
+ media: list[str],
463
+ offer_audio: bool | None,
464
+ offer_video: bool | None,
465
+ offer_data: bool | None,
466
+ ) -> WatchRequest:
467
+ return WatchRequest(
468
+ id=mountpoint_id,
469
+ pin=pin,
470
+ media=media,
471
+ offer_audio=offer_audio,
472
+ offer_video=offer_video,
473
+ offer_data=offer_data,
474
+ )
475
+
476
+
477
+ def start_request() -> StartRequest:
478
+ return StartRequest()
479
+
480
+
481
+ def pause_request() -> PauseRequest:
482
+ return PauseRequest()
483
+
484
+
485
+ def stop_request() -> StopRequest:
486
+ return StopRequest()
487
+
488
+
489
+ def switch_request(mountpoint_id: int) -> SwitchRequest:
490
+ return SwitchRequest(id=mountpoint_id)
491
+
492
+
493
+ def configure_request(streams: list[ConfigureStream]) -> ConfigureRequest:
494
+ values = [
495
+ ProtocolConfigureStream(
496
+ mid=item.mid,
497
+ send=item.send,
498
+ substream=item.substream,
499
+ temporal=item.temporal,
500
+ fallback=item.fallback_microseconds,
501
+ spatial_layer=item.spatial_layer,
502
+ temporal_layer=item.temporal_layer,
503
+ min_delay=item.min_delay,
504
+ max_delay=item.max_delay,
505
+ )
506
+ for item in streams
507
+ ]
508
+ return ConfigureRequest(streams=values)
509
+
510
+
511
+ def answer_jsep(answer: SessionDescription) -> Jsep:
512
+ return Jsep(type="answer", sdp=answer.sdp)
513
+
514
+
515
+ def trickle_candidate(candidate: IceCandidate) -> TrickleCandidate:
516
+ return TrickleCandidate(
517
+ candidate=candidate.candidate,
518
+ sdpMid=candidate.sdp_mid,
519
+ sdpMLineIndex=candidate.sdp_mline_index,
520
+ )
521
+
522
+
523
+ def trickle_complete() -> TrickleCandidate:
524
+ return TrickleCandidate(completed=True)
525
+
526
+
527
+ def create_session_manager() -> Any:
528
+ """Create and globally register janus-api's default session manager."""
529
+
530
+ from janus_api.conf import Janus
531
+ from janus_api.servers import JanusSessionManager
532
+
533
+ manager = JanusSessionManager()
534
+ Janus.set_manager(manager)
535
+ return manager
536
+
537
+
538
+ class JanusStreamingHandle:
539
+ def __init__(self, plugin: Any, *, default_timeout: float) -> None:
540
+ if default_timeout <= 0:
541
+ raise ValueError("default_timeout must be greater than zero")
542
+ self._plugin = plugin
543
+ self._default_timeout = default_timeout
544
+ self._closed = False
545
+
546
+ @classmethod
547
+ async def attach(
548
+ cls,
549
+ session: Any,
550
+ *,
551
+ mountpoint_id: int | None = None,
552
+ admin_key: str | None = None,
553
+ attach_timeout: float = 5.0,
554
+ default_timeout: float = 10.0,
555
+ on_event: Callable[[Any], Any] | None = None,
556
+ ) -> JanusStreamingHandle:
557
+ if attach_timeout <= 0:
558
+ raise ValueError("attach_timeout must be greater than zero")
559
+ if default_timeout <= 0:
560
+ raise ValueError("default_timeout must be greater than zero")
561
+ # Mountpoint IDs and the admin key belong in Streaming request bodies,
562
+ # not in core handle construction. Keep these parameters for the
563
+ # stable high-level factory signature used by admin/viewer clients.
564
+ _ = mountpoint_id, admin_key
565
+ kwargs: dict[str, Any] = {"session": session}
566
+ if on_event is not None:
567
+ kwargs["on_event"] = on_event
568
+ try:
569
+ async with asyncio.timeout(attach_timeout):
570
+ plugin = await StreamingPlugin(**kwargs).attach()
571
+ except TimeoutError as exc:
572
+ raise StreamingTimeout("attach", attach_timeout) from exc
573
+ except Exception as exc:
574
+ raise StreamingBackendError("could not attach Janus Streaming plugin") from exc
575
+ return cls(plugin, default_timeout=default_timeout)
576
+
577
+ @property
578
+ def handle_id(self) -> str:
579
+ return str(self._plugin.id)
580
+
581
+ async def request(
582
+ self,
583
+ body: Any,
584
+ *,
585
+ operation: str,
586
+ jsep: Any | None = None,
587
+ timeout_seconds: float | None = None,
588
+ ) -> Any:
589
+ if self._closed:
590
+ raise StreamingBackendError("Janus Streaming handle is closed")
591
+ return await self._run(
592
+ operation,
593
+ self._plugin.send(body, jsep=jsep),
594
+ timeout_seconds=timeout_seconds,
595
+ )
596
+
597
+ async def trickle(
598
+ self,
599
+ candidates: list[Any],
600
+ *,
601
+ timeout_seconds: float | None = None,
602
+ ) -> Any:
603
+ if self._closed:
604
+ raise StreamingBackendError("Janus Streaming handle is closed")
605
+ return await self._run(
606
+ "trickle",
607
+ self._plugin.trickle(candidates),
608
+ timeout_seconds=timeout_seconds,
609
+ )
610
+
611
+ async def close(self) -> None:
612
+ if self._closed:
613
+ return
614
+ self._closed = True
615
+ try:
616
+ await self._run("detach", self._plugin.detach(), timeout_seconds=3.0)
617
+ except StreamingBackendError:
618
+ return
619
+
620
+ async def _run(
621
+ self,
622
+ operation: str,
623
+ awaitable: Awaitable[T],
624
+ *,
625
+ timeout_seconds: float | None,
626
+ ) -> T:
627
+ effective = timeout_seconds if timeout_seconds is not None else self._default_timeout
628
+ if effective <= 0:
629
+ raise ValueError("timeout_seconds must be greater than zero")
630
+ try:
631
+ async with asyncio.timeout(effective):
632
+ return await awaitable
633
+ except TimeoutError as exc:
634
+ raise StreamingTimeout(operation, effective) from exc
635
+ except JanusCommandError:
636
+ raise
637
+ except Exception as exc:
638
+ raise StreamingBackendError(f"Janus Streaming operation {operation!r} failed") from exc