tldw-client 0.2.6__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tldw.sh contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.4
2
+ Name: tldw-client
3
+ Version: 0.2.6
4
+ Summary: Dependency-free tldw.sh asynchronous analysis client
5
+ Requires-Python: >=3.10
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: tldw,video,analysis,agents,mcp,api
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Project-URL: Homepage, https://tldw.sh
20
+ Project-URL: Documentation, https://tldw.sh/docs
21
+ Description-Content-Type: text/markdown
22
+
23
+ # tldw.sh Python client
24
+
25
+ A synchronous Python 3.10+ client with no runtime dependencies.
26
+
27
+ ```sh
28
+ python -m pip install tldw-client
29
+ ```
30
+
31
+ You can instead install the versioned wheel from your operator's `/agents` page.
32
+ Download it, verify it against the SHA-256 checksum shown on that page, then
33
+ install the verified local file:
34
+
35
+ ```sh
36
+ curl -fsSLo tldw_client-0.2.6-py3-none-any.whl \
37
+ 'https://YOUR_APP_ORIGIN/downloads/tldw_client-0.2.6-py3-none-any.whl' &&
38
+ echo 'SHA256_FROM_YOUR_AGENTS_PAGE tldw_client-0.2.6-py3-none-any.whl' | shasum -a 256 -c - &&
39
+ python -m pip install ./tldw_client-0.2.6-py3-none-any.whl
40
+ ```
41
+
42
+ The package is imported as `tldw_client`. Only `tldw-client` on PyPI and your
43
+ operator's verified download are this client; similarly named projects are not.
44
+
45
+ Use your deployment's API origin and a scoped **tldw API key**, never a model
46
+ provider key. Signup and one-time human verification happen in the web app.
47
+ REST, MCP and plugins share 100 monthly Free caption/text credits, Hobby/Pro
48
+ limits and purchased credits. Polling does not create another analysis or reset your allowance.
49
+
50
+ ```python
51
+ import os
52
+ from tldw_client import TldwClient, TldwTimeoutError
53
+
54
+ client = TldwClient(
55
+ api_url=os.environ["TLDW_API_URL"], token=os.environ["TLDW_API_TOKEN"]
56
+ )
57
+ receipt = client.submit_analysis(
58
+ "https://youtu.be/VIDEO_ID",
59
+ instructions="Extract the argument and flag weak evidence.",
60
+ idempotency_key="my-persisted-request-001",
61
+ )
62
+ print({"job_id": receipt["job_id"]}) # Persist before waiting.
63
+ try:
64
+ job = client.wait_for_job(receipt["job_id"], timeout_seconds=180)
65
+ result = client.get_summary(job["summary_id"])
66
+ print(result["content"])
67
+ except TldwTimeoutError as error:
68
+ print({"job_id": error.job_id, "status": "resume-polling-later"})
69
+ ```
70
+
71
+ `analyze(media, instructions=..., idempotency_key=..., timeout_seconds=180)`
72
+ combines submission, polling and result retrieval, returning a dictionary with
73
+ `receipt`, `job` and `summary`. A failure after acceptance retains `job_id` and
74
+ `receipt`; unknown submission errors retain `idempotency_key`. Reuse that key
75
+ with exactly the same source/instructions after an uncertain network outcome.
76
+
77
+ Other methods:
78
+
79
+ - `get_job(job_id)`, `get_summary(summary_id)`, `get_usage()` (`account:read`).
80
+ - `compare_tiktoks(urls, instructions=..., idempotency_key=...)` returns an
81
+ accepted comparison view. Persist its `id` before waiting.
82
+ - `get_comparison(id)`, `wait_for_comparison(id, timeout_seconds=180)` return
83
+ the unwrapped comparison view. Completed output needs `summaries:read`.
84
+
85
+ Polling accepts `on_progress(resource)` and `poll_interval_seconds` (default
86
+ 1.5, minimum 0.25). It honors larger server polling hints and Retry-After.
87
+ Waits have a finite one-hour maximum; each HTTP operation has a separate
88
+ 30-second budget, configurable through `request_timeout_seconds` (maximum 120).
89
+ Pass a `threading.Event` as `cancel_event` to cooperatively stop waiting. The
90
+ current bounded HTTP read can finish before cancellation is observed. Stopping
91
+ a wait never sends a job cancellation; accepted work remains on the server.
92
+
93
+ `TldwError` exposes `code`, `status`, `request_id`, `retry_after_seconds` and
94
+ durable IDs where known. `TldwJobError` marks failed/canceled work. The SDK never
95
+ follows server-supplied URLs or redirects, ignores environment proxies, validates
96
+ resource UUIDs and limits API responses to 8 MiB. Transient retries preserve the
97
+ same serialized body and idempotency key. Scope, signup and trial denials require
98
+ caller action and are not automatically resubmitted.
99
+
100
+ Credentials must come from your secret manager or private environment, never a
101
+ prompt, checked-in file or command argument. The client does not print keys.
102
+ Anonymous mode requires explicit loopback development access:
103
+ `TldwClient(api_url="http://localhost:8000", allow_anonymous=True)`. It grants no
104
+ admin access. Production uses HTTPS and a scoped key.
105
+
106
+ For maintainers, `python build_wheel.py dist` creates the wheel offline using
107
+ only the standard library. The PEP 517 backend supports both source distributions
108
+ and wheels with no build dependencies. A standard build frontend can run
109
+ `python -m build` to build the source archive and rebuild its wheel in isolation.
110
+ Run `python -m unittest discover -s tests` from this source directory to check
111
+ transport, polling, billing-safe retries and packaging. Releases are published
112
+ from `main` by the monorepo's manual release workflow through PyPI trusted
113
+ publishing; a successful local build publishes nothing.
114
+
115
+ ## Extract original source without generated analysis
116
+
117
+ ```python
118
+ receipt = client.submit_transcript(
119
+ "https://youtu.be/VIDEO_ID",
120
+ transcription="never",
121
+ max_credits="30.00",
122
+ idempotency_key="original-source-001",
123
+ )
124
+ print(receipt["job_id"]) # Persist before waiting.
125
+ job = client.wait_for_job(receipt["job_id"])
126
+ original = client.get_saved_transcript_page(job["transcript_id"], limit=100)
127
+ # Follow original["next_offset"] for more source content.
128
+ ```
129
+
130
+ `submit_transcript` posts to `POST /v1/transcripts` and always returns a durable
131
+ accepted receipt. It never requests a summary or invokes an LLM. Completed
132
+ transcript jobs have `output_mode="transcript"`, a `transcript_id`, and no
133
+ `summary_id`. `extract_transcript(media, ...)` combines submission, polling and
134
+ a full source read, returning `receipt`, `job` and `transcript`; timeout and
135
+ source-permission errors retain the accepted receipt and job ID.
136
+
137
+ Use `submit_transcript(upload_id=...)` for your parsed subtitle upload.
138
+ `preferred_caption_languages` selects original captions. Spending/ASR controls
139
+ and `refresh_source` are supported, but analysis instructions are not.
140
+ Submission needs `jobs:write`, polling `jobs:read`, and source reads
141
+ `summaries:read`; no account-read scope is needed. New transcript-only jobs
142
+ retain normal credit rates and ASR consent/caps. If you already have a saved
143
+ transcript ID, read it directly: saved-source reads start no processing job,
144
+ invoke no LLM and charge no processing credits.
145
+
146
+ ## Summary and transcript are separate reads
147
+
148
+ After the job completes, use its `summary_id` to retrieve generated output and
149
+ original source independently. Both calls require `summaries:read` and account
150
+ ownership. Neither starts another analysis, transcription or processing-credit
151
+ charge. The summary response includes transcript metadata, not the full source.
152
+
153
+ ```python
154
+ summary = client.get_summary(job["summary_id"])
155
+ transcript = client.get_transcript(job["summary_id"])
156
+ print(summary["content"]["answer"])
157
+ original_text = (
158
+ "\n".join(segment["text"] for segment in transcript["segments"])
159
+ if transcript["timed"]
160
+ else transcript["text"]
161
+ )
162
+ print(original_text)
163
+ ```
164
+
165
+ REST uses `GET /v1/summaries/{id}` and `GET /v1/summaries/{id}/transcript`.
166
+ MCP uses `get_summary` and paginated `get_transcript`. Timed YouTube/TikTok
167
+ sources expose `segments`; text/X sources expose `blocks` and `text`. X video
168
+ caption blocks retain `start_s` / `end_s` alongside separate untimed post context.
169
+ The saved source stays in its original language when requesting a translated summary.
170
+
171
+ ## Saved transcription inbox
172
+
173
+ `list_transcripts` returns the latest 50 distinct sources requested by your account,
174
+ including sources saved before an analysis failed. It reads metadata only and does
175
+ not mark anything read. All library, source and read-state calls require `summaries:read`.
176
+
177
+ ```python
178
+ page = client.list_transcripts(limit=50, unread_only=True)
179
+ for item in page["items"]:
180
+ print(item["id"], item["video"]["title"], item["last_requested_at"])
181
+ source = client.get_saved_transcript_page(item["id"], offset=0, limit=100)
182
+ # Follow source["next_offset"] for additional content. Mark only when ready.
183
+ client.mark_transcript_read(item["id"], read=True)
184
+ ```
185
+
186
+ Pass `cursor=page["next_cursor"]` with the same filters for older entries. `since`
187
+ is inclusive and `until` exclusive, using ISO 8601 timestamps against
188
+ `last_requested_at`. `read=False` marks a source unread again. Reading source
189
+ content through REST/MCP/SDK never implicitly changes its read state.
190
+ Use `get_saved_transcript_metadata(id)` to read one source’s title, dates and
191
+ unread status directly, including sources outside the current catalog page. This
192
+ GET does not load the transcript body or start a processing job.
193
+
194
+ X posts, X video captions and articles reuse an owned source snapshot for 24 hours.
195
+ Use `submit_analysis(..., refresh_source=True)` to retrieve current source content.
196
+ YouTube/TikTok keep reusing saved transcripts; another brief does not redownload
197
+ audio or repeat transcription when a usable source already exists.
198
+
199
+ ## X video captions and output language
200
+
201
+ Pass an X post URL directly. Default `scope=auto` detects one attached video and
202
+ analyzes its available captions plus separately labeled post context when the
203
+ deployment enables X video extraction. You do not need a subtitle URL or a
204
+ separate provider key. Use `{"type":"x","url":"YOUR_X_POST_URL","scope":"post"}`
205
+ only for post text alone; thread scope does not recursively analyze its videos.
206
+
207
+ ```python
208
+ receipt = client.submit_analysis(
209
+ "https://x.com/hamptonism/status/2071631335836225751",
210
+ instructions="Summarize the spoken content in Spanish. Keep it concise and avoid repetition.",
211
+ transcription="never",
212
+ idempotency_key="x-video-spanish-001",
213
+ )
214
+ print({"job_id": receipt["job_id"]}) # Persist before waiting.
215
+ job = client.wait_for_job(
216
+ receipt["job_id"],
217
+ on_progress=lambda state: print(
218
+ {"status": state["status"], "progress": state["progress"]}
219
+ ),
220
+ )
221
+ result = client.get_summary(job["summary_id"])
222
+ print(result["content"]["answer"])
223
+ ```
224
+
225
+ The response is a durable job receipt. Save its ID before waiting; a timeout does
226
+ not cancel the job. Use a fresh stable key for a different brief or language and
227
+ reuse the exact body/key after an uncertain submission. Without instructions,
228
+ the service produces a concise summary. A translated summary is generated text
229
+ inside the same schema-3 result, not dubbed audio, a translated video or an SRT
230
+ translation; the original captions remain saved separately.
231
+
232
+ Captioned X videos use actual video duration at 1 credit/minute under normal
233
+ rounding (128.197 seconds = 2.2 credits), not the post's reading time. Missing
234
+ captions fail explicitly with no customer processing charge; X audio
235
+ transcription and visual analysis are not enabled, even with `if_needed`.
236
+
237
+ ## Credit controls
238
+
239
+ Captions, text and existing cached transcripts cost **1 credit per source minute**.
240
+ Fresh audio transcription costs **3 credits per source minute** and requires an
241
+ eligible paid plan or server-confirmed billing exemption, plus explicit `transcription: "if_needed"` (Python:
242
+ `transcription="if_needed"`). The default `never` fails with a clear error when
243
+ new transcription would be necessary. Supplying `forceAsr: true` /
244
+ `force_asr=True` also requires that explicit permission.
245
+
246
+ Use `maxCredits: "30.00"` in JavaScript or `max_credits="30.00"` in Python to cap
247
+ a job. The server validates affordability before new transcription. The cap is
248
+ positive, at most 1,000,000 credits, and has at most two decimal places. It is
249
+ required for ordinary paid accounts when `transcription` is `if_needed`. Preserve
250
+ these controls in the same idempotent request body on retries. Jobs expose
251
+ `credits.estimated`, `reserved`, `final`, the billing class/rate and real source
252
+ duration; polling does not create another processing charge.
253
+
254
+ For `compareTikToks` / `compare_tiktoks`, the cap covers all distinct supplied
255
+ clips: each receives an equal share rounded down to 0.01 credit. Unused shares
256
+ are not redistributed. Comparison aggregation adds no customer processing credits.
257
+ Source minutes round up to 0.1 minute with a 0.5-minute minimum before the rate
258
+ multiplier. Raw text uses estimated reading minutes.
259
+
260
+ Free includes 100 credits per month for captions/text, Hobby costs USD20/month
261
+ with 1,000 credits, and Pro costs USD99/month with 5,000 credits. Included
262
+ allowance is used first, then purchased credits, which do not expire. Reading
263
+ account usage does not buy credits or change plans.
264
+
265
+ Server-confirmed billing exemption: the authenticated server decides whether an account is exempt. Exempt accounts may omit a credit ceiling and do not incur customer-credit charges; explicit transcription permission and resource/provider limits still apply. Ordinary paid accounts allowing fresh transcription must supply a ceiling or receive `TRANSCRIPTION_NOT_ALLOWED` (HTTP 422); `MAX_CREDITS_EXCEEDED` (HTTP 422) means a supplied ceiling is invalid or below the required charge. Clients validate supplied values, then pass requests through without fetching extra account scopes or inferring privileges from names, roles, or key prefixes.
@@ -0,0 +1,243 @@
1
+ # tldw.sh Python client
2
+
3
+ A synchronous Python 3.10+ client with no runtime dependencies.
4
+
5
+ ```sh
6
+ python -m pip install tldw-client
7
+ ```
8
+
9
+ You can instead install the versioned wheel from your operator's `/agents` page.
10
+ Download it, verify it against the SHA-256 checksum shown on that page, then
11
+ install the verified local file:
12
+
13
+ ```sh
14
+ curl -fsSLo tldw_client-0.2.6-py3-none-any.whl \
15
+ 'https://YOUR_APP_ORIGIN/downloads/tldw_client-0.2.6-py3-none-any.whl' &&
16
+ echo 'SHA256_FROM_YOUR_AGENTS_PAGE tldw_client-0.2.6-py3-none-any.whl' | shasum -a 256 -c - &&
17
+ python -m pip install ./tldw_client-0.2.6-py3-none-any.whl
18
+ ```
19
+
20
+ The package is imported as `tldw_client`. Only `tldw-client` on PyPI and your
21
+ operator's verified download are this client; similarly named projects are not.
22
+
23
+ Use your deployment's API origin and a scoped **tldw API key**, never a model
24
+ provider key. Signup and one-time human verification happen in the web app.
25
+ REST, MCP and plugins share 100 monthly Free caption/text credits, Hobby/Pro
26
+ limits and purchased credits. Polling does not create another analysis or reset your allowance.
27
+
28
+ ```python
29
+ import os
30
+ from tldw_client import TldwClient, TldwTimeoutError
31
+
32
+ client = TldwClient(
33
+ api_url=os.environ["TLDW_API_URL"], token=os.environ["TLDW_API_TOKEN"]
34
+ )
35
+ receipt = client.submit_analysis(
36
+ "https://youtu.be/VIDEO_ID",
37
+ instructions="Extract the argument and flag weak evidence.",
38
+ idempotency_key="my-persisted-request-001",
39
+ )
40
+ print({"job_id": receipt["job_id"]}) # Persist before waiting.
41
+ try:
42
+ job = client.wait_for_job(receipt["job_id"], timeout_seconds=180)
43
+ result = client.get_summary(job["summary_id"])
44
+ print(result["content"])
45
+ except TldwTimeoutError as error:
46
+ print({"job_id": error.job_id, "status": "resume-polling-later"})
47
+ ```
48
+
49
+ `analyze(media, instructions=..., idempotency_key=..., timeout_seconds=180)`
50
+ combines submission, polling and result retrieval, returning a dictionary with
51
+ `receipt`, `job` and `summary`. A failure after acceptance retains `job_id` and
52
+ `receipt`; unknown submission errors retain `idempotency_key`. Reuse that key
53
+ with exactly the same source/instructions after an uncertain network outcome.
54
+
55
+ Other methods:
56
+
57
+ - `get_job(job_id)`, `get_summary(summary_id)`, `get_usage()` (`account:read`).
58
+ - `compare_tiktoks(urls, instructions=..., idempotency_key=...)` returns an
59
+ accepted comparison view. Persist its `id` before waiting.
60
+ - `get_comparison(id)`, `wait_for_comparison(id, timeout_seconds=180)` return
61
+ the unwrapped comparison view. Completed output needs `summaries:read`.
62
+
63
+ Polling accepts `on_progress(resource)` and `poll_interval_seconds` (default
64
+ 1.5, minimum 0.25). It honors larger server polling hints and Retry-After.
65
+ Waits have a finite one-hour maximum; each HTTP operation has a separate
66
+ 30-second budget, configurable through `request_timeout_seconds` (maximum 120).
67
+ Pass a `threading.Event` as `cancel_event` to cooperatively stop waiting. The
68
+ current bounded HTTP read can finish before cancellation is observed. Stopping
69
+ a wait never sends a job cancellation; accepted work remains on the server.
70
+
71
+ `TldwError` exposes `code`, `status`, `request_id`, `retry_after_seconds` and
72
+ durable IDs where known. `TldwJobError` marks failed/canceled work. The SDK never
73
+ follows server-supplied URLs or redirects, ignores environment proxies, validates
74
+ resource UUIDs and limits API responses to 8 MiB. Transient retries preserve the
75
+ same serialized body and idempotency key. Scope, signup and trial denials require
76
+ caller action and are not automatically resubmitted.
77
+
78
+ Credentials must come from your secret manager or private environment, never a
79
+ prompt, checked-in file or command argument. The client does not print keys.
80
+ Anonymous mode requires explicit loopback development access:
81
+ `TldwClient(api_url="http://localhost:8000", allow_anonymous=True)`. It grants no
82
+ admin access. Production uses HTTPS and a scoped key.
83
+
84
+ For maintainers, `python build_wheel.py dist` creates the wheel offline using
85
+ only the standard library. The PEP 517 backend supports both source distributions
86
+ and wheels with no build dependencies. A standard build frontend can run
87
+ `python -m build` to build the source archive and rebuild its wheel in isolation.
88
+ Run `python -m unittest discover -s tests` from this source directory to check
89
+ transport, polling, billing-safe retries and packaging. Releases are published
90
+ from `main` by the monorepo's manual release workflow through PyPI trusted
91
+ publishing; a successful local build publishes nothing.
92
+
93
+ ## Extract original source without generated analysis
94
+
95
+ ```python
96
+ receipt = client.submit_transcript(
97
+ "https://youtu.be/VIDEO_ID",
98
+ transcription="never",
99
+ max_credits="30.00",
100
+ idempotency_key="original-source-001",
101
+ )
102
+ print(receipt["job_id"]) # Persist before waiting.
103
+ job = client.wait_for_job(receipt["job_id"])
104
+ original = client.get_saved_transcript_page(job["transcript_id"], limit=100)
105
+ # Follow original["next_offset"] for more source content.
106
+ ```
107
+
108
+ `submit_transcript` posts to `POST /v1/transcripts` and always returns a durable
109
+ accepted receipt. It never requests a summary or invokes an LLM. Completed
110
+ transcript jobs have `output_mode="transcript"`, a `transcript_id`, and no
111
+ `summary_id`. `extract_transcript(media, ...)` combines submission, polling and
112
+ a full source read, returning `receipt`, `job` and `transcript`; timeout and
113
+ source-permission errors retain the accepted receipt and job ID.
114
+
115
+ Use `submit_transcript(upload_id=...)` for your parsed subtitle upload.
116
+ `preferred_caption_languages` selects original captions. Spending/ASR controls
117
+ and `refresh_source` are supported, but analysis instructions are not.
118
+ Submission needs `jobs:write`, polling `jobs:read`, and source reads
119
+ `summaries:read`; no account-read scope is needed. New transcript-only jobs
120
+ retain normal credit rates and ASR consent/caps. If you already have a saved
121
+ transcript ID, read it directly: saved-source reads start no processing job,
122
+ invoke no LLM and charge no processing credits.
123
+
124
+ ## Summary and transcript are separate reads
125
+
126
+ After the job completes, use its `summary_id` to retrieve generated output and
127
+ original source independently. Both calls require `summaries:read` and account
128
+ ownership. Neither starts another analysis, transcription or processing-credit
129
+ charge. The summary response includes transcript metadata, not the full source.
130
+
131
+ ```python
132
+ summary = client.get_summary(job["summary_id"])
133
+ transcript = client.get_transcript(job["summary_id"])
134
+ print(summary["content"]["answer"])
135
+ original_text = (
136
+ "\n".join(segment["text"] for segment in transcript["segments"])
137
+ if transcript["timed"]
138
+ else transcript["text"]
139
+ )
140
+ print(original_text)
141
+ ```
142
+
143
+ REST uses `GET /v1/summaries/{id}` and `GET /v1/summaries/{id}/transcript`.
144
+ MCP uses `get_summary` and paginated `get_transcript`. Timed YouTube/TikTok
145
+ sources expose `segments`; text/X sources expose `blocks` and `text`. X video
146
+ caption blocks retain `start_s` / `end_s` alongside separate untimed post context.
147
+ The saved source stays in its original language when requesting a translated summary.
148
+
149
+ ## Saved transcription inbox
150
+
151
+ `list_transcripts` returns the latest 50 distinct sources requested by your account,
152
+ including sources saved before an analysis failed. It reads metadata only and does
153
+ not mark anything read. All library, source and read-state calls require `summaries:read`.
154
+
155
+ ```python
156
+ page = client.list_transcripts(limit=50, unread_only=True)
157
+ for item in page["items"]:
158
+ print(item["id"], item["video"]["title"], item["last_requested_at"])
159
+ source = client.get_saved_transcript_page(item["id"], offset=0, limit=100)
160
+ # Follow source["next_offset"] for additional content. Mark only when ready.
161
+ client.mark_transcript_read(item["id"], read=True)
162
+ ```
163
+
164
+ Pass `cursor=page["next_cursor"]` with the same filters for older entries. `since`
165
+ is inclusive and `until` exclusive, using ISO 8601 timestamps against
166
+ `last_requested_at`. `read=False` marks a source unread again. Reading source
167
+ content through REST/MCP/SDK never implicitly changes its read state.
168
+ Use `get_saved_transcript_metadata(id)` to read one source’s title, dates and
169
+ unread status directly, including sources outside the current catalog page. This
170
+ GET does not load the transcript body or start a processing job.
171
+
172
+ X posts, X video captions and articles reuse an owned source snapshot for 24 hours.
173
+ Use `submit_analysis(..., refresh_source=True)` to retrieve current source content.
174
+ YouTube/TikTok keep reusing saved transcripts; another brief does not redownload
175
+ audio or repeat transcription when a usable source already exists.
176
+
177
+ ## X video captions and output language
178
+
179
+ Pass an X post URL directly. Default `scope=auto` detects one attached video and
180
+ analyzes its available captions plus separately labeled post context when the
181
+ deployment enables X video extraction. You do not need a subtitle URL or a
182
+ separate provider key. Use `{"type":"x","url":"YOUR_X_POST_URL","scope":"post"}`
183
+ only for post text alone; thread scope does not recursively analyze its videos.
184
+
185
+ ```python
186
+ receipt = client.submit_analysis(
187
+ "https://x.com/hamptonism/status/2071631335836225751",
188
+ instructions="Summarize the spoken content in Spanish. Keep it concise and avoid repetition.",
189
+ transcription="never",
190
+ idempotency_key="x-video-spanish-001",
191
+ )
192
+ print({"job_id": receipt["job_id"]}) # Persist before waiting.
193
+ job = client.wait_for_job(
194
+ receipt["job_id"],
195
+ on_progress=lambda state: print(
196
+ {"status": state["status"], "progress": state["progress"]}
197
+ ),
198
+ )
199
+ result = client.get_summary(job["summary_id"])
200
+ print(result["content"]["answer"])
201
+ ```
202
+
203
+ The response is a durable job receipt. Save its ID before waiting; a timeout does
204
+ not cancel the job. Use a fresh stable key for a different brief or language and
205
+ reuse the exact body/key after an uncertain submission. Without instructions,
206
+ the service produces a concise summary. A translated summary is generated text
207
+ inside the same schema-3 result, not dubbed audio, a translated video or an SRT
208
+ translation; the original captions remain saved separately.
209
+
210
+ Captioned X videos use actual video duration at 1 credit/minute under normal
211
+ rounding (128.197 seconds = 2.2 credits), not the post's reading time. Missing
212
+ captions fail explicitly with no customer processing charge; X audio
213
+ transcription and visual analysis are not enabled, even with `if_needed`.
214
+
215
+ ## Credit controls
216
+
217
+ Captions, text and existing cached transcripts cost **1 credit per source minute**.
218
+ Fresh audio transcription costs **3 credits per source minute** and requires an
219
+ eligible paid plan or server-confirmed billing exemption, plus explicit `transcription: "if_needed"` (Python:
220
+ `transcription="if_needed"`). The default `never` fails with a clear error when
221
+ new transcription would be necessary. Supplying `forceAsr: true` /
222
+ `force_asr=True` also requires that explicit permission.
223
+
224
+ Use `maxCredits: "30.00"` in JavaScript or `max_credits="30.00"` in Python to cap
225
+ a job. The server validates affordability before new transcription. The cap is
226
+ positive, at most 1,000,000 credits, and has at most two decimal places. It is
227
+ required for ordinary paid accounts when `transcription` is `if_needed`. Preserve
228
+ these controls in the same idempotent request body on retries. Jobs expose
229
+ `credits.estimated`, `reserved`, `final`, the billing class/rate and real source
230
+ duration; polling does not create another processing charge.
231
+
232
+ For `compareTikToks` / `compare_tiktoks`, the cap covers all distinct supplied
233
+ clips: each receives an equal share rounded down to 0.01 credit. Unused shares
234
+ are not redistributed. Comparison aggregation adds no customer processing credits.
235
+ Source minutes round up to 0.1 minute with a 0.5-minute minimum before the rate
236
+ multiplier. Raw text uses estimated reading minutes.
237
+
238
+ Free includes 100 credits per month for captions/text, Hobby costs USD20/month
239
+ with 1,000 credits, and Pro costs USD99/month with 5,000 credits. Included
240
+ allowance is used first, then purchased credits, which do not expire. Reading
241
+ account usage does not buy credits or change plans.
242
+
243
+ Server-confirmed billing exemption: the authenticated server decides whether an account is exempt. Exempt accounts may omit a credit ceiling and do not incur customer-credit charges; explicit transcription permission and resource/provider limits still apply. Ordinary paid accounts allowing fresh transcription must supply a ceiling or receive `TRANSCRIPTION_NOT_ALLOWED` (HTTP 422); `MAX_CREDITS_EXCEEDED` (HTTP 422) means a supplied ceiling is invalid or below the required charge. Clients validate supplied values, then pass requests through without fetching extra account scopes or inferring privileges from names, roles, or key prefixes.