Skip to content

Commit 09739ca

Browse files
author
Chandler Casey
committed
2.4.0: add typed monitor lifecycle and event pagination
1 parent 48beece commit 09739ca

17 files changed

Lines changed: 786 additions & 14 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ jobs:
1818
node-version: ${{ matrix.node-version }}
1919
- run: npm install
2020
- run: npm run typecheck
21-
- run: npm run build
21+
- run: npm test

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ jobs:
3030
- run: npm install -g npm@latest
3131
- run: npm ci
3232
- run: npm run typecheck
33-
- run: npm run build
33+
- run: npm test
3434
- run: npm publish

‎CHANGELOG.md‎

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

6+
## [2.4.0] - 2026-09-27
7+
8+
### Added
9+
10+
- Monitor create, list, get, update/pause/resume, delete, check-now, events and
11+
automatic event pagination. Includes typed settings, news cursors, delivery
12+
state, inline/follow-up transcript results and check errors. Creation exposes
13+
the one-time webhook secret. Create/check share the existing idempotent retry
14+
transport. Python includes sync and async methods.
15+
- Single-video options for mode, timestamps and callback URL, including
16+
captions-only and plain-text requests generated by the Playground.
17+
- HTTP contract tests for monitor requests/responses, partial updates, pagination,
18+
errors and retries; release workflows run the tests before publishing.
19+
620
## [2.3.0] - 2026-09-15
721

822
### Added

‎README.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,58 @@ await tf.me(); // validate the key, read
118118
await tf.health(); // unauthenticated liveness probe
119119
```
120120

121+
## Monitors (2.4.0+)
122+
123+
Watch a YouTube channel or a TikTok/Instagram profile for new videos. Creating
124+
one records a free baseline and starts scheduled checks; existing videos are
125+
skipped. Checks with no new videos are free. A check finding videos costs
126+
1 credit, plus transcript charges when enabled. See [monitor docs](https://transcriptfetch.com/docs/monitors)
127+
for plan limits, audio billing, retention and webhook verification.
128+
129+
```ts
130+
const tf = new TranscriptFetch({ timeout: 120_000 });
131+
const monitor = await tf.monitors.create("@lexfridman", {
132+
intervalMinutes: 60,
133+
transcripts: true,
134+
// webhookUrl: "https://your-app.com/hooks/transcriptfetch", // optional
135+
// tab: "shorts", // YouTube only: videos (default), shorts, live
136+
});
137+
// Save monitor.webhookSecret securely: it is only returned on creation.
138+
139+
const all = await tf.monitors.list();
140+
const news = await tf.monitors.list({ since: all.lastEventId ?? undefined });
141+
const current = await tf.monitors.get(monitor.id);
142+
const page = await tf.monitors.events(monitor.id, { limit: 10 });
143+
for await (const event of tf.monitors.iterEvents(monitor.id, { since: current.lastEventId ?? undefined })) {
144+
console.log(event.type, event.data);
145+
}
146+
await tf.monitors.update(monitor.id, { status: "paused" });
147+
await tf.monitors.update(monitor.id, { webhookUrl: null, name: null, transcripts: false });
148+
// Omitted settings stay unchanged. null clears webhookUrl/name.
149+
await tf.monitors.update(monitor.id, { status: "active" });
150+
const check = await tf.monitors.check(monitor.id); // may spend credits
151+
if (check.error) console.error(check.error); // listing failure, even with HTTP 200
152+
await tf.monitors.delete(monitor.id); // removes events too
153+
```
154+
155+
All SDK result fields use camelCase, including `nextCursor`, `hasNew`,
156+
`videosEventId` and `webhookSecret`. `monitor.videos` events contain `data.videos`
157+
and optional `data.transcripts`; each transcript outcome is `ok`, `processing`
158+
or `error`. `monitor.transcript` events carry the completed or failed follow-up
159+
in `data`, linked by `videosEventId`. `delivery` describes webhook attempts.
160+
Reading events never marks them read: persist the latest event id and pass it
161+
as `since`. `iterEvents` follows cursor pages while retaining that lower bound.
162+
Create/check accept `idempotencyKey`; the generated key stays stable on retries.
163+
Use a longer client timeout for creation or manual checks, which read the platform.
164+
165+
Single-video options are also supported:
166+
167+
```ts
168+
const transcript = await tf.transcripts.video("dQw4w9WgXcQ", {
169+
mode: "captions", timestamps: false,
170+
});
171+
```
172+
121173
## Pagination
122174

123175
Skip cursor bookkeeping with the auto-paginating iterators:

‎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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "transcriptfetch",
3-
"version": "2.3.0",
3+
"version": "2.4.0",
44
"description": "Official Node.js/TypeScript SDK for the TranscriptFetch API: a production transcript API for YouTube, TikTok, Instagram and direct media URLs - captions in about a second, automatic AI speech-to-text when none exist - plus search, channel and playlist listing, as clean typed data.",
55
"keywords": [
66
"youtube",
@@ -53,7 +53,8 @@
5353
"scripts": {
5454
"build": "tsup",
5555
"typecheck": "tsc --noEmit",
56-
"prepublishOnly": "npm run build"
56+
"prepublishOnly": "npm run build",
57+
"test": "npm run build && node --test tests/*.test.mjs"
5758
},
5859
"devDependencies": {
5960
"@types/node": "^20.11.30",

‎src/client.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { resolveConfig, type ClientOptions, type ResolvedConfig } from "./config";
44
import { APIConnectionError, APITimeoutError } from "./errors";
55
import { normalizeMe, type Health, type Me } from "./models";
6+
import { Monitors } from "./resources/monitors";
67
import { Transcripts } from "./resources/transcripts";
78
import { backoffMs, buildHeaders, isRetryable, newIdempotencyKey, parseEnvelope } from "./transport";
89

@@ -33,6 +34,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
3334
export class TranscriptFetch {
3435
/** The transcripts resource: video, batch, channel, playlist, search, job. */
3536
readonly transcripts: Transcripts;
37+
readonly monitors: Monitors;
3638
private readonly config: ResolvedConfig;
3739

3840
constructor(apiKey?: string, options?: ClientOptions);
@@ -44,6 +46,7 @@ export class TranscriptFetch {
4446
: (apiKeyOrOptions ?? {});
4547
this.config = resolveConfig(opts);
4648
this.transcripts = new Transcripts(this);
49+
this.monitors = new Monitors(this);
4750
}
4851

4952
/** Low-level request with the shared retry loop. Resources call this. */

‎src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
export { TranscriptFetch, type RequestOptions } from "./client";
1818
export type { ClientOptions } from "./config";
19-
export type { ListOptions, SearchOptions } from "./resources/transcripts";
19+
export type { ListOptions, SearchOptions, VideoOptions } from "./resources/transcripts";
2020
export { VERSION } from "./version";
2121

2222
export {
@@ -52,3 +52,6 @@ export type {
5252
Me,
5353
Health,
5454
} from "./models";
55+
56+
export type { CreateMonitorOptions, UpdateMonitorOptions, MonitorEventsOptions } from "./resources/monitors";
57+
export type { Monitor, MonitorInterval, MonitorSnapshot, MonitorList, MonitorDeleted, MonitorDelivery, MonitorTranscriptResult, MonitorEvent, MonitorEventList, MonitorCheck } from "./monitor-models";

‎src/models.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ function normalizeSegments(raw: unknown): Segment[] {
273273
return Array.isArray(raw) ? raw.map(normalizeSegment) : [];
274274
}
275275

276-
function normalizeUsage(env: Wire): Usage | null {
276+
export function normalizeUsage(env: Wire): Usage | null {
277277
const raw = env.usage;
278278
if (!raw || typeof raw !== "object") return null;
279279
const u = raw as Wire;
@@ -291,7 +291,7 @@ function normalizeStats(raw: unknown): VideoStats | null {
291291
return { plays: numOrNull(pick(obj(raw), "plays")) };
292292
}
293293

294-
function normalizeVideo(raw: unknown): Video {
294+
export function normalizeVideo(raw: unknown): Video {
295295
const v = obj(raw);
296296
return {
297297
videoId: str(pick(v, "videoId", "video_id")),

‎src/monitor-models.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/** Typed monitor responses. Public fields use camelCase like the transcript models. */
2+
import {
3+
normalizeErrorBlock, normalizeTranscript, normalizeUsage, normalizeVideo,
4+
type ApiErrorBlock, type Transcript, type Usage, type Video,
5+
} from "./models";
6+
7+
export type MonitorInterval = 15 | 60 | 360 | 1440;
8+
export interface MonitorSnapshot {
9+
id: string;
10+
name: string | null;
11+
type: "channel";
12+
platform: string;
13+
target: string;
14+
}
15+
export interface Monitor extends MonitorSnapshot {
16+
kind: "monitor";
17+
options: { tab?: "videos" | "shorts" | "live" };
18+
status: "active" | "paused";
19+
hasNew: boolean;
20+
lastEventId: string | null;
21+
intervalMinutes: MonitorInterval;
22+
transcripts: boolean;
23+
webhookUrl: string | null;
24+
nextCheckAt: string | null;
25+
lastCheckedAt: string | null;
26+
lastError: (ApiErrorBlock & { at: string | null }) | null;
27+
createdAt: string;
28+
updatedAt: string;
29+
/** Returned on creation only. Save securely before discarding the response. */
30+
webhookSecret: string | null;
31+
/** Returned on creation only; existing videos skipped by the baseline. */
32+
baselineCount: number | null;
33+
usage: Usage | null;
34+
}
35+
export interface MonitorList {
36+
kind: "monitor_list";
37+
monitors: Monitor[];
38+
hasNew: boolean;
39+
lastEventId: string | null;
40+
limits: { maxMonitors: number | null; minIntervalMinutes: number };
41+
}
42+
export interface MonitorDeleted { kind: "monitor_deleted"; id: string }
43+
export interface MonitorDelivery {
44+
status: "none" | "pending" | "delivered" | "failed";
45+
attempts: number;
46+
lastAttemptAt: string | null;
47+
nextAttemptAt: string | null;
48+
lastError: string | null;
49+
deliveredAt: string | null;
50+
}
51+
export interface MonitorTranscriptResult {
52+
videoId: string;
53+
url: string;
54+
outcome: "ok" | "processing" | "error";
55+
/** Present when an audio job has already been queued. */
56+
jobId: string | null;
57+
transcript: Transcript | null;
58+
error: ApiErrorBlock | null;
59+
}
60+
interface MonitorEventBase {
61+
id: string;
62+
createdAt: string;
63+
monitor: MonitorSnapshot;
64+
creditsSpent: number;
65+
/** Absent on webhook bodies; present in event listing/check responses. */
66+
delivery: MonitorDelivery | null;
67+
}
68+
export type MonitorEvent = MonitorEventBase & (
69+
| { type: "monitor.videos"; data: { videos: Video[]; transcripts?: MonitorTranscriptResult[] } }
70+
| { type: "monitor.transcript"; data: MonitorTranscriptResult & { videosEventId: string } }
71+
);
72+
export interface MonitorEventList {
73+
kind: "monitor_event_list";
74+
monitorId: string;
75+
events: MonitorEvent[];
76+
nextCursor: string | null;
77+
}
78+
export interface MonitorCheck {
79+
kind: "monitor_check";
80+
monitor: Monitor;
81+
newVideos: number;
82+
event: MonitorEvent | null;
83+
/** An HTTP 200 check may still report a listing failure. */
84+
error: ApiErrorBlock | null;
85+
usage: Usage | null;
86+
}
87+
88+
type Wire = Record<string, unknown>;
89+
const obj = (v: unknown): Wire => v && typeof v === "object" ? v as Wire : {};
90+
const nullable = (v: unknown): string | null => typeof v === "string" ? v : null;
91+
const num = (v: unknown): number => typeof v === "number" ? v : 0;
92+
const arr = (v: unknown): unknown[] => Array.isArray(v) ? v : [];
93+
function snapshot(raw: unknown): MonitorSnapshot {
94+
const d = obj(raw);
95+
return { id: String(d.id ?? ""), name: nullable(d.name), type: "channel", platform: String(d.platform ?? ""), target: String(d.target ?? "") };
96+
}
97+
export function normalizeMonitor(env: Wire): Monitor {
98+
const d = obj(env.data);
99+
const error = normalizeErrorBlock(d.last_error);
100+
return {
101+
...snapshot(d), kind: "monitor", options: obj(d.options) as Monitor["options"],
102+
status: d.status as Monitor["status"], hasNew: d.has_new === true, lastEventId: nullable(d.last_event_id),
103+
intervalMinutes: num(d.interval_minutes) as MonitorInterval, transcripts: d.transcripts === true,
104+
webhookUrl: nullable(d.webhook_url), nextCheckAt: nullable(d.next_check_at), lastCheckedAt: nullable(d.last_checked_at),
105+
lastError: error ? { ...error, at: nullable(obj(d.last_error).at) } : null,
106+
createdAt: String(d.created_at ?? ""), updatedAt: String(d.updated_at ?? ""),
107+
webhookSecret: nullable(d.webhook_secret), baselineCount: typeof d.baseline_count === "number" ? d.baseline_count : null,
108+
usage: normalizeUsage(env),
109+
};
110+
}
111+
export function normalizeMonitorList(env: Wire): MonitorList {
112+
const d = obj(env.data), limits = obj(d.limits);
113+
return {
114+
kind: "monitor_list", monitors: arr(d.monitors).map(data => normalizeMonitor({ data })),
115+
hasNew: d.has_new === true, lastEventId: nullable(d.last_event_id),
116+
limits: { maxMonitors: typeof limits.max_monitors === "number" ? limits.max_monitors : null, minIntervalMinutes: num(limits.min_interval_minutes) },
117+
};
118+
}
119+
function transcriptResult(raw: unknown): MonitorTranscriptResult {
120+
const d = obj(raw);
121+
return {
122+
videoId: String(d.video_id ?? ""), url: String(d.url ?? ""), outcome: d.outcome as MonitorTranscriptResult["outcome"],
123+
jobId: nullable(d.job_id),
124+
transcript: d.transcript ? normalizeTranscript({ data: d.transcript }) : null,
125+
error: normalizeErrorBlock(d.error),
126+
};
127+
}
128+
export function normalizeMonitorEvent(raw: unknown): MonitorEvent {
129+
const e = obj(raw), d = obj(e.data), delivery = obj(e.delivery);
130+
const base: MonitorEventBase = {
131+
id: String(e.id ?? ""), createdAt: String(e.created_at ?? ""), monitor: snapshot(e.monitor), creditsSpent: num(e.credits_spent),
132+
delivery: e.delivery ? {
133+
status: delivery.status as MonitorDelivery["status"], attempts: num(delivery.attempts),
134+
lastAttemptAt: nullable(delivery.last_attempt_at), nextAttemptAt: nullable(delivery.next_attempt_at),
135+
lastError: nullable(delivery.last_error), deliveredAt: nullable(delivery.delivered_at),
136+
} : null,
137+
};
138+
return e.type === "monitor.videos"
139+
? { ...base, type: "monitor.videos", data: { videos: arr(d.videos).map(normalizeVideo), ...(Array.isArray(d.transcripts) ? { transcripts: d.transcripts.map(transcriptResult) } : {}) } }
140+
: { ...base, type: "monitor.transcript", data: { ...transcriptResult(d), videosEventId: String(d.videos_event_id ?? "") } };
141+
}
142+
export function normalizeMonitorEvents(env: Wire): MonitorEventList {
143+
const d = obj(env.data);
144+
return { kind: "monitor_event_list", monitorId: String(d.monitor_id ?? ""), events: arr(d.events).map(normalizeMonitorEvent), nextCursor: nullable(d.next_cursor) };
145+
}
146+
export function normalizeMonitorCheck(env: Wire): MonitorCheck {
147+
const d = obj(env.data);
148+
return { kind: "monitor_check", monitor: normalizeMonitor({ data: d.monitor }), newVideos: num(d.new_videos), event: d.event ? normalizeMonitorEvent(d.event) : null, error: normalizeErrorBlock(d.error), usage: normalizeUsage(env) };
149+
}

0 commit comments

Comments
 (0)