Skip to content

Commit 4da76b8

Browse files
Chandler Caseyclaude
andcommitted
Podcast support in the docs and model, and fix the 202 async path
Podcasts: /transcripts/video accepts a Spotify or Apple Podcasts episode URL or an RSS feed URL and resolves it to that episode's audio, so the README, JSDoc and keywords now say so. Adds Transcript.podcast (show, episode, published_at, feed_url, audio_url, resolved_via) and Transcript.platform, both already on the wire and both previously dropped on the floor by the normalizer. 202: a source without captions is transcribed from its audio and answers 202 with data.kind "transcript_job" and no text. The SDK ran that through the transcript normalizer and handed back an empty Transcript with no sign a job existed. Fixed the way the Python SDK does it, so the two behave the same: kind relaxed from a literal to a string, data: null tolerated (obj() already absorbed it), and the envelope's status, job_id and poll_url lifted onto the model. Additive, so nothing that compiled before stops compiling. TranscriptJob now extends Transcript instead of wrapping one, which is what makes the poll loop a plain `while (t.status === "processing")` with the same shape coming out of video() and job(). It keeps the extra error block, since a failed job is still delivered as ok:false at 200. The README poll loop was compiled against src before being written down. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ksoEUHvR7DHdNujkGv8UT
1 parent d9822ee commit 4da76b8

5 files changed

Lines changed: 155 additions & 47 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,28 @@ Added
1414
- `client.me()` - validate the API key and read the credit balance. Free, and
1515
unlike `health()` it authenticates, so it is a real credential test.
1616
- `transcripts.job(jobId)` - poll an async transcription job, created when a
17-
video has no captions and the API escalates to transcribing its audio. A job
18-
that failed is returned as a `TranscriptJob` with status `"failed"` rather
19-
than thrown, because the request itself succeeded.
20-
- `Me` and `TranscriptJob` types.
17+
source has no captions and the API escalates to transcribing its audio. A job
18+
that failed is returned with status `"failed"` rather than thrown, because
19+
the request itself succeeded.
20+
- Async job support on `Transcript`. A source without captions answers 202 with
21+
no text, which the SDK used to hand back as an empty transcript with no sign a
22+
job existed. `status`, `jobId` and `pollUrl` are now lifted from the envelope
23+
onto the model, so `video()` and `job()` return the same shape and the poll
24+
loop is a `while (t.status === "processing")`.
25+
- `Transcript.podcast`, the show and episode behind a podcast transcript, and
26+
`Transcript.platform` (`youtube` | `tiktok` | `instagram` | `podcast` | `file`).
27+
- `Me`, `TranscriptJob` and `PodcastMeta` types.
2128

2229
Changed
2330

2431
- Documented the real platform coverage. `video()` and `batch()` accept YouTube,
25-
TikTok and Instagram URLs as well as direct media file URLs, not YouTube only.
26-
No behaviour change: the input string has always been passed through as-is.
27-
`channel()`, `playlist()` and `search()` remain YouTube-only.
32+
TikTok and Instagram URLs, direct media file URLs, and podcast links (Spotify,
33+
Apple Podcasts, or an RSS feed), not YouTube only. No behaviour change: the
34+
input string has always been passed through as-is. `channel()`, `playlist()`
35+
and `search()` remain YouTube-only, because no other source has those.
36+
- `Transcript.kind` is a plain string rather than the literal `"transcript"`.
37+
A 202 returns `"transcript_job"`, and pinning the type made a valid response
38+
look like a wrong one.
2839

2940
## [0.1.0]
3041

‎README.md‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The official Node.js / TypeScript client for the [TranscriptFetch API](https://transcriptfetch.com). Fetch transcripts as clean, typed data, with built-in retries, idempotency, and a typed error hierarchy.
44

5-
- Transcripts from **YouTube, TikTok, Instagram, and direct media file URLs**
5+
- Transcripts from **YouTube, TikTok, Instagram, podcasts, and direct media file URLs**
66
- YouTube channel, playlist, and search listing
77
- Typed responses (full TypeScript types, ESM + CommonJS)
88
- Automatic retries on 429 and 5xx with backoff
@@ -45,14 +45,46 @@ Keep your key server-side. Never ship it to the browser.
4545
| YouTube URL or bare video ID | `https://youtu.be/aircAruvnKk`, `aircAruvnKk` |
4646
| TikTok video URL | `https://www.tiktok.com/@user/video/7137723462233555205` |
4747
| Instagram post or reel URL | `https://www.instagram.com/reel/Cxyz.../` |
48+
| Podcast episode or feed | `https://open.spotify.com/episode/...`, `https://podcasts.apple.com/...`, `https://feeds.example.com/show.xml` |
4849
| Direct media file URL | `https://example.com/talk.mp3` |
4950

5051
The string is sent to the API as-is, so the SDK never has to be upgraded for the
5152
API to accept a new input.
5253

54+
A podcast link is resolved to that episode's audio automatically, and the
55+
response carries a `podcast` block naming the show and episode:
56+
57+
```ts
58+
const t = await tf.transcripts.video("https://open.spotify.com/episode/...");
59+
console.log(t.podcast?.show, "-", t.podcast?.episode);
60+
```
61+
5362
`channel()`, `playlist()`, and `search()` are YouTube-only concepts and take
5463
YouTube handles, IDs, and queries.
5564

65+
## Sources without captions
66+
67+
When a source has no captions the API transcribes its audio, which takes longer
68+
than one request. You get a transcript back with `status: "processing"` and a
69+
`jobId` instead of text. Poll it:
70+
71+
```ts
72+
let t = await tf.transcripts.video("https://example.com/episode.mp3");
73+
74+
while (t.status === "processing") {
75+
await new Promise((r) => setTimeout(r, 5_000));
76+
// Polling is free: credits are charged once, on delivery.
77+
const polled = await tf.transcripts.job(t.jobId!);
78+
if (polled.status === "failed") throw new Error(polled.error?.message ?? "job failed");
79+
t = polled;
80+
}
81+
82+
console.log(t.text);
83+
```
84+
85+
Everything else answers in one call, so `status` is null there and no polling is
86+
needed.
87+
5688
## Endpoints
5789

5890
```ts

‎src/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* Official Node.js / TypeScript SDK for the TranscriptFetch API.
33
*
4-
* Transcribes YouTube, TikTok and Instagram videos as well as direct media
5-
* file URLs; channel, playlist and search listing are YouTube only.
4+
* Transcribes YouTube, TikTok and Instagram videos, direct media file URLs,
5+
* and podcast episodes (Spotify, Apple Podcasts, or an RSS feed); channel,
6+
* playlist and search listing are YouTube only.
67
*
78
* ```ts
89
* import { TranscriptFetch } from "transcriptfetch";
@@ -36,6 +37,7 @@ export type {
3637
Usage,
3738
Segment,
3839
Transcript,
40+
PodcastMeta,
3941
Video,
4042
VideoList,
4143
BatchResult,

‎src/models.ts‎

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,52 @@ export interface Segment {
2121
text: string;
2222
}
2323

24-
/** A single video's transcript. */
24+
/**
25+
* The show and episode behind a podcast transcript.
26+
*
27+
* Present only when the input resolved to a podcast episode. A job polled back
28+
* later carries the short form (show + episode); the rest is filled in on the
29+
* response that did the resolving, so treat every field as optional.
30+
*/
31+
export interface PodcastMeta {
32+
show: string | null;
33+
episode: string | null;
34+
publishedAt: string | null;
35+
feedUrl: string | null;
36+
audioUrl: string | null;
37+
/** How the link was matched to a feed, e.g. "rss" or "itunes_search". */
38+
resolvedVia: string | null;
39+
}
40+
41+
/**
42+
* A single video's transcript, from any supported source.
43+
*
44+
* This also covers the "not ready yet" case. A source with no captions is
45+
* transcribed from its audio, and the API answers 202 with `kind:
46+
* "transcript_job"` and no text. `kind` is therefore a plain string rather than
47+
* a literal: pinning it would make a perfectly good 202 look like a wrong
48+
* shape. When `status` is "processing", pass `jobId` to `transcripts.job()`
49+
* until it reports "completed".
50+
*/
2551
export interface Transcript {
26-
kind: "transcript";
52+
/** transcript | transcript_job */
53+
kind: string;
2754
videoId: string;
55+
/** Source platform: youtube | tiktok | instagram | podcast | file. */
56+
platform: string | null;
2857
title: string | null;
2958
text: string | null;
3059
segments: Segment[];
60+
/** Set only when the input resolved to a podcast episode. */
61+
podcast: PodcastMeta | null;
3162
usage: Usage | null;
63+
// Envelope-level fields, lifted onto the model so an async job round-trips as
64+
// one object (the API returns them beside `data`, not inside it).
65+
/** processing | completed | failed. Only set on async transcription jobs. */
66+
status: string | null;
67+
jobId: string | null;
68+
/** Path to poll for the finished transcript. */
69+
pollUrl: string | null;
3270
}
3371

3472
/** A video reference from a channel/playlist/search list (metadata only). */
@@ -78,22 +116,18 @@ export interface Me {
78116
}
79117

80118
/**
81-
* The state of an async transcription job.
119+
* A polled transcription job: a {@link Transcript} plus why it failed, if it did.
120+
*
121+
* It extends Transcript rather than wrapping one so the poll loop reads the
122+
* same whether the transcript arrived synchronously or via a job, and so the
123+
* completed job can be handed to anything that takes a Transcript.
82124
*
83-
* A job exists when a request had no captions to read and escalated to audio
84-
* transcription. "failed" is a normal outcome here rather than an HTTP error:
85-
* the endpoint answers 200 in all three states and `status` carries the meaning.
125+
* "failed" is a normal outcome here rather than an HTTP error: the endpoint
126+
* answers 200 in all three states and `status` carries the meaning.
86127
*/
87-
export interface TranscriptJob {
88-
kind: "transcript_job";
89-
jobId: string;
90-
/** processing | completed | failed */
91-
status: string;
92-
/** The finished transcript. Only present once `status` is "completed". */
93-
transcript: Transcript | null;
128+
export interface TranscriptJob extends Transcript {
94129
/** Why the job failed. Only present when `status` is "failed". */
95130
error: { code: string | null; message: string | null } | null;
96-
usage: Usage | null;
97131
}
98132

99133
/** The public health-check body. */
@@ -167,16 +201,41 @@ function normalizeVideo(raw: unknown): Video {
167201
};
168202
}
169203

170-
/** Parse a `{ data, usage }` transcript envelope. */
204+
function normalizePodcast(raw: unknown): PodcastMeta | null {
205+
if (!raw || typeof raw !== "object") return null;
206+
const p = raw as Wire;
207+
return {
208+
show: strOrNull(pick(p, "show")),
209+
episode: strOrNull(pick(p, "episode")),
210+
publishedAt: strOrNull(pick(p, "publishedAt", "published_at")),
211+
feedUrl: strOrNull(pick(p, "feedUrl", "feed_url")),
212+
audioUrl: strOrNull(pick(p, "audioUrl", "audio_url")),
213+
resolvedVia: strOrNull(pick(p, "resolvedVia", "resolved_via")),
214+
};
215+
}
216+
217+
/**
218+
* Parse a `{ data, usage }` transcript envelope.
219+
*
220+
* `data` is null while an async job is still processing, which `obj()` already
221+
* absorbs, so a 202 parses into a Transcript carrying only its job fields.
222+
*/
171223
export function normalizeTranscript(env: Wire): Transcript {
172224
const d = obj(env.data);
173225
return {
174-
kind: "transcript",
226+
// Trust the wire kind: a 202 says "transcript_job", a finished one says
227+
// "transcript".
228+
kind: str(pick(d, "kind")) || "transcript",
175229
videoId: str(pick(d, "videoId", "video_id")),
230+
platform: strOrNull(pick(d, "platform")),
176231
title: strOrNull(pick(d, "title")),
177232
text: strOrNull(pick(d, "text")),
178233
segments: normalizeSegments(d.segments),
234+
podcast: normalizePodcast(d.podcast),
179235
usage: normalizeUsage(env),
236+
status: strOrNull(pick(env, "status")),
237+
jobId: strOrNull(pick(env, "jobId", "job_id")),
238+
pollUrl: strOrNull(pick(env, "pollUrl", "poll_url")),
180239
};
181240
}
182241

@@ -205,27 +264,17 @@ export function normalizeMe(env: Wire): Me {
205264
}
206265

207266
/**
208-
* Parse a job-poll envelope.
209-
*
210-
* Unlike the other endpoints, status / job_id sit at the top level next to
211-
* `data` rather than inside it, and a failed job arrives as `ok: false` with an
212-
* `error` block at 200.
267+
* Parse a job-poll envelope: a transcript envelope that may also carry an
268+
* `error` block, which a failed job delivers at HTTP 200 alongside `ok: false`.
213269
*/
214270
export function normalizeJob(env: Wire): TranscriptJob {
215-
const status = str(pick(env, "status"));
216271
const rawError = obj(env.error);
217272
const hasError = env.error != null && typeof env.error === "object";
218273
return {
219-
kind: "transcript_job",
220-
jobId: str(pick(env, "jobId", "job_id")),
221-
status,
222-
// Only "completed" carries data; the other states leave it null so callers
223-
// cannot mistake an empty placeholder for a real (empty) transcript.
224-
transcript: status === "completed" ? normalizeTranscript(env) : null,
274+
...normalizeTranscript(env),
225275
error: hasError
226276
? { code: strOrNull(pick(rawError, "code")), message: strOrNull(pick(rawError, "message")) }
227277
: null,
228-
usage: normalizeUsage(env),
229278
};
230279
}
231280

‎src/resources/transcripts.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,17 @@ export class Transcripts {
4444
constructor(private readonly client: TranscriptFetch) {}
4545

4646
/**
47-
* Fetch a single video's transcript (text + timestamped segments).
47+
* Fetch a single transcript (text + timestamped segments).
4848
*
49-
* Accepts a YouTube, TikTok or Instagram URL, a bare YouTube video ID, or a
50-
* direct media file URL. The string is passed through to the API untouched,
51-
* so newly supported inputs work without an SDK upgrade.
49+
* Accepts a YouTube, TikTok or Instagram URL, a bare YouTube video ID, a
50+
* direct media file URL, or a podcast link (Spotify, Apple Podcasts, or an
51+
* RSS feed), which is resolved to that episode's audio and comes back with a
52+
* `podcast` block. The string is passed through to the API untouched, so
53+
* newly supported inputs work without an SDK upgrade.
54+
*
55+
* A source with no captions is transcribed from its audio, and the result
56+
* comes back as a job: `status` is "processing" and `jobId` is set, with no
57+
* text yet. Poll {@link job} with that id until it reports "completed".
5258
*/
5359
async video(video: string, options: { idempotencyKey?: string } = {}): Promise<Transcript> {
5460
const env = await this.client.request("POST", VIDEO, {
@@ -103,10 +109,18 @@ export class Transcripts {
103109
* Poll an async transcription job by id. Free: credits are charged once, on
104110
* delivery.
105111
*
106-
* A job is created when a video has no captions and the API escalates to
107-
* transcribing its audio. A failed job comes back as a `TranscriptJob` with
108-
* status "failed" rather than as a thrown error, because the request itself
109-
* succeeded.
112+
* Returns the same shape {@link video} does, plus an `error` block. A failed
113+
* job comes back as status "failed" rather than as a thrown error, because
114+
* the request itself succeeded.
115+
*
116+
* ```ts
117+
* let t = await tf.transcripts.video("https://example.com/episode.mp3");
118+
* while (t.status === "processing") {
119+
* await new Promise((r) => setTimeout(r, 5000));
120+
* t = await tf.transcripts.job(t.jobId!);
121+
* }
122+
* console.log(t.text);
123+
* ```
110124
*/
111125
async job(jobId: string): Promise<TranscriptJob> {
112126
const env = await this.client.request("GET", `${JOBS}/${encodeURIComponent(jobId)}`, {

0 commit comments

Comments
 (0)