scriptivox 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.
- scriptivox/__init__.py +145 -0
- scriptivox/_async_client.py +428 -0
- scriptivox/_client.py +437 -0
- scriptivox/_common.py +293 -0
- scriptivox/_error_codes.py +190 -0
- scriptivox/_exceptions.py +196 -0
- scriptivox/_types.py +473 -0
- scriptivox/_version.py +1 -0
- scriptivox/_webhooks.py +108 -0
- scriptivox/py.typed +0 -0
- scriptivox-0.1.0.dist-info/METADATA +235 -0
- scriptivox-0.1.0.dist-info/RECORD +14 -0
- scriptivox-0.1.0.dist-info/WHEEL +4 -0
- scriptivox-0.1.0.dist-info/licenses/LICENSE +21 -0
scriptivox/__init__.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Official Python SDK for the Scriptivox transcription API.
|
|
2
|
+
|
|
3
|
+
from scriptivox import Scriptivox
|
|
4
|
+
|
|
5
|
+
client = Scriptivox() # reads SCRIPTIVOX_API_KEY
|
|
6
|
+
job = client.transcribe(file="meeting.mp3", language="en")
|
|
7
|
+
done = client.wait(job["id"])
|
|
8
|
+
print(done["result"]["full_transcript"])
|
|
9
|
+
"""
|
|
10
|
+
from ._async_client import AsyncScriptivox
|
|
11
|
+
from ._client import Scriptivox, UploadResult
|
|
12
|
+
from ._common import DEFAULT_BASE_URL, UploadProgress, safe_filename
|
|
13
|
+
from ._error_codes import * # noqa: F401,F403 (one exception class per error.code)
|
|
14
|
+
from ._error_codes import API_ERROR_CODES, ERROR_CLASS_BY_CODE, HTTP_STATUS_BY_CODE, TRANSCRIPTION_ERROR_CODES
|
|
15
|
+
from ._error_codes import __all__ as _code_classes
|
|
16
|
+
from ._exceptions import (
|
|
17
|
+
APIConnectionError,
|
|
18
|
+
APIError,
|
|
19
|
+
APITimeoutError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
BadRequestError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
GoneError,
|
|
24
|
+
InternalServerError,
|
|
25
|
+
MethodNotAllowedError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
PayloadTooLargeError,
|
|
28
|
+
PaymentRequiredError,
|
|
29
|
+
PermissionDeniedError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ScriptivoxError,
|
|
32
|
+
ServiceUnavailableError,
|
|
33
|
+
TranscriptionFailedError,
|
|
34
|
+
UnprocessableEntityError,
|
|
35
|
+
UnsupportedMediaTypeError,
|
|
36
|
+
UploadFailedError,
|
|
37
|
+
WaitTimeoutError,
|
|
38
|
+
WebhookVerificationError,
|
|
39
|
+
)
|
|
40
|
+
from ._types import (
|
|
41
|
+
AutoTopup,
|
|
42
|
+
Balance,
|
|
43
|
+
FeedbackRequest,
|
|
44
|
+
FeedbackResult,
|
|
45
|
+
Transcription,
|
|
46
|
+
TranscriptionCancelled,
|
|
47
|
+
TranscriptionCreated,
|
|
48
|
+
TranscriptionError,
|
|
49
|
+
TranscriptionPage,
|
|
50
|
+
TranscriptionResult,
|
|
51
|
+
TranscriptionStatus,
|
|
52
|
+
TranscriptionSummary,
|
|
53
|
+
Upload,
|
|
54
|
+
Utterance,
|
|
55
|
+
WebhookSendResult,
|
|
56
|
+
WebhookTest,
|
|
57
|
+
WebhookTranscriptionCompleted,
|
|
58
|
+
WebhookTranscriptionDownloading,
|
|
59
|
+
WebhookTranscriptionFailed,
|
|
60
|
+
WebhookTranscriptionProcessing,
|
|
61
|
+
Word,
|
|
62
|
+
)
|
|
63
|
+
from ._version import __version__
|
|
64
|
+
from ._webhooks import (
|
|
65
|
+
ATTEMPT_HEADER,
|
|
66
|
+
DEFAULT_TOLERANCE,
|
|
67
|
+
DELIVERY_HEADER,
|
|
68
|
+
SIGNATURE_HEADER,
|
|
69
|
+
TIMESTAMP_HEADER,
|
|
70
|
+
WebhookEvent,
|
|
71
|
+
compute_signature,
|
|
72
|
+
verify_webhook,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"Scriptivox",
|
|
77
|
+
"AsyncScriptivox",
|
|
78
|
+
"verify_webhook",
|
|
79
|
+
"compute_signature",
|
|
80
|
+
"safe_filename",
|
|
81
|
+
"UploadProgress",
|
|
82
|
+
"UploadResult",
|
|
83
|
+
"DEFAULT_BASE_URL",
|
|
84
|
+
"DEFAULT_TOLERANCE",
|
|
85
|
+
"SIGNATURE_HEADER",
|
|
86
|
+
"TIMESTAMP_HEADER",
|
|
87
|
+
"DELIVERY_HEADER",
|
|
88
|
+
"ATTEMPT_HEADER",
|
|
89
|
+
"WebhookEvent",
|
|
90
|
+
"ScriptivoxError",
|
|
91
|
+
"APIError",
|
|
92
|
+
"APIConnectionError",
|
|
93
|
+
"APITimeoutError",
|
|
94
|
+
"BadRequestError",
|
|
95
|
+
"AuthenticationError",
|
|
96
|
+
"PaymentRequiredError",
|
|
97
|
+
"PermissionDeniedError",
|
|
98
|
+
"NotFoundError",
|
|
99
|
+
"MethodNotAllowedError",
|
|
100
|
+
"ConflictError",
|
|
101
|
+
"GoneError",
|
|
102
|
+
"PayloadTooLargeError",
|
|
103
|
+
"UnsupportedMediaTypeError",
|
|
104
|
+
"UnprocessableEntityError",
|
|
105
|
+
"RateLimitError",
|
|
106
|
+
"InternalServerError",
|
|
107
|
+
"ServiceUnavailableError",
|
|
108
|
+
"UploadFailedError",
|
|
109
|
+
"TranscriptionFailedError",
|
|
110
|
+
"WaitTimeoutError",
|
|
111
|
+
"WebhookVerificationError",
|
|
112
|
+
"AutoTopup",
|
|
113
|
+
"Balance",
|
|
114
|
+
"FeedbackRequest",
|
|
115
|
+
"FeedbackResult",
|
|
116
|
+
"Transcription",
|
|
117
|
+
"TranscriptionCancelled",
|
|
118
|
+
"TranscriptionCreated",
|
|
119
|
+
"TranscriptionError",
|
|
120
|
+
"TranscriptionPage",
|
|
121
|
+
"TranscriptionResult",
|
|
122
|
+
"TranscriptionStatus",
|
|
123
|
+
"TranscriptionSummary",
|
|
124
|
+
"Upload",
|
|
125
|
+
"Utterance",
|
|
126
|
+
"WebhookSendResult",
|
|
127
|
+
"WebhookTest",
|
|
128
|
+
"WebhookTranscriptionCompleted",
|
|
129
|
+
"WebhookTranscriptionDownloading",
|
|
130
|
+
"WebhookTranscriptionFailed",
|
|
131
|
+
"WebhookTranscriptionProcessing",
|
|
132
|
+
"Word",
|
|
133
|
+
"__version__",
|
|
134
|
+
*_code_classes,
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
# Tracebacks and reprs name the public module (scriptivox.ZeroBalanceError), not the private file it lives in.
|
|
138
|
+
for _name in __all__:
|
|
139
|
+
_obj = globals().get(_name)
|
|
140
|
+
if isinstance(_obj, type) and getattr(_obj, "__module__", "").startswith("scriptivox._"):
|
|
141
|
+
try:
|
|
142
|
+
_obj.__module__ = __name__
|
|
143
|
+
except (AttributeError, TypeError): # pragma: no cover
|
|
144
|
+
pass
|
|
145
|
+
del _name, _obj
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""The asyncio client: the same methods as Scriptivox, awaited; list() is an async iterator."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Union, cast, overload
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _common as c
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIError,
|
|
15
|
+
APITimeoutError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ScriptivoxError,
|
|
18
|
+
TranscriptionFailedError,
|
|
19
|
+
UploadFailedError,
|
|
20
|
+
WaitTimeoutError,
|
|
21
|
+
)
|
|
22
|
+
from ._types import (
|
|
23
|
+
Balance,
|
|
24
|
+
FeedbackResult,
|
|
25
|
+
Transcription,
|
|
26
|
+
TranscriptionCancelled,
|
|
27
|
+
TranscriptionCreated,
|
|
28
|
+
TranscriptionPage,
|
|
29
|
+
TranscriptionStatus,
|
|
30
|
+
TranscriptionSummary,
|
|
31
|
+
Upload,
|
|
32
|
+
WebhookSendResult,
|
|
33
|
+
)
|
|
34
|
+
from ._webhooks import HeadersLike, WebhookEvent, verify_webhook
|
|
35
|
+
|
|
36
|
+
from typing import Literal
|
|
37
|
+
|
|
38
|
+
from ._client import _MISSING, UploadResult, _resolve_key
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _aiter(chunks: Any) -> Any:
|
|
42
|
+
"""httpx.AsyncClient streams only async iterables. The file is read in 1 MiB chunks between sends."""
|
|
43
|
+
for chunk in chunks:
|
|
44
|
+
yield chunk
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncScriptivox:
|
|
48
|
+
"""The Scriptivox API client for asyncio.
|
|
49
|
+
|
|
50
|
+
>>> async with AsyncScriptivox() as client: # reads SCRIPTIVOX_API_KEY
|
|
51
|
+
... job = await client.transcribe(file="meeting.mp3", language="en")
|
|
52
|
+
... done = await client.wait(job["id"])
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
api_key: Optional[str] = None,
|
|
58
|
+
*,
|
|
59
|
+
base_url: Optional[str] = None,
|
|
60
|
+
max_retries: int = c.DEFAULT_MAX_RETRIES,
|
|
61
|
+
timeout: float = c.DEFAULT_TIMEOUT,
|
|
62
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
63
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._api_key = _resolve_key(api_key)
|
|
66
|
+
self.base_url = c.normalize_base_url(base_url or os.environ.get("SCRIPTIVOX_BASE_URL") or c.DEFAULT_BASE_URL)
|
|
67
|
+
self.max_retries = max_retries
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self._default_headers = dict(default_headers or {})
|
|
70
|
+
self._owns_client = http_client is None
|
|
71
|
+
self._http = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
72
|
+
|
|
73
|
+
# ─── lifecycle ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
if self._owns_client:
|
|
77
|
+
await self._http.aclose()
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> "AsyncScriptivox":
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
83
|
+
await self.aclose()
|
|
84
|
+
|
|
85
|
+
# ─── transport ────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async def _send(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
path: str,
|
|
91
|
+
*,
|
|
92
|
+
idempotent: bool,
|
|
93
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
94
|
+
body: Any = _MISSING,
|
|
95
|
+
idempotency_key: Optional[str] = None,
|
|
96
|
+
text: bool = False,
|
|
97
|
+
timeout: Optional[float] = None,
|
|
98
|
+
max_retries: Optional[int] = None,
|
|
99
|
+
) -> Any:
|
|
100
|
+
retries = self.max_retries if max_retries is None else max_retries
|
|
101
|
+
headers: Dict[str, str] = {
|
|
102
|
+
**self._default_headers,
|
|
103
|
+
"Authorization": self._api_key,
|
|
104
|
+
"Accept": "text/plain, text/vtt, application/json" if text else "application/json",
|
|
105
|
+
"User-Agent": c.user_agent(),
|
|
106
|
+
}
|
|
107
|
+
if idempotency_key:
|
|
108
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
109
|
+
kwargs: Dict[str, Any] = {}
|
|
110
|
+
if body is not _MISSING:
|
|
111
|
+
kwargs["json"] = body
|
|
112
|
+
attempt = 0
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
response = await self._http.request(
|
|
116
|
+
method,
|
|
117
|
+
self.base_url + path,
|
|
118
|
+
params=c.build_query(query or {}),
|
|
119
|
+
headers=headers,
|
|
120
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
121
|
+
**kwargs,
|
|
122
|
+
)
|
|
123
|
+
except httpx.TimeoutException as exc:
|
|
124
|
+
err: Exception = APITimeoutError(f"No answer from {method} {path} in time.")
|
|
125
|
+
err.__cause__ = exc
|
|
126
|
+
except httpx.TransportError as exc:
|
|
127
|
+
err = APIConnectionError(f"Could not reach the Scriptivox API ({method} {path}): {exc}")
|
|
128
|
+
err.__cause__ = exc
|
|
129
|
+
else:
|
|
130
|
+
if response.is_success:
|
|
131
|
+
if text:
|
|
132
|
+
return response.text
|
|
133
|
+
if not response.content:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return response.json()
|
|
137
|
+
except ValueError:
|
|
138
|
+
raise APIError(
|
|
139
|
+
status=response.status_code,
|
|
140
|
+
code="INVALID_RESPONSE",
|
|
141
|
+
message=f"The API answered {method} {path} with a body that is not JSON.",
|
|
142
|
+
request_id=response.headers.get("x-request-id"),
|
|
143
|
+
headers=response.headers,
|
|
144
|
+
) from None
|
|
145
|
+
api_err = c.error_from_response(response.status_code, response.text, response.headers)
|
|
146
|
+
delay = c.retry_delay_for(api_err, idempotent, attempt) if attempt < retries else None
|
|
147
|
+
if delay is None:
|
|
148
|
+
raise api_err
|
|
149
|
+
await asyncio.sleep(delay)
|
|
150
|
+
attempt += 1
|
|
151
|
+
continue
|
|
152
|
+
if idempotent and attempt < retries:
|
|
153
|
+
await asyncio.sleep(c.backoff(attempt))
|
|
154
|
+
attempt += 1
|
|
155
|
+
continue
|
|
156
|
+
raise err
|
|
157
|
+
|
|
158
|
+
async def _put(self, upload_url: str, headers: Mapping[str, str], source: c.FileSource, on_progress: Optional[c.ProgressCallback]) -> None:
|
|
159
|
+
attempt = 0
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
response = await self._http.request(
|
|
163
|
+
"PUT",
|
|
164
|
+
upload_url,
|
|
165
|
+
content=_aiter(c.counted(source, on_progress)),
|
|
166
|
+
headers={**headers, "Content-Length": str(source.size), "User-Agent": c.user_agent()},
|
|
167
|
+
timeout=c.UPLOAD_IDLE_TIMEOUT,
|
|
168
|
+
)
|
|
169
|
+
if response.is_success:
|
|
170
|
+
return
|
|
171
|
+
err: Exception = UploadFailedError(
|
|
172
|
+
f"The file upload was refused with HTTP {response.status_code}.",
|
|
173
|
+
status=response.status_code,
|
|
174
|
+
body=response.text[:2048],
|
|
175
|
+
)
|
|
176
|
+
transient = response.status_code >= 500
|
|
177
|
+
except httpx.TimeoutException as exc:
|
|
178
|
+
err = APITimeoutError("The upload stalled.")
|
|
179
|
+
err.__cause__ = exc
|
|
180
|
+
transient = True
|
|
181
|
+
except httpx.TransportError as exc:
|
|
182
|
+
err = APIConnectionError(f"The upload connection failed: {exc}")
|
|
183
|
+
err.__cause__ = exc
|
|
184
|
+
transient = True
|
|
185
|
+
if not transient or not source.repeatable or attempt >= self.max_retries:
|
|
186
|
+
raise err
|
|
187
|
+
await asyncio.sleep(c.backoff(attempt))
|
|
188
|
+
attempt += 1
|
|
189
|
+
|
|
190
|
+
# ─── upload and transcribe ────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async def upload(
|
|
193
|
+
self,
|
|
194
|
+
file: c.FileInput,
|
|
195
|
+
*,
|
|
196
|
+
filename: Optional[str] = None,
|
|
197
|
+
size: Optional[int] = None,
|
|
198
|
+
on_progress: Optional[c.ProgressCallback] = None,
|
|
199
|
+
idempotency_key: Optional[str] = None,
|
|
200
|
+
) -> UploadResult:
|
|
201
|
+
"""Upload a local file (POST /v1/upload, then a streamed PUT to the presigned URL).
|
|
202
|
+
|
|
203
|
+
``file`` is a path, bytes, or a binary file object. Large files are streamed, never read
|
|
204
|
+
into memory. Returns the ``upload_id`` for ``transcribe(upload_id=...)``.
|
|
205
|
+
"""
|
|
206
|
+
source = c.resolve_source(file, size)
|
|
207
|
+
raw_name = filename or source.name
|
|
208
|
+
if not raw_name:
|
|
209
|
+
raise ValueError('Uploading bytes or a stream needs a filename with an extension: pass filename="audio.mp3".')
|
|
210
|
+
safe = c.safe_filename(raw_name)
|
|
211
|
+
upload = cast(
|
|
212
|
+
Upload,
|
|
213
|
+
await self._send(
|
|
214
|
+
"POST",
|
|
215
|
+
"/v1/upload",
|
|
216
|
+
body={"filename": safe},
|
|
217
|
+
idempotency_key=idempotency_key or str(uuid.uuid4()),
|
|
218
|
+
idempotent=True,
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
if upload.get("method", "PUT").upper() != "PUT":
|
|
222
|
+
raise ScriptivoxError(f"The API asked for an upload method this SDK does not know: {upload.get('method')}.")
|
|
223
|
+
await self._put(upload["upload_url"], upload.get("headers") or {}, source, on_progress)
|
|
224
|
+
return {"upload_id": upload["upload_id"], "filename": safe, "size": source.size}
|
|
225
|
+
|
|
226
|
+
async def transcribe(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
url: Optional[str] = None,
|
|
230
|
+
file: Optional[c.FileInput] = None,
|
|
231
|
+
upload_id: Optional[str] = None,
|
|
232
|
+
language: Optional[str] = None,
|
|
233
|
+
diarize: Optional[bool] = None,
|
|
234
|
+
speaker_count: Optional[int] = None,
|
|
235
|
+
align: Optional[bool] = None,
|
|
236
|
+
webhook_url: Optional[str] = None,
|
|
237
|
+
filename: Optional[str] = None,
|
|
238
|
+
size: Optional[int] = None,
|
|
239
|
+
on_upload_progress: Optional[c.ProgressCallback] = None,
|
|
240
|
+
idempotency_key: Optional[str] = None,
|
|
241
|
+
) -> TranscriptionCreated:
|
|
242
|
+
"""Start a transcription from a public ``url``, a local ``file`` (uploaded first) or an earlier
|
|
243
|
+
``upload_id``. Returns as soon as the job is accepted (status "created"); call wait(id) next.
|
|
244
|
+
|
|
245
|
+
Every call sends an Idempotency-Key (a new UUID unless you pass one), so the SDK's own retries
|
|
246
|
+
never create a second billable job. Omit ``language`` to auto-detect.
|
|
247
|
+
"""
|
|
248
|
+
c.check_one_source(url, file, upload_id)
|
|
249
|
+
if file is not None:
|
|
250
|
+
upload_id = (await self.upload(file, filename=filename, size=size, on_progress=on_upload_progress))["upload_id"]
|
|
251
|
+
body = c.transcribe_body(
|
|
252
|
+
url,
|
|
253
|
+
upload_id,
|
|
254
|
+
{"language": language, "diarize": diarize, "speaker_count": speaker_count, "align": align, "webhook_url": webhook_url},
|
|
255
|
+
)
|
|
256
|
+
return cast(
|
|
257
|
+
TranscriptionCreated,
|
|
258
|
+
await self._send("POST", "/v1/transcribe", body=body, idempotency_key=idempotency_key or str(uuid.uuid4()), idempotent=True),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# ─── read ─────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
@overload
|
|
264
|
+
async def get(self, transcription_id: str) -> Transcription: ...
|
|
265
|
+
|
|
266
|
+
@overload
|
|
267
|
+
async def get(self, transcription_id: str, *, format: Literal["json"]) -> Transcription: ...
|
|
268
|
+
|
|
269
|
+
@overload
|
|
270
|
+
async def get(
|
|
271
|
+
self,
|
|
272
|
+
transcription_id: str,
|
|
273
|
+
*,
|
|
274
|
+
format: Literal["srt", "vtt", "text"],
|
|
275
|
+
max_words: Optional[int] = None,
|
|
276
|
+
max_chars: Optional[int] = None,
|
|
277
|
+
max_duration: Optional[int] = None,
|
|
278
|
+
sentence_aware: Optional[bool] = None,
|
|
279
|
+
include_speakers: Union[bool, Literal["auto", "true", "false"], None] = None,
|
|
280
|
+
strip_chars: Optional[str] = None,
|
|
281
|
+
include_timestamps: Optional[bool] = None,
|
|
282
|
+
) -> str: ...
|
|
283
|
+
|
|
284
|
+
async def get(
|
|
285
|
+
self,
|
|
286
|
+
transcription_id: str,
|
|
287
|
+
*,
|
|
288
|
+
format: str = "json",
|
|
289
|
+
max_words: Optional[int] = None,
|
|
290
|
+
max_chars: Optional[int] = None,
|
|
291
|
+
max_duration: Optional[int] = None,
|
|
292
|
+
sentence_aware: Optional[bool] = None,
|
|
293
|
+
include_speakers: Union[bool, str, None] = None,
|
|
294
|
+
strip_chars: Optional[str] = None,
|
|
295
|
+
include_timestamps: Optional[bool] = None,
|
|
296
|
+
) -> Union[Transcription, str]:
|
|
297
|
+
"""GET /v1/transcribe/{id}: the job (format "json"), or the SRT / VTT / text file as a string."""
|
|
298
|
+
query = c.export_query(
|
|
299
|
+
format,
|
|
300
|
+
{
|
|
301
|
+
"max_words": max_words,
|
|
302
|
+
"max_chars": max_chars,
|
|
303
|
+
"max_duration": max_duration,
|
|
304
|
+
"sentence_aware": sentence_aware,
|
|
305
|
+
"include_speakers": include_speakers,
|
|
306
|
+
"strip_chars": strip_chars,
|
|
307
|
+
"include_timestamps": include_timestamps,
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
return cast(
|
|
311
|
+
Union[Transcription, str],
|
|
312
|
+
await self._send("GET", f"/v1/transcribe/{c.path_id(transcription_id)}", query=query, idempotent=True, text=format != "json"),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def wait(
|
|
316
|
+
self,
|
|
317
|
+
transcription_id: str,
|
|
318
|
+
*,
|
|
319
|
+
timeout: float = c.DEFAULT_WAIT_TIMEOUT,
|
|
320
|
+
poll_interval: float = c.DEFAULT_POLL_INTERVAL,
|
|
321
|
+
max_poll_interval: float = c.DEFAULT_MAX_POLL_INTERVAL,
|
|
322
|
+
on_poll: Optional[Any] = None,
|
|
323
|
+
) -> Transcription:
|
|
324
|
+
"""Poll until the job ends and return it completed. A failed job raises TranscriptionFailedError
|
|
325
|
+
(its ``code`` is the job's error.code). The wait between polls starts at 2 s and grows to 10 s;
|
|
326
|
+
a 429 waits out its Retry-After. ``timeout`` is in seconds (default 2 hours)."""
|
|
327
|
+
deadline = c.monotonic() + timeout
|
|
328
|
+
interval = min(poll_interval, max_poll_interval)
|
|
329
|
+
while True:
|
|
330
|
+
try:
|
|
331
|
+
job: Transcription = await self.get(transcription_id)
|
|
332
|
+
except RateLimitError as exc:
|
|
333
|
+
if exc.retry_after is not None and c.monotonic() + exc.retry_after < deadline:
|
|
334
|
+
await asyncio.sleep(exc.retry_after)
|
|
335
|
+
continue
|
|
336
|
+
raise
|
|
337
|
+
if on_poll:
|
|
338
|
+
on_poll(job)
|
|
339
|
+
if job["status"] == "completed":
|
|
340
|
+
return job
|
|
341
|
+
if job["status"] == "failed":
|
|
342
|
+
raise TranscriptionFailedError(job)
|
|
343
|
+
if c.monotonic() + interval > deadline:
|
|
344
|
+
raise WaitTimeoutError(job, timeout)
|
|
345
|
+
await asyncio.sleep(interval)
|
|
346
|
+
interval = min(interval * 1.5, max_poll_interval)
|
|
347
|
+
|
|
348
|
+
async def list_page(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
status: Optional[TranscriptionStatus] = None,
|
|
352
|
+
from_: Optional[Any] = None,
|
|
353
|
+
to: Optional[Any] = None,
|
|
354
|
+
limit: Optional[int] = None,
|
|
355
|
+
order: Optional[Literal["desc", "asc"]] = None,
|
|
356
|
+
cursor: Optional[str] = None,
|
|
357
|
+
) -> TranscriptionPage:
|
|
358
|
+
"""One page of GET /v1/transcriptions. Most callers want list(), which follows the cursor."""
|
|
359
|
+
query = {"status": status, "from": from_, "to": to, "limit": limit, "order": order, "cursor": cursor}
|
|
360
|
+
return cast(TranscriptionPage, await self._send("GET", "/v1/transcriptions", query=query, idempotent=True))
|
|
361
|
+
|
|
362
|
+
async def list( # noqa: A003
|
|
363
|
+
self,
|
|
364
|
+
*,
|
|
365
|
+
status: Optional[TranscriptionStatus] = None,
|
|
366
|
+
from_: Optional[Any] = None,
|
|
367
|
+
to: Optional[Any] = None,
|
|
368
|
+
limit: Optional[int] = None,
|
|
369
|
+
order: Optional[Literal["desc", "asc"]] = None,
|
|
370
|
+
cursor: Optional[str] = None,
|
|
371
|
+
) -> AsyncIterator[TranscriptionSummary]:
|
|
372
|
+
"""Every matching transcription, newest first, fetched page by page as you iterate (async for).
|
|
373
|
+
``from_`` and ``to`` take a datetime or an ISO string. Items omit ``result``; get(id) has it."""
|
|
374
|
+
while True:
|
|
375
|
+
page = await self.list_page(status=status, from_=from_, to=to, limit=limit, order=order, cursor=cursor)
|
|
376
|
+
for item in page["items"]:
|
|
377
|
+
yield item
|
|
378
|
+
cursor = page.get("next_cursor")
|
|
379
|
+
if not cursor:
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
# ─── change ───────────────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
async def cancel(self, transcription_id: str) -> TranscriptionCancelled:
|
|
385
|
+
"""Stop a job before it starts transcribing (created, downloading or pending). You are not charged."""
|
|
386
|
+
return cast(TranscriptionCancelled, await self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/cancel", idempotent=True))
|
|
387
|
+
|
|
388
|
+
async def delete(self, transcription_id: str) -> None:
|
|
389
|
+
"""Delete a finished job and its transcript. Twice is fine; a job still running raises ConflictError (cancel it first)."""
|
|
390
|
+
await self._send("DELETE", f"/v1/transcribe/{c.path_id(transcription_id)}", idempotent=True)
|
|
391
|
+
|
|
392
|
+
async def balance(self) -> Balance:
|
|
393
|
+
"""Balance, reserved and available amounts in cents, the price per hour and the auto top-up settings."""
|
|
394
|
+
return cast(Balance, await self._send("GET", "/v1/balance", idempotent=True))
|
|
395
|
+
|
|
396
|
+
# ─── webhooks and feedback ────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
async def resend_webhook(self, transcription_id: str) -> WebhookSendResult:
|
|
399
|
+
"""Send a finished job's final webhook again now, with the same X-Scriptivox-Delivery id."""
|
|
400
|
+
return cast(WebhookSendResult, await self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/webhook/resend", idempotent=False))
|
|
401
|
+
|
|
402
|
+
async def test_webhook(self, url: Optional[str] = None) -> WebhookSendResult:
|
|
403
|
+
"""Send a signed ``webhook.test`` event to ``url`` (or your default webhook URL) and report the answer."""
|
|
404
|
+
return cast(WebhookSendResult, await self._send("POST", "/v1/webhooks/test", body={} if url is None else {"url": url}, idempotent=False))
|
|
405
|
+
|
|
406
|
+
async def feedback(
|
|
407
|
+
self,
|
|
408
|
+
transcription_id: str,
|
|
409
|
+
*,
|
|
410
|
+
rating: Optional[int] = None,
|
|
411
|
+
topics: Optional[List[str]] = None,
|
|
412
|
+
comment: Optional[str] = None,
|
|
413
|
+
their_words: Optional[str] = None,
|
|
414
|
+
problem: Optional[bool] = None,
|
|
415
|
+
what_happened: Optional[str] = None,
|
|
416
|
+
) -> FeedbackResult:
|
|
417
|
+
"""Rate a completed job (1–5) and/or report a problem with any job."""
|
|
418
|
+
body = {
|
|
419
|
+
k: v
|
|
420
|
+
for k, v in {"rating": rating, "topics": topics, "comment": comment, "their_words": their_words, "problem": problem, "what_happened": what_happened}.items()
|
|
421
|
+
if v is not None
|
|
422
|
+
}
|
|
423
|
+
return cast(FeedbackResult, await self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/feedback", body=body, idempotent=False))
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def verify_webhook(raw_body: Union[bytes, str], headers: HeadersLike, secret: str, *, tolerance: int = 300, now: Optional[float] = None) -> WebhookEvent:
|
|
427
|
+
"""Same as the module-level verify_webhook()."""
|
|
428
|
+
return verify_webhook(raw_body, headers, secret, tolerance=tolerance, now=now)
|