Skip to content

Commit 8d8b5da

Browse files
author
Transcript-Fetch-Admin
committed
2.5.0: markdown and chunks on video() and batch()
1 parent 4755c49 commit 8d8b5da

6 files changed

Lines changed: 157 additions & 5 deletions

File tree

‎CHANGELOG.md‎

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

6+
## [2.5.0] - 2026-10-28
7+
8+
### Added
9+
10+
- `markdown=` and `chunks=` on `transcripts.video()` and `transcripts.batch()`
11+
(sync and async). `chunks=True`, or `{"max_tokens": 800, "overlap_tokens":
12+
80}`, returns the transcript in pieces sized for an embedding model, each
13+
with `start`, `end`, a `url` that opens the video at that moment, and its
14+
token count (`Transcript.chunks`, typed as the new `Chunk`). `markdown=True`
15+
returns a timestamped Markdown rendering (`Transcript.markdown`). Both are
16+
free, and an audio-transcription job delivers them too. These are what the
17+
LangChain and LlamaIndex integrations build on.
18+
619
## [2.4.1] - 2026-10-02
720

821
### Fixed

‎src/transcriptfetch/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
ApiErrorBlock,
3434
BatchResponse,
3535
BatchResult,
36+
Chunk,
3637
Monitor,
3738
MonitorCheck,
3839
MonitorDeleted,
@@ -56,6 +57,7 @@
5657
VideoList,
5758
VideoStats,
5859
)
60+
from .resources.transcripts import ChunkOptions
5961

6062
__all__ = [
6163
"__version__",
@@ -80,6 +82,8 @@
8082
# models
8183
"Usage",
8284
"Account",
85+
"Chunk",
86+
"ChunkOptions",
8387
"Segment",
8488
"Transcript",
8589
"Video",

‎src/transcriptfetch/_version.py‎

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

‎src/transcriptfetch/models.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ class Segment(_Model):
3838
speaker: Optional[int] = None
3939

4040

41+
class Chunk(_Model):
42+
"""One embedding-sized piece of a transcript (``chunks=`` on a request).
43+
44+
Pieces break between caption lines, never mid-word, and overlap the
45+
previous piece slightly. ``start``/``end`` are seconds (``None`` when the
46+
transcript has no timings); ``url`` opens the video at ``start`` on
47+
YouTube and is the item's own link elsewhere; ``tokens`` is the piece's
48+
size in OpenAI's o200k_base encoding.
49+
"""
50+
51+
index: int = 0
52+
text: str = ""
53+
start: Optional[float] = None
54+
end: Optional[float] = None
55+
url: Optional[str] = None
56+
tokens: Optional[int] = None
57+
58+
4159
class Transcript(_Model):
4260
"""A single video's transcript, from any supported source.
4361
@@ -74,6 +92,12 @@ class Transcript(_Model):
7492
source: Optional[str] = None # "captions" | "audio" (AI transcription); None on a 202
7593
text: Optional[str] = None
7694
segments: List[Segment] = Field(default_factory=list)
95+
markdown: Optional[str] = None
96+
"""Set when the request asked for ``markdown=True``: title, channel, length
97+
and link, then paragraphs that each open with a timestamp link."""
98+
chunks: List[Chunk] = Field(default_factory=list)
99+
"""Set when the request asked for ``chunks``: the transcript in
100+
embedding-sized pieces (see :class:`Chunk`). Empty otherwise."""
77101
usage: Optional[Usage] = None
78102
# Envelope-level fields, lifted onto the model so an async job round-trips
79103
# as one object (the API returns them beside ``data``, not inside it).
@@ -83,7 +107,7 @@ class Transcript(_Model):
83107
error: Optional["ApiErrorBlock"] = None
84108
"""Why a job failed. Set only when ``status == "failed"``."""
85109

86-
@field_validator("segments", mode="before")
110+
@field_validator("segments", "chunks", mode="before")
87111
@classmethod
88112
def _null_segments_to_list(cls, v: Any) -> Any:
89113
return v or []
@@ -188,6 +212,10 @@ class BatchResult(_Model):
188212
source: Optional[str] = None # "captions" | "audio" on outcome "ok"
189213
text: Optional[str] = None
190214
segments: Optional[List[Segment]] = None
215+
markdown: Optional[str] = None
216+
"""Set on ``outcome == "ok"`` when the batch asked for ``markdown=True``."""
217+
chunks: Optional[List[Chunk]] = None
218+
"""Set on ``outcome == "ok"`` when the batch asked for ``chunks``."""
191219
job_id: Optional[str] = None
192220
"""Set when ``outcome == "processing"``: the audio-transcription job that
193221
will deliver this entry."""

‎src/transcriptfetch/resources/transcripts.py‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,17 @@
1111

1212
from __future__ import annotations
1313

14-
from typing import Any, AsyncIterator, Iterable, Iterator, Literal, Optional, Protocol
14+
from typing import (
15+
Any,
16+
AsyncIterator,
17+
Iterable,
18+
Iterator,
19+
Literal,
20+
Optional,
21+
Protocol,
22+
TypedDict,
23+
Union,
24+
)
1525

1626
from .._transport import parse_usage as _usage
1727
from ..models import ApiErrorBlock, BatchResponse, Transcript, VideoList
@@ -25,6 +35,27 @@
2535
_JOB = "/api/v2/transcripts/jobs/{job_id}"
2636

2737

38+
class ChunkOptions(TypedDict, total=False):
39+
"""Sizes for ``chunks=``: ``max_tokens`` 100 to 2000 (default 500) and
40+
``overlap_tokens`` 0 to 200, smaller than ``max_tokens`` (default 50)."""
41+
42+
max_tokens: int
43+
overlap_tokens: int
44+
45+
46+
Chunks = Union[bool, ChunkOptions]
47+
48+
49+
def _formats(markdown: Optional[bool], chunks: Optional[Chunks]) -> dict[str, Any]:
50+
"""The LLM-ready output options, only the ones set. Both are free."""
51+
out: dict[str, Any] = {}
52+
if markdown is not None:
53+
out["markdown"] = markdown
54+
if chunks is not None and chunks is not False:
55+
out["chunks"] = dict(chunks) if isinstance(chunks, dict) else True
56+
return out
57+
58+
2859
# ── Requester protocols (avoid importing the client → no circular import) ─────
2960
class _SyncRequester(Protocol):
3061
def _request(
@@ -109,6 +140,8 @@ def video(
109140
*,
110141
mode: Optional[Literal["auto", "captions", "audio"]] = None,
111142
timestamps: Optional[bool] = None,
143+
markdown: Optional[bool] = None,
144+
chunks: Optional[Chunks] = None,
112145
callback_url: Optional[str] = None,
113146
idempotency_key: Optional[str] = None,
114147
) -> Transcript:
@@ -117,6 +150,12 @@ def video(
117150
``video`` is a YouTube, TikTok or Instagram URL, a direct media file
118151
URL, or a bare YouTube ID.
119152
153+
``markdown=True`` adds :attr:`~transcriptfetch.Transcript.markdown`;
154+
``chunks=True`` (or ``{"max_tokens": 800, "overlap_tokens": 80}``)
155+
adds :attr:`~transcriptfetch.Transcript.chunks`, pieces sized for an
156+
embedding model with a link to the moment each one starts. Both are
157+
free, and an audio-transcription job delivers them too.
158+
120159
When the source has no captions the API transcribes its audio and
121160
answers with a job instead: the returned
122161
:class:`~transcriptfetch.Transcript` then has ``status ==
@@ -134,7 +173,8 @@ def video(
134173
"callback_url": callback_url,
135174
}.items()
136175
if v is not None
137-
},
176+
}
177+
| _formats(markdown, chunks),
138178
idempotent=True,
139179
idempotency_key=idempotency_key,
140180
)
@@ -204,6 +244,8 @@ def batch(
204244
video_ids: Iterable[str],
205245
*,
206246
mode: Optional[str] = None,
247+
markdown: Optional[bool] = None,
248+
chunks: Optional[Chunks] = None,
207249
idempotency_key: Optional[str] = None,
208250
) -> BatchResponse:
209251
"""Fetch up to 50 transcripts in one call (same inputs as :meth:`video`).
@@ -225,6 +267,7 @@ def batch(
225267
body: dict[str, Any] = {"videoIds": list(video_ids)}
226268
if mode is not None:
227269
body["mode"] = mode
270+
body.update(_formats(markdown, chunks))
228271
env = self._c._request(
229272
"POST",
230273
_BATCH,
@@ -278,6 +321,8 @@ async def video(
278321
*,
279322
mode: Optional[Literal["auto", "captions", "audio"]] = None,
280323
timestamps: Optional[bool] = None,
324+
markdown: Optional[bool] = None,
325+
chunks: Optional[Chunks] = None,
281326
callback_url: Optional[str] = None,
282327
idempotency_key: Optional[str] = None,
283328
) -> Transcript:
@@ -294,7 +339,8 @@ async def video(
294339
"callback_url": callback_url,
295340
}.items()
296341
if v is not None
297-
},
342+
}
343+
| _formats(markdown, chunks),
298344
idempotent=True,
299345
idempotency_key=idempotency_key,
300346
)
@@ -357,6 +403,8 @@ async def batch(
357403
video_ids: Iterable[str],
358404
*,
359405
mode: Optional[str] = None,
406+
markdown: Optional[bool] = None,
407+
chunks: Optional[Chunks] = None,
360408
idempotency_key: Optional[str] = None,
361409
) -> BatchResponse:
362410
"""Fetch up to 50 transcripts in one call. See :meth:`Transcripts.batch`
@@ -366,6 +414,7 @@ async def batch(
366414
body: dict[str, Any] = {"videoIds": list(video_ids)}
367415
if mode is not None:
368416
body["mode"] = mode
417+
body.update(_formats(markdown, chunks))
369418
env = await self._c._request(
370419
"POST",
371420
_BATCH,

‎tests/test_client.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,61 @@ def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
194194
monkeypatch.delenv("TRANSCRIPTFETCH_API_KEY", raising=False)
195195
with pytest.raises(ValueError):
196196
TranscriptFetch()
197+
198+
199+
CHUNKED_ENV = {
200+
"ok": True,
201+
"request_id": "req_chunks",
202+
"data": {
203+
"kind": "transcript",
204+
"video_id": "abc",
205+
"url": "https://www.youtube.com/watch?v=abc",
206+
"platform": "youtube",
207+
"title": "Example",
208+
"text": "hello world",
209+
"segments": [],
210+
"markdown": "# Example\n\n[0:00](https://www.youtube.com/watch?v=abc&t=0s) hello world",
211+
"chunks": [
212+
{"index": 0, "text": "hello world", "start": 0.0, "end": 2.5,
213+
"url": "https://www.youtube.com/watch?v=abc&t=0s", "tokens": 2},
214+
],
215+
},
216+
}
217+
218+
219+
@respx.mock
220+
def test_video_sends_and_parses_llm_formats() -> None:
221+
route = respx.post(f"{BASE}/api/v2/transcripts/video").mock(
222+
return_value=httpx.Response(200, json=CHUNKED_ENV)
223+
)
224+
with _client() as tf:
225+
t = tf.transcripts.video("abc", markdown=True, chunks={"max_tokens": 800})
226+
227+
body = json.loads(route.calls.last.request.content)
228+
assert body == {"video": "abc", "markdown": True, "chunks": {"max_tokens": 800}}
229+
assert t.markdown and t.markdown.startswith("# Example")
230+
assert len(t.chunks) == 1
231+
c = t.chunks[0]
232+
assert (c.index, c.start, c.end, c.tokens) == (0, 0.0, 2.5, 2)
233+
assert c.url == "https://www.youtube.com/watch?v=abc&t=0s"
234+
235+
236+
@respx.mock
237+
def test_video_omits_unset_formats_and_false_chunks() -> None:
238+
route = respx.post(f"{BASE}/api/v2/transcripts/video").mock(
239+
return_value=httpx.Response(200, json=VIDEO_ENV)
240+
)
241+
with _client() as tf:
242+
t = tf.transcripts.video("abc", chunks=False)
243+
assert json.loads(route.calls.last.request.content) == {"video": "abc"}
244+
assert t.chunks == [] and t.markdown is None
245+
246+
247+
@respx.mock
248+
def test_batch_sends_chunks_true() -> None:
249+
route = respx.post(f"{BASE}/api/v2/transcripts/batch").mock(
250+
return_value=httpx.Response(200, json=BATCH_ENV)
251+
)
252+
with _client() as tf:
253+
tf.transcripts.batch(["a", "b"], chunks=True)
254+
assert json.loads(route.calls.last.request.content) == {"videoIds": ["a", "b"], "chunks": True}

0 commit comments

Comments
 (0)