tldw-client 0.2.6__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,1221 @@
1
+ """A dependency-free, scoped-key client for the tldw.sh async API.
2
+
3
+ No server-returned URLs are followed. Waiting never cancels accepted server work.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import io
9
+ import json
10
+ import math
11
+ import re
12
+ import socket
13
+ import time
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from decimal import Decimal, InvalidOperation
17
+ from email.utils import parsedate_to_datetime
18
+ from http.client import HTTPConnection, HTTPException, HTTPSConnection
19
+ from urllib.error import HTTPError, URLError
20
+ from urllib.parse import urlencode, urlsplit
21
+ from urllib.request import (
22
+ HTTPHandler,
23
+ HTTPRedirectHandler,
24
+ HTTPSHandler,
25
+ ProxyHandler,
26
+ Request,
27
+ build_opener,
28
+ )
29
+
30
+ __all__ = [
31
+ "TldwCanceledError",
32
+ "TldwClient",
33
+ "TldwError",
34
+ "TldwJobError",
35
+ "TldwTimeoutError",
36
+ ]
37
+ __version__ = "0.2.6"
38
+ _MAX_BODY = 8 * 1024 * 1024
39
+ _TRANSIENT = {429, 502, 503, 504}
40
+
41
+
42
+ def _spending_options(transcription=None, max_credits=None, force_asr=None):
43
+ if transcription is not None and transcription not in ("never", "if_needed"):
44
+ raise ValueError("transcription must be never or if_needed.")
45
+ if force_asr is not None and not isinstance(force_asr, bool):
46
+ raise ValueError("force_asr must be a boolean.")
47
+ if force_asr and transcription != "if_needed":
48
+ raise ValueError("force_asr requires explicit transcription=if_needed.")
49
+ result = {}
50
+ if transcription is not None:
51
+ result["transcription"] = transcription
52
+ if force_asr is not None:
53
+ result["force_asr"] = force_asr
54
+ if max_credits is not None:
55
+ try:
56
+ if isinstance(max_credits, bool):
57
+ raise TypeError
58
+ value = Decimal(str(max_credits))
59
+ if (
60
+ not value.is_finite()
61
+ or not 0 < value <= 1_000_000
62
+ or value != value.quantize(Decimal("0.01"))
63
+ ):
64
+ raise ValueError
65
+ except (InvalidOperation, ValueError, TypeError):
66
+ raise ValueError(
67
+ "max_credits must be greater than zero, at most 1000000, with at most two decimal places."
68
+ ) from None
69
+ result["max_credits"] = format(value.quantize(Decimal("0.01")), "f")
70
+ return result
71
+
72
+
73
+ _JOB_STATES = {
74
+ "queued",
75
+ "fetching_metadata",
76
+ "fetching_transcript",
77
+ "downloading_audio",
78
+ "transcribing",
79
+ "summarizing",
80
+ "completed",
81
+ "failed",
82
+ "canceled",
83
+ }
84
+ _COMPARISON_STATES = {
85
+ "queued",
86
+ "waiting",
87
+ "processing",
88
+ "completed",
89
+ "failed",
90
+ "canceled",
91
+ }
92
+
93
+
94
+ class TldwError(Exception):
95
+ def __init__(
96
+ self,
97
+ message,
98
+ *,
99
+ code="CLIENT_ERROR",
100
+ status=None,
101
+ request_id=None,
102
+ retry_after_seconds=None,
103
+ job_id=None,
104
+ comparison_id=None,
105
+ details=None,
106
+ ):
107
+ super().__init__(message)
108
+ self.code = code
109
+ self.status = status
110
+ self.request_id = request_id
111
+ self.retry_after_seconds = retry_after_seconds
112
+ self.job_id = job_id
113
+ self.comparison_id = comparison_id
114
+ # Redacted API error.details, e.g. {"job_id": ...} or {"required_scope": ...}.
115
+ self.details = details
116
+
117
+
118
+ class TldwTimeoutError(TldwError):
119
+ def __init__(
120
+ self,
121
+ message="The wait ended. Accepted work continues on the server.",
122
+ **details,
123
+ ):
124
+ super().__init__(message, code="WAIT_TIMEOUT", **details)
125
+
126
+
127
+ class TldwCanceledError(TldwError):
128
+ def __init__(self):
129
+ super().__init__(
130
+ "The wait was canceled. Accepted work continues on the server.",
131
+ code="WAIT_CANCELED",
132
+ )
133
+
134
+
135
+ class TldwJobError(TldwError):
136
+ def __init__(self, resource, kind="job"):
137
+ error = resource.get("error") or {}
138
+ error = error if isinstance(error, dict) else {}
139
+ super().__init__(
140
+ error.get("message")
141
+ if isinstance(error.get("message"), str)
142
+ else f"The {kind} {resource['status']}.",
143
+ code=error.get("code")
144
+ if isinstance(error.get("code"), str)
145
+ else ("CANCELED" if resource["status"] == "canceled" else "JOB_FAILED"),
146
+ **{f"{kind}_id": resource["id"]},
147
+ )
148
+ self.resource = resource
149
+
150
+
151
+ def _identifier(value, label="ID"):
152
+ try:
153
+ if not isinstance(value, str) or len(value) != 36:
154
+ raise ValueError()
155
+ parsed = uuid.UUID(value)
156
+ if str(parsed) != value.lower():
157
+ raise ValueError()
158
+ return str(parsed)
159
+ except (ValueError, AttributeError):
160
+ raise ValueError(f"{label} must be a UUID.") from None
161
+
162
+
163
+ def _bound(value, label, minimum, maximum):
164
+ if (
165
+ isinstance(value, bool)
166
+ or not isinstance(value, (int, float))
167
+ or not math.isfinite(value)
168
+ or not minimum <= value <= maximum
169
+ ):
170
+ raise ValueError(f"{label} must be between {minimum} and {maximum}.")
171
+ return float(value)
172
+
173
+
174
+ def _object(value, label):
175
+ if not isinstance(value, dict):
176
+ raise TldwError(f"Invalid {label} response.", code="INVALID_RESPONSE")
177
+ return value
178
+
179
+
180
+ def _idempotency_key(value):
181
+ value = str(uuid.uuid4()) if value is None else value
182
+ if (
183
+ not isinstance(value, str)
184
+ or not 1 <= len(value) <= 128
185
+ or not all(33 <= ord(char) <= 126 for char in value)
186
+ ):
187
+ raise ValueError(
188
+ "idempotency_key must contain 1–128 printable ASCII characters without whitespace."
189
+ )
190
+ return value
191
+
192
+
193
+ def _retry_delay(value):
194
+ if not value:
195
+ return None
196
+ try:
197
+ seconds = float(value)
198
+ if not math.isfinite(seconds) or seconds < 0:
199
+ return None
200
+ return min(3600, seconds)
201
+ except ValueError:
202
+ try:
203
+ date = parsedate_to_datetime(value)
204
+ if date.tzinfo is None:
205
+ date = date.replace(tzinfo=timezone.utc)
206
+ return min(
207
+ 3600, max(0, (date - datetime.now(timezone.utc)).total_seconds())
208
+ )
209
+ except (ValueError, TypeError, OverflowError):
210
+ return None
211
+
212
+
213
+ def _safe_error_resource(resource, token):
214
+ if not token:
215
+ return resource
216
+
217
+ def redact(value, depth=0):
218
+ if depth > 100:
219
+ return "[OMITTED]"
220
+ if isinstance(value, str):
221
+ return value.replace(token, "[REDACTED]")
222
+ if isinstance(value, list):
223
+ return [redact(item, depth + 1) for item in value]
224
+ if isinstance(value, dict):
225
+ return {
226
+ str(field).replace(token, "[REDACTED]"): redact(item, depth + 1)
227
+ for field, item in value.items()
228
+ }
229
+ return value
230
+
231
+ return redact(resource)
232
+
233
+
234
+ class _NoRedirect(HTTPRedirectHandler):
235
+ def redirect_request(self, request, fp, code, message, headers, newurl):
236
+ return None
237
+
238
+
239
+ def _remaining(deadline):
240
+ remaining = deadline - time.monotonic()
241
+ if remaining <= 0:
242
+ raise TimeoutError()
243
+ return remaining
244
+
245
+
246
+ class _DeadlineReader(io.RawIOBase):
247
+ """Refresh the socket budget before every read, including HTTP framing."""
248
+
249
+ def __init__(self, raw, connection, deadline):
250
+ self._raw = raw
251
+ self._connection = connection
252
+ self._deadline = deadline
253
+
254
+ def readable(self):
255
+ return True
256
+
257
+ def readinto(self, buffer):
258
+ self._connection.settimeout(_remaining(self._deadline))
259
+ count = self._raw.readinto(buffer)
260
+ _remaining(self._deadline)
261
+ return count
262
+
263
+ def close(self):
264
+ try:
265
+ self._raw.close()
266
+ finally:
267
+ super().close()
268
+
269
+
270
+ class _DeadlineSocket:
271
+ """Adapt http.client's socket/file interface without background threads."""
272
+
273
+ def __init__(self, connection, deadline):
274
+ self._connection = connection
275
+ self._deadline = deadline
276
+
277
+ def __getattr__(self, name):
278
+ return getattr(self._connection, name)
279
+
280
+ def gettimeout(self):
281
+ # ssl.wrap_socket inherits this remaining budget for its handshake.
282
+ return _remaining(self._deadline)
283
+
284
+ def sendall(self, data):
285
+ self._connection.settimeout(_remaining(self._deadline))
286
+ self._connection.sendall(data)
287
+ _remaining(self._deadline)
288
+
289
+ def makefile(self, mode):
290
+ raw = self._connection.makefile(mode, buffering=0)
291
+ return io.BufferedReader(_DeadlineReader(raw, self._connection, self._deadline))
292
+
293
+
294
+ class _DeadlineConnection:
295
+ def __init__(self, *args, deadline, **kwargs):
296
+ self._deadline = deadline
297
+ super().__init__(*args, **kwargs)
298
+ self._create_connection = self._connect_socket
299
+
300
+ def _connect_socket(self, address, timeout, source_address):
301
+ # The system resolver is synchronous and has no portable cancellation
302
+ # API. Check elapsed time immediately afterwards, before opening a socket.
303
+ addresses = socket.getaddrinfo(*address, type=socket.SOCK_STREAM)
304
+ _remaining(self._deadline)
305
+ last_error = None
306
+ for family, kind, protocol, _, destination in addresses:
307
+ connection = socket.socket(family, kind, protocol)
308
+ try:
309
+ connection.settimeout(_remaining(self._deadline))
310
+ if source_address:
311
+ connection.bind(source_address)
312
+ connection.connect(destination)
313
+ _remaining(self._deadline)
314
+ return _DeadlineSocket(connection, self._deadline)
315
+ except OSError as error:
316
+ connection.close()
317
+ last_error = error
318
+ if last_error is not None:
319
+ raise last_error
320
+ raise OSError("The API host did not resolve to a stream address.")
321
+
322
+ def connect(self):
323
+ super().connect()
324
+ # HTTPSConnection replaces the initial socket during TLS negotiation.
325
+ if not isinstance(self.sock, _DeadlineSocket):
326
+ self.sock = _DeadlineSocket(self.sock, self._deadline)
327
+ _remaining(self._deadline)
328
+
329
+
330
+ class _DeadlineHTTPConnection(_DeadlineConnection, HTTPConnection):
331
+ pass
332
+
333
+
334
+ class _DeadlineHTTPSConnection(_DeadlineConnection, HTTPSConnection):
335
+ pass
336
+
337
+
338
+ class _DeadlineHTTPHandler(HTTPHandler):
339
+ def __init__(self, deadline):
340
+ super().__init__()
341
+ self._deadline = deadline
342
+
343
+ def http_open(self, request):
344
+ return self.do_open(
345
+ lambda *args, **kwargs: _DeadlineHTTPConnection(
346
+ *args, deadline=self._deadline, **kwargs
347
+ ),
348
+ request,
349
+ )
350
+
351
+
352
+ class _DeadlineHTTPSHandler(HTTPSHandler):
353
+ def __init__(self, deadline):
354
+ super().__init__()
355
+ self._deadline = deadline
356
+
357
+ def https_open(self, request):
358
+ return self.do_open(
359
+ lambda *args, **kwargs: _DeadlineHTTPSConnection(
360
+ *args, deadline=self._deadline, **kwargs
361
+ ),
362
+ request,
363
+ context=self._context,
364
+ )
365
+
366
+
367
+ def _http_transport(method, url, headers, payload, timeout):
368
+ """Return status/headers/body; never follow redirects or environment proxies."""
369
+ deadline = time.monotonic() + timeout
370
+ opener = build_opener(
371
+ _NoRedirect(),
372
+ ProxyHandler({}),
373
+ _DeadlineHTTPHandler(deadline),
374
+ _DeadlineHTTPSHandler(deadline),
375
+ )
376
+ request = Request(url, data=payload, headers=headers, method=method)
377
+ try:
378
+ response = opener.open(request, timeout=_remaining(deadline))
379
+ except HTTPError as error:
380
+ response = error
381
+ with response:
382
+ _remaining(deadline)
383
+ body = response.read(_MAX_BODY + 1)
384
+ _remaining(deadline)
385
+ return (
386
+ response.status,
387
+ {key.lower(): value for key, value in response.headers.items()},
388
+ body,
389
+ )
390
+
391
+
392
+ class TldwClient:
393
+ """Synchronous client; ``cancel_event`` cooperatively stops bounded polling.
394
+
395
+ An in-flight HTTP read can finish before cancellation is observed; it is
396
+ bounded by request_timeout_seconds. The SDK never sends a job cancellation.
397
+ """
398
+
399
+ def __init__(
400
+ self,
401
+ *,
402
+ api_url,
403
+ token=None,
404
+ allow_anonymous=False,
405
+ request_timeout_seconds=30,
406
+ transport=None,
407
+ _clock=time.monotonic,
408
+ _sleep=time.sleep,
409
+ ):
410
+ try:
411
+ parsed = urlsplit(api_url)
412
+ host = parsed.hostname
413
+ port = parsed.port
414
+ except (TypeError, ValueError):
415
+ raise ValueError("api_url must be an absolute HTTPS origin.") from None
416
+ loopback = host in {"localhost", "127.0.0.1", "::1"}
417
+ if (
418
+ not host
419
+ or (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback))
420
+ or parsed.username is not None
421
+ or parsed.password is not None
422
+ or parsed.query
423
+ or parsed.fragment
424
+ or parsed.path not in {"", "/"}
425
+ ):
426
+ raise ValueError(
427
+ "api_url must be an HTTPS origin without credentials, query, fragment or path; HTTP is allowed only on loopback."
428
+ )
429
+ if token is not None and (
430
+ not isinstance(token, str)
431
+ or not token
432
+ or not all(33 <= ord(char) <= 126 for char in token)
433
+ ):
434
+ raise ValueError(
435
+ "token must be nonempty printable ASCII without whitespace."
436
+ )
437
+ if not token and not (allow_anonymous and loopback):
438
+ raise ValueError(
439
+ "A tldw API token is required. Anonymous access requires explicit loopback development mode."
440
+ )
441
+ # Normalize the origin while retaining IPv6 brackets and a configured port.
442
+ authority = f"[{host}]" if ":" in host else host
443
+ if port is not None:
444
+ authority += f":{port}"
445
+ self.api_url = f"{parsed.scheme}://{authority}"
446
+ self.__token = token
447
+ self.request_timeout_seconds = _bound(
448
+ request_timeout_seconds, "request_timeout_seconds", 0.001, 120
449
+ )
450
+ self._transport = transport or _http_transport
451
+ self._clock = _clock
452
+ self._sleep = _sleep
453
+ self._poll_hints = {}
454
+
455
+ def _check_canceled(self, cancel_event):
456
+ if cancel_event is not None and cancel_event.is_set():
457
+ raise TldwCanceledError()
458
+
459
+ def _pause(self, seconds, cancel_event):
460
+ self._check_canceled(cancel_event)
461
+ if cancel_event is None:
462
+ self._sleep(seconds)
463
+ elif cancel_event.wait(seconds):
464
+ raise TldwCanceledError()
465
+
466
+ def _request(
467
+ self,
468
+ method,
469
+ path,
470
+ *,
471
+ body=None,
472
+ idempotency_key=None,
473
+ cancel_event=None,
474
+ deadline=None,
475
+ ):
476
+ deadline = (
477
+ self._clock() + self.request_timeout_seconds
478
+ if deadline is None
479
+ else deadline
480
+ )
481
+ payload = (
482
+ json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
483
+ if body is not None
484
+ else None
485
+ )
486
+ headers = {"Accept": "application/json"}
487
+ if self.__token:
488
+ headers["Authorization"] = f"Bearer {self.__token}"
489
+ if payload is not None:
490
+ headers["Content-Type"] = "application/json"
491
+ if idempotency_key:
492
+ headers["Idempotency-Key"] = idempotency_key
493
+ for attempt in range(4):
494
+ self._check_canceled(cancel_event)
495
+ remaining = deadline - self._clock()
496
+ if remaining <= 0:
497
+ raise TldwTimeoutError()
498
+ try:
499
+ status, response_headers, raw = self._transport(
500
+ method,
501
+ self.api_url + path,
502
+ dict(headers),
503
+ payload,
504
+ min(remaining, self.request_timeout_seconds),
505
+ )
506
+ except TimeoutError:
507
+ if method != "GET" or self._clock() >= deadline:
508
+ raise TldwTimeoutError(
509
+ "The API request timed out. Accepted work continues on the server."
510
+ if method == "GET"
511
+ else "The API request timed out. Retry submission with the same idempotency key if its outcome is unknown."
512
+ ) from None
513
+ # A slow read (for example one poll inside a long wait) has no side
514
+ # effects; retry it while the caller's deadline allows.
515
+ error = TldwTimeoutError(
516
+ "The API request timed out. Accepted work continues on the server."
517
+ )
518
+ if attempt == 3:
519
+ raise error from None
520
+ delay = min(10, 0.5 * 2**attempt)
521
+ except (URLError, OSError, HTTPException):
522
+ if self._clock() >= deadline:
523
+ raise TldwTimeoutError() from None
524
+ error = TldwError(
525
+ "Could not reach the configured tldw API. Check the connection before retrying.",
526
+ code="NETWORK_ERROR",
527
+ )
528
+ if attempt == 3:
529
+ raise error from None
530
+ delay = min(10, 0.5 * 2**attempt)
531
+ else:
532
+ self._check_canceled(cancel_event)
533
+ if self._clock() >= deadline:
534
+ raise TldwTimeoutError()
535
+ if len(raw) > _MAX_BODY:
536
+ raise TldwError(
537
+ "API response exceeded the client size limit.",
538
+ code="INVALID_RESPONSE",
539
+ )
540
+ retry_after = _retry_delay(response_headers.get("retry-after"))
541
+ try:
542
+ data = json.loads(raw)
543
+ except (ValueError, UnicodeError):
544
+ if status not in _TRANSIENT:
545
+ raise TldwError(
546
+ "API returned invalid JSON.",
547
+ code="INVALID_RESPONSE",
548
+ status=status,
549
+ ) from None
550
+ data = {}
551
+ if 200 <= status < 300:
552
+ return _object(data, "JSON"), retry_after
553
+ detail = data.get("error") if isinstance(data, dict) else {}
554
+ detail = detail if isinstance(detail, dict) else {}
555
+
556
+ def safe(value, fallback=None):
557
+ value = value if isinstance(value, str) else fallback
558
+ return (
559
+ value.replace(self.__token, "[REDACTED]")
560
+ if value and self.__token
561
+ else value
562
+ )
563
+
564
+ extra = detail.get("details")
565
+ extra = (
566
+ _safe_error_resource(extra, self.__token)
567
+ if isinstance(extra, dict)
568
+ else None
569
+ )
570
+ try:
571
+ active_job = _identifier((extra or {}).get("job_id"))
572
+ except ValueError:
573
+ active_job = None
574
+ error = TldwError(
575
+ safe(detail.get("message"), f"API request failed ({status})."),
576
+ code=safe(detail.get("code"), "HTTP_ERROR"),
577
+ status=status,
578
+ request_id=safe(
579
+ detail.get("request_id") or response_headers.get("x-request-id")
580
+ ),
581
+ retry_after_seconds=retry_after,
582
+ job_id=active_job,
583
+ details=extra,
584
+ )
585
+ if status not in _TRANSIENT or attempt == 3:
586
+ raise error
587
+ delay = max(retry_after or 0, min(10, 0.5 * 2**attempt))
588
+ if self._clock() + delay >= deadline:
589
+ if error.status:
590
+ raise error
591
+ raise TldwTimeoutError()
592
+ self._pause(delay, cancel_event)
593
+ raise TldwError("API retry limit reached.", code="RETRY_LIMIT")
594
+
595
+ def submit_analysis(
596
+ self,
597
+ media,
598
+ *,
599
+ instructions=None,
600
+ idempotency_key=None,
601
+ cancel_event=None,
602
+ transcription=None,
603
+ max_credits=None,
604
+ force_asr=None,
605
+ refresh_source=None,
606
+ ):
607
+ """Submit text or a public URL and retain the returned job_id before waiting.
608
+
609
+ X URLs default to auto: accessible captions for one attached video are
610
+ included when the deployment supports it. Explicit scope=post reads
611
+ only post text. Use instructions for focus or a requested output language
612
+ (for example, 'Summarize the spoken content in Spanish.'). X audio
613
+ transcription and visual analysis are not enabled. Missing captions fail
614
+ explicitly. Reuse the exact media, instructions and idempotency_key
615
+ after an unknown submission outcome.
616
+ X/article sources may reuse a saved snapshot for 24 hours. Set
617
+ refresh_source=True to fetch current source content without forcing ASR.
618
+ """
619
+ return self._submit_media(
620
+ "/v1/analyses",
621
+ media,
622
+ instructions=instructions,
623
+ idempotency_key=idempotency_key,
624
+ cancel_event=cancel_event,
625
+ transcription=transcription,
626
+ max_credits=max_credits,
627
+ force_asr=force_asr,
628
+ refresh_source=refresh_source,
629
+ )
630
+
631
+ def submit_transcript(
632
+ self,
633
+ media=None,
634
+ *,
635
+ upload_id=None,
636
+ preferred_caption_languages=None,
637
+ idempotency_key=None,
638
+ cancel_event=None,
639
+ transcription=None,
640
+ max_credits=None,
641
+ force_asr=None,
642
+ refresh_source=None,
643
+ ):
644
+ """Save original captions/source text without summary generation or an LLM call."""
645
+ return self._submit_media(
646
+ "/v1/transcripts",
647
+ media,
648
+ idempotency_key=idempotency_key,
649
+ cancel_event=cancel_event,
650
+ transcription=transcription,
651
+ max_credits=max_credits,
652
+ force_asr=force_asr,
653
+ refresh_source=refresh_source,
654
+ upload_id=upload_id,
655
+ preferred_caption_languages=preferred_caption_languages,
656
+ )
657
+
658
+ def _submit_media(
659
+ self,
660
+ path,
661
+ media,
662
+ *,
663
+ instructions=None,
664
+ idempotency_key=None,
665
+ cancel_event=None,
666
+ transcription=None,
667
+ max_credits=None,
668
+ force_asr=None,
669
+ refresh_source=None,
670
+ upload_id=None,
671
+ preferred_caption_languages=None,
672
+ ):
673
+ if refresh_source is not None and not isinstance(refresh_source, bool):
674
+ raise ValueError("refresh_source must be a boolean.")
675
+ stable_key = _idempotency_key(idempotency_key)
676
+ body = {
677
+ **_spending_options(transcription, max_credits, force_asr),
678
+ }
679
+ if path == "/v1/transcripts":
680
+ if (media is not None) == (upload_id is not None):
681
+ raise ValueError("Provide exactly one media or upload_id.")
682
+ if upload_id is not None:
683
+ body["upload_id"] = _identifier(upload_id, "Upload ID")
684
+ if preferred_caption_languages is not None:
685
+ if (
686
+ not isinstance(preferred_caption_languages, list)
687
+ or len(preferred_caption_languages) > 10
688
+ or any(
689
+ not isinstance(value, str)
690
+ or not re.fullmatch(
691
+ r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*", value
692
+ )
693
+ for value in preferred_caption_languages
694
+ )
695
+ ):
696
+ raise ValueError(
697
+ "preferred_caption_languages must contain at most 10 BCP-47 language tags."
698
+ )
699
+ body["preferred_caption_languages"] = preferred_caption_languages
700
+ if media is not None:
701
+ body["media"] = media
702
+ if instructions is not None:
703
+ body["instructions"] = instructions
704
+ if refresh_source is not None:
705
+ body["refresh_source"] = refresh_source
706
+ try:
707
+ data, retry_after = self._request(
708
+ "POST",
709
+ path,
710
+ body=body,
711
+ idempotency_key=stable_key,
712
+ cancel_event=cancel_event,
713
+ )
714
+ except TldwError as error:
715
+ error.idempotency_key = stable_key
716
+ raise
717
+ try:
718
+ _identifier(data.get("job_id"), "Accepted job ID")
719
+ if (
720
+ not isinstance(data.get("job"), dict)
721
+ or data["job"].get("id") != data["job_id"]
722
+ ):
723
+ raise ValueError()
724
+ if path == "/v1/transcripts" and (
725
+ data.get("output_mode") != "transcript"
726
+ or data["job"].get("output_mode") != "transcript"
727
+ ):
728
+ raise ValueError()
729
+ except ValueError:
730
+ error = TldwError(
731
+ "API returned an invalid accepted job.", code="INVALID_RESPONSE"
732
+ )
733
+ error.idempotency_key = stable_key
734
+ raise error from None
735
+ self._remember_poll_hint(
736
+ data["job_id"], "job", data.get("next_poll_after_ms"), retry_after
737
+ )
738
+ return data
739
+
740
+ def _remember_poll_hint(self, resource_id, kind, suggested, retry_after):
741
+ suggested = (
742
+ min(3600, suggested / 1000)
743
+ if isinstance(suggested, (int, float))
744
+ and math.isfinite(suggested)
745
+ and suggested > 0
746
+ else 0
747
+ )
748
+ delay = max(suggested, retry_after or 0)
749
+ if delay:
750
+ if len(self._poll_hints) >= 256:
751
+ self._poll_hints.pop(next(iter(self._poll_hints)))
752
+ self._poll_hints[(kind, resource_id)] = (self._clock() + delay, delay)
753
+
754
+ def _job(self, data, resource_id):
755
+ _object(data, "job")
756
+ progress = data.get("progress")
757
+ if (
758
+ data.get("id") != resource_id
759
+ or data.get("status") not in _JOB_STATES
760
+ or not isinstance(progress, int)
761
+ or isinstance(progress, bool)
762
+ or not 0 <= progress <= 100
763
+ or (
764
+ data.get("output_mode") is not None
765
+ and (
766
+ not isinstance(data["output_mode"], str)
767
+ or data["output_mode"] not in {"summary", "analysis", "transcript"}
768
+ )
769
+ )
770
+ ):
771
+ raise TldwError(
772
+ "API returned an invalid job.",
773
+ code="INVALID_RESPONSE",
774
+ job_id=resource_id,
775
+ )
776
+ if data["status"] == "completed":
777
+ try:
778
+ _identifier(
779
+ data.get(
780
+ "transcript_id"
781
+ if data.get("output_mode") == "transcript"
782
+ else "summary_id"
783
+ )
784
+ )
785
+ except ValueError:
786
+ raise TldwError(
787
+ "Completed job has no valid result ID.",
788
+ code="INVALID_RESPONSE",
789
+ job_id=resource_id,
790
+ ) from None
791
+ return data
792
+
793
+ def get_job(self, job_id, *, cancel_event=None):
794
+ job_id = _identifier(job_id)
795
+ data, _ = self._request("GET", f"/v1/jobs/{job_id}", cancel_event=cancel_event)
796
+ return self._job(data, job_id)
797
+
798
+ def get_summary(self, summary_id, *, cancel_event=None):
799
+ summary_id = _identifier(summary_id)
800
+ data, _ = self._request(
801
+ "GET", f"/v1/summaries/{summary_id}", cancel_event=cancel_event
802
+ )
803
+ if data.get("id") != summary_id or not isinstance(data.get("content"), dict):
804
+ raise TldwError("API returned an invalid summary.", code="INVALID_RESPONSE")
805
+ return data
806
+
807
+ def get_usage(self, *, cancel_event=None):
808
+ return self._request("GET", "/v1/me", cancel_event=cancel_event)[0]
809
+
810
+ def get_transcript(self, summary_id, *, cancel_event=None):
811
+ """Read the owner-private saved source separately from generated output.
812
+
813
+ Timed video sources return segments; text/X sources return blocks and
814
+ text. X caption blocks retain start_s/end_s. Requires summaries:read.
815
+ """
816
+ summary_id = _identifier(summary_id)
817
+ data, _ = self._request(
818
+ "GET", f"/v1/summaries/{summary_id}/transcript", cancel_event=cancel_event
819
+ )
820
+ return self._transcript(data)
821
+
822
+ def _transcript(self, data):
823
+ try:
824
+ _identifier(data.get("transcript_id"))
825
+ except (ValueError, TypeError):
826
+ raise TldwError(
827
+ "API returned an invalid transcript.", code="INVALID_RESPONSE"
828
+ ) from None
829
+ if (
830
+ not isinstance(data.get("timed"), bool)
831
+ or "text" not in data
832
+ or "language" not in data
833
+ or not isinstance(data.get("source"), str)
834
+ or (
835
+ data.get("language") is not None
836
+ and not isinstance(data["language"], str)
837
+ )
838
+ or not isinstance(data.get("segments"), list)
839
+ or not isinstance(data.get("blocks"), list)
840
+ or (
841
+ data.get("text") is not None
842
+ if data["timed"]
843
+ else not isinstance(data.get("text"), str)
844
+ )
845
+ ):
846
+ raise TldwError(
847
+ "API returned an invalid transcript.", code="INVALID_RESPONSE"
848
+ )
849
+ return data
850
+
851
+ def list_transcripts(
852
+ self,
853
+ *,
854
+ limit=50,
855
+ cursor=None,
856
+ since=None,
857
+ until=None,
858
+ unread_only=False,
859
+ cancel_event=None,
860
+ ):
861
+ """List saved-source metadata by latest owned request; never marks read.
862
+
863
+ since is inclusive, until exclusive; follow next_cursor with the same filters.
864
+ Timestamp strings use ISO 8601. Requires summaries:read.
865
+ """
866
+ if (
867
+ not isinstance(limit, int)
868
+ or isinstance(limit, bool)
869
+ or not 1 <= limit <= 100
870
+ ):
871
+ raise ValueError("limit must be an integer between 1 and 100.")
872
+ if not isinstance(unread_only, bool):
873
+ raise TypeError("unread_only must be a boolean.")
874
+ query = {"limit": str(limit), "unread_only": str(unread_only).lower()}
875
+ for name, value in (("cursor", cursor), ("since", since), ("until", until)):
876
+ if value is None:
877
+ continue
878
+ if (
879
+ not isinstance(value, str)
880
+ or not value
881
+ or len(value) > (1024 if name == "cursor" else 128)
882
+ ):
883
+ raise ValueError(f"{name} must be a bounded non-empty string.")
884
+ query[name] = value
885
+ data, _ = self._request(
886
+ "GET", "/v1/transcripts?" + urlencode(query), cancel_event=cancel_event
887
+ )
888
+ if not isinstance(data.get("items"), list) or (
889
+ data.get("next_cursor") is not None
890
+ and not isinstance(data["next_cursor"], str)
891
+ ):
892
+ raise TldwError(
893
+ "API returned an invalid transcript list.", code="INVALID_RESPONSE"
894
+ )
895
+ return data
896
+
897
+ def get_saved_transcript(self, transcript_id, *, cancel_event=None):
898
+ """Read the full original source by transcript id; never marks read."""
899
+ transcript_id = _identifier(transcript_id)
900
+ data, _ = self._request(
901
+ "GET", f"/v1/transcripts/{transcript_id}", cancel_event=cancel_event
902
+ )
903
+ return self._transcript(data)
904
+
905
+ def get_saved_transcript_metadata(self, transcript_id, *, cancel_event=None):
906
+ """Read source metadata by id without loading text, processing or marking read."""
907
+ transcript_id = _identifier(transcript_id)
908
+ data, _ = self._request(
909
+ "GET",
910
+ f"/v1/transcripts/{transcript_id}/metadata",
911
+ cancel_event=cancel_event,
912
+ )
913
+ if (
914
+ data.get("id") != transcript_id
915
+ or not isinstance(data.get("unread"), bool)
916
+ or not isinstance(data.get("video"), dict)
917
+ or not isinstance(data["video"].get("id"), str)
918
+ ):
919
+ raise TldwError(
920
+ "API returned invalid transcript metadata.", code="INVALID_RESPONSE"
921
+ )
922
+ return data
923
+
924
+ def get_saved_transcript_page(
925
+ self, transcript_id, *, offset=0, limit=100, cancel_event=None
926
+ ):
927
+ """Read a bounded source page; follow next_offset. Never marks read."""
928
+ transcript_id = _identifier(transcript_id)
929
+ for name, value, minimum, maximum in (
930
+ ("offset", offset, 0, 1_000_000_000),
931
+ ("limit", limit, 1, 500),
932
+ ):
933
+ if (
934
+ not isinstance(value, int)
935
+ or isinstance(value, bool)
936
+ or not minimum <= value <= maximum
937
+ ):
938
+ raise ValueError(
939
+ f"{name} must be an integer between {minimum} and {maximum}."
940
+ )
941
+ data, _ = self._request(
942
+ "GET",
943
+ f"/v1/transcripts/{transcript_id}/page?offset={offset}&limit={limit}",
944
+ cancel_event=cancel_event,
945
+ )
946
+ return self._transcript(data)
947
+
948
+ def mark_transcript_read(self, transcript_id, read=True, *, cancel_event=None):
949
+ """Explicitly mark your source read/unread; repeated marks are idempotent."""
950
+ transcript_id = _identifier(transcript_id)
951
+ if not isinstance(read, bool):
952
+ raise TypeError("read must be a boolean.")
953
+ data, _ = self._request(
954
+ "PATCH",
955
+ f"/v1/transcripts/{transcript_id}/read",
956
+ body={"read": read},
957
+ cancel_event=cancel_event,
958
+ )
959
+ if (
960
+ data.get("id") != transcript_id
961
+ or not isinstance(data.get("unread"), bool)
962
+ or (
963
+ data.get("read_at") is not None and not isinstance(data["read_at"], str)
964
+ )
965
+ ):
966
+ raise TldwError(
967
+ "API returned an invalid transcript read state.",
968
+ code="INVALID_RESPONSE",
969
+ )
970
+ return data
971
+
972
+ def _comparison(self, data, resource_id):
973
+ resource = _object(data.get("comparison"), "comparison")
974
+ if (
975
+ resource.get("id") != resource_id
976
+ or resource.get("status") not in _COMPARISON_STATES
977
+ ):
978
+ raise TldwError(
979
+ "API returned an invalid comparison.",
980
+ code="INVALID_RESPONSE",
981
+ comparison_id=resource_id,
982
+ )
983
+ return resource
984
+
985
+ def _wait(
986
+ self,
987
+ resource_id,
988
+ kind,
989
+ *,
990
+ timeout_seconds=180,
991
+ poll_interval_seconds=1.5,
992
+ cancel_event=None,
993
+ on_progress=None,
994
+ ):
995
+ deadline = self._clock() + _bound(
996
+ timeout_seconds, "timeout_seconds", 0.001, 3600
997
+ )
998
+ interval = _bound(poll_interval_seconds, "poll_interval_seconds", 0.25, 30)
999
+ if on_progress is not None and not callable(on_progress):
1000
+ raise ValueError("on_progress must be callable.")
1001
+ try:
1002
+ remembered = self._poll_hints.get((kind, resource_id))
1003
+ if remembered:
1004
+ interval = max(interval, remembered[1])
1005
+ delay = max(0, remembered[0] - self._clock())
1006
+ if self._clock() + delay >= deadline:
1007
+ raise TldwTimeoutError()
1008
+ if delay:
1009
+ self._pause(delay, cancel_event)
1010
+ while True:
1011
+ if self._clock() >= deadline:
1012
+ raise TldwTimeoutError()
1013
+ path = f"/v1/{'jobs' if kind == 'job' else 'comparisons'}/{resource_id}"
1014
+ data, retry_after = self._request(
1015
+ "GET", path, cancel_event=cancel_event, deadline=deadline
1016
+ )
1017
+ resource = (
1018
+ self._job(data, resource_id)
1019
+ if kind == "job"
1020
+ else self._comparison(data, resource_id)
1021
+ )
1022
+ if on_progress:
1023
+ on_progress(resource)
1024
+ if resource["status"] == "completed":
1025
+ self._poll_hints.pop((kind, resource_id), None)
1026
+ if kind == "comparison" and not resource.get("result"):
1027
+ raise TldwError(
1028
+ "Comparison result access is unavailable; summaries:read is required.",
1029
+ code="RESULT_SCOPE_REQUIRED",
1030
+ comparison_id=resource_id,
1031
+ )
1032
+ if kind == "comparison":
1033
+ _object(resource["result"], "comparison result")
1034
+ return resource
1035
+ if resource["status"] in {"failed", "canceled"}:
1036
+ self._poll_hints.pop((kind, resource_id), None)
1037
+ raise TldwJobError(
1038
+ _safe_error_resource(resource, self.__token), kind
1039
+ )
1040
+ suggested = resource.get("next_poll_after_ms")
1041
+ suggested = (
1042
+ min(3600, suggested / 1000)
1043
+ if isinstance(suggested, (int, float))
1044
+ and math.isfinite(suggested)
1045
+ and suggested > 0
1046
+ else 0
1047
+ )
1048
+ delay = max(interval, retry_after or 0, suggested)
1049
+ if self._clock() + delay >= deadline:
1050
+ raise TldwTimeoutError()
1051
+ self._pause(delay, cancel_event)
1052
+ except TldwError as error:
1053
+ setattr(error, f"{kind}_id", resource_id)
1054
+ raise
1055
+
1056
+ def wait_for_job(self, job_id, **options):
1057
+ return self._wait(_identifier(job_id), "job", **options)
1058
+
1059
+ def analyze(
1060
+ self,
1061
+ media,
1062
+ *,
1063
+ instructions=None,
1064
+ idempotency_key=None,
1065
+ cancel_event=None,
1066
+ timeout_seconds=180,
1067
+ on_progress=None,
1068
+ poll_interval_seconds=1.5,
1069
+ transcription=None,
1070
+ max_credits=None,
1071
+ force_asr=None,
1072
+ refresh_source=None,
1073
+ ):
1074
+ _bound(timeout_seconds, "timeout_seconds", 0.001, 3600)
1075
+ _bound(poll_interval_seconds, "poll_interval_seconds", 0.25, 30)
1076
+ if on_progress is not None and not callable(on_progress):
1077
+ raise ValueError("on_progress must be callable.")
1078
+ receipt = self.submit_analysis(
1079
+ media,
1080
+ instructions=instructions,
1081
+ idempotency_key=idempotency_key,
1082
+ cancel_event=cancel_event,
1083
+ transcription=transcription,
1084
+ max_credits=max_credits,
1085
+ force_asr=force_asr,
1086
+ refresh_source=refresh_source,
1087
+ )
1088
+ try:
1089
+ job = self.wait_for_job(
1090
+ receipt["job_id"],
1091
+ timeout_seconds=timeout_seconds,
1092
+ cancel_event=cancel_event,
1093
+ on_progress=on_progress,
1094
+ poll_interval_seconds=poll_interval_seconds,
1095
+ )
1096
+ if job.get("output_mode") == "transcript":
1097
+ raise TldwError(
1098
+ "Expected an analysis result, received a transcript job.",
1099
+ code="INVALID_RESPONSE",
1100
+ )
1101
+ summary = self.get_summary(job["summary_id"], cancel_event=cancel_event)
1102
+ return {"receipt": receipt, "job": job, "summary": summary}
1103
+ except TldwError as error:
1104
+ error.job_id = receipt["job_id"]
1105
+ error.receipt = _safe_error_resource(receipt, self.__token)
1106
+ raise
1107
+
1108
+ def extract_transcript(
1109
+ self,
1110
+ media=None,
1111
+ *,
1112
+ upload_id=None,
1113
+ preferred_caption_languages=None,
1114
+ idempotency_key=None,
1115
+ cancel_event=None,
1116
+ timeout_seconds=180,
1117
+ on_progress=None,
1118
+ poll_interval_seconds=1.5,
1119
+ transcription=None,
1120
+ max_credits=None,
1121
+ force_asr=None,
1122
+ refresh_source=None,
1123
+ ):
1124
+ """Submit once, wait, then read original source; never requests generated analysis."""
1125
+ _bound(timeout_seconds, "timeout_seconds", 0.001, 3600)
1126
+ _bound(poll_interval_seconds, "poll_interval_seconds", 0.25, 30)
1127
+ if on_progress is not None and not callable(on_progress):
1128
+ raise ValueError("on_progress must be callable.")
1129
+ receipt = self.submit_transcript(
1130
+ media,
1131
+ idempotency_key=idempotency_key,
1132
+ cancel_event=cancel_event,
1133
+ transcription=transcription,
1134
+ max_credits=max_credits,
1135
+ force_asr=force_asr,
1136
+ refresh_source=refresh_source,
1137
+ upload_id=upload_id,
1138
+ preferred_caption_languages=preferred_caption_languages,
1139
+ )
1140
+ try:
1141
+ job = self.wait_for_job(
1142
+ receipt["job_id"],
1143
+ timeout_seconds=timeout_seconds,
1144
+ cancel_event=cancel_event,
1145
+ on_progress=on_progress,
1146
+ poll_interval_seconds=poll_interval_seconds,
1147
+ )
1148
+ if job.get("output_mode") != "transcript":
1149
+ raise TldwError(
1150
+ "Expected a transcript extraction result.", code="INVALID_RESPONSE"
1151
+ )
1152
+ transcript = self.get_saved_transcript(
1153
+ job["transcript_id"], cancel_event=cancel_event
1154
+ )
1155
+ return {"receipt": receipt, "job": job, "transcript": transcript}
1156
+ except TldwError as error:
1157
+ error.job_id = receipt["job_id"]
1158
+ error.receipt = _safe_error_resource(receipt, self.__token)
1159
+ raise
1160
+
1161
+ def compare_tiktoks(
1162
+ self,
1163
+ urls,
1164
+ *,
1165
+ instructions=None,
1166
+ idempotency_key=None,
1167
+ cancel_event=None,
1168
+ transcription=None,
1169
+ max_credits=None,
1170
+ force_asr=None,
1171
+ ):
1172
+ if not isinstance(urls, list) or not 2 <= len(urls) <= 20:
1173
+ raise ValueError("Supply between 2 and 20 TikTok URLs.")
1174
+ stable_key = _idempotency_key(idempotency_key)
1175
+ body = {
1176
+ "urls": urls,
1177
+ **_spending_options(transcription, max_credits, force_asr),
1178
+ }
1179
+ if instructions is not None:
1180
+ body["instructions"] = instructions
1181
+ try:
1182
+ data, retry_after = self._request(
1183
+ "POST",
1184
+ "/v1/comparisons",
1185
+ body=body,
1186
+ idempotency_key=stable_key,
1187
+ cancel_event=cancel_event,
1188
+ )
1189
+ except TldwError as error:
1190
+ error.idempotency_key = stable_key
1191
+ raise
1192
+ try:
1193
+ resource_id = _identifier(
1194
+ _object(data.get("comparison"), "comparison").get("id"), "Comparison ID"
1195
+ )
1196
+ self._remember_poll_hint(
1197
+ resource_id,
1198
+ "comparison",
1199
+ data["comparison"].get("next_poll_after_ms"),
1200
+ retry_after,
1201
+ )
1202
+ return self._comparison(data, resource_id)
1203
+ except ValueError:
1204
+ error = TldwError(
1205
+ "API returned an invalid accepted comparison.", code="INVALID_RESPONSE"
1206
+ )
1207
+ error.idempotency_key = stable_key
1208
+ raise error from None
1209
+ except TldwError as error:
1210
+ error.idempotency_key = stable_key
1211
+ raise
1212
+
1213
+ def get_comparison(self, comparison_id, *, cancel_event=None):
1214
+ comparison_id = _identifier(comparison_id)
1215
+ data, _ = self._request(
1216
+ "GET", f"/v1/comparisons/{comparison_id}", cancel_event=cancel_event
1217
+ )
1218
+ return self._comparison(data, comparison_id)
1219
+
1220
+ def wait_for_comparison(self, comparison_id, **options):
1221
+ return self._wait(_identifier(comparison_id), "comparison", **options)