reedapt 0.1.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.
- reedapt/__init__.py +46 -0
- reedapt/_async_client.py +481 -0
- reedapt/_base.py +244 -0
- reedapt/_client.py +479 -0
- reedapt/_errors.py +117 -0
- reedapt/_version.py +1 -0
- reedapt/_webhooks.py +61 -0
- reedapt/py.typed +0 -0
- reedapt/types.py +197 -0
- reedapt-0.1.0.dist-info/METADATA +215 -0
- reedapt-0.1.0.dist-info/RECORD +13 -0
- reedapt-0.1.0.dist-info/WHEEL +4 -0
- reedapt-0.1.0.dist-info/licenses/LICENSE +21 -0
reedapt/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The official Python library for the Reedapt API. See https://docs.reedapt.com."""
|
|
2
|
+
|
|
3
|
+
from . import types
|
|
4
|
+
from ._async_client import AsyncReedapt
|
|
5
|
+
from ._base import RequestOptions
|
|
6
|
+
from ._client import Reedapt
|
|
7
|
+
from ._errors import (
|
|
8
|
+
APIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
ConnectionError,
|
|
12
|
+
InvalidRequestError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ReedaptError,
|
|
16
|
+
ServerError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
UploadError,
|
|
19
|
+
WaitError,
|
|
20
|
+
WebhookVerificationError,
|
|
21
|
+
)
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from ._webhooks import construct_webhook_event, verify_webhook
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Reedapt",
|
|
27
|
+
"AsyncReedapt",
|
|
28
|
+
"RequestOptions",
|
|
29
|
+
"types",
|
|
30
|
+
"verify_webhook",
|
|
31
|
+
"construct_webhook_event",
|
|
32
|
+
"ReedaptError",
|
|
33
|
+
"APIError",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"ConflictError",
|
|
37
|
+
"InvalidRequestError",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"ServerError",
|
|
40
|
+
"ConnectionError",
|
|
41
|
+
"TimeoutError",
|
|
42
|
+
"UploadError",
|
|
43
|
+
"WaitError",
|
|
44
|
+
"WebhookVerificationError",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
reedapt/_async_client.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Mapping, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from typing_extensions import Self, Unpack
|
|
8
|
+
|
|
9
|
+
from . import _base
|
|
10
|
+
from ._base import FileInput, RequestOptions, Retry, Source
|
|
11
|
+
from ._client import ProgressCallback, check_upload
|
|
12
|
+
from ._errors import ConflictError, ReedaptError, UploadError, WaitError
|
|
13
|
+
from ._webhooks import Webhooks
|
|
14
|
+
from .types import (
|
|
15
|
+
Dub,
|
|
16
|
+
DubFiles,
|
|
17
|
+
DubList,
|
|
18
|
+
DubsCreated,
|
|
19
|
+
Languages,
|
|
20
|
+
Transcript,
|
|
21
|
+
Upload,
|
|
22
|
+
UploadedPart,
|
|
23
|
+
Usage,
|
|
24
|
+
UsageReport,
|
|
25
|
+
WebhookDelivery,
|
|
26
|
+
WebhookDeliveryList,
|
|
27
|
+
WebhookEndpoint,
|
|
28
|
+
WebhookEndpointList,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncReedapt:
|
|
33
|
+
"""The asyncio client. Same methods as ``Reedapt``, awaited.
|
|
34
|
+
|
|
35
|
+
``api_key`` defaults to the ``REEDAPT_API_KEY`` environment variable.
|
|
36
|
+
Use ``async with``, or call ``await close()``, to release connections.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
api_key: Optional[str] = None,
|
|
43
|
+
base_url: Optional[str] = None,
|
|
44
|
+
timeout: float = _base.DEFAULT_TIMEOUT,
|
|
45
|
+
max_retries: int = _base.DEFAULT_MAX_RETRIES,
|
|
46
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
47
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._settings = _base.Settings(api_key, base_url, timeout, max_retries, default_headers)
|
|
50
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
51
|
+
self._owns_http = http_client is None
|
|
52
|
+
self.uploads = AsyncUploads(self)
|
|
53
|
+
self.dubs = AsyncDubs(self)
|
|
54
|
+
self.webhook_endpoints = AsyncWebhookEndpoints(self)
|
|
55
|
+
self.webhooks = Webhooks()
|
|
56
|
+
self.languages = AsyncLanguagesResource(self)
|
|
57
|
+
self.usage = AsyncUsageResource(self)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def base_url(self) -> str:
|
|
61
|
+
return self._settings.base_url
|
|
62
|
+
|
|
63
|
+
async def close(self) -> None:
|
|
64
|
+
if self._owns_http:
|
|
65
|
+
await self._http.aclose()
|
|
66
|
+
|
|
67
|
+
async def __aenter__(self) -> Self:
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, *_: object) -> None:
|
|
71
|
+
await self.close()
|
|
72
|
+
|
|
73
|
+
async def dub(
|
|
74
|
+
self,
|
|
75
|
+
file: FileInput,
|
|
76
|
+
*,
|
|
77
|
+
source_language: str,
|
|
78
|
+
target_languages: Sequence[str],
|
|
79
|
+
filename: Optional[str] = None,
|
|
80
|
+
voice_cloning: Optional[bool] = None,
|
|
81
|
+
num_speakers: Optional[int] = None,
|
|
82
|
+
media_category: Optional[str] = None,
|
|
83
|
+
metadata: Optional[Mapping[str, str]] = None,
|
|
84
|
+
concurrency: int = 4,
|
|
85
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
86
|
+
idempotency_key: Optional[str] = None,
|
|
87
|
+
) -> DubsCreated:
|
|
88
|
+
"""Uploads a file, or has Reedapt fetch a public ``https://`` link, and
|
|
89
|
+
starts a dub for each target language. Returns once the dubs have
|
|
90
|
+
started, not when they finish."""
|
|
91
|
+
if _base.is_link(file):
|
|
92
|
+
upload = await self.uploads.from_url(
|
|
93
|
+
str(file), filename=filename, idempotency_key=_base.suffixed(idempotency_key, "upload")
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
upload = await self.uploads.upload_file(
|
|
97
|
+
file,
|
|
98
|
+
filename=filename,
|
|
99
|
+
concurrency=concurrency,
|
|
100
|
+
on_progress=on_progress,
|
|
101
|
+
idempotency_key=_base.suffixed(idempotency_key, "upload"),
|
|
102
|
+
)
|
|
103
|
+
dub_options: RequestOptions = {}
|
|
104
|
+
if idempotency_key:
|
|
105
|
+
dub_options["idempotency_key"] = f"{idempotency_key}:dubs"
|
|
106
|
+
return await self.dubs.create(
|
|
107
|
+
upload_id=upload["id"],
|
|
108
|
+
source_language=source_language,
|
|
109
|
+
target_languages=target_languages,
|
|
110
|
+
voice_cloning=voice_cloning,
|
|
111
|
+
num_speakers=num_speakers,
|
|
112
|
+
media_category=media_category,
|
|
113
|
+
metadata=metadata,
|
|
114
|
+
**dub_options,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def _request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
path: str,
|
|
121
|
+
*,
|
|
122
|
+
body: Any = None,
|
|
123
|
+
params: Optional[Dict[str, Any]] = None,
|
|
124
|
+
retry: Retry = "safe",
|
|
125
|
+
idempotent: bool = False,
|
|
126
|
+
text: bool = False,
|
|
127
|
+
options: Optional[RequestOptions] = None,
|
|
128
|
+
) -> Any:
|
|
129
|
+
options = options or {}
|
|
130
|
+
key = _base.idempotency_key_for(idempotent, options)
|
|
131
|
+
headers = self._settings.headers(body is not None, key, options)
|
|
132
|
+
retry = "safe" if key else retry
|
|
133
|
+
timeout = options.get("timeout", self._settings.timeout)
|
|
134
|
+
max_retries = options.get("max_retries", self._settings.max_retries)
|
|
135
|
+
query = {k: v for k, v in (params or {}).items() if v is not None}
|
|
136
|
+
attempt = 0
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
try:
|
|
140
|
+
response = await self._http.request(
|
|
141
|
+
method,
|
|
142
|
+
self._settings.base_url + path,
|
|
143
|
+
headers=headers,
|
|
144
|
+
json=body,
|
|
145
|
+
params=query or None,
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
)
|
|
148
|
+
except httpx.TransportError as e:
|
|
149
|
+
raise _base.connection_error(method, path, e, timeout) from e
|
|
150
|
+
return _base.parse(response, text)
|
|
151
|
+
except ReedaptError as error:
|
|
152
|
+
if attempt >= max_retries or not _base.should_retry(error, retry):
|
|
153
|
+
raise
|
|
154
|
+
await asyncio.sleep(_base.retry_delay(error, attempt))
|
|
155
|
+
attempt += 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AsyncUploads:
|
|
159
|
+
def __init__(self, client: AsyncReedapt) -> None:
|
|
160
|
+
self._client = client
|
|
161
|
+
|
|
162
|
+
async def create(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
filename: Optional[str] = None,
|
|
166
|
+
size: Optional[int] = None,
|
|
167
|
+
source_url: Optional[str] = None,
|
|
168
|
+
**options: Unpack[RequestOptions],
|
|
169
|
+
) -> Upload:
|
|
170
|
+
"""Starts an upload. Most code wants ``upload_file`` or ``from_url`` instead."""
|
|
171
|
+
body = {k: v for k, v in (("filename", filename), ("size", size), ("source_url", source_url)) if v is not None}
|
|
172
|
+
return await self._client._request("POST", "/uploads", body=body, idempotent=True, options=options)
|
|
173
|
+
|
|
174
|
+
async def sign_parts(
|
|
175
|
+
self, upload_id: str, part_numbers: Sequence[int], **options: Unpack[RequestOptions]
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""Signs links for up to 100 more parts."""
|
|
178
|
+
return await self._client._request(
|
|
179
|
+
"POST",
|
|
180
|
+
f"/uploads/{_base.quote(upload_id)}/parts",
|
|
181
|
+
body={"part_numbers": list(part_numbers)},
|
|
182
|
+
options=options,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def complete(
|
|
186
|
+
self, upload_id: str, parts: Sequence[UploadedPart], **options: Unpack[RequestOptions]
|
|
187
|
+
) -> Upload:
|
|
188
|
+
return await self._client._request(
|
|
189
|
+
"POST", f"/uploads/{_base.quote(upload_id)}/complete", body={"parts": list(parts)}, options=options
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def retrieve(self, upload_id: str, **options: Unpack[RequestOptions]) -> Upload:
|
|
193
|
+
return await self._client._request("GET", f"/uploads/{_base.quote(upload_id)}", options=options)
|
|
194
|
+
|
|
195
|
+
async def upload_file(
|
|
196
|
+
self,
|
|
197
|
+
file: FileInput,
|
|
198
|
+
*,
|
|
199
|
+
filename: Optional[str] = None,
|
|
200
|
+
concurrency: int = 4,
|
|
201
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
202
|
+
idempotency_key: Optional[str] = None,
|
|
203
|
+
) -> Upload:
|
|
204
|
+
"""Sends a file to storage in parts, several at once, and completes the
|
|
205
|
+
upload. Expired links are signed again and failed parts retried.
|
|
206
|
+
|
|
207
|
+
``file`` is a path, bytes (with ``filename``), or a binary file object.
|
|
208
|
+
Each part in flight (16 MiB or more) is held in memory."""
|
|
209
|
+
source = Source(file, filename)
|
|
210
|
+
try:
|
|
211
|
+
create_options: RequestOptions = {"idempotency_key": idempotency_key} if idempotency_key else {}
|
|
212
|
+
upload = await self.create(filename=source.filename, size=source.size, **create_options)
|
|
213
|
+
links: Dict[int, str] = {p["part_number"]: p["url"] for p in upload.get("parts") or []}
|
|
214
|
+
sign_lock = asyncio.Lock()
|
|
215
|
+
uploaded = 0
|
|
216
|
+
|
|
217
|
+
async def link_for(n: int, refresh: bool) -> str:
|
|
218
|
+
async with sign_lock:
|
|
219
|
+
if refresh:
|
|
220
|
+
links.pop(n, None)
|
|
221
|
+
if n not in links:
|
|
222
|
+
wanted = [n] if refresh else _base.sign_batch(n, upload["part_count"], links)
|
|
223
|
+
signed = await self.sign_parts(upload["id"], wanted)
|
|
224
|
+
links.update({p["part_number"]: p["url"] for p in signed["parts"]})
|
|
225
|
+
if n not in links:
|
|
226
|
+
raise UploadError(f"No link was signed for part {n} of {upload['id']}.")
|
|
227
|
+
return links[n]
|
|
228
|
+
|
|
229
|
+
queue = list(range(upload["part_count"], 0, -1))
|
|
230
|
+
parts: List[UploadedPart] = []
|
|
231
|
+
|
|
232
|
+
async def worker() -> None:
|
|
233
|
+
nonlocal uploaded
|
|
234
|
+
while queue:
|
|
235
|
+
n = queue.pop()
|
|
236
|
+
start, end = _base.part_range(n, upload["part_size"], source.size)
|
|
237
|
+
data = await asyncio.get_running_loop().run_in_executor(None, source.read, start, end)
|
|
238
|
+
etag = await self._put_part(n, data, link_for)
|
|
239
|
+
parts.append({"part_number": n, "etag": etag})
|
|
240
|
+
uploaded += end - start
|
|
241
|
+
if on_progress is not None:
|
|
242
|
+
on_progress(uploaded, source.size)
|
|
243
|
+
|
|
244
|
+
tasks = [asyncio.ensure_future(worker()) for _ in range(max(1, min(concurrency, upload["part_count"])))]
|
|
245
|
+
try:
|
|
246
|
+
await asyncio.gather(*tasks)
|
|
247
|
+
except BaseException:
|
|
248
|
+
# One failed part stops the others.
|
|
249
|
+
for task in tasks:
|
|
250
|
+
task.cancel()
|
|
251
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
252
|
+
raise
|
|
253
|
+
parts.sort(key=lambda p: p["part_number"])
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
return await self.complete(upload["id"], parts)
|
|
257
|
+
except ConflictError as e:
|
|
258
|
+
# A retried complete that had already gone through.
|
|
259
|
+
if e.code == "UPLOAD_COMPLETE":
|
|
260
|
+
return await self.retrieve(upload["id"])
|
|
261
|
+
raise
|
|
262
|
+
finally:
|
|
263
|
+
source.close()
|
|
264
|
+
|
|
265
|
+
async def _put_part(self, n: int, data: bytes, link_for: Callable[[int, bool], Any]) -> str:
|
|
266
|
+
refresh = False
|
|
267
|
+
problem = ""
|
|
268
|
+
for attempt in range(_base.PART_ATTEMPTS):
|
|
269
|
+
if attempt:
|
|
270
|
+
await asyncio.sleep(_base.backoff(attempt))
|
|
271
|
+
url = await link_for(n, refresh)
|
|
272
|
+
try:
|
|
273
|
+
# No API key here: the signed link carries its own permission.
|
|
274
|
+
response = await self._client._http.put(
|
|
275
|
+
url, content=data, timeout=max(self._client._settings.timeout, 600)
|
|
276
|
+
)
|
|
277
|
+
except httpx.TransportError as e:
|
|
278
|
+
problem, refresh = str(e), False
|
|
279
|
+
continue
|
|
280
|
+
etag, refresh, problem = _base.part_outcome(n, response)
|
|
281
|
+
if etag:
|
|
282
|
+
return etag
|
|
283
|
+
raise UploadError(f"Part {n} failed: {problem}.")
|
|
284
|
+
|
|
285
|
+
async def from_url(
|
|
286
|
+
self,
|
|
287
|
+
source_url: str,
|
|
288
|
+
*,
|
|
289
|
+
filename: Optional[str] = None,
|
|
290
|
+
wait: bool = True,
|
|
291
|
+
poll_interval: float = 10,
|
|
292
|
+
timeout: float = 3 * 60 * 60,
|
|
293
|
+
idempotency_key: Optional[str] = None,
|
|
294
|
+
) -> Upload:
|
|
295
|
+
"""Has Reedapt download a public link. Waits until it's ready unless ``wait`` is False."""
|
|
296
|
+
options: RequestOptions = {"idempotency_key": idempotency_key} if idempotency_key else {}
|
|
297
|
+
upload = await self.create(source_url=source_url, filename=filename, **options)
|
|
298
|
+
if not wait:
|
|
299
|
+
return upload
|
|
300
|
+
return await self.wait_until_ready(upload["id"], poll_interval=poll_interval, timeout=timeout)
|
|
301
|
+
|
|
302
|
+
async def wait_until_ready(
|
|
303
|
+
self, upload_id: str, *, poll_interval: float = 10, timeout: float = 3 * 60 * 60
|
|
304
|
+
) -> Upload:
|
|
305
|
+
"""Polls an upload until it can be dubbed. Raises ``WaitError`` if it failed."""
|
|
306
|
+
loop = asyncio.get_running_loop()
|
|
307
|
+
deadline = loop.time() + timeout
|
|
308
|
+
while True:
|
|
309
|
+
upload = await self.retrieve(upload_id)
|
|
310
|
+
check_upload(upload)
|
|
311
|
+
if upload["status"] == "uploaded":
|
|
312
|
+
return upload
|
|
313
|
+
if loop.time() + poll_interval > deadline:
|
|
314
|
+
raise WaitError(f"Upload {upload_id} was still {upload['status']} when the wait ran out.")
|
|
315
|
+
await asyncio.sleep(poll_interval)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class AsyncDubs:
|
|
319
|
+
def __init__(self, client: AsyncReedapt) -> None:
|
|
320
|
+
self._client = client
|
|
321
|
+
|
|
322
|
+
async def create(
|
|
323
|
+
self,
|
|
324
|
+
*,
|
|
325
|
+
upload_id: str,
|
|
326
|
+
source_language: str,
|
|
327
|
+
target_languages: Sequence[str],
|
|
328
|
+
voice_cloning: Optional[bool] = None,
|
|
329
|
+
num_speakers: Optional[int] = None,
|
|
330
|
+
media_category: Optional[str] = None,
|
|
331
|
+
metadata: Optional[Mapping[str, str]] = None,
|
|
332
|
+
**options: Unpack[RequestOptions],
|
|
333
|
+
) -> DubsCreated:
|
|
334
|
+
"""Starts one dub per target language. Languages that can't start come back in ``refused``."""
|
|
335
|
+
body = _base.dub_body(
|
|
336
|
+
upload_id, source_language, list(target_languages), voice_cloning, num_speakers, media_category, metadata
|
|
337
|
+
)
|
|
338
|
+
return await self._client._request("POST", "/dubs", body=body, idempotent=True, options=options)
|
|
339
|
+
|
|
340
|
+
async def retrieve(self, dub_id: str, **options: Unpack[RequestOptions]) -> Dub:
|
|
341
|
+
return await self._client._request("GET", f"/dubs/{_base.quote(dub_id)}", options=options)
|
|
342
|
+
|
|
343
|
+
async def list(
|
|
344
|
+
self, *, limit: Optional[int] = None, offset: Optional[int] = None, **options: Unpack[RequestOptions]
|
|
345
|
+
) -> DubList:
|
|
346
|
+
"""One page of the account's dubs, newest first."""
|
|
347
|
+
return await self._client._request("GET", "/dubs", params={"limit": limit, "offset": offset}, options=options)
|
|
348
|
+
|
|
349
|
+
async def list_all(self, *, page_size: int = 100) -> AsyncIterator[Dub]:
|
|
350
|
+
"""Every dub, newest first, fetching pages as you go."""
|
|
351
|
+
seen = set()
|
|
352
|
+
offset = 0
|
|
353
|
+
while True:
|
|
354
|
+
page = await self.list(limit=page_size, offset=offset)
|
|
355
|
+
for dub in page["data"]:
|
|
356
|
+
# Dubs started while paging shift later pages; skip the repeats.
|
|
357
|
+
if dub["id"] not in seen:
|
|
358
|
+
seen.add(dub["id"])
|
|
359
|
+
yield dub
|
|
360
|
+
if not page["has_more"] or not page["data"]:
|
|
361
|
+
return
|
|
362
|
+
offset += page_size
|
|
363
|
+
|
|
364
|
+
async def cancel(self, dub_id: str, **options: Unpack[RequestOptions]) -> None:
|
|
365
|
+
"""Stops an unfinished dub and deletes it. Its minutes are returned."""
|
|
366
|
+
await self._client._request("POST", f"/dubs/{_base.quote(dub_id)}/cancel", options=options)
|
|
367
|
+
|
|
368
|
+
async def files(self, dub_id: str, **options: Unpack[RequestOptions]) -> DubFiles:
|
|
369
|
+
"""Download links for a completed dub's files. Each link lasts an hour."""
|
|
370
|
+
return await self._client._request("GET", f"/dubs/{_base.quote(dub_id)}/files", options=options)
|
|
371
|
+
|
|
372
|
+
async def transcript(self, dub_id: str, **options: Unpack[RequestOptions]) -> Transcript:
|
|
373
|
+
"""Every line of a completed dub with its timing, speaker, original words and translation."""
|
|
374
|
+
return await self._client._request("GET", f"/dubs/{_base.quote(dub_id)}/transcript", options=options)
|
|
375
|
+
|
|
376
|
+
async def subtitles(
|
|
377
|
+
self,
|
|
378
|
+
dub_id: str,
|
|
379
|
+
*,
|
|
380
|
+
format: Literal["srt", "vtt"] = "srt",
|
|
381
|
+
language: Literal["target", "source"] = "target",
|
|
382
|
+
**options: Unpack[RequestOptions],
|
|
383
|
+
) -> str:
|
|
384
|
+
"""A completed dub's subtitles as SRT or WebVTT text, in the dub's language
|
|
385
|
+
(``target``) or the original words (``source``)."""
|
|
386
|
+
return await self._client._request(
|
|
387
|
+
"GET",
|
|
388
|
+
f"/dubs/{_base.quote(dub_id)}/subtitles",
|
|
389
|
+
params={"format": format, "language": language},
|
|
390
|
+
text=True,
|
|
391
|
+
options=options,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
async def wait_until_done(
|
|
395
|
+
self,
|
|
396
|
+
dub_id: str,
|
|
397
|
+
*,
|
|
398
|
+
poll_interval: float = 30,
|
|
399
|
+
timeout: float = 12 * 60 * 60,
|
|
400
|
+
on_update: Optional[Callable[[Dub], None]] = None,
|
|
401
|
+
) -> Dub:
|
|
402
|
+
"""Polls a dub until it is ``completed`` or ``failed`` and returns it.
|
|
403
|
+
Check ``status`` on the result. Webhooks are better for long dubs."""
|
|
404
|
+
loop = asyncio.get_running_loop()
|
|
405
|
+
deadline = loop.time() + timeout
|
|
406
|
+
while True:
|
|
407
|
+
dub = await self.retrieve(dub_id)
|
|
408
|
+
if on_update is not None:
|
|
409
|
+
on_update(dub)
|
|
410
|
+
if dub["status"] in ("completed", "failed"):
|
|
411
|
+
return dub
|
|
412
|
+
if loop.time() + poll_interval > deadline:
|
|
413
|
+
raise WaitError(f"Dub {dub_id} was still {dub['status']} when the wait ran out.")
|
|
414
|
+
await asyncio.sleep(poll_interval)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class AsyncWebhookEndpoints:
|
|
418
|
+
def __init__(self, client: AsyncReedapt) -> None:
|
|
419
|
+
self._client = client
|
|
420
|
+
|
|
421
|
+
async def create(
|
|
422
|
+
self, *, url: str, description: Optional[str] = None, **options: Unpack[RequestOptions]
|
|
423
|
+
) -> WebhookEndpoint:
|
|
424
|
+
"""Adds an endpoint. The answer's ``secret`` is shown only this once."""
|
|
425
|
+
body = {"url": url, **({"description": description} if description is not None else {})}
|
|
426
|
+
# Not retried after a network failure: it could add the endpoint twice.
|
|
427
|
+
return await self._client._request("POST", "/webhook_endpoints", body=body, retry="unsafe", options=options)
|
|
428
|
+
|
|
429
|
+
async def list(self, **options: Unpack[RequestOptions]) -> WebhookEndpointList:
|
|
430
|
+
return await self._client._request("GET", "/webhook_endpoints", options=options)
|
|
431
|
+
|
|
432
|
+
async def delete(self, endpoint_id: str, **options: Unpack[RequestOptions]) -> None:
|
|
433
|
+
await self._client._request("DELETE", f"/webhook_endpoints/{_base.quote(endpoint_id)}", options=options)
|
|
434
|
+
|
|
435
|
+
async def send_test(self, endpoint_id: str, **options: Unpack[RequestOptions]) -> None:
|
|
436
|
+
"""Sends a ``webhook.test`` event to the endpoint."""
|
|
437
|
+
await self._client._request(
|
|
438
|
+
"POST", f"/webhook_endpoints/{_base.quote(endpoint_id)}/test", retry="unsafe", options=options
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
async def deliveries(
|
|
442
|
+
self, endpoint_id: str, *, limit: Optional[int] = None, **options: Unpack[RequestOptions]
|
|
443
|
+
) -> WebhookDeliveryList:
|
|
444
|
+
"""Recent deliveries to the endpoint, with what it answered."""
|
|
445
|
+
return await self._client._request(
|
|
446
|
+
"GET", f"/webhook_endpoints/{_base.quote(endpoint_id)}/deliveries", params={"limit": limit}, options=options
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
async def resend_delivery(
|
|
450
|
+
self, endpoint_id: str, delivery_id: str, **options: Unpack[RequestOptions]
|
|
451
|
+
) -> WebhookDelivery:
|
|
452
|
+
"""Sends a past delivery's event to the endpoint again, now. It keeps its event id,
|
|
453
|
+
so a receiver that skips repeats by event id skips it too."""
|
|
454
|
+
return await self._client._request(
|
|
455
|
+
"POST",
|
|
456
|
+
f"/webhook_endpoints/{_base.quote(endpoint_id)}/deliveries/{_base.quote(delivery_id)}/resend",
|
|
457
|
+
retry="unsafe",
|
|
458
|
+
options=options,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class AsyncLanguagesResource:
|
|
463
|
+
def __init__(self, client: AsyncReedapt) -> None:
|
|
464
|
+
self._client = client
|
|
465
|
+
|
|
466
|
+
async def list(self, **options: Unpack[RequestOptions]) -> Languages:
|
|
467
|
+
"""The codes this key's plan can dub into right now."""
|
|
468
|
+
return await self._client._request("GET", "/languages", options=options)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class AsyncUsageResource:
|
|
472
|
+
def __init__(self, client: AsyncReedapt) -> None:
|
|
473
|
+
self._client = client
|
|
474
|
+
|
|
475
|
+
async def retrieve(self, **options: Unpack[RequestOptions]) -> Usage:
|
|
476
|
+
"""The plan, the kind of key and the minutes left."""
|
|
477
|
+
return await self._client._request("GET", "/usage", options=options)
|
|
478
|
+
|
|
479
|
+
async def report(self, *, month: Optional[str] = None, **options: Unpack[RequestOptions]) -> UsageReport:
|
|
480
|
+
"""A month (``YYYY-MM``, UTC; default this month) of minutes and dubs, per key and per language."""
|
|
481
|
+
return await self._client._request("GET", "/usage/report", params={"month": month}, options=options)
|