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.
- package/dist/cli/index.mjs +1464 -0
- package/package.json +15 -3
- package/src/cli/commands/download.ts +119 -0
- package/src/cli/commands/watch.ts +143 -0
- package/src/cli/index.ts +83 -0
- package/src/cli/types.ts +26 -0
- package/src/cli/utils/errors.ts +86 -0
- package/src/cli/utils/format.ts +49 -0
|
@@ -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 {};
|