Skip to content

Commit 64d255d

Browse files
Chandler Caseyclaude
andcommitted
Batch audio fallback: mode param, processing entries, drop cached
The batch endpoint now transcribes captionless entries from audio by default. Type that end to end: - transcripts.batch() (sync + async) gains mode="auto" | "captions"; omitted means the server default (auto). "audio" is not accepted on batch. - BatchResult gains job_id and status for entries that came back as outcome "processing" (free on that call, billed on delivery at the audio rate; re-send the batch or poll transcripts.job()). - BatchResult.cached deprecated: the API omits the key entirely now, so a defaulted bool silently misreported. Optional[bool] = None keeps 1.x import/attribute compatibility without inventing False. - Segment gains optional speaker (int), the best-effort diarization label podcast transcriptions carry. - Map wire code "upstream_error" (503 upstream platform block) to UpstreamUnavailableError by code as well as status; retries already covered 503 and are unchanged. Per-key limits remain 429. Version 1.0.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent be47b5e commit 64d255d

8 files changed

Lines changed: 144 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
All notable changes to this project are documented here. This project adheres to
44
[Semantic Versioning](https://semver.org).
55

6+
## [1.0.2] - 2026-08-28
7+
8+
Batch grew audio fallback on the API side; this release types it.
9+
10+
- `transcripts.batch()` (sync + async) gains `mode="auto" | "captions"`.
11+
`"auto"` (the API default) transcribes captionless entries from audio: those
12+
come back with `outcome == "processing"` and a `job_id`, cost nothing on that
13+
call, and are charged on delivery at the audio rate — re-send the same batch
14+
later, or poll the job via `transcripts.job()`. `"captions"` keeps the old
15+
behaviour (captionless entries fail as `no_transcript`). `"audio"` is not
16+
accepted on batch.
17+
- `BatchResult` gains `job_id` and `status` for those processing entries.
18+
- Deprecated: `BatchResult.cached`. The API no longer reports cache hits on
19+
batch results (the key is omitted entirely), so the field is now
20+
`Optional[bool]` defaulting to `None` instead of a silently-false `bool`.
21+
Kept for import/attribute compatibility within 1.x; do not branch on it.
22+
- `Segment` gains optional `speaker` (int): best-effort diarization label on
23+
podcast transcriptions — ids are voice-separation hints, not named
24+
identification, and non-podcast sources never carry them.
25+
- Error mapping: the wire code `upstream_error` (503, upstream platform
26+
blocked) now maps to `UpstreamUnavailableError` by code as well as by
27+
status. Behaviour is unchanged — 503 already mapped and retried — but the
28+
docstring no longer claims 502 only. Per-key rate limits remain 429
29+
(`RateLimitError`); upstream blocks are never 429.
30+
631
## [1.0.0] - 2026-08-05
732

833
First stable release. The published docs promise the SDKs follow semver, and a

‎README.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Get an API key (100 free credits) at <https://transcriptfetch.com/app>. One cred
3131

3232
```python
3333
tf.transcripts.video(video) # single transcript (text + segments)
34-
tf.transcripts.batch(video_ids) # up to 50 transcripts in one call
34+
tf.transcripts.batch(video_ids, mode=) # up to 50 transcripts in one call
3535
tf.transcripts.channel(channel, limit=, cursor=) # a YouTube channel's videos (metadata)
3636
tf.transcripts.playlist(playlist, limit=, cursor=) # a YouTube playlist's videos
3737
tf.transcripts.search(query, limit=, cursor=) # search YouTube
@@ -64,6 +64,17 @@ print(t.platform) # "podcast"
6464
print(t.podcast.show, "-", t.podcast.episode)
6565
```
6666

67+
Podcast transcriptions include best-effort speaker diarization: each segment may carry a `speaker` integer (0, 1, …) identifying who is talking. The ids are hints from voice separation, not named identification, and non-podcast sources never carry them.
68+
69+
Batch works the same way by default (`mode="auto"`): entries with no caption track are transcribed from audio, come back with `outcome == "processing"` and a `job_id`, cost nothing on that call, and are charged on delivery at the audio rate. Re-send the same batch once the jobs have had time to finish and the text comes back normally — or poll each `job_id` with `tf.transcripts.job()`. Pass `mode="captions"` to read existing caption tracks only, in which case a captionless video fails as `no_transcript` (the old behaviour):
70+
71+
```python
72+
res = tf.transcripts.batch(ids) # captionless entries -> "processing" + job_id
73+
pending = [r.job_id for r in res.results if r.outcome == "processing"]
74+
75+
res = tf.transcripts.batch(ids, mode="captions") # captions only, no audio fallback
76+
```
77+
6778
## Pagination
6879

6980
List endpoints are cursor-paginated. Iterate every result without managing cursors:

‎src/transcriptfetch/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.1"
1+
__version__ = "1.0.2"

‎src/transcriptfetch/errors.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwarg
7777

7878

7979
class UpstreamUnavailableError(APIError):
80-
"""502: the upstream transcript service was unreachable. Safe to retry."""
80+
"""502/503: the upstream platform blocked or was unreachable. Safe to retry.
81+
82+
Upstream platform blocks answer 503 with code ``upstream_error`` — never
83+
429, which is reserved for per-key rate limits (:class:`RateLimitError`).
84+
"""
8185

8286

8387
class InternalServerError(APIError):
@@ -91,6 +95,7 @@ class InternalServerError(APIError):
9195
"idempotency_conflict": IdempotencyConflictError,
9296
"rate_limited": RateLimitError,
9397
"upstream_unavailable": UpstreamUnavailableError,
98+
"upstream_error": UpstreamUnavailableError,
9499
"internal_error": InternalServerError,
95100
}
96101

‎src/transcriptfetch/models.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,18 @@ class Usage(_Model):
2525

2626

2727
class Segment(_Model):
28-
"""A single timestamped caption cue."""
28+
"""A single timestamped caption cue.
29+
30+
``speaker`` is set only on podcast episodes transcribed from audio, where
31+
best-effort diarization labels each segment with a small integer (0, 1, …)
32+
identifying who is talking. The ids are hints from voice separation, not
33+
named identification, and non-podcast sources never carry them.
34+
"""
2935

3036
start: float = 0.0
3137
duration: float = 0.0
3238
text: str = ""
39+
speaker: Optional[int] = None
3340

3441

3542
class Podcast(_Model):
@@ -110,14 +117,34 @@ class VideoList(_Model):
110117

111118

112119
class BatchResult(_Model):
113-
"""One video's result inside a batch response."""
120+
"""One video's result inside a batch response.
121+
122+
Entries with no caption track are transcribed from audio by default (batch
123+
``mode="auto"``): those come back with ``outcome == "processing"`` and a
124+
``job_id``, cost nothing on that call, and are charged on delivery at the
125+
audio rate. Either re-send the same batch once the jobs have had time to
126+
finish (the text then comes back normally) or poll ``job_id`` via
127+
``transcripts.job()``. Send ``mode="captions"`` to keep the old behaviour,
128+
where captionless entries fail as ``no_transcript`` instead.
129+
"""
114130

115131
video_id: str = ""
116-
outcome: Optional[str] = None # ok | no_transcript | blocked | error | null
132+
outcome: Optional[str] = None # ok | processing | no_transcript | blocked | error | null
117133
title: Optional[str] = None
118134
text: Optional[str] = None
119135
segments: Optional[List[Segment]] = None
120-
cached: bool = False
136+
cached: Optional[bool] = None
137+
"""Deprecated. The API no longer reports cache hits on batch results and
138+
omits the key entirely, so this is ``None`` on current responses; it is kept
139+
(rather than removed) so 1.0.x code reading it keeps importing and running.
140+
Do not branch on it."""
141+
job_id: Optional[str] = None
142+
"""Set when ``outcome == "processing"``: the audio-transcription job that
143+
will deliver this entry. Poll it with ``transcripts.job(job_id)``, or just
144+
re-send the same batch later."""
145+
status: Optional[str] = None
146+
"""Job state for an audio-transcription entry (``"processing"`` until the
147+
job delivers). ``None`` for ordinary caption results."""
121148
bytes: int = 0
122149

123150

‎src/transcriptfetch/resources/transcripts.py‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,33 @@ def search(
158158
return _parse_videolist(env)
159159

160160
def batch(
161-
self, video_ids: Iterable[str], *, idempotency_key: Optional[str] = None
161+
self,
162+
video_ids: Iterable[str],
163+
*,
164+
mode: Optional[str] = None,
165+
idempotency_key: Optional[str] = None,
162166
) -> BatchResponse:
163-
"""Fetch up to 50 transcripts in one call (same inputs as :meth:`video`)."""
167+
"""Fetch up to 50 transcripts in one call (same inputs as :meth:`video`).
168+
169+
``mode`` controls where each entry's text may come from:
170+
171+
- ``"auto"`` (the default) reads captions and transcribes the audio
172+
when there are none. Captionless entries come back with ``outcome ==
173+
"processing"`` and a ``job_id``, cost nothing on this call, and are
174+
charged on delivery at the audio rate. Re-send the same batch once
175+
they have finished and the text is returned normally — or poll each
176+
``job_id`` via :meth:`job`.
177+
- ``"captions"`` reads an existing caption track only, so a captionless
178+
video fails as ``no_transcript`` — the behaviour batch had before
179+
audio fallback existed.
180+
181+
(``"audio"`` is not accepted on batch.)
182+
"""
183+
body: dict[str, Any] = {"videoIds": list(video_ids)}
184+
if mode is not None:
185+
body["mode"] = mode
164186
env = self._c._request(
165-
"POST", _BATCH, body={"videoIds": list(video_ids)},
187+
"POST", _BATCH, body=body,
166188
idempotent=True, idempotency_key=idempotency_key,
167189
)
168190
return _parse_batch(env)
@@ -253,10 +275,21 @@ async def search(
253275
return _parse_videolist(env)
254276

255277
async def batch(
256-
self, video_ids: Iterable[str], *, idempotency_key: Optional[str] = None
278+
self,
279+
video_ids: Iterable[str],
280+
*,
281+
mode: Optional[str] = None,
282+
idempotency_key: Optional[str] = None,
257283
) -> BatchResponse:
284+
"""Fetch up to 50 transcripts in one call. See :meth:`Transcripts.batch`
285+
for the ``mode`` semantics (``"auto"`` transcribes captionless entries
286+
from audio and returns them as ``outcome == "processing"`` jobs;
287+
``"captions"`` keeps the old captions-only behaviour)."""
288+
body: dict[str, Any] = {"videoIds": list(video_ids)}
289+
if mode is not None:
290+
body["mode"] = mode
258291
env = await self._c._request(
259-
"POST", _BATCH, body={"videoIds": list(video_ids)},
292+
"POST", _BATCH, body=body,
260293
idempotent=True, idempotency_key=idempotency_key,
261294
)
262295
return _parse_batch(env)

‎tests/data.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,20 @@
2525
"data": {
2626
"kind": "transcript_batch",
2727
"results": [
28-
{"video_id": "a", "outcome": "ok", "text": "one", "cached": False, "bytes": 10},
28+
# The API no longer includes a "cached" key on batch results.
29+
{"video_id": "a", "outcome": "ok", "text": "one", "bytes": 10},
2930
{
3031
"video_id": "b",
3132
"outcome": "no_transcript",
3233
"segments": None,
33-
"cached": True,
34+
"bytes": 0,
35+
},
36+
# mode="auto" (default): a captionless entry escalates to audio.
37+
{
38+
"video_id": "c",
39+
"outcome": "processing",
40+
"status": "processing",
41+
"job_id": "asr_batch_1",
3442
"bytes": 0,
3543
},
3644
],

‎tests/test_client.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,28 @@ def test_batch() -> None:
8383
return_value=httpx.Response(200, json=BATCH_ENV)
8484
)
8585
with _client() as tf:
86-
res = tf.transcripts.batch(["a", "b"])
87-
assert [r.video_id for r in res.results] == ["a", "b"]
86+
res = tf.transcripts.batch(["a", "b", "c"])
87+
assert [r.video_id for r in res.results] == ["a", "b", "c"]
8888
assert res.results[1].outcome == "no_transcript"
89-
assert json.loads(route.calls.last.request.content)["videoIds"] == ["a", "b"]
89+
# "cached" is gone from the API; the deprecated field must not invent False.
90+
assert res.results[0].cached is None
91+
# A captionless entry escalated to audio: processing + a pollable job_id.
92+
assert res.results[2].outcome == "processing"
93+
assert res.results[2].status == "processing"
94+
assert res.results[2].job_id == "asr_batch_1"
95+
body = json.loads(route.calls.last.request.content)
96+
assert body["videoIds"] == ["a", "b", "c"]
97+
assert "mode" not in body # server default (auto) unless asked for
98+
99+
100+
@respx.mock
101+
def test_batch_mode_captions() -> None:
102+
route = respx.post(f"{BASE}/api/v1/transcripts/batch").mock(
103+
return_value=httpx.Response(200, json=BATCH_ENV)
104+
)
105+
with _client() as tf:
106+
tf.transcripts.batch(["a", "b", "c"], mode="captions")
107+
assert json.loads(route.calls.last.request.content)["mode"] == "captions"
90108

91109

92110
@respx.mock

0 commit comments

Comments
 (0)