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,489 @@
1
+ """Validated request bodies for :mod:`janus.plugin.streaming`.
2
+
3
+ These models intentionally live in the Streaming distribution rather than in
4
+ ``janus-api-core``. Core transports plugin bodies as opaque mappings; this
5
+ module owns the Streaming protocol vocabulary and its field-level validation.
6
+
7
+ The wire field names follow the current Janus multistream API. Deprecated
8
+ single-track create/configure fields are retained for compatibility, while new
9
+ code should prefer ``media`` and ``streams``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Annotated, Literal, TypedDict
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
17
+
18
+ type MediaKind = Literal["audio", "video", "data"]
19
+ type MountpointType = Literal["rtp", "live", "ondemand", "rtsp"]
20
+
21
+
22
+ class Track(TypedDict, total=False):
23
+ """Wire-level track options accepted by dynamic mountpoint creation."""
24
+
25
+ type: MediaKind
26
+ mid: str
27
+ msid: str | None
28
+ label: str | None
29
+ mcast: str | None
30
+ iface: str | None
31
+ port: int | None
32
+ rtcpport: int | None
33
+ pt: int | None
34
+ codec: str | None
35
+ fmtp: str | None
36
+ skew: bool
37
+ simulcast: bool
38
+ port2: int | None
39
+ port3: int | None
40
+ svc: bool
41
+ h264sps: str | None
42
+ collision: int | None
43
+ datatype: Literal["text", "binary"]
44
+ databuffermsg: bool
45
+
46
+
47
+ class AudioTrack(TypedDict, total=False):
48
+ type: Literal["audio"]
49
+ mid: str
50
+ msid: str | None
51
+ label: str | None
52
+ mcast: str | None
53
+ iface: str | None
54
+ port: int | None
55
+ rtcpport: int | None
56
+ pt: int | None
57
+ codec: str | None
58
+ fmtp: str | None
59
+ skew: bool
60
+
61
+
62
+ class VideoTrack(TypedDict, total=False):
63
+ type: Literal["video"]
64
+ mid: str
65
+ msid: str | None
66
+ label: str | None
67
+ mcast: str | None
68
+ iface: str | None
69
+ port: int | None
70
+ rtcpport: int | None
71
+ pt: int | None
72
+ codec: str | None
73
+ fmtp: str | None
74
+ skew: bool
75
+ simulcast: bool
76
+ port2: int | None
77
+ port3: int | None
78
+ svc: bool
79
+ h264sps: str | None
80
+ collision: int | None
81
+
82
+
83
+ class DataTrack(TypedDict, total=False):
84
+ type: Literal["data"]
85
+ mid: str
86
+ msid: str | None
87
+ label: str | None
88
+ mcast: str | None
89
+ iface: str | None
90
+ port: int | None
91
+ datatype: Literal["text", "binary"]
92
+ databuffermsg: bool
93
+
94
+
95
+ type MediaStream = AudioTrack | VideoTrack | DataTrack
96
+
97
+
98
+ class MountPoint(TypedDict, total=False):
99
+ """Keyword options accepted by :meth:`StreamingPlugin.create`.
100
+
101
+ Runtime validation is performed by :class:`CreateRequest`; this TypedDict
102
+ exists to keep the convenience helper discoverable without depending on a
103
+ core-owned named-plugin type.
104
+ """
105
+
106
+ type: MountpointType
107
+ id: int
108
+ name: str | None
109
+ description: str | None
110
+ metadata: str | None
111
+ secret: str | None
112
+ pin: str | None
113
+ is_private: bool
114
+ permanent: bool
115
+ enabled: bool
116
+ media: list[MediaStream | CreateMedia]
117
+ filename: str | None
118
+ audio: bool | None
119
+ video: bool | None
120
+ data: bool | None
121
+ audioport: int | None
122
+ audiortcpport: int | None
123
+ audiomcast: str | None
124
+ audioiface: str | None
125
+ audiopt: int | None
126
+ audiocodec: str | None
127
+ audiofmtp: str | None
128
+ audioskew: bool | None
129
+ videoport: int | None
130
+ videortcpport: int | None
131
+ videomcast: str | None
132
+ videoiface: str | None
133
+ videopt: int | None
134
+ videocodec: str | None
135
+ videofmtp: str | None
136
+ videosimulcast: bool | None
137
+ videoport2: int | None
138
+ videoport3: int | None
139
+ videoskew: bool | None
140
+ videosvc: bool | None
141
+ h264sps: str | None
142
+ collision: int | None
143
+ dataport: int | None
144
+ datamcast: str | None
145
+ dataiface: str | None
146
+ datatype: Literal["text", "binary"] | None
147
+ databuffermsg: bool | None
148
+ threads: int | None
149
+ bufferkf_ms: int | None
150
+ bufferkf_bytes: int | None
151
+ srtpsuite: Literal[32, 80] | None
152
+ srtpcrypto: str | None
153
+ e2ee: bool | None
154
+ playoutdelay_ext: bool | None
155
+ abscapturetime_src_ext_id: int | None
156
+ url: str | None
157
+ rtsp_user: str | None
158
+ rtsp_pwd: str | None
159
+ rtsp_quirk: bool | None
160
+ rtsp_failcheck: bool | None
161
+ rtspiface: str | None
162
+ rtsp_reconnect_delay: int | None
163
+ rtsp_session_timeout: int | None
164
+ rtsp_timeout: int | None
165
+ rtsp_conn_timeout: int | None
166
+ rtsp_notify_changes: bool | None
167
+
168
+
169
+ class StreamingRequestModel(BaseModel):
170
+ """Strict, immutable base for outbound Streaming request bodies."""
171
+
172
+ model_config = ConfigDict(
173
+ extra="forbid",
174
+ frozen=True,
175
+ populate_by_name=True,
176
+ str_strip_whitespace=True,
177
+ )
178
+
179
+
180
+ class CreateMediaBase(StreamingRequestModel):
181
+ """Fields shared by tracks in a multistream RTP mountpoint."""
182
+
183
+ type: MediaKind
184
+ mid: str = Field(min_length=1, max_length=64)
185
+ msid: str | None = None
186
+ label: str | None = None
187
+
188
+
189
+ class CreateAudioMedia(CreateMediaBase):
190
+ type: Literal["audio"] = "audio"
191
+ mcast: str | None = None
192
+ iface: str | None = None
193
+ port: int | None = Field(default=None, ge=1, le=65535)
194
+ rtcpport: int | None = Field(default=None, ge=1, le=65535)
195
+ pt: int | None = Field(default=None, ge=0, le=127)
196
+ codec: str | None = None
197
+ fmtp: str | None = None
198
+ skew: bool = False
199
+
200
+
201
+ class CreateVideoMedia(CreateMediaBase):
202
+ type: Literal["video"] = "video"
203
+ mcast: str | None = None
204
+ iface: str | None = None
205
+ port: int | None = Field(default=None, ge=1, le=65535)
206
+ rtcpport: int | None = Field(default=None, ge=1, le=65535)
207
+ pt: int | None = Field(default=None, ge=0, le=127)
208
+ codec: str | None = None
209
+ fmtp: str | None = None
210
+ skew: bool = False
211
+ simulcast: bool = False
212
+ port2: int | None = Field(default=None, ge=1, le=65535)
213
+ port3: int | None = Field(default=None, ge=1, le=65535)
214
+ svc: bool = False
215
+ h264sps: str | None = None
216
+ collision: int | None = Field(default=None, ge=0)
217
+
218
+
219
+ class CreateDataMedia(CreateMediaBase):
220
+ type: Literal["data"] = "data"
221
+ mcast: str | None = None
222
+ iface: str | None = None
223
+ port: int | None = Field(default=None, ge=1, le=65535)
224
+ datatype: Literal["text", "binary"] = "text"
225
+ databuffermsg: bool = False
226
+
227
+
228
+ type CreateMedia = Annotated[
229
+ CreateAudioMedia | CreateVideoMedia | CreateDataMedia,
230
+ Field(discriminator="type"),
231
+ ]
232
+
233
+
234
+ class RecordingMedia(StreamingRequestModel):
235
+ """Per-track recording selection for the modern recording API."""
236
+
237
+ mid: str = Field(min_length=1, max_length=64)
238
+ filename: str | None = None
239
+
240
+
241
+ class ConfigureStream(StreamingRequestModel):
242
+ """One media-line update in a ``configure`` request."""
243
+
244
+ mid: str = Field(min_length=1, max_length=64)
245
+ send: bool | None = None
246
+ substream: int | None = Field(default=None, ge=0, le=2)
247
+ temporal: int | None = Field(default=None, ge=0, le=2)
248
+ fallback: int | None = Field(default=250_000, ge=0, description="Microseconds")
249
+ spatial_layer: int | None = Field(default=None, ge=0, le=1)
250
+ temporal_layer: int | None = Field(default=None, ge=0, le=2)
251
+ min_delay: int | None = Field(default=None, ge=0)
252
+ max_delay: int | None = Field(default=None, ge=0)
253
+
254
+ @model_validator(mode="after")
255
+ def validate_delay_range(self) -> ConfigureStream:
256
+ if (
257
+ self.min_delay is not None
258
+ and self.max_delay is not None
259
+ and self.min_delay > self.max_delay
260
+ ):
261
+ raise ValueError("min_delay must not exceed max_delay")
262
+ return self
263
+
264
+
265
+ class ListRequest(StreamingRequestModel):
266
+ request: Literal["list"] = "list"
267
+
268
+
269
+ class InfoRequest(StreamingRequestModel):
270
+ request: Literal["info"] = "info"
271
+ id: int = Field(ge=0)
272
+ secret: str | None = Field(default=None, repr=False)
273
+
274
+
275
+ class CreateRequest(StreamingRequestModel):
276
+ """Create an RTP, RTSP, live-file, or on-demand mountpoint."""
277
+
278
+ request: Literal["create"] = "create"
279
+ admin_key: str | None = Field(default=None, repr=False)
280
+ type: MountpointType
281
+ id: int | None = Field(default=None, ge=0)
282
+ name: str | None = None
283
+ description: str | None = None
284
+ metadata: str | None = None
285
+ secret: str | None = Field(default=None, repr=False)
286
+ pin: str | None = Field(default=None, repr=False)
287
+ is_private: bool = True
288
+ permanent: bool = False
289
+ enabled: bool = False
290
+ media: list[CreateMedia] = Field(default_factory=list)
291
+
292
+ # File mountpoints.
293
+ filename: str | None = None
294
+ audio: bool | None = None
295
+ video: bool | None = None
296
+ data: bool | None = None
297
+
298
+ # Legacy single-track RTP syntax.
299
+ audioport: int | None = Field(default=None, ge=1, le=65535)
300
+ audiortcpport: int | None = Field(default=None, ge=1, le=65535)
301
+ audiomcast: str | None = None
302
+ audioiface: str | None = None
303
+ audiopt: int | None = Field(default=None, ge=0, le=127)
304
+ audiocodec: str | None = None
305
+ audiofmtp: str | None = None
306
+ audioskew: bool | None = None
307
+ videoport: int | None = Field(default=None, ge=1, le=65535)
308
+ videortcpport: int | None = Field(default=None, ge=1, le=65535)
309
+ videomcast: str | None = None
310
+ videoiface: str | None = None
311
+ videopt: int | None = Field(default=None, ge=0, le=127)
312
+ videocodec: str | None = None
313
+ videofmtp: str | None = None
314
+ videosimulcast: bool | None = None
315
+ videoport2: int | None = Field(default=None, ge=1, le=65535)
316
+ videoport3: int | None = Field(default=None, ge=1, le=65535)
317
+ videoskew: bool | None = None
318
+ videosvc: bool | None = None
319
+ h264sps: str | None = None
320
+ collision: int | None = Field(default=None, ge=0)
321
+ dataport: int | None = Field(default=None, ge=1, le=65535)
322
+ datamcast: str | None = None
323
+ dataiface: str | None = None
324
+ datatype: Literal["text", "binary"] | None = None
325
+ databuffermsg: bool | None = None
326
+
327
+ threads: int | None = Field(default=None, ge=0)
328
+ bufferkf_ms: int | None = Field(default=None, ge=0)
329
+ bufferkf_bytes: int | None = Field(default=None, ge=0)
330
+ srtpsuite: Literal[32, 80] | None = None
331
+ srtpcrypto: str | None = Field(default=None, repr=False)
332
+ e2ee: bool | None = None
333
+ playoutdelay_ext: bool | None = None
334
+ abscapturetime_src_ext_id: int | None = Field(default=None, ge=1, le=14)
335
+
336
+ # RTSP mountpoints.
337
+ url: str | None = None
338
+ rtsp_user: str | None = None
339
+ rtsp_pwd: str | None = Field(default=None, repr=False)
340
+ rtsp_quirk: bool | None = None
341
+ rtsp_failcheck: bool | None = None
342
+ rtspiface: str | None = None
343
+ rtsp_reconnect_delay: int | None = Field(default=None, ge=0)
344
+ rtsp_session_timeout: int | None = Field(default=None, ge=0)
345
+ rtsp_timeout: int | None = Field(default=None, ge=0)
346
+ rtsp_conn_timeout: int | None = Field(default=None, ge=0)
347
+ rtsp_notify_changes: bool | None = None
348
+
349
+ @model_validator(mode="after")
350
+ def validate_srtp_pair(self) -> CreateRequest:
351
+ if (self.srtpsuite is None) != (self.srtpcrypto is None):
352
+ raise ValueError("srtpsuite and srtpcrypto must be provided together")
353
+ return self
354
+
355
+
356
+ class DestroyRequest(StreamingRequestModel):
357
+ request: Literal["destroy"] = "destroy"
358
+ id: int = Field(ge=0)
359
+ secret: str | None = Field(default=None, repr=False)
360
+ permanent: bool = False
361
+
362
+
363
+ class EditRequest(StreamingRequestModel):
364
+ request: Literal["edit"] = "edit"
365
+ id: int = Field(ge=0)
366
+ secret: str | None = Field(default=None, repr=False)
367
+ new_description: str | None = None
368
+ new_metadata: str | None = None
369
+ new_secret: str | None = Field(default=None, repr=False)
370
+ new_pin: str | None = Field(default=None, repr=False)
371
+ new_is_private: bool | None = None
372
+ permanent: bool = False
373
+ edited_event: bool = False
374
+
375
+
376
+ class EnableRequest(StreamingRequestModel):
377
+ request: Literal["enable"] = "enable"
378
+ id: int = Field(ge=0)
379
+ secret: str | None = Field(default=None, repr=False)
380
+
381
+
382
+ class DisableRequest(StreamingRequestModel):
383
+ request: Literal["disable"] = "disable"
384
+ id: int = Field(ge=0)
385
+ stop_recording: bool = True
386
+ secret: str | None = Field(default=None, repr=False)
387
+
388
+
389
+ class KickAllRequest(StreamingRequestModel):
390
+ request: Literal["kick_all"] = "kick_all"
391
+ id: int = Field(ge=0)
392
+ secret: str | None = Field(default=None, repr=False)
393
+
394
+
395
+ class RecordingRequest(StreamingRequestModel):
396
+ request: Literal["recording"] = "recording"
397
+ action: Literal["start", "stop"]
398
+ id: int = Field(ge=0)
399
+ media: list[RecordingMedia] = Field(default_factory=list)
400
+
401
+
402
+ class WatchRequest(StreamingRequestModel):
403
+ request: Literal["watch"] = "watch"
404
+ id: int = Field(ge=0)
405
+ pin: str | None = Field(default=None, repr=False)
406
+ media: list[str] = Field(default_factory=list)
407
+ offer_audio: bool | None = None
408
+ offer_video: bool | None = None
409
+ offer_data: bool | None = None
410
+
411
+
412
+ class StartRequest(StreamingRequestModel):
413
+ request: Literal["start"] = "start"
414
+
415
+
416
+ class PauseRequest(StreamingRequestModel):
417
+ request: Literal["pause"] = "pause"
418
+
419
+
420
+ class ConfigureRequest(StreamingRequestModel):
421
+ request: Literal["configure"] = "configure"
422
+ streams: list[ConfigureStream] = Field(default_factory=list)
423
+ audio: bool | None = None
424
+ video: bool | None = None
425
+ data: bool | None = None
426
+
427
+
428
+ class SwitchRequest(StreamingRequestModel):
429
+ request: Literal["switch"] = "switch"
430
+ id: int = Field(ge=0)
431
+
432
+
433
+ class StopRequest(StreamingRequestModel):
434
+ request: Literal["stop"] = "stop"
435
+
436
+
437
+ type StreamingRequest = (
438
+ ListRequest
439
+ | InfoRequest
440
+ | CreateRequest
441
+ | DestroyRequest
442
+ | EditRequest
443
+ | EnableRequest
444
+ | DisableRequest
445
+ | KickAllRequest
446
+ | RecordingRequest
447
+ | WatchRequest
448
+ | StartRequest
449
+ | PauseRequest
450
+ | ConfigureRequest
451
+ | SwitchRequest
452
+ | StopRequest
453
+ )
454
+
455
+
456
+ __all__ = (
457
+ "AudioTrack",
458
+ "ConfigureRequest",
459
+ "ConfigureStream",
460
+ "CreateAudioMedia",
461
+ "CreateDataMedia",
462
+ "CreateMedia",
463
+ "CreateMediaBase",
464
+ "CreateRequest",
465
+ "CreateVideoMedia",
466
+ "DataTrack",
467
+ "DestroyRequest",
468
+ "DisableRequest",
469
+ "EditRequest",
470
+ "EnableRequest",
471
+ "InfoRequest",
472
+ "KickAllRequest",
473
+ "ListRequest",
474
+ "MediaKind",
475
+ "MediaStream",
476
+ "MountPoint",
477
+ "MountpointType",
478
+ "PauseRequest",
479
+ "RecordingMedia",
480
+ "RecordingRequest",
481
+ "StartRequest",
482
+ "StopRequest",
483
+ "StreamingRequest",
484
+ "StreamingRequestModel",
485
+ "SwitchRequest",
486
+ "Track",
487
+ "VideoTrack",
488
+ "WatchRequest",
489
+ )
@@ -0,0 +1,189 @@
1
+ """Forward-compatible response bodies for :mod:`janus.plugin.streaming`.
2
+
3
+ Janus wraps these bodies in its generic plugin-data envelope. The named
4
+ response vocabulary belongs here, while ``janus-api-core`` remains responsible
5
+ only for parsing and transporting the outer Janus envelope.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from .models import SessionDescription
15
+ from .requests import MediaKind, MountpointType
16
+
17
+
18
+ class StreamingResponseModel(BaseModel):
19
+ """Base that tolerates fields introduced by newer Janus releases."""
20
+
21
+ model_config = ConfigDict(
22
+ extra="allow",
23
+ frozen=True,
24
+ populate_by_name=True,
25
+ str_strip_whitespace=True,
26
+ )
27
+
28
+
29
+ class MediaSummary(StreamingResponseModel):
30
+ mid: str
31
+ label: str | None = None
32
+ msid: str | None = None
33
+ type: MediaKind
34
+ age_ms: int | None = Field(default=None, ge=0)
35
+
36
+
37
+ class MediaInfo(MediaSummary):
38
+ mindex: int | None = Field(default=None, ge=0)
39
+ pt: int | None = Field(default=None, ge=0, le=127)
40
+ codec: str | None = None
41
+ rtpmap: str | None = None
42
+ fmtp: str | None = None
43
+ port: int | None = Field(default=None, ge=1, le=65535)
44
+ rtp_port: int | None = Field(default=None, ge=1, le=65535)
45
+
46
+
47
+ class MountPointSummary(StreamingResponseModel):
48
+ id: int = Field(ge=0)
49
+ type: MountpointType
50
+ description: str | None = None
51
+ metadata: str | None = None
52
+ enabled: bool | None = None
53
+ media: list[MediaSummary] = Field(default_factory=list)
54
+
55
+
56
+ class MountPointInfo(StreamingResponseModel):
57
+ id: int = Field(ge=0)
58
+ name: str | None = None
59
+ description: str | None = None
60
+ metadata: str | None = None
61
+ secret: str | None = Field(default=None, repr=False)
62
+ pin: str | None = Field(default=None, repr=False)
63
+ is_private: bool | None = None
64
+ viewers: int | None = Field(default=None, ge=0)
65
+ enabled: bool | None = None
66
+ type: MountpointType
67
+ media: list[MediaInfo] = Field(default_factory=list)
68
+
69
+
70
+ class CreatedPort(StreamingResponseModel):
71
+ type: MediaKind
72
+ mid: str
73
+ msid: str | None = None
74
+ port: int = Field(ge=1, le=65535)
75
+
76
+
77
+ class CreatedMountPoint(StreamingResponseModel):
78
+ id: int = Field(ge=0)
79
+ type: MountpointType
80
+ description: str | None = None
81
+ is_private: bool | None = None
82
+ ports: list[CreatedPort] = Field(default_factory=list)
83
+
84
+
85
+ class ListResponse(StreamingResponseModel):
86
+ streaming: Literal["list"] = "list"
87
+ mountpoints: list[MountPointSummary] = Field(alias="list", default_factory=list)
88
+
89
+
90
+ class InfoResponse(StreamingResponseModel):
91
+ streaming: Literal["info"] = "info"
92
+ mountpoint: MountPointInfo = Field(alias="info")
93
+
94
+
95
+ class CreatedResponse(StreamingResponseModel):
96
+ streaming: Literal["created"] = "created"
97
+ create: str
98
+ permanent: bool = False
99
+ stream: CreatedMountPoint
100
+
101
+
102
+ class EditedResponse(StreamingResponseModel):
103
+ streaming: Literal["edited"] = "edited"
104
+ id: int = Field(ge=0)
105
+ permanent: bool | None = None
106
+ metadata: str | None = None
107
+
108
+
109
+ class DestroyedResponse(StreamingResponseModel):
110
+ streaming: Literal["destroyed"] = "destroyed"
111
+ id: int = Field(ge=0)
112
+
113
+
114
+ class OkResponse(StreamingResponseModel):
115
+ streaming: Literal["ok"] = "ok"
116
+
117
+
118
+ class KickedAllResponse(StreamingResponseModel):
119
+ streaming: Literal["kicked_all"] = "kicked_all"
120
+
121
+
122
+ class PreparingResponse(StreamingResponseModel):
123
+ status: Literal["preparing"] = "preparing"
124
+ jsep: SessionDescription | None = None
125
+
126
+
127
+ class StartingResponse(StreamingResponseModel):
128
+ status: Literal["starting"] = "starting"
129
+ jsep: SessionDescription | None = None
130
+
131
+
132
+ class PausingResponse(StreamingResponseModel):
133
+ status: Literal["pausing"] = "pausing"
134
+
135
+
136
+ class StoppingResponse(StreamingResponseModel):
137
+ status: Literal["stopping"] = "stopping"
138
+
139
+
140
+ class SwitchedResponse(StreamingResponseModel):
141
+ switched: Literal["ok"] = "ok"
142
+ id: int = Field(ge=0)
143
+
144
+
145
+ class StreamingErrorResponse(StreamingResponseModel):
146
+ error_code: int | None = None
147
+ error: str
148
+
149
+
150
+ type StreamingResponse = (
151
+ ListResponse
152
+ | InfoResponse
153
+ | CreatedResponse
154
+ | EditedResponse
155
+ | DestroyedResponse
156
+ | OkResponse
157
+ | KickedAllResponse
158
+ | PreparingResponse
159
+ | StartingResponse
160
+ | PausingResponse
161
+ | StoppingResponse
162
+ | SwitchedResponse
163
+ | StreamingErrorResponse
164
+ )
165
+
166
+
167
+ __all__ = (
168
+ "CreatedMountPoint",
169
+ "CreatedPort",
170
+ "CreatedResponse",
171
+ "DestroyedResponse",
172
+ "EditedResponse",
173
+ "InfoResponse",
174
+ "KickedAllResponse",
175
+ "ListResponse",
176
+ "MediaInfo",
177
+ "MediaSummary",
178
+ "MountPointInfo",
179
+ "MountPointSummary",
180
+ "OkResponse",
181
+ "PausingResponse",
182
+ "PreparingResponse",
183
+ "StartingResponse",
184
+ "StoppingResponse",
185
+ "StreamingErrorResponse",
186
+ "StreamingResponse",
187
+ "StreamingResponseModel",
188
+ "SwitchedResponse",
189
+ )