vocalstack 1.0.0a0__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,835 @@
1
+ """The resource groups of :class:`vocalstack.VocalStack`, one per tag of
2
+ the API document (``/v1/openapi.json``)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import IO, TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, TypeVar, Union
11
+
12
+ import websockets
13
+ from pydantic import BaseModel
14
+
15
+ from . import _models as m
16
+ from ._http import Transport, listener_query
17
+ from .errors import VocalStackError
18
+
19
+ if TYPE_CHECKING:
20
+ from .realtime import RealtimeSession
21
+
22
+ T = TypeVar("T", bound=BaseModel)
23
+
24
+ #: A file to upload: a path, bytes, or an open binary file.
25
+ Upload = Union[str, "os.PathLike[str]", bytes, bytearray, IO[bytes]]
26
+
27
+
28
+ def _body(model: Type[BaseModel], fields: Dict[str, Any]) -> Dict[str, Any]:
29
+ """Validate keyword arguments (snake_case or the wire's camelCase)
30
+ against a request model and dump them as the wire expects."""
31
+ return model.model_validate(fields).model_dump(by_alias=True, exclude_none=True, mode="json")
32
+
33
+
34
+ def _one(model: Type[T], data: Any) -> T:
35
+ return model.model_validate(data)
36
+
37
+
38
+ def _many(model: Type[T], data: Any, key: str) -> List[T]:
39
+ return [model.model_validate(x) for x in ((data or {}).get(key) or [])]
40
+
41
+
42
+ async def _all_pages(page: Callable[[Optional[str]], Any], key: str) -> List[Any]:
43
+ """Every item of a paged list: each page asked for with the previous
44
+ page's ``nextCursor`` until one comes back without it."""
45
+ out: List[Any] = []
46
+ cursor: Optional[str] = None
47
+ while True:
48
+ data = (await page(cursor)) or {}
49
+ out.extend(data.get(key) or [])
50
+ cursor = data.get("nextCursor")
51
+ if not cursor:
52
+ return out
53
+
54
+
55
+ def _file_part(file: Upload, file_name: Optional[str], default: str) -> tuple:
56
+ if isinstance(file, (bytes, bytearray)):
57
+ return (file_name or default, bytes(file))
58
+ if isinstance(file, (str, os.PathLike)):
59
+ path = os.fspath(file)
60
+ with open(path, "rb") as fh:
61
+ return (file_name or os.path.basename(path), fh.read())
62
+ name = file_name or os.path.basename(getattr(file, "name", "") or "") or default
63
+ return (name, file.read())
64
+
65
+
66
+ def _p(segment: Any) -> str:
67
+ from urllib.parse import quote
68
+
69
+ return quote(str(segment), safe="")
70
+
71
+
72
+ class _Group:
73
+ def __init__(self, t: Transport) -> None:
74
+ self._t = t
75
+
76
+
77
+ class _ParentResource(_Group):
78
+ """What broadcasts and transcriptions share: recordings, share links."""
79
+
80
+ _base = ""
81
+ #: The list's key: what it holds (``broadcasts`` or ``transcriptions``).
82
+ _key = ""
83
+ _model: Type[Union[m.Broadcast, m.Transcription]] = m.Broadcast
84
+ _update: Type[Union[m.UpdateBroadcast, m.UpdateTranscription]] = m.UpdateBroadcast
85
+
86
+ def _path(self, id: str, rest: str = "") -> str:
87
+ return f"{self._base}/{_p(id)}{rest}"
88
+
89
+ async def list(self, lifecycle: str = "active") -> List[Any]:
90
+ """Every one, newest first: ``lifecycle`` is ``active``, ``archived``,
91
+ or ``all`` (both in one list). A paged list is read to its last page
92
+ (``transcriptions.list_page`` reads one)."""
93
+ items = await _all_pages(lambda c: self._t.json("GET", self._base, query={"lifecycle": lifecycle, "cursor": c}), self._key)
94
+ return [self._model.model_validate(x) for x in items]
95
+
96
+ async def get(self, id: str) -> Any:
97
+ return _one(self._model, await self._t.json("GET", self._path(id)))
98
+
99
+ async def update(self, id: str, **fields: Any) -> Any:
100
+ """Rename, or change the default settings (a broadcast: also its
101
+ access, password or link, ``code``)."""
102
+ body = _body(self._update, fields)
103
+ return _one(self._model, await self._t.json("PATCH", self._path(id), json_body=body))
104
+
105
+ async def archive(self, id: str) -> Any:
106
+ return _one(self._model, await self._t.json("POST", self._path(id, "/archive")))
107
+
108
+ async def restore(self, id: str) -> Any:
109
+ return _one(self._model, await self._t.json("POST", self._path(id, "/restore")))
110
+
111
+ async def purge(self, id: str) -> None:
112
+ """Delete an archived one and everything in it, for good."""
113
+ await self._t.json("POST", self._path(id, "/purge"))
114
+
115
+ async def list_recordings(self, id: str, lifecycle: str = "active") -> List[m.Recording]:
116
+ """Every recording, newest first (every page of ``list_recordings_page``)."""
117
+ items = await _all_pages(
118
+ lambda c: self._t.json("GET", self._path(id, "/recordings"), query={"lifecycle": lifecycle, "cursor": c}), "recordings"
119
+ )
120
+ return [m.Recording.model_validate(x) for x in items]
121
+
122
+ async def list_recordings_page(
123
+ self, id: str, *, lifecycle: str = "active", cursor: Optional[str] = None, limit: Optional[int] = None
124
+ ) -> m.RecordingPage:
125
+ """One page of recordings, newest first; ``cursor`` is the previous page's ``next_cursor``."""
126
+ data = await self._t.json("GET", self._path(id, "/recordings"), query={"lifecycle": lifecycle, "cursor": cursor, "limit": limit})
127
+ return _one(m.RecordingPage, data)
128
+
129
+ async def start_recording(
130
+ self, id: str, *, idempotency_key: Optional[str] = None, **fields: Any
131
+ ) -> m.PublishGrant:
132
+ """Start a live recording. The grant's ``stream_url`` is the publisher
133
+ socket (``vs.recordings.publish(grant)``). A 409 ``conflict`` with
134
+ ``details["takeover"]`` means another device is recording: pass
135
+ ``takeover=True`` to record here instead."""
136
+ body = _body(m.StartRecording, fields)
137
+ data = await self._t.json(
138
+ "POST", self._path(id, "/recordings"), json_body=body, idempotency_key=idempotency_key
139
+ )
140
+ return _one(m.PublishGrant, data)
141
+
142
+ async def list_share_links(self, id: str) -> List[m.ShareLink]:
143
+ return _many(m.ShareLink, await self._t.json("GET", self._path(id, "/share-links")), "links")
144
+
145
+ async def create_share_link(self, id: str, **fields: Any) -> m.ShareLink:
146
+ body = _body(m.CreateShareLink, fields)
147
+ return _one(m.ShareLink, await self._t.json("POST", self._path(id, "/share-links"), json_body=body))
148
+
149
+ async def events(
150
+ self, id: str, *, listener_token: Optional[str] = None, share_code: Optional[str] = None
151
+ ) -> Any:
152
+ """The events socket a waiting screen needs (``recording.started``:
153
+ it is live, ``recording.ended``, ``job.progress``,
154
+ ``stream.ingest``; each names the transcription the recording is
155
+ made in, ``transcriptionId``): a ``websockets`` connection yielding
156
+ JSON text."""
157
+ url = self._t.ws_url(self._path(id, "/events"), listener_query(listener_token, share_code))
158
+ return await websockets.connect(url, additional_headers=self._t.auth_headers())
159
+
160
+
161
+ class Broadcasts(_ParentResource):
162
+ """``vs.broadcasts``: the links listeners open."""
163
+
164
+ _base = "/v1/broadcasts"
165
+ _key = "broadcasts"
166
+ _model = m.Broadcast
167
+ _update = m.UpdateBroadcast
168
+
169
+ async def create(self, *, idempotency_key: Optional[str] = None, **fields: Any) -> m.Broadcast:
170
+ body = _body(m.CreateBroadcast, fields)
171
+ data = await self._t.json("POST", self._base, json_body=body, idempotency_key=idempotency_key)
172
+ return _one(m.Broadcast, data)
173
+
174
+ async def add_audio_source(self, id: str, **fields: Any) -> m.Broadcast:
175
+ """A stream the broadcast can record from (a YouTube live, a radio address)."""
176
+ body = _body(m.CreateAudioSource, fields)
177
+ return _one(m.Broadcast, await self._t.json("POST", self._path(id, "/audio-sources"), json_body=body))
178
+
179
+ async def remove_audio_source(self, id: str, source_id: str) -> m.Broadcast:
180
+ data = await self._t.json("DELETE", self._path(id, f"/audio-sources/{_p(source_id)}"))
181
+ return _one(m.Broadcast, data)
182
+
183
+ async def check_audio_source(self, id: str, source_id: str, *, fresh: bool = False) -> m.AudioSourceCheck:
184
+ data = await self._t.json(
185
+ "GET",
186
+ self._path(id, f"/audio-sources/{_p(source_id)}/check"),
187
+ query={"fresh": True if fresh else None},
188
+ )
189
+ return _one(m.AudioSourceCheck, data)
190
+
191
+
192
+ class Transcriptions(_ParentResource):
193
+ """``vs.transcriptions``: a file, a link, a microphone or a stream, transcribed and stored."""
194
+
195
+ _base = "/v1/transcriptions"
196
+ _key = "transcriptions"
197
+ _model = m.Transcription
198
+ _update = m.UpdateTranscription
199
+
200
+ async def list_page(
201
+ self, *, lifecycle: str = "active", cursor: Optional[str] = None, limit: Optional[int] = None
202
+ ) -> m.TranscriptionPage:
203
+ """One page of transcriptions, newest first (``limit`` 1 to 200, 100
204
+ by default); ``cursor`` is the previous page's ``next_cursor``, and
205
+ the last page has none."""
206
+ data = await self._t.json("GET", self._base, query={"lifecycle": lifecycle, "cursor": cursor, "limit": limit})
207
+ return _one(m.TranscriptionPage, data)
208
+
209
+ async def upload_recording(
210
+ self, id: str, *, idempotency_key: Optional[str] = None, **fields: Any
211
+ ) -> m.PublishGrant:
212
+ """A file or link recording in this transcription: no live phase,
213
+ transcribed at full speed. It has no job: open the grant's socket
214
+ with ``vs.recordings.publish(grant)`` (with ``url`` the server
215
+ fetches the link) and read it until the session is done."""
216
+ body = _body(m.UploadRecording, fields)
217
+ data = await self._t.json(
218
+ "POST", self._path(id, "/recordings/upload"), json_body=body, idempotency_key=idempotency_key
219
+ )
220
+ return _one(m.PublishGrant, data)
221
+
222
+ async def create(
223
+ self,
224
+ *,
225
+ file: Optional[Upload] = None,
226
+ file_name: Optional[str] = None,
227
+ idempotency_key: Optional[str] = None,
228
+ **fields: Any,
229
+ ) -> m.Transcription:
230
+ """From a file (``file``: a path, bytes or an open binary file) or a
231
+ public link (``url=...``). The answer's ``transcription.recording_id``
232
+ is the job: ``await vs.jobs.wait(recording_id)``, then
233
+ ``await vs.recordings.transcript(recording_id)``."""
234
+ options = _body(m.CreateTranscription, fields)
235
+ if file is None:
236
+ data = await self._t.json("POST", self._base, json_body=options, idempotency_key=idempotency_key)
237
+ else:
238
+ part = await asyncio.to_thread(_file_part, file, file_name, "upload")
239
+ data = await self._t.json(
240
+ "POST",
241
+ self._base,
242
+ data={"options": json.dumps(options)},
243
+ files={"file": part},
244
+ idempotency_key=idempotency_key,
245
+ )
246
+ return _one(m.Transcription, data)
247
+
248
+ async def create_from_url(self, url: str, **fields: Any) -> m.Transcription:
249
+ """``create(url=url, ...)``."""
250
+ return await self.create(url=url, **fields)
251
+
252
+ async def create_live(self, *, idempotency_key: Optional[str] = None, **fields: Any) -> m.Transcription:
253
+ """Captured as it is spoken (a microphone, or a stream the server
254
+ pulls). Created idle: ``start_recording(id)`` starts it."""
255
+ body = _body(m.CreateLiveTranscription, fields)
256
+ data = await self._t.json("POST", f"{self._base}/live", json_body=body, idempotency_key=idempotency_key)
257
+ return _one(m.Transcription, data)
258
+
259
+ async def probe(self, url: str) -> m.LinkProbe:
260
+ """Whether a link is live (a live transcription) or a file."""
261
+ return _one(m.LinkProbe, await self._t.json("GET", f"{self._base}/probe", query={"url": url}))
262
+
263
+ async def attach_broadcast(self, id: str, broadcast_id: str) -> m.Transcription:
264
+ data = await self._t.json("PUT", self._path(id, f"/broadcasts/{_p(broadcast_id)}"))
265
+ return _one(m.Transcription, data)
266
+
267
+ async def detach_broadcast(self, id: str, broadcast_id: str) -> m.Transcription:
268
+ data = await self._t.json("DELETE", self._path(id, f"/broadcasts/{_p(broadcast_id)}"))
269
+ return _one(m.Transcription, data)
270
+
271
+
272
+ class Recordings(_Group):
273
+ """``vs.recordings``: one take, live or uploaded."""
274
+
275
+ def __init__(self, t: Transport, ready_deadline_s: float) -> None:
276
+ super().__init__(t)
277
+ self._ready_deadline_s = ready_deadline_s
278
+
279
+ @staticmethod
280
+ def _path(id: str, rest: str = "") -> str:
281
+ return f"/v1/recordings/{_p(id)}{rest}"
282
+
283
+ async def get(
284
+ self, id: str, *, listener_token: Optional[str] = None, share_code: Optional[str] = None
285
+ ) -> m.Recording:
286
+ return _one(m.Recording, await self._t.json("GET", self._path(id), query=listener_query(listener_token, share_code)))
287
+
288
+ async def pause(self, id: str) -> m.Recording:
289
+ return _one(m.Recording, await self._t.json("POST", self._path(id, "/pause")))
290
+
291
+ async def resume(self, id: str) -> m.Recording:
292
+ return _one(m.Recording, await self._t.json("POST", self._path(id, "/resume")))
293
+
294
+ async def stop(self, id: str, reason: Optional[str] = None) -> m.Recording:
295
+ """Only from paused."""
296
+ data = await self._t.json("POST", self._path(id, "/stop"), json_body={"reason": reason})
297
+ return _one(m.Recording, data)
298
+
299
+ async def settings(self, id: str, **fields: Any) -> m.Recording:
300
+ body = _body(m.SettingsPatch, fields)
301
+ return _one(m.Recording, await self._t.json("PATCH", self._path(id, "/settings"), json_body=body))
302
+
303
+ async def publish_token(self, id: str, *, takeover: bool = False) -> m.PublishGrant:
304
+ data = await self._t.json("POST", self._path(id, "/publish-token"), json_body={"takeover": takeover})
305
+ return _one(m.PublishGrant, data)
306
+
307
+ async def publish(
308
+ self,
309
+ grant: m.PublishGrant,
310
+ *,
311
+ ready_deadline_s: Optional[float] = None,
312
+ on_frame: Optional[Callable[[Any], None]] = None,
313
+ ) -> "RealtimeSession":
314
+ """Open the publisher socket a grant names and wait until it is
315
+ ready: ``send_audio`` raw PCM (f32le, 16 kHz, mono), end with ``eof()``.
316
+ ``on_frame`` sees every server frame, lifecycle included."""
317
+ from .realtime import RealtimeSession
318
+
319
+ if not grant.stream_url:
320
+ raise VocalStackError("invalid_state", "this grant has no stream_url (an upload has no publisher socket)")
321
+ return await RealtimeSession.connect(
322
+ grant.session_id or grant.recording.id,
323
+ grant.stream_url,
324
+ ready_deadline_s=ready_deadline_s or self._ready_deadline_s,
325
+ on_frame=on_frame,
326
+ )
327
+
328
+ async def archive(self, id: str) -> m.Recording:
329
+ return _one(m.Recording, await self._t.json("POST", self._path(id, "/archive")))
330
+
331
+ async def restore(self, id: str) -> m.Recording:
332
+ return _one(m.Recording, await self._t.json("POST", self._path(id, "/restore")))
333
+
334
+ async def purge(self, id: str) -> None:
335
+ await self._t.json("POST", self._path(id, "/purge"))
336
+
337
+ async def transcript(
338
+ self,
339
+ id: str,
340
+ *,
341
+ after: Optional[int] = None,
342
+ lang: Optional[str] = None,
343
+ listener_token: Optional[str] = None,
344
+ share_code: Optional[str] = None,
345
+ ) -> m.TranscriptSnapshot:
346
+ """Segments (with word timings), sentences and speakers; ``lang`` reads it translated."""
347
+ query = {**listener_query(listener_token, share_code), "after": after, "lang": lang}
348
+ data = await self._t.json("GET", self._path(id, "/transcript"), query=query)
349
+ return _one(m.TranscriptSnapshot, data)
350
+
351
+ async def translation(
352
+ self,
353
+ id: str,
354
+ lang: str,
355
+ sentences: Optional[List[int]] = None,
356
+ *,
357
+ listener_token: Optional[str] = None,
358
+ share_code: Optional[str] = None,
359
+ ) -> Dict[str, Any]:
360
+ """Translate the given sentence ids (at most 40): ``{"lang", "translations"}``."""
361
+ query = {**listener_query(listener_token, share_code), "lang": lang, "sentences": ",".join(map(str, sentences)) if sentences else None}
362
+ return await self._t.json("GET", self._path(id, "/translation"), query=query)
363
+
364
+ async def summarize(self, id: str, *, idempotency_key: Optional[str] = None) -> m.SummaryStatus:
365
+ data = await self._t.json("POST", self._path(id, "/summarize"), idempotency_key=idempotency_key)
366
+ return _one(m.SummaryStatus, data)
367
+
368
+ async def summary(
369
+ self, id: str, *, lang: Optional[str] = None, listener_token: Optional[str] = None, share_code: Optional[str] = None
370
+ ) -> m.SummaryStatus:
371
+ query = {**listener_query(listener_token, share_code), "lang": lang}
372
+ data = await self._t.json("GET", self._path(id, "/summary"), query=query)
373
+ return _one(m.SummaryStatus, data)
374
+
375
+ async def export(
376
+ self, id: str, format: str, *, lang: Optional[str] = None, share_code: Optional[str] = None
377
+ ) -> bytes:
378
+ """An export file: ``json``, ``srt``, ``xlsx`` or ``pdf``; ``lang`` makes it read in that language."""
379
+ resp = await self._t.raw(
380
+ "GET", self._path(id, "/export"), headers={"accept": "*/*"}, query={"format": format, "lang": lang, "shareCode": share_code}
381
+ )
382
+ return resp.content
383
+
384
+ async def audio_url(
385
+ self,
386
+ id: str,
387
+ *,
388
+ kind: Optional[str] = None,
389
+ lang: Optional[str] = None,
390
+ format: Optional[str] = None,
391
+ listener_token: Optional[str] = None,
392
+ share_code: Optional[str] = None,
393
+ ) -> str:
394
+ """The signed media URL the server redirects to (``kind``: delivery, master, source)."""
395
+ query = {**listener_query(listener_token, share_code), "kind": kind, "lang": lang, "format": format}
396
+ resp = await self._t.raw("GET", self._path(id, "/audio"), query=query)
397
+ loc = resp.headers.get("location")
398
+ if not loc:
399
+ raise VocalStackError("not_found", "no signed URL in the redirect", status=resp.status_code)
400
+ return loc
401
+
402
+ async def clip(
403
+ self, id: str, start_s: float, end_s: float, *, listener_token: Optional[str] = None, share_code: Optional[str] = None
404
+ ) -> bytes:
405
+ """A stretch of the recording's audio, ``start_s`` to ``end_s`` seconds (at most 120 s)."""
406
+ query = {**listener_query(listener_token, share_code), "startS": start_s, "endS": end_s}
407
+ resp = await self._t.raw("GET", self._path(id, "/audio/clip"), headers={"accept": "*/*"}, query=query)
408
+ return resp.content
409
+
410
+ async def voice(self, id: str, lang: Optional[str] = None) -> List[m.VoiceTrack]:
411
+ data = await self._t.json("GET", self._path(id, "/voice"), query={"lang": lang})
412
+ return [m.VoiceTrack.model_validate(x) for x in data or []]
413
+
414
+ async def render_voice(self, id: str, langs: List[str], *, idempotency_key: Optional[str] = None) -> List[m.VoiceTrack]:
415
+ data = await self._t.json("POST", self._path(id, "/voice"), json_body={"langs": langs}, idempotency_key=idempotency_key)
416
+ return [m.VoiceTrack.model_validate(x) for x in data or []]
417
+
418
+ async def voice_clip(self, id: str, sentence: int, lang: str, *, format: Optional[str] = None) -> bytes:
419
+ resp = await self._t.raw(
420
+ "GET", self._path(id, f"/voice/{sentence}"), headers={"accept": "*/*"}, query={"lang": lang, "format": format}
421
+ )
422
+ return resp.content
423
+
424
+ async def media_token(self, id: str) -> Dict[str, Any]:
425
+ return await self._t.json("GET", self._path(id, "/media-token"))
426
+
427
+ async def name_speaker(self, id: str, speaker: int, name: Optional[str]) -> None:
428
+ await self._t.json("PUT", self._path(id, f"/speakers/{speaker}"), json_body={"name": name})
429
+
430
+ async def events(self, id: str, *, after: Optional[int] = None, limit: Optional[int] = None) -> List[m.RecordingEvent]:
431
+ """The durable event log with wall times (owner only): every event
432
+ after ``after`` (a ``seq``; the whole log without it), read ``limit`` a page."""
433
+
434
+ def page(cursor: Optional[str]) -> Any:
435
+ q = {"cursor": cursor, "limit": limit} if cursor else {"after": after, "limit": limit}
436
+ return self._t.json("GET", self._path(id, "/events"), query=q)
437
+
438
+ return [m.RecordingEvent.model_validate(x) for x in await _all_pages(page, "events")]
439
+
440
+ async def events_page(
441
+ self, id: str, *, cursor: Optional[str] = None, after: Optional[int] = None, limit: Optional[int] = None
442
+ ) -> m.RecordingEvents:
443
+ """One page of the event log (500 by default, 1000 at most): from
444
+ ``after`` a ``seq``, or the previous page's ``next_cursor`` as ``cursor``."""
445
+ data = await self._t.json("GET", self._path(id, "/events"), query={"cursor": cursor, "after": after, "limit": limit})
446
+ return _one(m.RecordingEvents, data)
447
+
448
+ async def subscribe(
449
+ self,
450
+ id: str,
451
+ *,
452
+ lang: Optional[str] = None,
453
+ voice: Optional[str] = None,
454
+ listener_token: Optional[str] = None,
455
+ share_code: Optional[str] = None,
456
+ as_owner: bool = True,
457
+ ) -> Any:
458
+ """The recording's live fan-out socket: a ``websockets`` connection
459
+ whose first frame is ``subscribe.ready``; read the backfill with
460
+ ``transcript(id, after=...)``."""
461
+ url = self._t.ws_url(
462
+ self._path(id, "/subscribe"),
463
+ {
464
+ "lang": lang,
465
+ "voice": voice,
466
+ **listener_query(listener_token, share_code),
467
+ "role": None if as_owner else "listener",
468
+ "device": self._t.device_id,
469
+ },
470
+ )
471
+ headers = self._t.auth_headers() if as_owner else {}
472
+ return await websockets.connect(url, max_size=None, additional_headers=headers)
473
+
474
+
475
+ _TERMINAL = {"done", "failed", "cropping"}
476
+
477
+
478
+ class Jobs(_Group):
479
+ """``vs.jobs``: a file or link recording's transcription job. States:
480
+ queued, fetching, transcribing, done, failed, and cropping (waiting for ``start``)."""
481
+
482
+ async def get(self, recording_id: str) -> m.TranscriptionJob:
483
+ return _one(m.TranscriptionJob, await self._t.json("GET", f"/v1/recordings/{_p(recording_id)}/job"))
484
+
485
+ async def start(self, recording_id: str, *, start_s: Optional[float] = None, end_s: Optional[float] = None) -> m.TranscriptionJob:
486
+ """Release a job waiting in ``cropping``: the window to transcribe, in seconds."""
487
+ body = _body(m.StartJob, {"start_s": start_s, "end_s": end_s})
488
+ return _one(m.TranscriptionJob, await self._t.json("POST", f"/v1/recordings/{_p(recording_id)}/job/start", json_body=body))
489
+
490
+ async def retry(self, recording_id: str) -> m.TranscriptionJob:
491
+ """Re-queue a failed job from its stored source."""
492
+ return _one(m.TranscriptionJob, await self._t.json("POST", f"/v1/recordings/{_p(recording_id)}/job/retry"))
493
+
494
+ async def preview(self, recording_id: str) -> bytes:
495
+ """The file the server fetched for a link job waiting in ``cropping``."""
496
+ resp = await self._t.raw("GET", f"/v1/recordings/{_p(recording_id)}/job/preview", headers={"accept": "*/*"})
497
+ return resp.content
498
+
499
+ async def wait(
500
+ self,
501
+ recording_id: str,
502
+ *,
503
+ interval_s: float = 2.0,
504
+ timeout_s: Optional[float] = None,
505
+ on_progress: Optional[Callable[[m.TranscriptionJob], None]] = None,
506
+ ) -> m.TranscriptionJob:
507
+ """Poll until the job is ``done``, ``failed`` or ``cropping`` and
508
+ return it. It does not raise on ``failed``: check ``job.state`` and ``job.error``.
509
+ ``on_progress`` sees every job read, the last one included: its
510
+ ``state`` and ``progress`` (``pct``, ``processed_s``, the fetch phase)."""
511
+ deadline = None if timeout_s is None else time.monotonic() + timeout_s
512
+ while True:
513
+ job = await self.get(recording_id)
514
+ if on_progress is not None:
515
+ on_progress(job)
516
+ if job.state in _TERMINAL:
517
+ return job
518
+ if deadline is not None and time.monotonic() + interval_s > deadline:
519
+ raise VocalStackError("timeout", f"job still {job.state} after {timeout_s}s", retryable=True)
520
+ await asyncio.sleep(interval_s)
521
+
522
+
523
+ class Corrections(_Group):
524
+ """``vs.corrections``: edits to a finished transcript, each revertible."""
525
+
526
+ async def list(self, recording_id: str) -> List[m.Correction]:
527
+ """Every correction, newest first (every page of ``list_page``)."""
528
+ path = f"/v1/recordings/{_p(recording_id)}/corrections"
529
+ items = await _all_pages(lambda c: self._t.json("GET", path, query={"cursor": c}), "corrections")
530
+ return [m.Correction.model_validate(x) for x in items]
531
+
532
+ async def list_page(self, recording_id: str, *, cursor: Optional[str] = None, limit: Optional[int] = None) -> m.CorrectionPage:
533
+ """One page of corrections, newest first; ``cursor`` is the previous page's ``next_cursor``."""
534
+ data = await self._t.json("GET", f"/v1/recordings/{_p(recording_id)}/corrections", query={"cursor": cursor, "limit": limit})
535
+ return _one(m.CorrectionPage, data)
536
+
537
+ async def apply(self, recording_id: str, **fields: Any) -> List[m.Correction]:
538
+ """Apply a staged batch atomically (``CorrectionBatch``)."""
539
+ body = _body(m.CorrectionBatch, fields)
540
+ data = await self._t.json("POST", f"/v1/recordings/{_p(recording_id)}/corrections", json_body=body)
541
+ return _many(m.Correction, data, "corrections")
542
+
543
+ async def retranscribe(self, recording_id: str, *, seqs: List[int], lang: str) -> List[m.Correction]:
544
+ """Read lines again with the language forced."""
545
+ data = await self._t.json(
546
+ "POST", f"/v1/recordings/{_p(recording_id)}/retranscribe", json_body={"seqs": seqs, "lang": lang}
547
+ )
548
+ return _many(m.Correction, data, "corrections")
549
+
550
+ async def revert(self, correction_id: str) -> m.Correction:
551
+ return _one(m.Correction, await self._t.json("POST", f"/v1/corrections/{_p(correction_id)}/revert"))
552
+
553
+
554
+ class ShareLinks(_Group):
555
+ """``vs.share_links``: named public links (create and list them on their owner)."""
556
+
557
+ async def update(self, id: str, **fields: Any) -> m.ShareLink:
558
+ body = _body(m.UpdateShareLink, fields)
559
+ return _one(m.ShareLink, await self._t.json("PATCH", f"/v1/share-links/{_p(id)}", json_body=body))
560
+
561
+ async def revoke(self, id: str) -> m.ShareLink:
562
+ return _one(m.ShareLink, await self._t.json("DELETE", f"/v1/share-links/{_p(id)}"))
563
+
564
+
565
+ class Voices(_Group):
566
+ """``vs.voices``: the account's cloned voices, and what a language offers."""
567
+
568
+ async def list(self) -> List[m.Voice]:
569
+ return _many(m.Voice, await self._t.json("GET", "/v1/voices"), "voices")
570
+
571
+ async def get(self, id: str) -> m.Voice:
572
+ return _one(m.Voice, await self._t.json("GET", f"/v1/voices/{_p(id)}"))
573
+
574
+ async def create(
575
+ self,
576
+ name: str,
577
+ *,
578
+ sample: Optional[Upload] = None,
579
+ sample_url: Optional[str] = None,
580
+ file_name: Optional[str] = None,
581
+ gender: Optional[str] = None,
582
+ color: Optional[str] = None,
583
+ draft: bool = False,
584
+ idempotency_key: Optional[str] = None,
585
+ ) -> m.Voice:
586
+ """Build a voice from a sample or a link. Calling it asserts you have
587
+ the speaker's permission, unless ``draft``."""
588
+ meta = _body(
589
+ m.CreateVoice,
590
+ {"name": name, "sample_url": sample_url, "gender": gender, "color": color, "draft": draft or None, "consent": not draft},
591
+ )
592
+ # Multipart even without a sample: the meta part rides as a field.
593
+ files: Dict[str, Any] = {"meta": (None, json.dumps(meta), "application/json")}
594
+ if sample is not None:
595
+ files["sample"] = await asyncio.to_thread(_file_part, sample, file_name, "sample.webm")
596
+ data = await self._t.json("POST", "/v1/voices", files=files, idempotency_key=idempotency_key)
597
+ return _one(m.Voice, data)
598
+
599
+ async def create_from_speaker(
600
+ self, *, recording_id: str, speaker: int, name: str, color: Optional[str] = None, idempotency_key: Optional[str] = None
601
+ ) -> m.Voice:
602
+ """Build a voice from one speaker of a recording (asserts permission)."""
603
+ body = _body(
604
+ m.CreateVoiceFromSpeaker,
605
+ {"recording_id": recording_id, "speaker": speaker, "name": name, "color": color, "consent": True},
606
+ )
607
+ data = await self._t.json("POST", "/v1/voices/from-speaker", json_body=body, idempotency_key=idempotency_key)
608
+ return _one(m.Voice, data)
609
+
610
+ async def update(self, id: str, **fields: Any) -> m.Voice:
611
+ body = _body(m.PatchVoice, fields)
612
+ return _one(m.Voice, await self._t.json("PATCH", f"/v1/voices/{_p(id)}", json_body=body))
613
+
614
+ async def delete(self, id: str) -> None:
615
+ await self._t.json("DELETE", f"/v1/voices/{_p(id)}")
616
+
617
+ async def available(
618
+ self,
619
+ lang: str,
620
+ *,
621
+ recording: Optional[str] = None,
622
+ listener_token: Optional[str] = None,
623
+ share_code: Optional[str] = None,
624
+ as_owner: bool = True,
625
+ ) -> m.VoiceCatalogue:
626
+ """The voices that can speak ``lang``, best first (with a token, also
627
+ the account's ready clones). ``recording`` also offers the cloned
628
+ voices that recording uses, to whoever may read it: its owner, or a
629
+ listener with ``listener_token`` or ``share_code``."""
630
+ data = await self._t.json(
631
+ "GET",
632
+ "/v1/voices/available",
633
+ query={**listener_query(listener_token, share_code), "lang": lang, "recording": recording},
634
+ anonymous=not as_owner,
635
+ )
636
+ return _one(m.VoiceCatalogue, data)
637
+
638
+ async def preview(self, voice: str, lang: str, *, format: Optional[str] = None) -> bytes:
639
+ resp = await self._t.raw(
640
+ "GET", "/v1/voices/preview", headers={"accept": "*/*"}, query={"voice": voice, "lang": lang, "format": format}
641
+ )
642
+ return resp.content
643
+
644
+ async def clone_preview(
645
+ self,
646
+ id: str,
647
+ *,
648
+ lang: Optional[str] = None,
649
+ format: Optional[str] = None,
650
+ recording: Optional[str] = None,
651
+ listener_token: Optional[str] = None,
652
+ share_code: Optional[str] = None,
653
+ as_owner: bool = True,
654
+ ) -> bytes:
655
+ """A cloned voice speaking ``lang``. With ``recording``, as it speaks
656
+ in that recording, for whoever may read it (its owner, or a listener
657
+ with ``listener_token`` or ``share_code``)."""
658
+ resp = await self._t.raw(
659
+ "GET",
660
+ f"/v1/voices/{_p(id)}/preview",
661
+ headers={"accept": "*/*"},
662
+ query={**listener_query(listener_token, share_code), "lang": lang, "format": format, "recording": recording},
663
+ anonymous=not as_owner,
664
+ )
665
+ return resp.content
666
+
667
+ async def sample(self, id: str) -> bytes:
668
+ resp = await self._t.raw("GET", f"/v1/voices/{_p(id)}/sample", headers={"accept": "*/*"})
669
+ return resp.content
670
+
671
+
672
+ class Search(_Group):
673
+ """``vs.search``: full-text search over the caller's transcripts."""
674
+
675
+ async def query(
676
+ self,
677
+ q: str,
678
+ *,
679
+ broadcasts: Optional[List[str]] = None,
680
+ listener_tokens: Optional[List[str]] = None,
681
+ recording: Optional[str] = None,
682
+ lang: Optional[str] = None,
683
+ limit: Optional[int] = None,
684
+ ) -> m.SearchResults:
685
+ """``q`` is 2 or more characters; ``broadcasts`` narrows to those links (codes)."""
686
+ query = {
687
+ "q": q,
688
+ "broadcasts": ",".join(broadcasts) if broadcasts else None,
689
+ "listenerTokens": ",".join(listener_tokens) if listener_tokens else None,
690
+ "recording": recording,
691
+ "lang": lang,
692
+ "limit": limit,
693
+ }
694
+ return _one(m.SearchResults, await self._t.json("GET", "/v1/search", query=query))
695
+
696
+
697
+ class Me(_Group):
698
+ """``vs.me``: who the credential is."""
699
+
700
+ async def get(self) -> Dict[str, Any]:
701
+ return await self._t.json("GET", "/v1/me")
702
+
703
+
704
+ class Billing(_Group):
705
+ """``vs.billing``: the account's plan, credit balance and limits, the
706
+ price list, the ledger and the per-recording charges."""
707
+
708
+ async def get(self) -> m.BillingView:
709
+ """The account: plan, rates, balance, limits, subscription, top-up URL."""
710
+ return _one(m.BillingView, await self._t.json("GET", "/v1/billing"))
711
+
712
+ async def plans(self) -> m.PlanList:
713
+ """The visible plans with their current rates and limits (no credential needed)."""
714
+ return _one(m.PlanList, await self._t.json("GET", "/v1/billing/plans"))
715
+
716
+ async def ledger(self, *, cursor: Optional[str] = None, limit: Optional[int] = None) -> m.LedgerPage:
717
+ """The ledger, newest first; ``cursor`` is the previous page's ``next_cursor``."""
718
+ return _one(m.LedgerPage, await self._t.json("GET", "/v1/billing/ledger", query={"cursor": cursor, "limit": limit}))
719
+
720
+ async def charges(self, *, cursor: Optional[str] = None, limit: Optional[int] = None) -> m.ChargePage:
721
+ """The per-recording charges, newest first."""
722
+ return _one(m.ChargePage, await self._t.json("GET", "/v1/billing/charges", query={"cursor": cursor, "limit": limit}))
723
+
724
+ async def create_checkout(self, *, idempotency_key: Optional[str] = None, **fields: Any) -> m.CheckoutView:
725
+ """Open a checkout at the payment provider (``kind="topup", amount=``
726
+ or ``kind="subscribe", plan_id=, interval=``, with ``accept_terms=True``
727
+ and ``waive_withdrawal=True``); send the customer to ``url``. A
728
+ signed-in person only, never an API key."""
729
+ body = _body(m.CreateCheckout, fields)
730
+ return _one(m.CheckoutView, await self._t.json("POST", "/v1/billing/checkouts", json_body=body, idempotency_key=idempotency_key))
731
+
732
+ async def get_checkout(self, id: str) -> m.CheckoutView:
733
+ """A checkout's status (by its id or the provider's)."""
734
+ return _one(m.CheckoutView, await self._t.json("GET", f"/v1/billing/checkouts/{id}"))
735
+
736
+ async def cancel_subscription(self) -> m.SubscriptionView:
737
+ """Cancel the subscription at the end of its period."""
738
+ return _one(m.SubscriptionView, await self._t.json("POST", "/v1/billing/subscription/cancel"))
739
+
740
+ async def uncancel_subscription(self) -> m.SubscriptionView:
741
+ """Take back a scheduled cancellation."""
742
+ return _one(m.SubscriptionView, await self._t.json("POST", "/v1/billing/subscription/uncancel"))
743
+
744
+ async def set_auto_top_up(self, **fields: Any) -> m.AutoTopUpView:
745
+ """Turn auto top-up on (``threshold`` and ``amount`` from the plan list's choices) or off."""
746
+ body = _body(m.AutoTopUpSettings, fields)
747
+ return _one(m.AutoTopUpView, await self._t.json("PUT", "/v1/billing/auto-top-up", json_body=body))
748
+
749
+ async def portal(self) -> m.PortalLink:
750
+ """A link to the provider's customer portal (payment methods, invoices, receipts)."""
751
+ return _one(m.PortalLink, await self._t.json("GET", "/v1/billing/portal"))
752
+
753
+ async def retry_auto_top_up(self) -> m.AutoTopUpView:
754
+ """Charge the configured auto top-up amount now (under the day's cap)."""
755
+ return _one(m.AutoTopUpView, await self._t.json("POST", "/v1/billing/auto-top-up/retry"))
756
+
757
+ async def set_low_balance_alert(self, **fields: Any) -> m.LowBalanceAlertView:
758
+ """Turn the low-balance email on or off (``enabled``) and set its line (``threshold``)."""
759
+ body = _body(m.LowBalanceAlertSettings, fields)
760
+ return _one(m.LowBalanceAlertView, await self._t.json("PUT", "/v1/billing/low-balance-alert", json_body=body))
761
+
762
+ async def set_interval(self, **fields: Any) -> m.SubscriptionView:
763
+ """Renew ``interval="month"`` or ``"year"`` from the next period."""
764
+ body = _body(m.SetInterval, fields)
765
+ return _one(m.SubscriptionView, await self._t.json("POST", "/v1/billing/subscription/interval", json_body=body))
766
+
767
+ async def unset_interval(self) -> m.SubscriptionView:
768
+ """Take back a scheduled interval switch."""
769
+ return _one(m.SubscriptionView, await self._t.json("DELETE", "/v1/billing/subscription/interval"))
770
+
771
+ async def usage(self, *, start_date: Optional[str] = None, end_date: Optional[str] = None, tz: Optional[str] = None) -> m.UsageReport:
772
+ """What the account spent per day (``YYYY-MM-DD`` in ``tz``), source and kind."""
773
+ return _one(m.UsageReport, await self._t.json("GET", "/v1/billing/usage", query={"startDate": start_date, "endDate": end_date, "tz": tz}))
774
+
775
+ async def payments(self, *, cursor: Optional[str] = None, limit: Optional[int] = None) -> m.PaymentPage:
776
+ """The payments history, newest first."""
777
+ return _one(m.PaymentPage, await self._t.json("GET", "/v1/billing/payments", query={"cursor": cursor, "limit": limit}))
778
+
779
+ async def payment_document(self, id: str) -> m.PaymentDocument:
780
+ """A payment's invoice or receipt: ``url``, or ``pending`` while the provider makes it."""
781
+ return _one(m.PaymentDocument, await self._t.json("GET", f"/v1/billing/payments/{id}/document"))
782
+
783
+ async def payment_methods(self) -> m.PaymentMethodList:
784
+ """The saved payment methods."""
785
+ return _one(m.PaymentMethodList, await self._t.json("GET", "/v1/billing/payment-methods"))
786
+
787
+ async def delete_payment_method(self, id: str) -> None:
788
+ """Remove a saved payment method."""
789
+ await self._t.json("DELETE", f"/v1/billing/payment-methods/{id}")
790
+
791
+ async def manage_payment_methods(self) -> m.PortalLink:
792
+ """A link to the provider's portal to add a card or choose the default."""
793
+ return _one(m.PortalLink, await self._t.json("POST", "/v1/billing/payment-methods/manage"))
794
+
795
+ async def details(self) -> m.BillingDetails:
796
+ """The invoice details."""
797
+ return _one(m.BillingDetails, await self._t.json("GET", "/v1/billing/details"))
798
+
799
+ async def set_details(self, **fields: Any) -> m.BillingDetails:
800
+ """Save the invoice details (``name``, ``email``, ``line1``, ``city``, ``postal_code``, ``country``, ``tax_id``)."""
801
+ body = _body(m.BillingDetails, fields)
802
+ return _one(m.BillingDetails, await self._t.json("PUT", "/v1/billing/details", json_body=body))
803
+
804
+ async def check_promo_code(self, **fields: Any) -> m.PromoCodeView:
805
+ """What a promo code gives (``code``, ``for_="topup"`` or ``"subscribe"``)."""
806
+ body = _body(m.CheckPromoCode, fields)
807
+ return _one(m.PromoCodeView, await self._t.json("POST", "/v1/billing/promo-codes/check", json_body=body))
808
+
809
+
810
+ class Languages(_Group):
811
+ async def list(self) -> m.LanguageLists:
812
+ """Spoken and translation languages."""
813
+ return _one(m.LanguageLists, await self._t.json("GET", "/v1/languages"))
814
+
815
+
816
+ class Rtmp(_Group):
817
+ async def status(self) -> m.RtmpStatus:
818
+ """The account's RTMP ingest: server URL (RTMPS where available), the stream key (in full only in the answer that made it, masked_stream_key after), whether an encoder is sending."""
819
+ return _one(m.RtmpStatus, await self._t.json("GET", "/v1/rtmp"))
820
+
821
+
822
+ class Events(_Group):
823
+ async def account(self) -> Any:
824
+ """The account socket: ``events.ready``, then ``lists.changed``
825
+ whenever a list of broadcasts or transcriptions is out of date."""
826
+ return await websockets.connect(self._t.ws_url("/v1/events"), additional_headers=self._t.auth_headers())
827
+
828
+
829
+ class Meta(_Group):
830
+ async def health(self) -> m.Health:
831
+ return _one(m.Health, await self._t.json("GET", "/v1/health"))
832
+
833
+ async def openapi(self) -> Dict[str, Any]:
834
+ """The OpenAPI document the models are generated from."""
835
+ return await self._t.json("GET", "/v1/openapi.json")