tokwatchr 0.6.5 → 0.7.1

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,1464 @@
1
+ #! /usr/bin/env node
2
+ import { cac } from "cac";
3
+ import { basename, dirname, join } from "node:path";
4
+ import pc from "picocolors";
5
+ import { Impit } from "impit";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { EventEmitter } from "node:events";
8
+ import { createWriteStream, existsSync, mkdirSync } from "node:fs";
9
+ //#region package.json
10
+ var version = "0.7.1";
11
+ //#endregion
12
+ //#region src/api/client.ts
13
+ /**
14
+ * Create an `Impit` HTTP client configured for TikTok.
15
+ *
16
+ * Uses browser TLS fingerprint emulation to bypass bot detection.
17
+ */
18
+ function createClient(options = {}) {
19
+ const impitOptions = { browser: options.browser ?? "chrome" };
20
+ if (options.proxyUrl) impitOptions.proxyUrl = options.proxyUrl;
21
+ if (options.timeout) impitOptions.timeout = options.timeout;
22
+ if (options.headers && Object.keys(options.headers).length > 0) impitOptions.headers = options.headers;
23
+ if (options.cookieJar) {
24
+ const jar = options.cookieJar;
25
+ impitOptions.cookieJar = {
26
+ setCookie: (cookie, url) => Promise.resolve(jar.setCookie(cookie, url)),
27
+ getCookieString: (url) => Promise.resolve(jar.getCookieString(url))
28
+ };
29
+ }
30
+ return new Impit(impitOptions);
31
+ }
32
+ //#endregion
33
+ //#region src/errors.ts
34
+ var TikTokLiveError = class extends Error {
35
+ name = "TikTokLiveError";
36
+ };
37
+ var UserOfflineError = class extends TikTokLiveError {
38
+ name = "UserOfflineError";
39
+ constructor(username) {
40
+ super(`User "${username}" is not currently live (or does not exist)`);
41
+ }
42
+ };
43
+ var UserNotFoundError = class extends TikTokLiveError {
44
+ name = "UserNotFoundError";
45
+ constructor(username) {
46
+ super(`User "${username}" does not exist on TikTok`);
47
+ }
48
+ };
49
+ var RoomResolveError = class extends TikTokLiveError {
50
+ name = "RoomResolveError";
51
+ constructor(username, cause) {
52
+ super(`Failed to resolve room ID for "${username}" — may not exist or be offline${cause ? `: ${cause}` : ""}`);
53
+ }
54
+ };
55
+ var StreamFetchError = class extends TikTokLiveError {
56
+ name = "StreamFetchError";
57
+ constructor(roomId, cause) {
58
+ super(`Failed to fetch stream URL for room ${roomId}${cause ? `: ${cause}` : ""}`);
59
+ }
60
+ };
61
+ var DownloadFailedError = class extends TikTokLiveError {
62
+ name = "DownloadFailedError";
63
+ constructor(message, cause) {
64
+ super(`${message}${cause ? `: ${cause}` : ""}`);
65
+ }
66
+ };
67
+ var FfmpegError = class extends TikTokLiveError {
68
+ name = "FfmpegError";
69
+ constructor(message, exitCode) {
70
+ super(`FFmpeg error: ${message}${exitCode != null ? ` (exit code ${exitCode})` : ""}`);
71
+ }
72
+ };
73
+ var AbortError = class extends TikTokLiveError {
74
+ name = "AbortError";
75
+ constructor() {
76
+ super("Download was aborted");
77
+ }
78
+ };
79
+ //#endregion
80
+ //#region src/api/room.ts
81
+ /**
82
+ * TikTok API endpoint patterns for room ID resolution.
83
+ */
84
+ const TIKTOK_LIVE_URL = "https://www.tiktok.com/@{username}/live";
85
+ const TIKTOK_PROFILE_URL = "https://www.tiktok.com/@{username}";
86
+ const TIKTOK_API_ROOM_URL = "https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId={username}&sourceType=54";
87
+ /**
88
+ * Check whether a TikTok username exists by hitting the profile page.
89
+ * Throws UserNotFoundError if the account doesn't exist.
90
+ */
91
+ async function checkUserExists(username, impIt, signal) {
92
+ const url = TIKTOK_PROFILE_URL.replace("{username}", username);
93
+ if ((await impIt.fetch(url, {
94
+ headers: {
95
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
96
+ "Accept-Language": "en-US,en;q=0.9"
97
+ },
98
+ signal
99
+ })).status === 404) throw new UserNotFoundError(username);
100
+ }
101
+ /**
102
+ * Resolve a TikTok username to a live room ID.
103
+ *
104
+ * Strategy:
105
+ * 0. Verify the user exists (profile page check).
106
+ * 1. Scrape the user's live page HTML for the room ID in SIGI_STATE.
107
+ * 2. Fall back to the TikTok API.
108
+ * 3. Throw if neither works.
109
+ */
110
+ async function resolveRoomId(username, impIt, options = {}) {
111
+ const cleanUsername = username.replace(/^@/, "").trim();
112
+ await checkUserExists(cleanUsername, impIt, options.signal);
113
+ const roomId = await tryScrapeRoomId(cleanUsername, impIt, options.signal);
114
+ if (roomId) return roomId;
115
+ const apiRoomId = await tryApiRoomId(cleanUsername, impIt, options.signal);
116
+ if (apiRoomId) return apiRoomId;
117
+ throw new RoomResolveError(cleanUsername);
118
+ }
119
+ /**
120
+ * Try to extract room ID from the TikTok live page HTML.
121
+ */
122
+ async function tryScrapeRoomId(username, impIt, signal) {
123
+ try {
124
+ const url = TIKTOK_LIVE_URL.replace("{username}", username);
125
+ const response = await impIt.fetch(url, {
126
+ headers: {
127
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
128
+ "Accept-Language": "en-US,en;q=0.9"
129
+ },
130
+ signal
131
+ });
132
+ if (!response.ok) return null;
133
+ const html = await response.text();
134
+ const roomId = extractRoomIdFromSigiState(html);
135
+ if (roomId) return roomId;
136
+ const roomIdMatch = html.match(/"roomId"\s*:\s*"(\d+)"/);
137
+ if (roomIdMatch?.[1]) return roomIdMatch[1];
138
+ const roomIdParamMatch = html.match(/room_id=(\d+)/);
139
+ if (roomIdParamMatch?.[1]) return roomIdParamMatch[1];
140
+ return null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ /**
146
+ * Try to extract room ID from SIGI_STATE embedded JSON.
147
+ */
148
+ function extractRoomIdFromSigiState(html) {
149
+ try {
150
+ const match = html.match(/<script[^>]*id=["']SIGI_STATE["'][^>]*type=["']application\/json["'][^>]*>([\s\S]*?)<\/script>/i);
151
+ if (!match) return null;
152
+ const sigiState = JSON.parse(match[1]);
153
+ const liveRoom = sigiState?.LiveRoom?.liveRoomInfo;
154
+ if (liveRoom?.roomId) return String(liveRoom.roomId);
155
+ const liveRoom2 = sigiState?.LiveRoom?.liveRoomInfo?.user?.roomId;
156
+ if (liveRoom2) return String(liveRoom2);
157
+ return null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Try to resolve room ID via the TikTok API.
164
+ */
165
+ async function tryApiRoomId(username, impIt, signal) {
166
+ try {
167
+ const url = TIKTOK_API_ROOM_URL.replace("{username}", encodeURIComponent(username));
168
+ const response = await impIt.fetch(url, {
169
+ headers: {
170
+ Accept: "application/json, text/plain, */*",
171
+ Referer: `https://www.tiktok.com/@${username}`
172
+ },
173
+ signal
174
+ });
175
+ if (!response.ok) return null;
176
+ const dataObj = (await response.json()).data;
177
+ if (!dataObj) return null;
178
+ if (dataObj.liveRoom?.status !== 2) return null;
179
+ const user = dataObj.user;
180
+ if (!user) return null;
181
+ const roomId = user.roomId;
182
+ if (roomId) return String(roomId);
183
+ return null;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+ //#endregion
189
+ //#region src/utils/quality.ts
190
+ /**
191
+ * Quality ladder sorted from highest to lowest.
192
+ */
193
+ const QUALITY_ORDER = [
194
+ "fullhd1",
195
+ "hd1",
196
+ "sd2",
197
+ "sd1"
198
+ ];
199
+ const QUALITY_LABELS = {
200
+ fullhd1: "1080p",
201
+ hd1: "720p",
202
+ sd2: "540p",
203
+ sd1: "360p"
204
+ };
205
+ const QUALITY_LEVELS = {
206
+ fullhd1: 4,
207
+ hd1: 3,
208
+ sd2: 2,
209
+ sd1: 1
210
+ };
211
+ /**
212
+ * Build a QualityOption from a raw url object.
213
+ */
214
+ function buildQualityOption(key, flv, hls) {
215
+ return {
216
+ key,
217
+ label: QUALITY_LABELS[key],
218
+ level: QUALITY_LEVELS[key],
219
+ flv,
220
+ hls: hls ?? ""
221
+ };
222
+ }
223
+ /**
224
+ * Select a quality from available options.
225
+ *
226
+ * @param qualities - Available quality options to choose from.
227
+ * @param preference - "best" (highest available), "worst" (lowest), or a specific key.
228
+ * @returns The selected quality option.
229
+ */
230
+ function selectQuality(qualities, preference) {
231
+ if (qualities.length === 0) throw new Error("No stream qualities available");
232
+ if (preference === "best") return qualities[0];
233
+ if (preference === "worst") return qualities[qualities.length - 1];
234
+ return qualities.find((q) => q.key === preference) ?? qualities[0];
235
+ }
236
+ /**
237
+ * Parse quality options from the TikTok room/info response.
238
+ *
239
+ * Supports both the legacy `flv_pull_url` format and the newer
240
+ * `live_core_sdk_data` format.
241
+ */
242
+ function parseQualities(data) {
243
+ if (!data || typeof data !== "object") return [];
244
+ const d = data;
245
+ const sdkQualities = parseSdkQualities(d);
246
+ if (sdkQualities.length > 0) return sdkQualities;
247
+ return parseLegacyQualities(d);
248
+ }
249
+ function parseSdkQualities(data) {
250
+ try {
251
+ const streamUrl = data.stream_url;
252
+ if (!streamUrl) return [];
253
+ const sdkData = streamUrl.live_core_sdk_data;
254
+ if (!sdkData) return [];
255
+ const pullData = sdkData.pull_data;
256
+ if (!pullData) return [];
257
+ const streamDataStr = pullData.stream_data;
258
+ if (typeof streamDataStr !== "string") return [];
259
+ const innerData = JSON.parse(streamDataStr)?.data;
260
+ if (!innerData) return [];
261
+ const qualities = [];
262
+ for (const key of QUALITY_ORDER) {
263
+ const q = innerData[key];
264
+ if (!q) continue;
265
+ const main = q.main;
266
+ if (!main) continue;
267
+ const flv = main.flv;
268
+ if (typeof flv !== "string" || flv.length === 0) continue;
269
+ const hls = typeof main.hls === "string" ? main.hls : void 0;
270
+ qualities.push(buildQualityOption(key, flv, hls));
271
+ }
272
+ return qualities;
273
+ } catch {
274
+ return [];
275
+ }
276
+ }
277
+ function parseLegacyQualities(data) {
278
+ try {
279
+ const streamUrl = data.stream_url;
280
+ if (!streamUrl) return [];
281
+ const flvPullUrl = streamUrl.flv_pull_url;
282
+ if (!flvPullUrl) return [];
283
+ const qualities = [];
284
+ const keyMap = {
285
+ FULL_HD1: "fullhd1",
286
+ HD1: "hd1",
287
+ SD2: "sd2",
288
+ SD1: "sd1"
289
+ };
290
+ for (const [tikTokKey, url] of Object.entries(flvPullUrl)) {
291
+ const key = keyMap[tikTokKey];
292
+ if (key && typeof url === "string" && url.length > 0) qualities.push(buildQualityOption(key, url));
293
+ }
294
+ qualities.sort((a, b) => b.level - a.level);
295
+ return qualities;
296
+ } catch {
297
+ return [];
298
+ }
299
+ }
300
+ //#endregion
301
+ //#region src/api/stream.ts
302
+ /**
303
+ * Fetch stream info for a given room ID.
304
+ *
305
+ * Calls the TikTok webcast room/info API and parses the response
306
+ * to extract stream URLs and quality options.
307
+ */
308
+ async function fetchStreamInfo(roomId, username, impIt, options = {}) {
309
+ const url = `https://webcast.tiktok.com/webcast/room/info/?aid=1988&room_id=${encodeURIComponent(roomId)}`;
310
+ const response = await impIt.fetch(url, {
311
+ headers: {
312
+ Accept: "application/json, text/plain, */*",
313
+ Referer: `https://www.tiktok.com/@${username}/live`
314
+ },
315
+ signal: options.signal
316
+ });
317
+ if (!response.ok) throw new StreamFetchError(roomId, `HTTP ${response.status}: ${response.statusText}`);
318
+ const data = (await response.json()).data;
319
+ if (!data) throw new StreamFetchError(roomId, "No data in response");
320
+ const status = data.status;
321
+ if (status !== 2) throw new StreamFetchError(roomId, `Room is not live (status: ${status})`);
322
+ const qualities = parseQualities(data);
323
+ if (qualities.length === 0) throw new StreamFetchError(roomId, "No stream qualities found");
324
+ const selectedQuality = selectQuality(qualities, options.quality ?? "best");
325
+ const title = typeof data.title === "string" ? data.title : "";
326
+ const viewerCount = typeof data.stats?.viewer_count === "number" ? data.stats.viewer_count : 0;
327
+ return {
328
+ roomId,
329
+ username,
330
+ title,
331
+ qualities,
332
+ selectedQuality,
333
+ streamUrl: selectedQuality.flv,
334
+ viewerCount,
335
+ startedAt: /* @__PURE__ */ new Date()
336
+ };
337
+ }
338
+ //#endregion
339
+ //#region src/download/ffmpeg.ts
340
+ /**
341
+ * Parse ffmpeg stderr for duration and time progress.
342
+ *
343
+ * ffmpeg outputs lines like:
344
+ * frame= 123 fps= 30 q=28.0 size= 1024kB time=00:01:23.45 ...
345
+ */
346
+ function parseFfmpegProgress(line) {
347
+ const result = {};
348
+ const timeMatch = line.match(/time=(\d+):(\d+):(\d+)\.(\d+)/);
349
+ if (timeMatch) {
350
+ const hours = Number(timeMatch[1]);
351
+ const minutes = Number(timeMatch[2]);
352
+ const seconds = Number(timeMatch[3]);
353
+ result.duration = hours * 3600 + minutes * 60 + seconds;
354
+ }
355
+ const sizeMatch = line.match(/size=\s*(\d+)(\w+)B/);
356
+ if (sizeMatch && sizeMatch.length >= 3) {
357
+ const value = Number(sizeMatch[1]);
358
+ const unit = sizeMatch[2];
359
+ if (unit === "k" || unit === "K") result.sizeBytes = value * 1024;
360
+ else if (unit === "m" || unit === "M") result.sizeBytes = value * 1024 * 1024;
361
+ else result.sizeBytes = value;
362
+ }
363
+ return result.sizeBytes !== void 0 || result.duration !== void 0 ? result : null;
364
+ }
365
+ /**
366
+ * Download a livestream via ffmpeg.
367
+ *
368
+ * Spawns ffmpeg as a subprocess, pipes the stream URL as input,
369
+ * and writes the transcoded/remuxed output to the specified path.
370
+ *
371
+ * Advantages over raw HTTP:
372
+ * - Proper container format (mp4/mkv)
373
+ * - Stream copy (fast) or re-encode
374
+ * - Better handling of stream interruptions
375
+ */
376
+ async function downloadWithFfmpeg(options) {
377
+ const { ffmpegPath, url, outputPath, quality, args: extraArgs, bitrate, signal, onProgress, maxDuration, timeout = 30 } = options;
378
+ const ffmpegArgs = [
379
+ "-y",
380
+ "-i",
381
+ url
382
+ ];
383
+ if (maxDuration && maxDuration > 0 && maxDuration < Infinity) ffmpegArgs.push("-t", String(maxDuration));
384
+ if (bitrate) ffmpegArgs.push("-c:v", "libx264", "-b:v", bitrate, "-c:a", "copy");
385
+ else if (extraArgs && extraArgs.length > 0) ffmpegArgs.push(...extraArgs);
386
+ else ffmpegArgs.push("-c", "copy");
387
+ ffmpegArgs.push(outputPath);
388
+ return new Promise((resolve, reject) => {
389
+ const proc = spawn(ffmpegPath, ffmpegArgs, {
390
+ stdio: [
391
+ "ignore",
392
+ "pipe",
393
+ "pipe"
394
+ ],
395
+ signal
396
+ });
397
+ let stderrBuffer = "";
398
+ let sizeBytes = 0;
399
+ let duration = 0;
400
+ let firstDataTimer = setTimeout(() => {
401
+ firstDataTimer = null;
402
+ proc.kill("SIGTERM");
403
+ reject(new FfmpegError(`ffmpeg produced no output within ${timeout}s — check the stream URL`));
404
+ }, timeout * 1e3);
405
+ proc.stderr?.on("data", (chunk) => {
406
+ if (firstDataTimer) {
407
+ clearTimeout(firstDataTimer);
408
+ firstDataTimer = null;
409
+ }
410
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
411
+ stderrBuffer += text;
412
+ const lines = text.split("\n");
413
+ for (const line of lines) {
414
+ const parsed = parseFfmpegProgress(line);
415
+ if (parsed) {
416
+ if (parsed.duration !== void 0) duration = parsed.duration;
417
+ if (parsed.sizeBytes !== void 0) sizeBytes = parsed.sizeBytes;
418
+ onProgress?.({
419
+ downloadedBytes: sizeBytes,
420
+ downloadedMB: sizeBytes / (1024 * 1024),
421
+ duration,
422
+ speed: duration > 0 ? sizeBytes / duration : 0,
423
+ speedMBps: duration > 0 ? sizeBytes / duration / (1024 * 1024) : 0,
424
+ quality,
425
+ state: "recording"
426
+ });
427
+ }
428
+ }
429
+ });
430
+ proc.on("error", (err) => {
431
+ if (firstDataTimer) {
432
+ clearTimeout(firstDataTimer);
433
+ firstDataTimer = null;
434
+ }
435
+ if (err.name === "AbortError") {
436
+ resolve({
437
+ sizeBytes,
438
+ duration,
439
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4"
440
+ });
441
+ return;
442
+ }
443
+ reject(new FfmpegError(err.message));
444
+ });
445
+ proc.on("close", (code) => {
446
+ if (firstDataTimer) {
447
+ clearTimeout(firstDataTimer);
448
+ firstDataTimer = null;
449
+ }
450
+ if (signal?.aborted) {
451
+ resolve({
452
+ sizeBytes,
453
+ duration,
454
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4"
455
+ });
456
+ return;
457
+ }
458
+ if (code === 0) if (sizeBytes > 0) resolve({
459
+ sizeBytes,
460
+ duration,
461
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4"
462
+ });
463
+ else reject(new FfmpegError(extractFfmpegError(stderrBuffer) || "Stream was empty", code));
464
+ else if (code === null) if (sizeBytes > 0) resolve({
465
+ sizeBytes,
466
+ duration,
467
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4"
468
+ });
469
+ else reject(new FfmpegError("Process was killed before any data was received", code));
470
+ else if (code === 1 || code === 255) if (sizeBytes > 0) resolve({
471
+ sizeBytes,
472
+ duration,
473
+ format: outputPath.endsWith(".mkv") ? "mkv" : "mp4"
474
+ });
475
+ else reject(new FfmpegError(extractFfmpegError(stderrBuffer) || `FFmpeg exited with code ${code} and no data`, code));
476
+ else reject(new FfmpegError(extractFfmpegError(stderrBuffer), code));
477
+ });
478
+ });
479
+ }
480
+ /**
481
+ * Extract a meaningful error message from ffmpeg stderr output.
482
+ */
483
+ function extractFfmpegError(stderr) {
484
+ const lines = stderr.split("\n").filter((l) => l.trim().length > 0);
485
+ const errorLines = lines.filter((l) => l.toLowerCase().includes("error") || l.toLowerCase().includes("invalid") || l.toLowerCase().includes("cannot"));
486
+ if (errorLines.length > 0) return errorLines[0].trim();
487
+ return lines[lines.length - 1]?.trim() ?? "Unknown ffmpeg error";
488
+ }
489
+ //#endregion
490
+ //#region src/download/raw-http.ts
491
+ /**
492
+ * Download a livestream via raw HTTP FLV streaming.
493
+ *
494
+ * Writes the incoming FLV data directly to disk without transcoding.
495
+ * No ffmpeg required.
496
+ */
497
+ async function downloadRawHttp(options) {
498
+ const { url, outputPath, quality, signal, onProgress, maxDuration } = options;
499
+ const response = await new Impit({ browser: "chrome" }).fetch(url, { signal });
500
+ if (!response.ok) throw new DownloadFailedError(`HTTP ${response.status}: ${response.statusText}`);
501
+ const bodyReader = response.body?.getReader();
502
+ if (!bodyReader) throw new DownloadFailedError("No response body stream");
503
+ const fileStream = createWriteStream(outputPath);
504
+ const startTime = Date.now();
505
+ let downloadedBytes = 0;
506
+ let lastProgressTime = startTime;
507
+ let lastProgressBytes = 0;
508
+ let aborted = false;
509
+ const reader = bodyReader;
510
+ return new Promise((resolve, reject) => {
511
+ const abortHandler = () => {
512
+ aborted = true;
513
+ reader.cancel().catch(() => {});
514
+ fileStream.close();
515
+ reject(new AbortError());
516
+ };
517
+ signal?.addEventListener("abort", abortHandler, { once: true });
518
+ let durationTimer = null;
519
+ if (maxDuration && maxDuration > 0 && maxDuration < Infinity) durationTimer = setTimeout(() => {
520
+ aborted = true;
521
+ reader.cancel().catch(() => {});
522
+ fileStream.close();
523
+ resolve({
524
+ sizeBytes: downloadedBytes,
525
+ duration: (Date.now() - startTime) / 1e3,
526
+ format: "flv"
527
+ });
528
+ }, maxDuration * 1e3);
529
+ function pump() {
530
+ reader.read().then(({ done, value }) => {
531
+ if (done || aborted) {
532
+ fileStream.close();
533
+ if (!aborted) resolve({
534
+ sizeBytes: downloadedBytes,
535
+ duration: (Date.now() - startTime) / 1e3,
536
+ format: "flv"
537
+ });
538
+ if (durationTimer) clearTimeout(durationTimer);
539
+ signal?.removeEventListener("abort", abortHandler);
540
+ return;
541
+ }
542
+ downloadedBytes += value.byteLength;
543
+ fileStream.write(value);
544
+ const now = Date.now();
545
+ const elapsed = now - lastProgressTime;
546
+ if (elapsed >= 1e3) {
547
+ const speed = (downloadedBytes - lastProgressBytes) / (elapsed / 1e3);
548
+ lastProgressTime = now;
549
+ lastProgressBytes = downloadedBytes;
550
+ onProgress?.({
551
+ downloadedBytes,
552
+ downloadedMB: downloadedBytes / (1024 * 1024),
553
+ duration: (now - startTime) / 1e3,
554
+ speed,
555
+ speedMBps: speed / (1024 * 1024),
556
+ quality,
557
+ state: "recording"
558
+ });
559
+ }
560
+ pump();
561
+ }).catch((err) => {
562
+ if (aborted) return;
563
+ fileStream.close();
564
+ if (durationTimer) clearTimeout(durationTimer);
565
+ signal?.removeEventListener("abort", abortHandler);
566
+ reject(err instanceof Error ? new DownloadFailedError("Stream read error", err) : new DownloadFailedError("Stream read error"));
567
+ });
568
+ }
569
+ fileStream.on("error", (err) => {
570
+ if (aborted) return;
571
+ if (durationTimer) clearTimeout(durationTimer);
572
+ signal?.removeEventListener("abort", abortHandler);
573
+ reject(new DownloadFailedError("File write error", err));
574
+ });
575
+ pump();
576
+ });
577
+ }
578
+ //#endregion
579
+ //#region src/utils/template.ts
580
+ /**
581
+ * Render a filename template with runtime values.
582
+ *
583
+ * Available variables:
584
+ * - {username} - TikTok username
585
+ * - {date} - Current date in YYYYMMDD format
586
+ * - {time} - Current time in HHmmss format
587
+ * - {title} - Stream title (sanitized)
588
+ *
589
+ * Default template: "{username}={date}_{time}"
590
+ */
591
+ function renderFilename(template, values) {
592
+ const now = /* @__PURE__ */ new Date();
593
+ const date = values.date ?? formatDate(now);
594
+ const time = values.time ?? formatTime(now);
595
+ let result = template;
596
+ result = result.replaceAll("{username}", values.username);
597
+ result = result.replaceAll("{date}", date);
598
+ result = result.replaceAll("{time}", time);
599
+ if (values.title) result = result.replaceAll("{title}", sanitize(values.title));
600
+ if (values.part !== void 0) result = result.replaceAll("{part}", String(values.part));
601
+ return result;
602
+ }
603
+ function formatDate(date) {
604
+ return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
605
+ }
606
+ function formatTime(date) {
607
+ return `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}${String(date.getSeconds()).padStart(2, "0")}`;
608
+ }
609
+ /**
610
+ * Sanitize a string for use in filenames.
611
+ * Replaces characters that are problematic across OSes.
612
+ */
613
+ function sanitize(input) {
614
+ return input.replaceAll(/[/\\?%*:|"<>]/g, "_").replaceAll(/\s+/g, "_").replaceAll(/_+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64);
615
+ }
616
+ //#endregion
617
+ //#region src/TikTokLiveDownloader.ts
618
+ const DEFAULT_OPTIONS = {
619
+ output: ".",
620
+ filename: "{username}={date}_{time}",
621
+ quality: "best",
622
+ format: "mp4",
623
+ useFfmpeg: false,
624
+ ffmpegPath: null,
625
+ ffmpegArgs: [],
626
+ bitrate: null,
627
+ maxDuration: Infinity,
628
+ maxSegmentDuration: Infinity,
629
+ checkInterval: 180,
630
+ proxyUrl: null,
631
+ cookieJar: null,
632
+ browser: "chrome",
633
+ timeout: 30,
634
+ headers: {},
635
+ onProgress: null,
636
+ onStart: null,
637
+ onError: null,
638
+ signal: null
639
+ };
640
+ /**
641
+ * Main class for downloading TikTok livestreams.
642
+ *
643
+ * Usage:
644
+ * ```ts
645
+ * const d = new TikTokLiveDownloader('username')
646
+ * d.on('progress', s => console.log(`${s.downloadedMB}MB`))
647
+ * const result = await d.start()
648
+ * ```
649
+ */
650
+ var TikTokLiveDownloader = class {
651
+ username;
652
+ options;
653
+ emitter;
654
+ impIt;
655
+ _state = "idle";
656
+ abortController;
657
+ _stats = null;
658
+ _result = null;
659
+ constructor(username, opts = {}) {
660
+ this.username = username.replace(/^@/, "").trim();
661
+ this.emitter = new EventEmitter();
662
+ this.abortController = new AbortController();
663
+ this.options = this.resolveOptions(opts);
664
+ this.impIt = createClient({
665
+ browser: this.options.browser,
666
+ proxyUrl: this.options.proxyUrl,
667
+ timeout: this.options.timeout * 1e3,
668
+ headers: this.options.headers,
669
+ cookieJar: this.options.cookieJar
670
+ });
671
+ if (this.options.signal) this.options.signal.addEventListener("abort", () => this.abortController.abort(), { once: true });
672
+ }
673
+ on(event, listener) {
674
+ this.emitter.on(event, listener);
675
+ return this;
676
+ }
677
+ once(event, listener) {
678
+ this.emitter.once(event, listener);
679
+ return this;
680
+ }
681
+ off(event, listener) {
682
+ this.emitter.off(event, listener);
683
+ return this;
684
+ }
685
+ emit(event, ...args) {
686
+ this.emitter.emit(event, ...args);
687
+ }
688
+ get state() {
689
+ return this._state;
690
+ }
691
+ get stats() {
692
+ return this._stats;
693
+ }
694
+ get result() {
695
+ return this._result;
696
+ }
697
+ /**
698
+ * Start recording. If the user is not currently live, polls
699
+ * until they go live and then starts recording.
700
+ *
701
+ * Resolves when the stream ends or maxDuration is reached.
702
+ */
703
+ async start() {
704
+ return this._run(true);
705
+ }
706
+ /**
707
+ * Start recording immediately. Throws if the user is not live.
708
+ */
709
+ async startRecording() {
710
+ return this._run(false);
711
+ }
712
+ /**
713
+ * Wait until the user goes live (does not record).
714
+ */
715
+ async waitForLive() {
716
+ this.setState("waiting");
717
+ const roomId = await this.resolveRoomIdWithRetry();
718
+ const info = await this.fetchStreamInfo(roomId);
719
+ this.setState("idle");
720
+ return info;
721
+ }
722
+ /**
723
+ * Gracefully stop the recording.
724
+ */
725
+ async stop() {
726
+ if (this._state !== "recording" && this._state !== "waiting") return;
727
+ this.setState("stopping");
728
+ this.abortController.abort();
729
+ await new Promise((resolve) => {
730
+ const check = () => {
731
+ if (this._state === "done") resolve();
732
+ else setTimeout(check, 100);
733
+ };
734
+ check();
735
+ });
736
+ }
737
+ /**
738
+ * Immediately abort the recording.
739
+ */
740
+ abort() {
741
+ this.abortController.abort();
742
+ }
743
+ setState(state) {
744
+ this._state = state;
745
+ }
746
+ resolveOptions(opts) {
747
+ const resolved = {
748
+ ...DEFAULT_OPTIONS,
749
+ ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== void 0))
750
+ };
751
+ if (opts.useFfmpeg === void 0 && opts.ffmpegPath === void 0) {
752
+ const systemPath = detectSystemFfmpeg();
753
+ if (systemPath) {
754
+ resolved.useFfmpeg = true;
755
+ resolved.ffmpegPath = systemPath;
756
+ } else resolved.useFfmpeg = false;
757
+ } else if (opts.useFfmpeg !== false) {
758
+ resolved.useFfmpeg = true;
759
+ resolved.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
760
+ }
761
+ if (resolved.format === "mp4" || resolved.format === "mkv") {
762
+ if (!resolved.useFfmpeg) resolved.format = "flv";
763
+ }
764
+ return resolved;
765
+ }
766
+ async _run(waitForLive) {
767
+ this.abortController = new AbortController();
768
+ if (this.options.signal) this.options.signal.addEventListener("abort", () => this.abortController.abort(), { once: true });
769
+ const pendingRemuxes = [];
770
+ try {
771
+ this.setState("waiting");
772
+ let roomId = await this.resolveRoomIdOnce();
773
+ if (!roomId && waitForLive) roomId = await this.resolveRoomIdWithRetry();
774
+ if (!roomId) throw new UserOfflineError(this.username);
775
+ this.setState("waiting");
776
+ const firstInfo = waitForLive ? await this.pollStreamInfo(roomId) : await this.fetchStreamInfo(roomId);
777
+ if (!(this.options.maxSegmentDuration > 0 && this.options.maxSegmentDuration < Infinity)) {
778
+ const tsResult = await this.downloadSegment(firstInfo, 1);
779
+ const finalResult = await this.remuxSegment(tsResult);
780
+ this._result = finalResult;
781
+ this.setState("done");
782
+ this.emit("complete", [finalResult]);
783
+ return finalResult;
784
+ }
785
+ let info = firstInfo;
786
+ let partNum = 1;
787
+ while (true) {
788
+ this.abortController.signal?.throwIfAborted();
789
+ this._state = "waiting";
790
+ const tsResult = await this.downloadSegment(info, partNum);
791
+ const remuxPromise = this.remuxSegment(tsResult);
792
+ pendingRemuxes.push(remuxPromise);
793
+ this._result = tsResult;
794
+ this.emit("segment", tsResult, partNum);
795
+ partNum++;
796
+ await sleep(1e3);
797
+ try {
798
+ const freshRoomId = await this.resolveRoomIdOnce();
799
+ if (!freshRoomId) break;
800
+ info = await this.fetchStreamInfo(freshRoomId);
801
+ } catch {
802
+ break;
803
+ }
804
+ }
805
+ const finalResults = await Promise.all(pendingRemuxes);
806
+ this._result = finalResults[finalResults.length - 1] ?? null;
807
+ this.setState("done");
808
+ this.emit("complete", finalResults);
809
+ return finalResults[finalResults.length - 1];
810
+ } catch (err) {
811
+ if (err instanceof StreamFetchError) this.emit("waiting", {
812
+ username: this.username,
813
+ phase: "stream",
814
+ elapsed: 0
815
+ });
816
+ if (pendingRemuxes.length > 0) await Promise.allSettled(pendingRemuxes);
817
+ this.setState("done");
818
+ const error = err instanceof Error ? err : new Error(String(err));
819
+ this.emit("error", error);
820
+ this.options.onError?.(error);
821
+ throw error;
822
+ }
823
+ }
824
+ async resolveRoomIdOnce() {
825
+ try {
826
+ return await resolveRoomId(this.username, this.impIt, { signal: this.abortController.signal });
827
+ } catch (error) {
828
+ if (error instanceof UserNotFoundError) throw error;
829
+ try {
830
+ const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(this.username)}&sourceType=54`;
831
+ const resp = await this.impIt.fetch(url, { signal: this.abortController.signal });
832
+ if (resp.ok) {
833
+ const roomId = ((await resp.json()).data?.user)?.roomId;
834
+ if (roomId) return String(roomId);
835
+ }
836
+ } catch {}
837
+ return null;
838
+ }
839
+ }
840
+ async resolveRoomIdWithRetry() {
841
+ const interval = this.options.checkInterval;
842
+ const maxInterval = Math.max(interval, 180);
843
+ const waitStart = Date.now();
844
+ while (true) {
845
+ this.abortController.signal?.throwIfAborted();
846
+ const roomId = await this.resolveRoomIdOnce();
847
+ if (roomId) return roomId;
848
+ this.emit("waiting", {
849
+ username: this.username,
850
+ phase: "room",
851
+ elapsed: (Date.now() - waitStart) / 1e3
852
+ });
853
+ await sleep(maxInterval * 1e3);
854
+ }
855
+ }
856
+ async fetchStreamInfo(roomId) {
857
+ const info = await fetchStreamInfo(roomId, this.username, this.impIt, {
858
+ quality: this.options.quality,
859
+ signal: this.abortController.signal
860
+ });
861
+ this.emit("start", info);
862
+ this.options.onStart?.(info);
863
+ return info;
864
+ }
865
+ /**
866
+ * Poll for stream info until the stream becomes active.
867
+ * Used in waitForLive mode when the room exists but the stream
868
+ * has not yet started broadcasting.
869
+ */
870
+ async pollStreamInfo(roomId) {
871
+ const interval = this.options.checkInterval;
872
+ const maxInterval = Math.max(interval, 180);
873
+ const waitStart = Date.now();
874
+ while (true) {
875
+ this.abortController.signal?.throwIfAborted();
876
+ try {
877
+ return await this.fetchStreamInfo(roomId);
878
+ } catch (err) {
879
+ if (err instanceof StreamFetchError) {
880
+ this.emit("waiting", {
881
+ username: this.username,
882
+ phase: "stream",
883
+ elapsed: (Date.now() - waitStart) / 1e3
884
+ });
885
+ await sleep(maxInterval * 1e3);
886
+ continue;
887
+ }
888
+ throw err;
889
+ }
890
+ }
891
+ }
892
+ /**
893
+ * Download a single segment. Always downloads to .ts (crash-safe).
894
+ * Does NOT remux — that is handled by remuxSegment() running in the background.
895
+ */
896
+ async downloadSegment(info, partNumber) {
897
+ const startTime = /* @__PURE__ */ new Date();
898
+ const quality = info.selectedQuality;
899
+ const segmentMaxDuration = this.options.maxSegmentDuration > 0 && this.options.maxSegmentDuration < Infinity ? this.options.maxSegmentDuration : this.options.maxDuration;
900
+ const outputDir = this.options.output;
901
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
902
+ const fileBase = renderFilename(this.options.filename, {
903
+ username: this.username,
904
+ title: info.title,
905
+ part: partNumber
906
+ });
907
+ const fileName = partNumber && !this.options.filename.includes("{part}") ? `${fileBase}_part${partNumber}` : fileBase;
908
+ this.emit("progress", {
909
+ downloadedBytes: 0,
910
+ downloadedMB: 0,
911
+ duration: 0,
912
+ speed: 0,
913
+ speedMBps: 0,
914
+ quality: quality.key,
915
+ state: "recording"
916
+ });
917
+ this.setState("recording");
918
+ const onProgress = (stats) => {
919
+ this._stats = stats;
920
+ this.emit("progress", stats);
921
+ this.options.onProgress?.(stats);
922
+ };
923
+ if (this.options.useFfmpeg && this.options.ffmpegPath) {
924
+ const tsPath = join(outputDir, `${fileName}.ts`);
925
+ const result = await downloadWithFfmpeg({
926
+ ffmpegPath: this.options.ffmpegPath,
927
+ url: info.streamUrl,
928
+ outputPath: tsPath,
929
+ quality: quality.key,
930
+ signal: this.abortController.signal,
931
+ onProgress,
932
+ maxDuration: segmentMaxDuration < Infinity ? segmentMaxDuration : void 0,
933
+ timeout: this.options.timeout * 1e3
934
+ });
935
+ return {
936
+ filePath: tsPath,
937
+ sizeBytes: result.sizeBytes,
938
+ sizeMB: result.sizeBytes / (1024 * 1024),
939
+ duration: result.duration,
940
+ username: this.username,
941
+ roomId: info.roomId,
942
+ quality: quality.key,
943
+ format: "ts",
944
+ startedAt: startTime,
945
+ endedAt: /* @__PURE__ */ new Date()
946
+ };
947
+ }
948
+ const outputPath = join(outputDir, `${fileName}.flv`);
949
+ const result = await downloadRawHttp({
950
+ url: info.streamUrl,
951
+ outputPath,
952
+ quality: quality.key,
953
+ signal: this.abortController.signal,
954
+ onProgress,
955
+ maxDuration: segmentMaxDuration < Infinity ? segmentMaxDuration : void 0
956
+ });
957
+ return {
958
+ filePath: outputPath,
959
+ sizeBytes: result.sizeBytes,
960
+ sizeMB: result.sizeBytes / (1024 * 1024),
961
+ duration: result.duration,
962
+ username: this.username,
963
+ roomId: info.roomId,
964
+ quality: quality.key,
965
+ format: "flv",
966
+ startedAt: startTime,
967
+ endedAt: /* @__PURE__ */ new Date()
968
+ };
969
+ }
970
+ /**
971
+ * Remux a downloaded .ts segment to the target format with audio normalization.
972
+ * Runs in background, does not block the download loop.
973
+ *
974
+ * If remux fails, keeps the .ts as a playable fallback.
975
+ */
976
+ async remuxSegment(tsResult) {
977
+ if (!this.options.useFfmpeg || !this.options.ffmpegPath || tsResult.format !== "ts") return tsResult;
978
+ const targetFormat = this.options.format;
979
+ const inputPath = tsResult.filePath;
980
+ if (targetFormat === "ts") return tsResult;
981
+ const finalExt = targetFormat === "mkv" ? "mkv" : "mp4";
982
+ const finalPath = inputPath.replace(/\.ts$/, `.${finalExt}`);
983
+ if (finalPath === inputPath) return tsResult;
984
+ this.emit("remux", {
985
+ filePath: inputPath,
986
+ inputSizeMB: tsResult.sizeMB,
987
+ status: "started"
988
+ });
989
+ try {
990
+ await this.remuxAndNormalize(this.options.ffmpegPath, inputPath, finalPath);
991
+ let realSizeBytes = tsResult.sizeBytes;
992
+ try {
993
+ const { statSync } = await import("node:fs");
994
+ realSizeBytes = statSync(finalPath).size;
995
+ } catch {}
996
+ try {
997
+ const { unlinkSync } = await import("node:fs");
998
+ unlinkSync(inputPath);
999
+ } catch {}
1000
+ this.emit("remux", {
1001
+ filePath: inputPath,
1002
+ outputPath: finalPath,
1003
+ inputSizeMB: tsResult.sizeMB,
1004
+ outputSizeMB: realSizeBytes / (1024 * 1024),
1005
+ status: "completed"
1006
+ });
1007
+ return {
1008
+ ...tsResult,
1009
+ filePath: finalPath,
1010
+ sizeBytes: realSizeBytes,
1011
+ sizeMB: realSizeBytes / (1024 * 1024),
1012
+ format: finalExt
1013
+ };
1014
+ } catch (err) {
1015
+ const msg = err instanceof Error ? err.message : String(err);
1016
+ console.warn(`tokwatchr: remux failed for ${inputPath}, keeping .ts as fallback: ${msg}`);
1017
+ this.emit("remux", {
1018
+ filePath: inputPath,
1019
+ inputSizeMB: tsResult.sizeMB,
1020
+ status: "failed"
1021
+ });
1022
+ return tsResult;
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Remux a .ts file to the target container with EBU R128 audio normalization.
1027
+ *
1028
+ * Two-pass loudnorm (equivalent to `ffmpeg-normalize --preset streaming-video -c:a aac`):
1029
+ * 1. Measure integrated loudness, LRA, true peak
1030
+ * 2. Apply linear normalization + encode AAC + copy video
1031
+ *
1032
+ * If the measurement pass fails (short file, edge case), falls back to
1033
+ * plain AAC encode without loudnorm.
1034
+ */
1035
+ async remuxAndNormalize(ffmpegPath, inputPath, outputPath) {
1036
+ const measured = await this.measureLoudness(ffmpegPath, inputPath);
1037
+ const args = [
1038
+ "-hide_banner",
1039
+ "-y",
1040
+ "-i",
1041
+ inputPath,
1042
+ "-c:v",
1043
+ "copy"
1044
+ ];
1045
+ if (measured) args.push("-af", [
1046
+ "loudnorm=I=-14",
1047
+ "LRA=7",
1048
+ "TP=-2",
1049
+ "linear=true",
1050
+ `measured_I=${measured.inputI}`,
1051
+ `measured_LRA=${measured.inputLra}`,
1052
+ `measured_TP=${measured.inputTp}`,
1053
+ `measured_thresh=${measured.inputThresh}`,
1054
+ `offset=${measured.offset}`
1055
+ ].join(":"));
1056
+ args.push("-c:a", "aac", "-b:a", "128k");
1057
+ args.push(outputPath);
1058
+ return new Promise((resolve, reject) => {
1059
+ const stderrChunks = [];
1060
+ const proc = spawn(ffmpegPath, args, {
1061
+ stdio: [
1062
+ "ignore",
1063
+ "ignore",
1064
+ "pipe"
1065
+ ],
1066
+ timeout: 6e5
1067
+ });
1068
+ proc.stderr?.on("data", (chunk) => {
1069
+ stderrChunks.push(chunk);
1070
+ });
1071
+ proc.on("error", (err) => reject(err));
1072
+ proc.on("close", (code) => {
1073
+ if (code === 0) resolve();
1074
+ else {
1075
+ const stderrOutput = Buffer.concat(stderrChunks).toString("utf-8");
1076
+ const tail = stderrOutput.length > 1500 ? `...${stderrOutput.slice(-1500)}` : stderrOutput;
1077
+ const msg = tail ? `Remux exited with code ${code}: ${tail}` : `Remux exited with code ${code}`;
1078
+ reject(new Error(msg));
1079
+ }
1080
+ });
1081
+ });
1082
+ }
1083
+ /**
1084
+ * Measure loudness of a .ts file using ffmpeg's loudnorm filter.
1085
+ *
1086
+ * Runs `loudnorm` with `print_format=json` and parses the JSON
1087
+ * output from stderr. Returns the measured values for use in
1088
+ * the second pass, or null if measurement failed.
1089
+ */
1090
+ async measureLoudness(ffmpegPath, inputPath) {
1091
+ return new Promise((resolve, reject) => {
1092
+ const proc = spawn(ffmpegPath, [
1093
+ "-hide_banner",
1094
+ "-y",
1095
+ "-i",
1096
+ inputPath,
1097
+ "-af",
1098
+ "loudnorm=I=-14:LRA=7:TP=-2:print_format=json",
1099
+ "-f",
1100
+ "null",
1101
+ "-"
1102
+ ], {
1103
+ stdio: [
1104
+ "ignore",
1105
+ "pipe",
1106
+ "pipe"
1107
+ ],
1108
+ timeout: 3e5
1109
+ });
1110
+ let stderr = "";
1111
+ proc.stderr?.on("data", (chunk) => {
1112
+ stderr += chunk.toString("utf-8");
1113
+ });
1114
+ proc.on("error", (err) => reject(err));
1115
+ proc.on("close", (code) => {
1116
+ if (code !== 0) {
1117
+ resolve(null);
1118
+ return;
1119
+ }
1120
+ try {
1121
+ resolve(parseLoudnormJson(stderr));
1122
+ } catch {
1123
+ resolve(null);
1124
+ }
1125
+ });
1126
+ });
1127
+ }
1128
+ };
1129
+ /**
1130
+ * Parse the JSON output from ffmpeg's loudnorm print_format=json.
1131
+ *
1132
+ * Handles two output styles:
1133
+ * Multi-line:
1134
+ * [Parsed_loudnorm_0 @ 0x...]
1135
+ * { "input_i": "-23.5", ... }
1136
+ * Single-line:
1137
+ * [Parsed_loudnorm_0 @ 0x...] { "input_i": "-23.5", ... }
1138
+ */
1139
+ function parseLoudnormJson(stderr) {
1140
+ try {
1141
+ let start = -1;
1142
+ let depth = 0;
1143
+ for (let i = 0; i < stderr.length; i++) {
1144
+ const ch = stderr[i];
1145
+ if (ch === "{") {
1146
+ if (start === -1) start = i;
1147
+ depth++;
1148
+ } else if (ch === "}") {
1149
+ depth--;
1150
+ if (depth === 0 && start !== -1) {
1151
+ const jsonStr = stderr.slice(start, i + 1);
1152
+ const data = JSON.parse(jsonStr);
1153
+ if (typeof data.input_i === "string" && typeof data.input_lra === "string" && typeof data.input_tp === "string" && typeof data.input_thresh === "string" && typeof data.target_offset === "string") return {
1154
+ inputI: data.input_i,
1155
+ inputLra: data.input_lra,
1156
+ inputTp: data.input_tp,
1157
+ inputThresh: data.input_thresh,
1158
+ offset: data.target_offset
1159
+ };
1160
+ return null;
1161
+ }
1162
+ }
1163
+ }
1164
+ } catch {}
1165
+ return null;
1166
+ }
1167
+ /**
1168
+ * Detect whether `ffmpeg` is available on the system PATH.
1169
+ *
1170
+ * Returns "ffmpeg" (or the resolved path) if found, or null if not.
1171
+ */
1172
+ function detectSystemFfmpeg() {
1173
+ try {
1174
+ return spawnSync("ffmpeg", ["-version"], {
1175
+ stdio: "pipe",
1176
+ timeout: 5e3
1177
+ }).status === 0 ? "ffmpeg" : null;
1178
+ } catch {
1179
+ return null;
1180
+ }
1181
+ }
1182
+ function sleep(ms) {
1183
+ return new Promise((resolve) => setTimeout(resolve, ms));
1184
+ }
1185
+ //#endregion
1186
+ //#region src/cli/utils/format.ts
1187
+ /**
1188
+ * Format a byte count into a human-readable string.
1189
+ *
1190
+ * @example formatBytes(1_234_567) // "1.2 MB"
1191
+ */
1192
+ function formatBytes(bytes) {
1193
+ const units = [
1194
+ "B",
1195
+ "KB",
1196
+ "MB",
1197
+ "GB"
1198
+ ];
1199
+ let value = bytes;
1200
+ let unitIndex = 0;
1201
+ while (value >= 1024 && unitIndex < units.length - 1) {
1202
+ value /= 1024;
1203
+ unitIndex++;
1204
+ }
1205
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
1206
+ }
1207
+ /**
1208
+ * Format a duration in seconds to a concise string.
1209
+ *
1210
+ * @example formatDuration(3661) // "1h 1m 1s"
1211
+ */
1212
+ function formatDuration(seconds) {
1213
+ const h = Math.floor(seconds / 3600);
1214
+ const m = Math.floor(seconds % 3600 / 60);
1215
+ const s = Math.floor(seconds % 60);
1216
+ const parts = [];
1217
+ if (h > 0) parts.push(`${h}h`);
1218
+ if (m > 0) parts.push(`${m}m`);
1219
+ parts.push(`${s}s`);
1220
+ return parts.join(" ");
1221
+ }
1222
+ /**
1223
+ * Format a speed in bytes/sec to a human-readable string.
1224
+ *
1225
+ * @example formatSpeed(3_200_000) // "3.1 MB/s"
1226
+ */
1227
+ function formatSpeed(bytesPerSec) {
1228
+ const units = [
1229
+ "B/s",
1230
+ "KB/s",
1231
+ "MB/s",
1232
+ "GB/s"
1233
+ ];
1234
+ let value = bytesPerSec;
1235
+ let unitIndex = 0;
1236
+ while (value >= 1024 && unitIndex < units.length - 1) {
1237
+ value /= 1024;
1238
+ unitIndex++;
1239
+ }
1240
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
1241
+ }
1242
+ //#endregion
1243
+ //#region src/cli/commands/download.ts
1244
+ /**
1245
+ * Execute the `download` command.
1246
+ *
1247
+ * Uses plain console output (no spinner) so terminal stays in cooked
1248
+ * mode and SIGINT flows normally through process.on().
1249
+ */
1250
+ async function executeDownload(username, options) {
1251
+ let downloader;
1252
+ const onSignal = async () => {
1253
+ await downloader.stop();
1254
+ };
1255
+ process.on("SIGINT", onSignal);
1256
+ process.on("SIGTERM", onSignal);
1257
+ downloader = new TikTokLiveDownloader(username, {
1258
+ output: options.output,
1259
+ quality: options.quality,
1260
+ format: options.format,
1261
+ proxyUrl: options.proxy,
1262
+ useFfmpeg: options.ffmpeg
1263
+ });
1264
+ console.log(`${pc.dim("Resolving room...")}`);
1265
+ downloader.on("error", () => {});
1266
+ downloader.on("start", (info) => {
1267
+ console.log(`\n${pc.blue(`@${info.username}`)}`);
1268
+ console.log(` ${pc.green("Recording...")}`);
1269
+ });
1270
+ downloader.on("progress", (stats) => {
1271
+ const line = `${formatBytes(stats.downloadedBytes)} @ ${formatSpeed(stats.speed)} [${formatDuration(stats.duration)}]`;
1272
+ process.stderr.write(`\r ${line} `);
1273
+ });
1274
+ downloader.on("remux", (info) => {
1275
+ switch (info.status) {
1276
+ case "started":
1277
+ process.stderr.write(`\n ${pc.dim("Remuxing...")}`);
1278
+ break;
1279
+ case "completed":
1280
+ process.stderr.write(`\r ${pc.green("Remuxed:")} ${basename(info.outputPath ?? "")}\n`);
1281
+ break;
1282
+ case "failed":
1283
+ process.stderr.write(`\n ${pc.yellow("Remux failed, keeping .ts as fallback")}\n`);
1284
+ break;
1285
+ }
1286
+ });
1287
+ downloader.on("complete", (results) => {
1288
+ process.stderr.write("\n");
1289
+ for (const r of results) console.log(` ${pc.green("Saved:")} ${basename(r.filePath)} ${pc.dim(`(${formatBytes(r.sizeBytes)}, ${formatDuration(r.duration)})`)}`);
1290
+ const totalMB = results.reduce((sum, r) => sum + r.sizeMB, 0);
1291
+ console.log(` Done — ${results.length} segment(s), ${totalMB.toFixed(1)}MB total`);
1292
+ if (results.length > 0) console.log(` ${pc.dim(`Output: ${dirname(results[0]?.filePath ?? "")}/`)}`);
1293
+ });
1294
+ try {
1295
+ await downloader.startRecording();
1296
+ } catch (error) {
1297
+ if (error instanceof UserNotFoundError) {
1298
+ console.error(pc.red("[error]"), "User not found. Check the username and try again.");
1299
+ process.exit(1);
1300
+ }
1301
+ if (error instanceof UserOfflineError) {
1302
+ console.error(pc.red("[error]"), error.message);
1303
+ process.exit(1);
1304
+ }
1305
+ console.error(pc.red("[error]"), String(error));
1306
+ throw error;
1307
+ }
1308
+ }
1309
+ //#endregion
1310
+ //#region src/cli/commands/watch.ts
1311
+ /**
1312
+ * Execute the `watch` command.
1313
+ *
1314
+ * Uses plain console output (no spinner) so terminal stays in cooked
1315
+ * mode and SIGINT flows normally through process.on().
1316
+ */
1317
+ async function executeWatch(username, options) {
1318
+ let downloader;
1319
+ let stopped = false;
1320
+ const onSignal = async () => {
1321
+ stopped = true;
1322
+ await downloader.stop();
1323
+ };
1324
+ process.on("SIGINT", onSignal);
1325
+ process.on("SIGTERM", onSignal);
1326
+ downloader = new TikTokLiveDownloader(username, {
1327
+ output: options.output,
1328
+ quality: options.quality,
1329
+ format: options.format,
1330
+ proxyUrl: options.proxy,
1331
+ useFfmpeg: options.ffmpeg,
1332
+ maxDuration: options.maxDuration ? options.maxDuration * 60 : void 0,
1333
+ maxSegmentDuration: (options.segmentDuration ?? 20) * 60,
1334
+ checkInterval: (options.interval ?? 3) * 60
1335
+ });
1336
+ downloader.on("error", () => {});
1337
+ downloader.on("start", (info) => {
1338
+ console.log(`\n${pc.blue(`@${info.username}`)}`);
1339
+ console.log(` ${pc.green("Recording...")}`);
1340
+ });
1341
+ downloader.on("waiting", (info) => {
1342
+ process.stderr.write(`\r ${pc.dim(`Waiting... ${formatDuration(info.elapsed)}`)} `);
1343
+ });
1344
+ downloader.on("progress", (stats) => {
1345
+ const line = `${formatBytes(stats.downloadedBytes)} @ ${formatSpeed(stats.speed)} [${formatDuration(stats.duration)}]`;
1346
+ process.stderr.write(`\r ${line} `);
1347
+ });
1348
+ downloader.on("remux", (info) => {
1349
+ switch (info.status) {
1350
+ case "started":
1351
+ process.stderr.write(`\n ${pc.dim("Remuxing...")}`);
1352
+ break;
1353
+ case "completed":
1354
+ process.stderr.write(`\r ${pc.green("Remuxed:")} ${basename(info.outputPath ?? "")}\n`);
1355
+ break;
1356
+ case "failed":
1357
+ process.stderr.write(`\n ${pc.yellow("Remux failed, keeping .ts as fallback")}\n`);
1358
+ break;
1359
+ }
1360
+ });
1361
+ downloader.on("segment", (result, partNum) => {
1362
+ process.stderr.write("\n");
1363
+ console.log(` ${pc.green("Segment")} ${partNum}: ${basename(result.filePath)} ${pc.dim(`(${formatBytes(result.sizeBytes)}, ${formatDuration(result.duration)})`)}`);
1364
+ });
1365
+ downloader.on("complete", (results) => {
1366
+ process.stderr.write("\n");
1367
+ for (const r of results) console.log(` ${pc.green("Saved:")} ${basename(r.filePath)} ${pc.dim(`(${formatBytes(r.sizeBytes)}, ${formatDuration(r.duration)})`)}`);
1368
+ const totalMB = results.reduce((sum, r) => sum + r.sizeMB, 0);
1369
+ console.log(` Done — ${results.length} segment(s), ${totalMB.toFixed(1)}MB total`);
1370
+ if (results.length > 0) console.log(` ${pc.dim(`Output: ${dirname(results[0]?.filePath ?? "")}/`)}`);
1371
+ });
1372
+ console.log(`${pc.dim("Waiting for ")}${pc.blue(username)}${pc.dim(" to go live...")}`);
1373
+ while (true) {
1374
+ if (stopped) break;
1375
+ try {
1376
+ await downloader.start();
1377
+ if (stopped) break;
1378
+ console.log(`\n ${pc.dim("Stream ended, watching for next...")}`);
1379
+ } catch (error) {
1380
+ if (stopped) break;
1381
+ if (error instanceof UserNotFoundError) {
1382
+ console.error(pc.red("[error]"), "User not found. Check the username and try again.");
1383
+ process.exit(1);
1384
+ }
1385
+ console.error(`\n ${pc.yellow(`[warning] ${error}`)}`);
1386
+ await new Promise((r) => setTimeout(r, 1e4));
1387
+ console.log(` ${pc.dim("Retrying...")}`);
1388
+ }
1389
+ }
1390
+ }
1391
+ //#endregion
1392
+ //#region src/cli/utils/errors.ts
1393
+ /**
1394
+ * Base error for all CLI-level failures.
1395
+ * Separate from tokwatchr's own error classes.
1396
+ */
1397
+ var CliError = class extends Error {
1398
+ constructor(message, options) {
1399
+ super(message, options);
1400
+ this.name = "CliError";
1401
+ }
1402
+ };
1403
+ /**
1404
+ * Render a caught error to stderr and exit with a non-zero code.
1405
+ * Tokwatchr errors are rendered with their own messages; unexpected
1406
+ * errors include the stack trace in debug mode.
1407
+ */
1408
+ function handleFatalError(error) {
1409
+ if (error instanceof CliError) {
1410
+ console.error(pc.red("[error]"), error.message);
1411
+ process.exit(1);
1412
+ }
1413
+ if (error instanceof Error) switch (error.name) {
1414
+ case "UserNotFoundError":
1415
+ console.error(pc.red("[error]"), "User not found. Check the username and try again.");
1416
+ break;
1417
+ case "UserOfflineError":
1418
+ console.error(pc.red("[error]"), error.message);
1419
+ break;
1420
+ case "RoomResolveError":
1421
+ console.error(pc.red("[error]"), "Could not find the user's livestream room.");
1422
+ break;
1423
+ case "StreamFetchError":
1424
+ console.error(pc.red("[error]"), "Could not fetch stream info. Check the username and try again.");
1425
+ break;
1426
+ case "DownloadFailedError":
1427
+ console.error(pc.red("[error]"), `Download failed: ${error.message}`);
1428
+ break;
1429
+ case "FfmpegError":
1430
+ console.error(pc.red("[error]"), error.message);
1431
+ break;
1432
+ case "AbortError":
1433
+ console.error(pc.blue("[info]"), "Aborted.");
1434
+ break;
1435
+ default:
1436
+ console.error(pc.red("[error]"), `Unexpected error: ${error.message}`);
1437
+ if (process.env.DEBUG) console.error(error.stack);
1438
+ break;
1439
+ }
1440
+ else console.error(pc.red("[error]"), String(error));
1441
+ process.exit(1);
1442
+ }
1443
+ //#endregion
1444
+ //#region src/cli/index.ts
1445
+ const cli = cac("tokwatchr");
1446
+ cli.command("download <username>", "Download a TikTok livestream (user must be live)").option("-o, --output <dir>", "Output directory [cwd]").option("-q, --quality <quality>", "Quality: best|worst|fullhd1|hd1|sd2|sd1 [best]").option("-f, --format <format>", "Format: mp4|mkv|ts|flv [mp4]").option("--proxy <url>", "HTTP/SOCKS proxy URL").option("--no-ffmpeg", "Skip ffmpeg processing (output .flv)").example("tokwatchr download officialgeilegisela").example("tokwatchr download tv_asahi_news -o ./vods -q hd1").example("tokwatchr download username --proxy socks5://localhost:1080").action(async (username, options) => {
1447
+ try {
1448
+ await executeDownload(username, options);
1449
+ } catch (error) {
1450
+ handleFatalError(error);
1451
+ }
1452
+ });
1453
+ cli.command("watch <username>", "Wait for a user to go live, then start recording").option("-o, --output <dir>", "Output directory [cwd]").option("-q, --quality <quality>", "Quality: best|worst|fullhd1|hd1|sd2|sd1 [best]").option("-f, --format <format>", "Format: mp4|mkv|ts|flv [mp4]").option("-d, --max-duration <minutes>", "Max recording duration in minutes [no limit]").option("-s, --segment-duration <minutes>", "Split into N-minute segments [20]").option("-i, --interval <minutes>", "Poll interval in minutes [3]").option("--proxy <url>", "HTTP/SOCKS proxy URL").option("--no-ffmpeg", "Skip ffmpeg processing (output .flv)").example("tokwatchr watch username").example("tokwatchr watch username -s 10 -d 120").example("tokwatchr watch username -i 1").action(async (username, options) => {
1454
+ try {
1455
+ await executeWatch(username, options);
1456
+ } catch (error) {
1457
+ handleFatalError(error);
1458
+ }
1459
+ });
1460
+ cli.help();
1461
+ cli.version(version);
1462
+ cli.parse();
1463
+ //#endregion
1464
+ export {};