Skip to content

Commit 21bbdf0

Browse files
Chandler Caseyclaude
andcommitted
Release 1.0.0: full endpoint coverage, accurate platform docs
The public docs promise the SDKs follow semver, but 0.x reserves the right to break anything. Freeze the surface at 1.0.0 and make it match the API first. - Add me() and transcripts.job() to both the sync and async clients, which completes coverage of all 8 v1 endpoints. - A 202 (no captions, so the API transcribes the audio) used to raise a Pydantic validation error, because Transcript.kind was pinned to the literal "transcript" while the 202 carries "transcript_job". kind is now a str and the envelope's status/job_id/poll_url are lifted onto the model, so the documented poll-until-done flow actually works. - Document what the video/batch inputs really are: YouTube, TikTok, Instagram and direct media file URLs. Behavior is unchanged, the clients always passed the input straight through. channel/playlist/search stay described as YouTube-only, because that is what they are. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ksoEUHvR7DHdNujkGv8UT
1 parent 9cdb02b commit 21bbdf0

15 files changed

Lines changed: 289 additions & 29 deletions

‎CHANGELOG.md‎

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

6+
## [1.0.0] - 2026-08-05
7+
8+
First stable release. The published docs promise the SDKs follow semver, and a
9+
0.x version explicitly reserves the right to break anything, so the surface is
10+
declared stable at 1.0.0 rather than contradicting that.
11+
12+
- Full API coverage: added `me()` (validate the key + read the balance, free)
13+
and `transcripts.job()` (poll an audio-transcription job), on both clients.
14+
- `transcripts.video` / `batch` are now documented as accepting any supported
15+
source: YouTube, TikTok, Instagram, and direct media file URLs. `channel`,
16+
`playlist` and `search` remain YouTube-only concepts. No behavior change; the
17+
clients always passed the input through untouched.
18+
- Fixed: a 202 (no captions, so audio transcription started) raised a validation
19+
error because `Transcript.kind` was pinned to `"transcript"`. It now returns a
20+
`Transcript` with `status="processing"` and a `job_id` to poll.
21+
- New `Account` model; `Transcript` gains `status`, `job_id` and `poll_url`.
22+
623
## [0.1.0] - Unreleased
724

825
Initial release.

‎README.md‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# TranscriptFetch Python SDK
22

3-
Official, typed Python client for the [TranscriptFetch](https://transcriptfetch.com) API: fetch YouTube transcripts, channels, playlists, and search results as clean, structured data. Sync + async, fully type-hinted.
3+
Official, typed Python client for the [TranscriptFetch](https://transcriptfetch.com) API: fetch transcripts as clean, structured data, plus YouTube channel, playlist and search listings. Sync + async, fully type-hinted.
4+
5+
Transcripts come from **YouTube, TikTok, Instagram, or a direct media file URL** (mp3/mp4/wav and friends). Channel, playlist and search are YouTube-only, since no other supported platform has those concepts.
46

57
```bash
68
pip install transcriptfetch-sdk
@@ -14,7 +16,7 @@ from transcriptfetch import TranscriptFetch
1416
# api_key falls back to the TRANSCRIPTFETCH_API_KEY env var
1517
tf = TranscriptFetch(api_key="tf_live_...")
1618

17-
t = tf.transcripts.video("https://youtu.be/aircAruvnKk")
19+
t = tf.transcripts.video("https://youtu.be/aircAruvnKk") # or a TikTok / Instagram / file URL
1820
print(t.title)
1921
print(t.text)
2022
for seg in t.segments:
@@ -28,15 +30,31 @@ Get an API key (100 free credits) at <https://transcriptfetch.com/app>. One cred
2830
## Endpoints
2931

3032
```python
31-
tf.transcripts.video(video) # single transcript (text + segments)
32-
tf.transcripts.channel(channel, limit=, cursor=) # a channel's videos (metadata)
33-
tf.transcripts.playlist(playlist, limit=, cursor=) # a playlist's videos
33+
tf.transcripts.video(video) # single transcript (text + segments)
34+
tf.transcripts.batch(video_ids) # up to 50 transcripts in one call
35+
tf.transcripts.channel(channel, limit=, cursor=) # a YouTube channel's videos (metadata)
36+
tf.transcripts.playlist(playlist, limit=, cursor=) # a YouTube playlist's videos
3437
tf.transcripts.search(query, limit=, cursor=) # search YouTube
35-
tf.transcripts.batch(video_ids) # up to 50 transcripts in one call
36-
tf.health() # unauthenticated liveness probe
38+
tf.transcripts.job(job_id) # poll an audio-transcription job (free)
39+
tf.me() # validate the key + read the balance (free)
40+
tf.health() # unauthenticated liveness probe
3741
```
3842

39-
`video`/`channel`/`playlist` accept URLs or raw IDs (normalized automatically).
43+
`video` and `batch` take a YouTube, TikTok or Instagram URL, a direct media file URL, or a bare YouTube ID. `channel`/`playlist` take a URL, an `@handle`/`PL…` ID, or a raw ID. IDs and URLs are normalized automatically.
44+
45+
## Videos without captions
46+
47+
When a source has no captions, the API transcribes its audio and answers with a job instead of a transcript. That comes back as a `Transcript` with `status == "processing"` and a `job_id`; poll it for free until it completes:
48+
49+
```python
50+
import time
51+
52+
t = tf.transcripts.video("https://www.tiktok.com/@user/video/7137723462233555205")
53+
while t.status == "processing":
54+
time.sleep(3)
55+
t = tf.transcripts.job(t.job_id)
56+
print(t.text)
57+
```
4058

4159
## Pagination
4260

‎examples/transcribe_job.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Fetch a source that may have no captions, then poll until it is transcribed.
2+
3+
Any supported input works here: a YouTube, TikTok or Instagram URL, or a direct
4+
media file URL. When captions exist the first call already returns the text;
5+
when they do not, the API transcribes the audio and hands back a job to poll.
6+
Polling is free, so the loop below costs at most the one credit on delivery.
7+
"""
8+
9+
import time
10+
11+
from transcriptfetch import TranscriptFetch
12+
13+
URL = "https://www.tiktok.com/@nasa/video/7137723462233555205"
14+
15+
with TranscriptFetch() as tf: # reads TRANSCRIPTFETCH_API_KEY
16+
t = tf.transcripts.video(URL)
17+
18+
while t.status == "processing" and t.job_id:
19+
print("transcribing…", t.job_id)
20+
time.sleep(3)
21+
t = tf.transcripts.job(t.job_id)
22+
23+
print("title:", t.title)
24+
print("text:", (t.text or "(no transcript)")[:500])

‎pyproject.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ build-backend = "hatchling.build"
55
[project]
66
name = "transcriptfetch-sdk"
77
dynamic = ["version"]
8-
description = "Official Python SDK for the TranscriptFetch API: fetch YouTube transcripts, channels, playlists, and search as clean, typed data."
8+
description = "Official Python SDK for the TranscriptFetch API: fetch transcripts from YouTube, TikTok, Instagram and direct media URLs as clean, typed data."
99
readme = "README.md"
1010
license = "MIT"
1111
requires-python = ">=3.9"
1212
authors = [{ name = "TranscriptFetch", email = "support@transcriptfetch.com" }]
13-
keywords = ["youtube", "transcript", "captions", "subtitles", "api", "sdk", "rag", "llm"]
13+
keywords = ["youtube", "tiktok", "instagram", "transcript", "captions", "subtitles", "api", "sdk", "rag", "llm"]
1414
classifiers = [
15-
"Development Status :: 4 - Beta",
15+
"Development Status :: 5 - Production/Stable",
1616
"Intended Audience :: Developers",
1717
"License :: OSI Approved :: MIT License",
1818
"Programming Language :: Python :: 3",

‎src/transcriptfetch/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
UpstreamUnavailableError,
2727
)
2828
from .models import (
29+
Account,
2930
BatchResponse,
3031
BatchResult,
3132
Segment,
@@ -41,6 +42,7 @@
4142
"AsyncTranscriptFetch",
4243
# models
4344
"Usage",
45+
"Account",
4446
"Segment",
4547
"Transcript",
4648
"Video",

‎src/transcriptfetch/_transport.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ._config import ClientConfig
1717
from ._version import __version__
1818
from .errors import raise_api_error
19+
from .models import Usage
1920

2021
USER_AGENT = f"transcriptfetch-python/{__version__}"
2122

@@ -34,6 +35,12 @@ def build_headers(
3435
return headers
3536

3637

38+
def parse_usage(env: dict[str, Any]) -> Optional[Usage]:
39+
"""Lift the envelope-level ``usage`` block off a response, if present."""
40+
raw = env.get("usage")
41+
return Usage.model_validate(raw) if isinstance(raw, dict) else None
42+
43+
3744
def new_idempotency_key() -> str:
3845
return uuid.uuid4().hex
3946

‎src/transcriptfetch/_version.py‎

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

‎src/transcriptfetch/async_client.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
is_retryable,
1616
new_idempotency_key,
1717
parse_envelope,
18+
parse_usage,
1819
)
1920
from .errors import APIConnectionError, APITimeoutError
21+
from .models import Account
2022
from .resources.transcripts import AsyncTranscripts
2123

2224

@@ -78,6 +80,13 @@ async def _request(
7880
continue
7981
return parse_envelope(resp)
8082

83+
async def me(self) -> Account:
84+
"""Validate the API key and read the account's credit balance. Free."""
85+
env = await self._request("GET", "/api/v1/me")
86+
account = Account.model_validate(env.get("data") or {})
87+
account.usage = parse_usage(env)
88+
return account
89+
8190
async def health(self) -> dict[str, Any]:
8291
"""Unauthenticated liveness probe."""
8392
return await self._request("GET", "/api/v1/health", auth=False)

‎src/transcriptfetch/client.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
is_retryable,
1616
new_idempotency_key,
1717
parse_envelope,
18+
parse_usage,
1819
)
1920
from .errors import APIConnectionError, APITimeoutError
21+
from .models import Account
2022
from .resources.transcripts import Transcripts
2123

2224

@@ -75,6 +77,13 @@ def _request(
7577
continue
7678
return parse_envelope(resp)
7779

80+
def me(self) -> Account:
81+
"""Validate the API key and read the account's credit balance. Free."""
82+
env = self._request("GET", "/api/v1/me")
83+
account = Account.model_validate(env.get("data") or {})
84+
account.usage = parse_usage(env)
85+
return account
86+
7887
def health(self) -> dict[str, Any]:
7988
"""Unauthenticated liveness probe."""
8089
return self._request("GET", "/api/v1/health", auth=False)

‎src/transcriptfetch/models.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,43 @@ class Segment(_Model):
3333

3434

3535
class Transcript(_Model):
36-
"""A single video's transcript (``kind == "transcript"``)."""
36+
"""A single video's transcript, from any supported source.
3737
38-
kind: Literal["transcript"] = "transcript"
38+
Also covers the "not ready yet" case. When a video has no captions the API
39+
transcribes the audio and answers 202 with ``kind == "transcript_job"``,
40+
which is why ``kind`` is a plain ``str``: pinning it to a literal would turn
41+
a 202 into a validation error instead of a pollable job. In that case
42+
``text``/``segments`` are empty and ``status``/``job_id`` are set, so pass
43+
``job_id`` to ``transcripts.job()`` until ``status == "completed"``.
44+
"""
45+
46+
kind: str = "transcript"
3947
video_id: str = ""
4048
title: Optional[str] = None
4149
text: Optional[str] = None
4250
segments: List[Segment] = Field(default_factory=list)
4351
usage: Optional[Usage] = None
52+
# Envelope-level fields, lifted onto the model so an async job round-trips
53+
# as one object (the API returns them beside ``data``, not inside it).
54+
status: Optional[str] = None # "processing" | "completed", async jobs only
55+
job_id: Optional[str] = None
56+
poll_url: Optional[str] = None
4457

4558
@field_validator("segments", mode="before")
4659
@classmethod
4760
def _null_segments_to_list(cls, v: Any) -> Any:
4861
return v or []
4962

5063

64+
class Account(_Model):
65+
"""The calling key's account (``kind == "me"``): identity + credit balance."""
66+
67+
kind: Literal["me"] = "me"
68+
user_id: str = ""
69+
credits: Optional[int] = None # null for unlimited (admin) accounts
70+
usage: Optional[Usage] = None
71+
72+
5173
class Video(_Model):
5274
"""A video reference from a channel/playlist/search list (metadata only)."""
5375

0 commit comments

Comments
 (0)