tokwatchr 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import type { Browser, ImpitOptions } from "impit";
2
+ import { Impit } from "impit";
3
+ import type { CookieJarLike } from "../types.js";
4
+
5
+ export interface CreateClientOptions {
6
+ browser?: Browser;
7
+ proxyUrl?: string | null;
8
+ timeout?: number;
9
+ headers?: Record<string, string>;
10
+ cookieJar?: CookieJarLike | null;
11
+ }
12
+
13
+ /**
14
+ * Create an `Impit` HTTP client configured for TikTok.
15
+ *
16
+ * Uses browser TLS fingerprint emulation to bypass bot detection.
17
+ */
18
+ export function createClient(options: CreateClientOptions = {}): Impit {
19
+ const impitOptions: ImpitOptions = {
20
+ browser: options.browser ?? "chrome",
21
+ };
22
+
23
+ if (options.proxyUrl) {
24
+ impitOptions.proxyUrl = options.proxyUrl;
25
+ }
26
+
27
+ if (options.timeout) {
28
+ impitOptions.timeout = options.timeout;
29
+ }
30
+
31
+ if (options.headers && Object.keys(options.headers).length > 0) {
32
+ impitOptions.headers = options.headers;
33
+ }
34
+
35
+ if (options.cookieJar) {
36
+ const jar = options.cookieJar;
37
+ impitOptions.cookieJar = {
38
+ setCookie: (cookie: string, url: string) =>
39
+ Promise.resolve(jar.setCookie(cookie, url)),
40
+ getCookieString: (url: string) =>
41
+ Promise.resolve(jar.getCookieString(url)),
42
+ };
43
+ }
44
+
45
+ return new Impit(impitOptions);
46
+ }
@@ -0,0 +1,193 @@
1
+ import type { Impit } from "impit";
2
+ import { RoomResolveError, UserNotFoundError } from "../errors.js";
3
+
4
+ export interface RoomResolveOptions {
5
+ impIt?: Impit;
6
+ signal?: AbortSignal;
7
+ }
8
+
9
+ /**
10
+ * TikTok API endpoint patterns for room ID resolution.
11
+ */
12
+ const TIKTOK_LIVE_URL = "https://www.tiktok.com/@{username}/live";
13
+ const TIKTOK_PROFILE_URL = "https://www.tiktok.com/@{username}";
14
+ const TIKTOK_API_ROOM_URL =
15
+ "https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId={username}&sourceType=54";
16
+
17
+ /**
18
+ * Check whether a TikTok username exists by hitting the profile page.
19
+ * Throws UserNotFoundError if the account doesn't exist.
20
+ */
21
+ async function checkUserExists(
22
+ username: string,
23
+ impIt: Impit,
24
+ signal?: AbortSignal,
25
+ ): Promise<void> {
26
+ const url = TIKTOK_PROFILE_URL.replace("{username}", username);
27
+ const response = await impIt.fetch(url, {
28
+ headers: {
29
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
30
+ "Accept-Language": "en-US,en;q=0.9",
31
+ },
32
+ signal,
33
+ });
34
+
35
+ if (response.status === 404) {
36
+ throw new UserNotFoundError(username);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolve a TikTok username to a live room ID.
42
+ *
43
+ * Strategy:
44
+ * 0. Verify the user exists (profile page check).
45
+ * 1. Scrape the user's live page HTML for the room ID in SIGI_STATE.
46
+ * 2. Fall back to the TikTok API.
47
+ * 3. Throw if neither works.
48
+ */
49
+ export async function resolveRoomId(
50
+ username: string,
51
+ impIt: Impit,
52
+ options: RoomResolveOptions = {},
53
+ ): Promise<string> {
54
+ const cleanUsername = username.replace(/^@/, "").trim();
55
+
56
+ // Strategy 0: Verify the user exists
57
+ await checkUserExists(cleanUsername, impIt, options.signal);
58
+
59
+ // Strategy 1: Scrape the live page
60
+ const roomId = await tryScrapeRoomId(cleanUsername, impIt, options.signal);
61
+ if (roomId) {
62
+ return roomId;
63
+ }
64
+
65
+ // Strategy 2: Use the API
66
+ const apiRoomId = await tryApiRoomId(cleanUsername, impIt, options.signal);
67
+ if (apiRoomId) {
68
+ return apiRoomId;
69
+ }
70
+
71
+ throw new RoomResolveError(cleanUsername);
72
+ }
73
+
74
+ /**
75
+ * Try to extract room ID from the TikTok live page HTML.
76
+ */
77
+ async function tryScrapeRoomId(
78
+ username: string,
79
+ impIt: Impit,
80
+ signal?: AbortSignal,
81
+ ): Promise<string | null> {
82
+ try {
83
+ const url = TIKTOK_LIVE_URL.replace("{username}", username);
84
+ const response = await impIt.fetch(url, {
85
+ headers: {
86
+ Accept:
87
+ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
88
+ "Accept-Language": "en-US,en;q=0.9",
89
+ },
90
+ signal,
91
+ });
92
+
93
+ if (!response.ok) {
94
+ return null;
95
+ }
96
+
97
+ const html = await response.text();
98
+
99
+ // Try SIGI_STATE JSON first
100
+ const roomId = extractRoomIdFromSigiState(html);
101
+ if (roomId) return roomId;
102
+
103
+ // Fallback: regex for "roomId":"<digits>"
104
+ const roomIdMatch = html.match(/"roomId"\s*:\s*"(\d+)"/);
105
+ if (roomIdMatch?.[1]) return roomIdMatch[1];
106
+
107
+ // Fallback: regex for room_id=<digits>
108
+ const roomIdParamMatch = html.match(/room_id=(\d+)/);
109
+ if (roomIdParamMatch?.[1]) return roomIdParamMatch[1];
110
+
111
+ return null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Try to extract room ID from SIGI_STATE embedded JSON.
119
+ */
120
+ function extractRoomIdFromSigiState(html: string): string | null {
121
+ try {
122
+ const match = html.match(
123
+ /<script[^>]*id=["']SIGI_STATE["'][^>]*type=["']application\/json["'][^>]*>([\s\S]*?)<\/script>/i,
124
+ );
125
+ if (!match) return null;
126
+
127
+ const sigiState = JSON.parse(match[1] as string);
128
+
129
+ // Traverse potential paths
130
+ const liveRoom = sigiState?.LiveRoom?.liveRoomInfo;
131
+ if (liveRoom?.roomId) {
132
+ return String(liveRoom.roomId);
133
+ }
134
+
135
+ // Alternate path
136
+ const liveRoom2 = sigiState?.LiveRoom?.liveRoomInfo?.user?.roomId;
137
+ if (liveRoom2) {
138
+ return String(liveRoom2);
139
+ }
140
+
141
+ return null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Try to resolve room ID via the TikTok API.
149
+ */
150
+ async function tryApiRoomId(
151
+ username: string,
152
+ impIt: Impit,
153
+ signal?: AbortSignal,
154
+ ): Promise<string | null> {
155
+ try {
156
+ const url = TIKTOK_API_ROOM_URL.replace(
157
+ "{username}",
158
+ encodeURIComponent(username),
159
+ );
160
+ const response = await impIt.fetch(url, {
161
+ headers: {
162
+ Accept: "application/json, text/plain, */*",
163
+ Referer: `https://www.tiktok.com/@${username}`,
164
+ },
165
+ signal,
166
+ });
167
+
168
+ if (!response.ok) {
169
+ return null;
170
+ }
171
+
172
+ const data = (await response.json()) as Record<string, unknown>;
173
+ const dataObj = data.data as Record<string, unknown> | undefined;
174
+
175
+ if (!dataObj) return null;
176
+
177
+ // Check status - 2 means live
178
+ const liveRoom = dataObj.liveRoom as Record<string, unknown> | undefined;
179
+ if (liveRoom?.status !== 2) return null;
180
+
181
+ const user = dataObj.user as Record<string, unknown> | undefined;
182
+ if (!user) return null;
183
+
184
+ const roomId = user.roomId;
185
+ if (roomId) {
186
+ return String(roomId);
187
+ }
188
+
189
+ return null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
@@ -0,0 +1,76 @@
1
+ import type { Impit } from "impit";
2
+ import { StreamFetchError } from "../errors.js";
3
+ import type { StreamInfo, StreamQualityKey } from "../types.js";
4
+ import { parseQualities, selectQuality } from "../utils/quality.js";
5
+
6
+ export interface StreamInfoOptions {
7
+ quality?: "best" | "worst" | StreamQualityKey;
8
+ signal?: AbortSignal;
9
+ }
10
+
11
+ /**
12
+ * Fetch stream info for a given room ID.
13
+ *
14
+ * Calls the TikTok webcast room/info API and parses the response
15
+ * to extract stream URLs and quality options.
16
+ */
17
+ export async function fetchStreamInfo(
18
+ roomId: string,
19
+ username: string,
20
+ impIt: Impit,
21
+ options: StreamInfoOptions = {},
22
+ ): Promise<StreamInfo> {
23
+ const url = `https://webcast.tiktok.com/webcast/room/info/?aid=1988&room_id=${encodeURIComponent(roomId)}`;
24
+
25
+ const response = await impIt.fetch(url, {
26
+ headers: {
27
+ Accept: "application/json, text/plain, */*",
28
+ Referer: `https://www.tiktok.com/@${username}/live`,
29
+ },
30
+ signal: options.signal,
31
+ });
32
+
33
+ if (!response.ok) {
34
+ throw new StreamFetchError(
35
+ roomId,
36
+ `HTTP ${response.status}: ${response.statusText}`,
37
+ );
38
+ }
39
+
40
+ const json = (await response.json()) as Record<string, unknown>;
41
+ const data = json.data as Record<string, unknown> | undefined;
42
+
43
+ if (!data) {
44
+ throw new StreamFetchError(roomId, "No data in response");
45
+ }
46
+
47
+ const status = data.status;
48
+ if (status !== 2) {
49
+ throw new StreamFetchError(roomId, `Room is not live (status: ${status})`);
50
+ }
51
+
52
+ const qualities = parseQualities(data);
53
+ if (qualities.length === 0) {
54
+ throw new StreamFetchError(roomId, "No stream qualities found");
55
+ }
56
+
57
+ const selectedQuality = selectQuality(qualities, options.quality ?? "best");
58
+
59
+ const title = typeof data.title === "string" ? data.title : "";
60
+ const viewerCount =
61
+ typeof (data.stats as Record<string, unknown> | undefined)?.viewer_count ===
62
+ "number"
63
+ ? ((data.stats as Record<string, unknown>).viewer_count as number)
64
+ : 0;
65
+
66
+ return {
67
+ roomId,
68
+ username,
69
+ title,
70
+ qualities,
71
+ selectedQuality,
72
+ streamUrl: selectedQuality.flv,
73
+ viewerCount,
74
+ startedAt: new Date(),
75
+ };
76
+ }
@@ -0,0 +1,303 @@
1
+ import { spawn } from "node:child_process";
2
+ import { FfmpegError } from "../errors.js";
3
+ import type { DownloadStats, StreamQualityKey } from "../types.js";
4
+
5
+ export interface FfmpegDownloadOptions {
6
+ ffmpegPath: string;
7
+ url: string;
8
+ outputPath: string;
9
+ quality: StreamQualityKey;
10
+ args?: string[];
11
+ bitrate?: string | null;
12
+ signal?: AbortSignal;
13
+ onProgress?: (stats: DownloadStats) => void;
14
+ maxDuration?: number;
15
+ /**
16
+ * Startup timeout in ms. If ffmpeg produces no stderr output within
17
+ * this window, it is killed and the promise rejects. Prevents
18
+ * indefinite hangs on bad/ stalled stream URLs.
19
+ * @default 30_000
20
+ */
21
+ timeout?: number;
22
+ }
23
+
24
+ /**
25
+ * Parse ffmpeg stderr for duration and time progress.
26
+ *
27
+ * ffmpeg outputs lines like:
28
+ * frame= 123 fps= 30 q=28.0 size= 1024kB time=00:01:23.45 ...
29
+ */
30
+ function parseFfmpegProgress(
31
+ line: string,
32
+ ): { duration?: number; sizeBytes?: number } | null {
33
+ const result: { duration?: number; sizeBytes?: number } = {};
34
+
35
+ // Parse time=HH:MM:SS.MS
36
+ const timeMatch = line.match(/time=(\d+):(\d+):(\d+)\.(\d+)/);
37
+ if (timeMatch) {
38
+ const hours = Number(timeMatch[1]);
39
+ const minutes = Number(timeMatch[2]);
40
+ const seconds = Number(timeMatch[3]);
41
+ result.duration = hours * 3600 + minutes * 60 + seconds;
42
+ }
43
+
44
+ // Parse size= 1024kB
45
+ const sizeMatch = line.match(/size=\s*(\d+)(\w+)B/);
46
+ if (sizeMatch && sizeMatch.length >= 3) {
47
+ const value = Number(sizeMatch[1]);
48
+ // biome-ignore lint/style/noNonNullAssertion: length >= 3 guarantees index 2
49
+ const unit = sizeMatch[2]!;
50
+ if (unit === "k" || unit === "K") {
51
+ result.sizeBytes = value * 1024;
52
+ } else if (unit === "m" || unit === "M") {
53
+ result.sizeBytes = value * 1024 * 1024;
54
+ } else {
55
+ result.sizeBytes = value;
56
+ }
57
+ }
58
+
59
+ return result.sizeBytes !== undefined || result.duration !== undefined
60
+ ? result
61
+ : null;
62
+ }
63
+
64
+ /**
65
+ * Download a livestream via ffmpeg.
66
+ *
67
+ * Spawns ffmpeg as a subprocess, pipes the stream URL as input,
68
+ * and writes the transcoded/remuxed output to the specified path.
69
+ *
70
+ * Advantages over raw HTTP:
71
+ * - Proper container format (mp4/mkv)
72
+ * - Stream copy (fast) or re-encode
73
+ * - Better handling of stream interruptions
74
+ */
75
+ export async function downloadWithFfmpeg(
76
+ options: FfmpegDownloadOptions,
77
+ ): Promise<{
78
+ sizeBytes: number;
79
+ duration: number;
80
+ format: "mp4" | "mkv";
81
+ }> {
82
+ const {
83
+ ffmpegPath,
84
+ url,
85
+ outputPath,
86
+ quality,
87
+ args: extraArgs,
88
+ bitrate,
89
+ signal,
90
+ onProgress,
91
+ maxDuration,
92
+ timeout = 30_000,
93
+ } = options;
94
+
95
+ const ffmpegArgs: string[] = [
96
+ "-y", // overwrite output
97
+ "-i",
98
+ url, // input URL
99
+ ];
100
+
101
+ if (maxDuration && maxDuration > 0 && maxDuration < Infinity) {
102
+ ffmpegArgs.push("-t", String(maxDuration));
103
+ }
104
+
105
+ if (bitrate) {
106
+ ffmpegArgs.push("-c:v", "libx264", "-b:v", bitrate, "-c:a", "copy");
107
+ } else if (extraArgs && extraArgs.length > 0) {
108
+ ffmpegArgs.push(...extraArgs);
109
+ } else {
110
+ ffmpegArgs.push("-c", "copy");
111
+ }
112
+
113
+ ffmpegArgs.push(outputPath);
114
+
115
+ return new Promise((resolve, reject) => {
116
+ const proc = spawn(ffmpegPath, ffmpegArgs, {
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ signal,
119
+ });
120
+
121
+ let stderrBuffer = "";
122
+ let sizeBytes = 0;
123
+ let duration = 0;
124
+
125
+ const abortHandler = () => {
126
+ // SIGTERM tells ffmpeg to flush its output (moov atom, etc.)
127
+ // and exit cleanly. This produces a playable file even when
128
+ // stopped mid-recording.
129
+ proc.kill("SIGTERM");
130
+ // Safety net: if ffmpeg doesn't respond, force-kill after 15s.
131
+ // The close handler will still fire and resolve with partial data.
132
+ setTimeout(() => {
133
+ if (proc.exitCode === null) {
134
+ proc.kill("SIGKILL");
135
+ }
136
+ }, 15_000);
137
+ };
138
+
139
+ let aborted = false;
140
+ const onAbort = () => {
141
+ aborted = true;
142
+ abortHandler();
143
+ };
144
+
145
+ signal?.addEventListener("abort", onAbort, { once: true });
146
+
147
+ // Startup timeout — if ffmpeg produces no stderr output within
148
+ // `timeout` ms, it likely stalled on a bad URL. Kill it so the
149
+ // caller doesn't hang indefinitely.
150
+ let firstDataTimer: ReturnType<typeof setTimeout> | null = setTimeout(
151
+ () => {
152
+ firstDataTimer = null;
153
+ signal?.removeEventListener("abort", onAbort);
154
+ proc.kill("SIGTERM");
155
+ reject(
156
+ new FfmpegError(
157
+ `ffmpeg produced no output within ${timeout}ms — check the stream URL`,
158
+ ),
159
+ );
160
+ },
161
+ timeout,
162
+ );
163
+
164
+ // Parse stderr for progress
165
+ proc.stderr?.on("data", (chunk: Buffer | string) => {
166
+ // Clear first-data timer on first output — ffmpeg is working
167
+ if (firstDataTimer) {
168
+ clearTimeout(firstDataTimer);
169
+ firstDataTimer = null;
170
+ }
171
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
172
+ stderrBuffer += text;
173
+
174
+ const lines = text.split("\n");
175
+ for (const line of lines) {
176
+ const parsed = parseFfmpegProgress(line);
177
+ if (parsed) {
178
+ if (parsed.duration !== undefined) {
179
+ duration = parsed.duration;
180
+ }
181
+ if (parsed.sizeBytes !== undefined) {
182
+ sizeBytes = parsed.sizeBytes;
183
+ }
184
+
185
+ onProgress?.({
186
+ downloadedBytes: sizeBytes,
187
+ downloadedMB: sizeBytes / (1024 * 1024),
188
+ duration,
189
+ speed: duration > 0 ? sizeBytes / duration : 0,
190
+ speedMBps: duration > 0 ? sizeBytes / duration / (1024 * 1024) : 0,
191
+ quality,
192
+ state: "recording",
193
+ });
194
+ }
195
+ }
196
+ });
197
+
198
+ proc.on("error", (err) => {
199
+ if (firstDataTimer) {
200
+ clearTimeout(firstDataTimer);
201
+ firstDataTimer = null;
202
+ }
203
+ signal?.removeEventListener("abort", onAbort);
204
+ reject(new FfmpegError(err.message));
205
+ });
206
+
207
+ proc.on("close", (code) => {
208
+ if (firstDataTimer) {
209
+ clearTimeout(firstDataTimer);
210
+ firstDataTimer = null;
211
+ }
212
+ signal?.removeEventListener("abort", onAbort);
213
+
214
+ // When the user aborted, resolve with whatever we got.
215
+ // ffmpeg receives SIGTERM first, which tells it to flush
216
+ // its output and exit cleanly, producing a playable file.
217
+ if (aborted) {
218
+ resolve({
219
+ sizeBytes,
220
+ duration,
221
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
222
+ });
223
+ return;
224
+ }
225
+
226
+ if (code === 0) {
227
+ // Clean exit — check we actually got data
228
+ if (sizeBytes > 0) {
229
+ resolve({
230
+ sizeBytes,
231
+ duration,
232
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
233
+ });
234
+ } else {
235
+ reject(
236
+ new FfmpegError(
237
+ extractFfmpegError(stderrBuffer) || "Stream was empty",
238
+ code,
239
+ ),
240
+ );
241
+ }
242
+ } else if (code === null) {
243
+ // Process killed by signal (SIGTERM, SIGKILL, etc.)
244
+ if (sizeBytes > 0) {
245
+ resolve({
246
+ sizeBytes,
247
+ duration,
248
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
249
+ });
250
+ } else {
251
+ reject(
252
+ new FfmpegError(
253
+ "Process was killed before any data was received",
254
+ code,
255
+ ),
256
+ );
257
+ }
258
+ } else if (code === 1 || code === 255) {
259
+ // ffmpeg exit codes for interruption / end-of-stream
260
+ if (sizeBytes > 0) {
261
+ resolve({
262
+ sizeBytes,
263
+ duration,
264
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4",
265
+ });
266
+ } else {
267
+ const errorMsg = extractFfmpegError(stderrBuffer);
268
+ reject(
269
+ new FfmpegError(
270
+ errorMsg || `FFmpeg exited with code ${code} and no data`,
271
+ code,
272
+ ),
273
+ );
274
+ }
275
+ } else {
276
+ // Extract error from stderr
277
+ const errorMsg = extractFfmpegError(stderrBuffer);
278
+ reject(new FfmpegError(errorMsg, code));
279
+ }
280
+ });
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Extract a meaningful error message from ffmpeg stderr output.
286
+ */
287
+ function extractFfmpegError(stderr: string): string {
288
+ const lines = stderr.split("\n").filter((l) => l.trim().length > 0);
289
+ const errorLines = lines.filter(
290
+ (l) =>
291
+ l.toLowerCase().includes("error") ||
292
+ l.toLowerCase().includes("invalid") ||
293
+ l.toLowerCase().includes("cannot"),
294
+ );
295
+
296
+ if (errorLines.length > 0) {
297
+ // biome-ignore lint/style/noNonNullAssertion: length > 0 guarantees index 0
298
+ return errorLines[0]!.trim();
299
+ }
300
+
301
+ // Return last meaningful line
302
+ return lines[lines.length - 1]?.trim() ?? "Unknown ffmpeg error";
303
+ }