Skip to content

Commit d9822ee

Browse files
Chandler Caseyclaude
andcommitted
1.0.0: cover /me and job polling, document real platform support
Three things, all pointing at the same gap between what the SDK does and what it says it does. Coverage: the client was missing two of the API's eight endpoints. Adds client.me() (free key validation + balance) and transcripts.job(jobId) (poll an async audio-transcription job). A failed job is delivered as ok:false at HTTP 200, which the shared envelope parser treats as a request error, so parseEnvelope grew an allowFailureEnvelope option used only by that route: the request succeeded, the job did not. Docs: the README claimed YouTube only. video() and batch() have always passed the input string straight through, so TikTok, Instagram and direct media file URLs already worked. No behaviour change, just an accurate README and JSDoc. channel/playlist/search stay marked as YouTube-only, because they are. Version: /docs/v1 promises the SDKs follow semver, and 0.x means the opposite. Declaring the surface stable at 1.0.0 makes the published promise true. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ksoEUHvR7DHdNujkGv8UT
1 parent dbcf06e commit d9822ee

11 files changed

Lines changed: 191 additions & 18 deletions

File tree

‎CHANGELOG.md‎

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

6+
## [1.0.0]
7+
8+
First stable release. The public docs promise the SDKs follow semver, and a 0.x
9+
version says the opposite ("anything may break"), so the surface is now declared
10+
stable: breaking changes land only in a new major.
11+
12+
Added
13+
14+
- `client.me()` - validate the API key and read the credit balance. Free, and
15+
unlike `health()` it authenticates, so it is a real credential test.
16+
- `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.
21+
22+
Changed
23+
24+
- 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.
28+
629
## [0.1.0]
730

831
Initial release.

‎README.md‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# TranscriptFetch Node.js SDK
22

3-
The official Node.js / TypeScript client for the [TranscriptFetch API](https://transcriptfetch.com). Fetch YouTube transcripts, channels, playlists, and search as clean, typed data, with built-in retries, idempotency, and a typed error hierarchy.
3+
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**
6+
- YouTube channel, playlist, and search listing
57
- Typed responses (full TypeScript types, ESM + CommonJS)
68
- Automatic retries on 429 and 5xx with backoff
79
- Auto-generated idempotency keys on writes
@@ -34,14 +36,33 @@ console.log("credits left:", t.usage?.balance);
3436

3537
Keep your key server-side. Never ship it to the browser.
3638

39+
## Supported inputs
40+
41+
`transcripts.video()` and `transcripts.batch()` accept:
42+
43+
| Input | Example |
44+
| --- | --- |
45+
| YouTube URL or bare video ID | `https://youtu.be/aircAruvnKk`, `aircAruvnKk` |
46+
| TikTok video URL | `https://www.tiktok.com/@user/video/7137723462233555205` |
47+
| Instagram post or reel URL | `https://www.instagram.com/reel/Cxyz.../` |
48+
| Direct media file URL | `https://example.com/talk.mp3` |
49+
50+
The string is sent to the API as-is, so the SDK never has to be upgraded for the
51+
API to accept a new input.
52+
53+
`channel()`, `playlist()`, and `search()` are YouTube-only concepts and take
54+
YouTube handles, IDs, and queries.
55+
3756
## Endpoints
3857

3958
```ts
4059
await tf.transcripts.video(video); // single transcript (text + segments)
41-
await tf.transcripts.channel(channel, { limit, cursor }); // a channel's videos (metadata)
42-
await tf.transcripts.playlist(playlist, { limit, cursor }); // a playlist's videos
43-
await tf.transcripts.search(query, { limit, cursor }); // search YouTube
4460
await tf.transcripts.batch(videoIds); // up to 50 transcripts in one call
61+
await tf.transcripts.channel(channel, { limit, cursor }); // a YouTube channel's videos (metadata)
62+
await tf.transcripts.playlist(playlist, { limit, cursor }); // a YouTube playlist's videos
63+
await tf.transcripts.search(query, { limit, cursor }); // search YouTube
64+
await tf.transcripts.job(jobId); // poll an async transcription job (free)
65+
await tf.me(); // validate the key, read the balance (free)
4566
await tf.health(); // unauthenticated liveness probe
4667
```
4768

@@ -100,6 +121,11 @@ const tf = new TranscriptFetch({
100121
- API docs: https://transcriptfetch.com/docs
101122
- Python SDK: https://github.com/TranscriptFetch/python-sdk
102123

124+
## Versioning
125+
126+
This package follows [Semantic Versioning](https://semver.org/). Breaking
127+
changes only land in a new major version.
128+
103129
## License
104130

105131
MIT

‎examples/quickstart.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TranscriptFetch } from "transcriptfetch";
44

55
const tf = new TranscriptFetch();
66

7+
// A TikTok or Instagram URL, or a direct media file URL, works here too.
78
const t = await tf.transcripts.video("https://youtu.be/aircAruvnKk");
89
console.log(t.title);
910
console.log(t.text?.slice(0, 200), "...");

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
{
22
"name": "transcriptfetch",
3-
"version": "0.1.0",
4-
"description": "Official Node.js/TypeScript SDK for the TranscriptFetch API: fetch YouTube transcripts, channels, playlists, and search as clean, typed data.",
3+
"version": "1.0.0",
4+
"description": "Official Node.js/TypeScript SDK for the TranscriptFetch API: transcripts for YouTube, TikTok, Instagram and direct media URLs, plus YouTube channel, playlist and search listing, as clean typed data.",
55
"keywords": [
66
"youtube",
7+
"tiktok",
8+
"instagram",
79
"transcript",
810
"captions",
911
"subtitles",

‎src/client.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { resolveConfig, type ClientOptions, type ResolvedConfig } from "./config";
44
import { APIConnectionError, APITimeoutError } from "./errors";
5-
import type { Health } from "./models";
5+
import { normalizeMe, type Health, type Me } from "./models";
66
import { Transcripts } from "./resources/transcripts";
77
import { backoffMs, buildHeaders, isRetryable, newIdempotencyKey, parseEnvelope } from "./transport";
88

@@ -13,6 +13,8 @@ export interface RequestOptions {
1313
auth?: boolean;
1414
/** Auto-generate an Idempotency-Key when one is not supplied. */
1515
idempotent?: boolean;
16+
/** Accept a 2xx `ok: false` body as data rather than raising (job polling). */
17+
allowFailureEnvelope?: boolean;
1618
}
1719

1820
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
@@ -29,7 +31,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
2931
* ```
3032
*/
3133
export class TranscriptFetch {
32-
/** The transcripts resource: video, channel, playlist, search, batch. */
34+
/** The transcripts resource: video, batch, channel, playlist, search, job. */
3335
readonly transcripts: Transcripts;
3436
private readonly config: ResolvedConfig;
3537

@@ -78,10 +80,20 @@ export class TranscriptFetch {
7880
attempt += 1;
7981
continue;
8082
}
81-
return parseEnvelope(response);
83+
return parseEnvelope(response, { allowFailureEnvelope: options.allowFailureEnvelope });
8284
}
8385
}
8486

87+
/**
88+
* Validate the API key and read the account's credit balance. Free.
89+
*
90+
* Unlike {@link health}, this authenticates, so it is the right credential
91+
* test for an integration.
92+
*/
93+
async me(): Promise<Me> {
94+
return normalizeMe(await this.request("GET", "/api/v1/me"));
95+
}
96+
8597
/** Unauthenticated liveness probe. No credits used. */
8698
async health(): Promise<Health> {
8799
return (await this.request("GET", "/api/v1/health", { auth: false })) as unknown as Health;

‎src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +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.
6+
*
47
* ```ts
58
* import { TranscriptFetch } from "transcriptfetch";
69
*
@@ -37,5 +40,7 @@ export type {
3740
VideoList,
3841
BatchResult,
3942
BatchResponse,
43+
TranscriptJob,
44+
Me,
4045
Health,
4146
} from "./models";

‎src/models.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ export interface BatchResponse {
6868
usage: Usage | null;
6969
}
7070

71+
/** The account behind the API key, from `/me`. */
72+
export interface Me {
73+
kind: "me";
74+
userId: string;
75+
/** Remaining credit balance. */
76+
credits: number;
77+
usage: Usage | null;
78+
}
79+
80+
/**
81+
* The state of an async transcription job.
82+
*
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.
86+
*/
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;
94+
/** Why the job failed. Only present when `status` is "failed". */
95+
error: { code: string | null; message: string | null } | null;
96+
usage: Usage | null;
97+
}
98+
7199
/** The public health-check body. */
72100
export interface Health {
73101
status: string;
@@ -165,6 +193,42 @@ export function normalizeVideoList(env: Wire): VideoList {
165193
};
166194
}
167195

196+
/** Parse a `{ data, usage }` account envelope. */
197+
export function normalizeMe(env: Wire): Me {
198+
const d = obj(env.data);
199+
return {
200+
kind: "me",
201+
userId: str(pick(d, "userId", "user_id")),
202+
credits: num(pick(d, "credits")),
203+
usage: normalizeUsage(env),
204+
};
205+
}
206+
207+
/**
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.
213+
*/
214+
export function normalizeJob(env: Wire): TranscriptJob {
215+
const status = str(pick(env, "status"));
216+
const rawError = obj(env.error);
217+
const hasError = env.error != null && typeof env.error === "object";
218+
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,
225+
error: hasError
226+
? { code: strOrNull(pick(rawError, "code")), message: strOrNull(pick(rawError, "message")) }
227+
: null,
228+
usage: normalizeUsage(env),
229+
};
230+
}
231+
168232
/** Parse a `{ data, usage }` batch envelope. */
169233
export function normalizeBatch(env: Wire): BatchResponse {
170234
const d = obj(env.data);

‎src/resources/transcripts.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
2-
* The `transcripts` resource: video / channel / playlist / search / batch,
3-
* plus auto-paginating async iterators.
2+
* The `transcripts` resource: video / batch / channel / playlist / search /
3+
* job, plus auto-paginating async iterators.
44
*/
55

66
import type { TranscriptFetch } from "../client";
77
import {
88
normalizeBatch,
9+
normalizeJob,
910
normalizeTranscript,
1011
normalizeVideoList,
1112
type BatchResponse,
1213
type Transcript,
14+
type TranscriptJob,
1315
type Video,
1416
type VideoList,
1517
} from "../models";
@@ -19,6 +21,7 @@ const CHANNEL = "/api/v1/transcripts/channel";
1921
const PLAYLIST = "/api/v1/transcripts/playlist";
2022
const SEARCH = "/api/v1/transcripts/search";
2123
const BATCH = "/api/v1/transcripts/batch";
24+
const JOBS = "/api/v1/transcripts/jobs";
2225

2326
/** Options for the paginated list endpoints (channel, playlist, search). */
2427
export interface ListOptions {
@@ -40,7 +43,13 @@ function listBody(key: string, value: string, options: ListOptions): Record<stri
4043
export class Transcripts {
4144
constructor(private readonly client: TranscriptFetch) {}
4245

43-
/** Fetch a single video's transcript (text + timestamped segments). */
46+
/**
47+
* Fetch a single video's transcript (text + timestamped segments).
48+
*
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.
52+
*/
4453
async video(video: string, options: { idempotencyKey?: string } = {}): Promise<Transcript> {
4554
const env = await this.client.request("POST", VIDEO, {
4655
body: { video },
@@ -80,7 +89,7 @@ export class Transcripts {
8089
return normalizeVideoList(env);
8190
}
8291

83-
/** Fetch up to 50 transcripts in one call. */
92+
/** Fetch up to 50 transcripts in one call. Same accepted inputs as {@link video}. */
8493
async batch(videoIds: string[], options: { idempotencyKey?: string } = {}): Promise<BatchResponse> {
8594
const env = await this.client.request("POST", BATCH, {
8695
body: { videoIds },
@@ -90,6 +99,22 @@ export class Transcripts {
9099
return normalizeBatch(env);
91100
}
92101

102+
/**
103+
* Poll an async transcription job by id. Free: credits are charged once, on
104+
* delivery.
105+
*
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.
110+
*/
111+
async job(jobId: string): Promise<TranscriptJob> {
112+
const env = await this.client.request("GET", `${JOBS}/${encodeURIComponent(jobId)}`, {
113+
allowFailureEnvelope: true,
114+
});
115+
return normalizeJob(env);
116+
}
117+
93118
// ── Auto-paginating iterators ───────────────────────────────────────────────
94119

95120
/** Iterate every video in a channel, transparently following cursors. */

‎src/transport.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,25 @@ export function backoffMs(attempt: number, retryAfter?: string | null): number {
4646

4747
type Envelope = Record<string, unknown>;
4848

49+
export interface ParseOptions {
50+
/**
51+
* Return a 2xx `ok: false` body instead of throwing.
52+
*
53+
* Only the job-poll endpoint needs this: a job that failed to transcribe is a
54+
* reportable state delivered at HTTP 200, not a failed request.
55+
*/
56+
allowFailureEnvelope?: boolean;
57+
}
58+
4959
/**
5060
* Return the parsed JSON body, or throw the mapped APIError. Handles both the
5161
* `{ ok, request_id, data, usage }` envelope and the bare health-check body
5262
* (which has no `ok` field).
5363
*/
54-
export async function parseEnvelope(response: Response): Promise<Envelope> {
64+
export async function parseEnvelope(
65+
response: Response,
66+
options: ParseOptions = {},
67+
): Promise<Envelope> {
5568
let payload: unknown = null;
5669
try {
5770
payload = await response.json();
@@ -62,7 +75,9 @@ export async function parseEnvelope(response: Response): Promise<Envelope> {
6275
const isObj = payload != null && typeof payload === "object";
6376
const requestId = isObj ? ((payload as Envelope).request_id as string | undefined) ?? null : null;
6477

65-
const isError = response.status >= 400 || (isObj && (payload as Envelope).ok === false);
78+
const failureEnvelope =
79+
isObj && (payload as Envelope).ok === false && !options.allowFailureEnvelope;
80+
const isError = response.status >= 400 || failureEnvelope;
6681
if (isError) {
6782
raiseApiError(
6883
response.status,

0 commit comments

Comments
 (0)