tokwatchr 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +368 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +1146 -0
- package/package.json +55 -0
- package/src/TikTokLiveDownloader.ts +794 -0
- package/src/api/client.ts +46 -0
- package/src/api/room.ts +193 -0
- package/src/api/stream.ts +76 -0
- package/src/download/ffmpeg.ts +303 -0
- package/src/download/raw-http.ts +150 -0
- package/src/errors.ts +58 -0
- package/src/index.ts +45 -0
- package/src/types.ts +155 -0
- package/src/utils/quality.ts +171 -0
- package/src/utils/template.ts +67 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { Impit } from "impit";
|
|
7
|
+
import { createClient } from "./api/client.js";
|
|
8
|
+
import { resolveRoomId } from "./api/room.js";
|
|
9
|
+
import { fetchStreamInfo } from "./api/stream.js";
|
|
10
|
+
import { downloadWithFfmpeg } from "./download/ffmpeg.js";
|
|
11
|
+
import { downloadRawHttp } from "./download/raw-http.js";
|
|
12
|
+
import { UserNotFoundError, UserOfflineError } from "./errors.js";
|
|
13
|
+
import type {
|
|
14
|
+
DownloaderState,
|
|
15
|
+
DownloadResult,
|
|
16
|
+
DownloadStats,
|
|
17
|
+
ResolvedOptions,
|
|
18
|
+
StreamInfo,
|
|
19
|
+
TikTokLiveDownloaderEvents,
|
|
20
|
+
TikTokLiveDownloaderOptions,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
import { renderFilename } from "./utils/template.js";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_OPTIONS: ResolvedOptions = {
|
|
25
|
+
output: ".",
|
|
26
|
+
filename: "{username}={date}_{time}",
|
|
27
|
+
quality: "best",
|
|
28
|
+
format: "mp4",
|
|
29
|
+
useFfmpeg: false,
|
|
30
|
+
ffmpegPath: null,
|
|
31
|
+
ffmpegArgs: [],
|
|
32
|
+
bitrate: null,
|
|
33
|
+
maxDuration: Infinity,
|
|
34
|
+
maxSegmentDuration: Infinity,
|
|
35
|
+
checkInterval: 30_000,
|
|
36
|
+
proxyUrl: null,
|
|
37
|
+
cookieJar: null,
|
|
38
|
+
browser: "chrome",
|
|
39
|
+
timeout: 30_000,
|
|
40
|
+
headers: {},
|
|
41
|
+
onProgress: null,
|
|
42
|
+
onStart: null,
|
|
43
|
+
onError: null,
|
|
44
|
+
signal: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Main class for downloading TikTok livestreams.
|
|
49
|
+
*
|
|
50
|
+
* Usage:
|
|
51
|
+
* ```ts
|
|
52
|
+
* const d = new TikTokLiveDownloader('username')
|
|
53
|
+
* d.on('progress', s => console.log(`${s.downloadedMB}MB`))
|
|
54
|
+
* const result = await d.start()
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class TikTokLiveDownloader {
|
|
58
|
+
private readonly username: string;
|
|
59
|
+
private readonly options: ResolvedOptions;
|
|
60
|
+
private readonly emitter: EventEmitter;
|
|
61
|
+
private readonly impIt: Impit;
|
|
62
|
+
|
|
63
|
+
private _state: DownloaderState = "idle";
|
|
64
|
+
private abortController: AbortController;
|
|
65
|
+
private _stats: DownloadStats | null = null;
|
|
66
|
+
private _result: DownloadResult | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(username: string, opts: TikTokLiveDownloaderOptions = {}) {
|
|
69
|
+
this.username = username.replace(/^@/, "").trim();
|
|
70
|
+
this.emitter = new EventEmitter();
|
|
71
|
+
this.abortController = new AbortController();
|
|
72
|
+
this.options = this.resolveOptions(opts);
|
|
73
|
+
this.impIt = createClient({
|
|
74
|
+
browser: this.options.browser,
|
|
75
|
+
proxyUrl: this.options.proxyUrl,
|
|
76
|
+
timeout: this.options.timeout,
|
|
77
|
+
headers: this.options.headers,
|
|
78
|
+
cookieJar: this.options.cookieJar,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Wire external AbortSignal into our internal one
|
|
82
|
+
if (this.options.signal) {
|
|
83
|
+
this.options.signal.addEventListener(
|
|
84
|
+
"abort",
|
|
85
|
+
() => this.abortController.abort(),
|
|
86
|
+
{ once: true },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Public event API ──────────────────────────────────
|
|
92
|
+
|
|
93
|
+
on<E extends keyof TikTokLiveDownloaderEvents>(
|
|
94
|
+
event: E,
|
|
95
|
+
listener: (...args: TikTokLiveDownloaderEvents[E]) => void,
|
|
96
|
+
): this {
|
|
97
|
+
this.emitter.on(event, listener as (...args: unknown[]) => void);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
once<E extends keyof TikTokLiveDownloaderEvents>(
|
|
102
|
+
event: E,
|
|
103
|
+
listener: (...args: TikTokLiveDownloaderEvents[E]) => void,
|
|
104
|
+
): this {
|
|
105
|
+
this.emitter.once(event, listener as (...args: unknown[]) => void);
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
off<E extends keyof TikTokLiveDownloaderEvents>(
|
|
110
|
+
event: E,
|
|
111
|
+
listener: (...args: TikTokLiveDownloaderEvents[E]) => void,
|
|
112
|
+
): this {
|
|
113
|
+
this.emitter.off(event, listener as (...args: unknown[]) => void);
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private emit<E extends keyof TikTokLiveDownloaderEvents>(
|
|
118
|
+
event: E,
|
|
119
|
+
...args: TikTokLiveDownloaderEvents[E]
|
|
120
|
+
): void {
|
|
121
|
+
this.emitter.emit(event, ...args);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── State accessors ───────────────────────────────────
|
|
125
|
+
|
|
126
|
+
get state(): DownloaderState {
|
|
127
|
+
return this._state;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get stats(): DownloadStats | null {
|
|
131
|
+
return this._stats;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get result(): DownloadResult | null {
|
|
135
|
+
return this._result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Lifecycle ─────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Start recording. If the user is not currently live, polls
|
|
142
|
+
* until they go live and then starts recording.
|
|
143
|
+
*
|
|
144
|
+
* Resolves when the stream ends or maxDuration is reached.
|
|
145
|
+
*/
|
|
146
|
+
async start(): Promise<DownloadResult> {
|
|
147
|
+
return this._run(true);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Start recording immediately. Throws if the user is not live.
|
|
152
|
+
*/
|
|
153
|
+
async startRecording(): Promise<DownloadResult> {
|
|
154
|
+
return this._run(false);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wait until the user goes live (does not record).
|
|
159
|
+
*/
|
|
160
|
+
async waitForLive(): Promise<StreamInfo> {
|
|
161
|
+
this.setState("waiting");
|
|
162
|
+
const roomId = await this.resolveRoomIdWithRetry();
|
|
163
|
+
const info = await this.fetchStreamInfo(roomId);
|
|
164
|
+
this.setState("idle");
|
|
165
|
+
return info;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Gracefully stop the recording.
|
|
170
|
+
*/
|
|
171
|
+
async stop(): Promise<void> {
|
|
172
|
+
if (this._state !== "recording" && this._state !== "waiting") {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.setState("stopping");
|
|
176
|
+
this.abortController.abort();
|
|
177
|
+
// Wait for state to transition to done, with timeout
|
|
178
|
+
await new Promise<void>((resolve) => {
|
|
179
|
+
let settled = false;
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
settled = true;
|
|
182
|
+
resolve();
|
|
183
|
+
}, 5_000);
|
|
184
|
+
const check = () => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
if (this._state === "done") {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
resolve();
|
|
189
|
+
} else {
|
|
190
|
+
setTimeout(check, 100);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
check();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Immediately abort the recording.
|
|
199
|
+
*/
|
|
200
|
+
abort(): void {
|
|
201
|
+
this.abortController.abort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Internal ─────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
private setState(state: DownloaderState): void {
|
|
207
|
+
this._state = state;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private resolveOptions(opts: TikTokLiveDownloaderOptions): ResolvedOptions {
|
|
211
|
+
const resolved: ResolvedOptions = {
|
|
212
|
+
...DEFAULT_OPTIONS,
|
|
213
|
+
...Object.fromEntries(
|
|
214
|
+
Object.entries(opts).filter(([, v]) => v !== undefined),
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Auto-detect ffmpeg when not explicitly configured
|
|
219
|
+
if (opts.useFfmpeg === undefined && opts.ffmpegPath === undefined) {
|
|
220
|
+
const systemPath = detectSystemFfmpeg();
|
|
221
|
+
if (systemPath) {
|
|
222
|
+
resolved.useFfmpeg = true;
|
|
223
|
+
resolved.ffmpegPath = systemPath;
|
|
224
|
+
} else {
|
|
225
|
+
resolved.useFfmpeg = false;
|
|
226
|
+
}
|
|
227
|
+
} else if (opts.useFfmpeg !== false) {
|
|
228
|
+
resolved.useFfmpeg = true;
|
|
229
|
+
resolved.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Determine output format
|
|
233
|
+
if (resolved.format === "mp4" || resolved.format === "mkv") {
|
|
234
|
+
if (!resolved.useFfmpeg) {
|
|
235
|
+
resolved.format = "flv";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return resolved;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async _run(waitForLive: boolean): Promise<DownloadResult> {
|
|
243
|
+
// Track background remuxes so we can await them on error/abort
|
|
244
|
+
const pendingRemuxes: Promise<DownloadResult>[] = [];
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// Phase 1: Resolve room
|
|
248
|
+
this.setState("waiting");
|
|
249
|
+
let roomId = await this.resolveRoomIdOnce();
|
|
250
|
+
|
|
251
|
+
// If offline and waitForLive, poll
|
|
252
|
+
if (!roomId && waitForLive) {
|
|
253
|
+
roomId = await this.resolveRoomIdWithRetry();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!roomId) {
|
|
257
|
+
throw new UserOfflineError(this.username);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Phase 2: Get stream info (refreshed per segment to avoid stale URLs)
|
|
261
|
+
this.setState("waiting");
|
|
262
|
+
const firstInfo = await this.fetchStreamInfo(roomId);
|
|
263
|
+
|
|
264
|
+
const segmentEnabled =
|
|
265
|
+
this.options.maxSegmentDuration > 0 &&
|
|
266
|
+
this.options.maxSegmentDuration < Infinity;
|
|
267
|
+
|
|
268
|
+
if (!segmentEnabled) {
|
|
269
|
+
// Single segment — download .ts then remux
|
|
270
|
+
const tsResult = await this.downloadSegment(firstInfo, 1);
|
|
271
|
+
const finalResult = await this.remuxSegment(tsResult);
|
|
272
|
+
this._result = finalResult;
|
|
273
|
+
this.setState("done");
|
|
274
|
+
this.emit("complete", [finalResult]);
|
|
275
|
+
return finalResult;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Phase 3: Segmented download loop (non-blocking remux)
|
|
279
|
+
let info = firstInfo;
|
|
280
|
+
let partNum = 1;
|
|
281
|
+
|
|
282
|
+
while (true) {
|
|
283
|
+
this.abortController.signal?.throwIfAborted();
|
|
284
|
+
|
|
285
|
+
this._state = "waiting";
|
|
286
|
+
const tsResult = await this.downloadSegment(info, partNum);
|
|
287
|
+
|
|
288
|
+
// Fire remux in background — doesn't block next segment
|
|
289
|
+
const remuxPromise = this.remuxSegment(tsResult);
|
|
290
|
+
pendingRemuxes.push(remuxPromise);
|
|
291
|
+
|
|
292
|
+
this._result = tsResult;
|
|
293
|
+
this.emit("segment", tsResult, partNum);
|
|
294
|
+
|
|
295
|
+
partNum++;
|
|
296
|
+
|
|
297
|
+
// Check if the stream is still live — refresh room info
|
|
298
|
+
await sleep(1_000);
|
|
299
|
+
try {
|
|
300
|
+
const freshRoomId = await this.resolveRoomIdOnce();
|
|
301
|
+
if (!freshRoomId) break;
|
|
302
|
+
|
|
303
|
+
info = await this.fetchStreamInfo(freshRoomId);
|
|
304
|
+
} catch {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// All segments downloaded — wait for remuxes to finish
|
|
310
|
+
const finalResults = await Promise.all(pendingRemuxes);
|
|
311
|
+
this._result = finalResults[finalResults.length - 1] ?? null;
|
|
312
|
+
this.setState("done");
|
|
313
|
+
this.emit("complete", finalResults);
|
|
314
|
+
// biome-ignore lint/style/noNonNullAssertion: at least one result
|
|
315
|
+
return finalResults[finalResults.length - 1]!;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
// Await any background remuxes before shutting down,
|
|
318
|
+
// so they can finish producing .mp4 and delete temp .ts files.
|
|
319
|
+
if (pendingRemuxes.length > 0) {
|
|
320
|
+
await Promise.allSettled(pendingRemuxes);
|
|
321
|
+
}
|
|
322
|
+
this.setState("done");
|
|
323
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
324
|
+
this.emit("error", error);
|
|
325
|
+
this.options.onError?.(error);
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async resolveRoomIdOnce(): Promise<string | null> {
|
|
331
|
+
try {
|
|
332
|
+
return await resolveRoomId(this.username, this.impIt, {
|
|
333
|
+
signal: this.abortController.signal,
|
|
334
|
+
});
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// User-not-found should fail fast, not retry
|
|
337
|
+
if (error instanceof UserNotFoundError) {
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check if the user is offline (room/info will tell us)
|
|
342
|
+
try {
|
|
343
|
+
// Try fetching room info directly to see if user is live
|
|
344
|
+
const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(this.username)}&sourceType=54`;
|
|
345
|
+
const resp = await this.impIt.fetch(url, {
|
|
346
|
+
signal: this.abortController.signal,
|
|
347
|
+
});
|
|
348
|
+
if (resp.ok) {
|
|
349
|
+
const data = (await resp.json()) as Record<string, unknown>;
|
|
350
|
+
const dataObj = data.data as Record<string, unknown> | undefined;
|
|
351
|
+
const user = dataObj?.user as Record<string, unknown> | undefined;
|
|
352
|
+
const roomId = user?.roomId;
|
|
353
|
+
if (roomId) return String(roomId);
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
// ignore
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private async resolveRoomIdWithRetry(): Promise<string> {
|
|
363
|
+
const interval = this.options.checkInterval;
|
|
364
|
+
const maxInterval = Math.max(interval, 30_000);
|
|
365
|
+
|
|
366
|
+
while (true) {
|
|
367
|
+
this.abortController.signal?.throwIfAborted();
|
|
368
|
+
|
|
369
|
+
const roomId = await this.resolveRoomIdOnce();
|
|
370
|
+
if (roomId) {
|
|
371
|
+
return roomId;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await sleep(maxInterval);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async fetchStreamInfo(roomId: string): Promise<StreamInfo> {
|
|
379
|
+
const info = await fetchStreamInfo(roomId, this.username, this.impIt, {
|
|
380
|
+
quality: this.options.quality,
|
|
381
|
+
signal: this.abortController.signal,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
this.emit("start", info);
|
|
385
|
+
this.options.onStart?.(info);
|
|
386
|
+
|
|
387
|
+
return info;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Download a single segment. Always downloads to .ts (crash-safe).
|
|
392
|
+
* Does NOT remux — that is handled by remuxSegment() running in the background.
|
|
393
|
+
*/
|
|
394
|
+
private async downloadSegment(
|
|
395
|
+
info: StreamInfo,
|
|
396
|
+
partNumber?: number,
|
|
397
|
+
): Promise<DownloadResult> {
|
|
398
|
+
const startTime = new Date();
|
|
399
|
+
const quality = info.selectedQuality;
|
|
400
|
+
const segmentMaxDuration =
|
|
401
|
+
this.options.maxSegmentDuration > 0 &&
|
|
402
|
+
this.options.maxSegmentDuration < Infinity
|
|
403
|
+
? this.options.maxSegmentDuration
|
|
404
|
+
: this.options.maxDuration;
|
|
405
|
+
|
|
406
|
+
const outputDir = this.options.output;
|
|
407
|
+
if (!existsSync(outputDir)) {
|
|
408
|
+
mkdirSync(outputDir, { recursive: true });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const fileBase = renderFilename(this.options.filename, {
|
|
412
|
+
username: this.username,
|
|
413
|
+
title: info.title,
|
|
414
|
+
part: partNumber,
|
|
415
|
+
});
|
|
416
|
+
const fileName =
|
|
417
|
+
partNumber && !this.options.filename.includes("{part}")
|
|
418
|
+
? `${fileBase}_part${partNumber}`
|
|
419
|
+
: fileBase;
|
|
420
|
+
|
|
421
|
+
this.emit("progress", {
|
|
422
|
+
downloadedBytes: 0,
|
|
423
|
+
downloadedMB: 0,
|
|
424
|
+
duration: 0,
|
|
425
|
+
speed: 0,
|
|
426
|
+
speedMBps: 0,
|
|
427
|
+
quality: quality.key,
|
|
428
|
+
state: "recording",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
this.setState("recording");
|
|
432
|
+
|
|
433
|
+
const onProgress = (stats: DownloadStats) => {
|
|
434
|
+
this._stats = stats;
|
|
435
|
+
this.emit("progress", stats);
|
|
436
|
+
this.options.onProgress?.(stats);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
if (this.options.useFfmpeg && this.options.ffmpegPath) {
|
|
440
|
+
const tsPath = join(outputDir, `${fileName}.ts`);
|
|
441
|
+
|
|
442
|
+
const result = await downloadWithFfmpeg({
|
|
443
|
+
ffmpegPath: this.options.ffmpegPath,
|
|
444
|
+
url: info.streamUrl,
|
|
445
|
+
outputPath: tsPath,
|
|
446
|
+
quality: quality.key,
|
|
447
|
+
signal: this.abortController.signal,
|
|
448
|
+
onProgress,
|
|
449
|
+
maxDuration:
|
|
450
|
+
segmentMaxDuration < Infinity ? segmentMaxDuration : undefined,
|
|
451
|
+
timeout: this.options.timeout,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
filePath: tsPath,
|
|
456
|
+
sizeBytes: result.sizeBytes,
|
|
457
|
+
sizeMB: result.sizeBytes / (1024 * 1024),
|
|
458
|
+
duration: result.duration,
|
|
459
|
+
username: this.username,
|
|
460
|
+
roomId: info.roomId,
|
|
461
|
+
quality: quality.key,
|
|
462
|
+
format: "ts" as const,
|
|
463
|
+
startedAt: startTime,
|
|
464
|
+
endedAt: new Date(),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Raw HTTP path — write directly to .flv
|
|
469
|
+
const outputPath = join(outputDir, `${fileName}.flv`);
|
|
470
|
+
const result = await downloadRawHttp({
|
|
471
|
+
url: info.streamUrl,
|
|
472
|
+
outputPath,
|
|
473
|
+
quality: quality.key,
|
|
474
|
+
signal: this.abortController.signal,
|
|
475
|
+
onProgress,
|
|
476
|
+
maxDuration:
|
|
477
|
+
segmentMaxDuration < Infinity ? segmentMaxDuration : undefined,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
filePath: outputPath,
|
|
482
|
+
sizeBytes: result.sizeBytes,
|
|
483
|
+
sizeMB: result.sizeBytes / (1024 * 1024),
|
|
484
|
+
duration: result.duration,
|
|
485
|
+
username: this.username,
|
|
486
|
+
roomId: info.roomId,
|
|
487
|
+
quality: quality.key,
|
|
488
|
+
format: "flv" as const,
|
|
489
|
+
startedAt: startTime,
|
|
490
|
+
endedAt: new Date(),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Remux a downloaded .ts segment to the target format with audio normalization.
|
|
496
|
+
* Runs in background, does not block the download loop.
|
|
497
|
+
*
|
|
498
|
+
* If remux fails, keeps the .ts as a playable fallback.
|
|
499
|
+
*/
|
|
500
|
+
private async remuxSegment(
|
|
501
|
+
tsResult: DownloadResult,
|
|
502
|
+
): Promise<DownloadResult> {
|
|
503
|
+
// Can only remux ffmpeg-downloaded .ts files
|
|
504
|
+
if (
|
|
505
|
+
!this.options.useFfmpeg ||
|
|
506
|
+
!this.options.ffmpegPath ||
|
|
507
|
+
tsResult.format !== "ts"
|
|
508
|
+
) {
|
|
509
|
+
return tsResult;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const targetFormat = this.options.format;
|
|
513
|
+
const inputPath = tsResult.filePath;
|
|
514
|
+
|
|
515
|
+
// If target is .ts, nothing to do
|
|
516
|
+
if (targetFormat === "ts") return tsResult;
|
|
517
|
+
|
|
518
|
+
const finalExt = targetFormat === "mkv" ? "mkv" : "mp4";
|
|
519
|
+
const finalPath = inputPath.replace(/\.ts$/, `.${finalExt}`);
|
|
520
|
+
|
|
521
|
+
if (finalPath === inputPath) return tsResult;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
await this.remuxAndNormalize(
|
|
525
|
+
this.options.ffmpegPath,
|
|
526
|
+
inputPath,
|
|
527
|
+
finalPath,
|
|
528
|
+
);
|
|
529
|
+
// Remux succeeded — delete temp .ts
|
|
530
|
+
try {
|
|
531
|
+
const { unlinkSync } = await import("node:fs");
|
|
532
|
+
unlinkSync(inputPath);
|
|
533
|
+
} catch {
|
|
534
|
+
// ignore cleanup failure
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
...tsResult,
|
|
538
|
+
filePath: finalPath,
|
|
539
|
+
format: finalExt as "mp4" | "mkv",
|
|
540
|
+
};
|
|
541
|
+
} catch (err) {
|
|
542
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
543
|
+
console.warn(
|
|
544
|
+
`tokwatchr: remux failed for ${inputPath}, keeping .ts as fallback: ${msg}`,
|
|
545
|
+
);
|
|
546
|
+
return tsResult;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Remux a .ts file to the target container with EBU R128 audio normalization.
|
|
552
|
+
*
|
|
553
|
+
* Two-pass loudnorm (equivalent to `ffmpeg-normalize --preset streaming-video -c:a aac`):
|
|
554
|
+
* 1. Measure integrated loudness, LRA, true peak
|
|
555
|
+
* 2. Apply linear normalization + encode AAC + copy video
|
|
556
|
+
*
|
|
557
|
+
* If the measurement pass fails (short file, edge case), falls back to
|
|
558
|
+
* plain AAC encode without loudnorm.
|
|
559
|
+
*/
|
|
560
|
+
private async remuxAndNormalize(
|
|
561
|
+
ffmpegPath: string,
|
|
562
|
+
inputPath: string,
|
|
563
|
+
outputPath: string,
|
|
564
|
+
): Promise<void> {
|
|
565
|
+
const measured = await this.measureLoudness(ffmpegPath, inputPath);
|
|
566
|
+
|
|
567
|
+
const args = ["-hide_banner", "-y", "-i", inputPath, "-c:v", "copy"];
|
|
568
|
+
|
|
569
|
+
if (measured) {
|
|
570
|
+
args.push(
|
|
571
|
+
"-af",
|
|
572
|
+
[
|
|
573
|
+
"loudnorm=I=-14",
|
|
574
|
+
"LRA=7",
|
|
575
|
+
"TP=-2",
|
|
576
|
+
"linear=true",
|
|
577
|
+
`measured_I=${measured.inputI}`,
|
|
578
|
+
`measured_LRA=${measured.inputLra}`,
|
|
579
|
+
`measured_TP=${measured.inputTp}`,
|
|
580
|
+
`measured_thresh=${measured.inputThresh}`,
|
|
581
|
+
`offset=${measured.offset}`,
|
|
582
|
+
].join(":"),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
args.push("-c:a", "aac", "-b:a", "128k");
|
|
587
|
+
args.push(outputPath);
|
|
588
|
+
|
|
589
|
+
return new Promise<void>((resolve, reject) => {
|
|
590
|
+
const stderrChunks: Buffer[] = [];
|
|
591
|
+
const proc = spawn(ffmpegPath, args, {
|
|
592
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
593
|
+
timeout: 600_000,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
597
|
+
stderrChunks.push(chunk);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
proc.on("error", (err) => reject(err));
|
|
601
|
+
proc.on("close", (code) => {
|
|
602
|
+
if (code === 0) {
|
|
603
|
+
resolve();
|
|
604
|
+
} else {
|
|
605
|
+
const stderrOutput = Buffer.concat(stderrChunks).toString("utf-8");
|
|
606
|
+
const tail =
|
|
607
|
+
stderrOutput.length > 1500
|
|
608
|
+
? `...${stderrOutput.slice(-1500)}`
|
|
609
|
+
: stderrOutput;
|
|
610
|
+
const msg = tail
|
|
611
|
+
? `Remux exited with code ${code}: ${tail}`
|
|
612
|
+
: `Remux exited with code ${code}`;
|
|
613
|
+
reject(new Error(msg));
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Measure loudness of a .ts file using ffmpeg's loudnorm filter.
|
|
621
|
+
*
|
|
622
|
+
* Runs `loudnorm` with `print_format=json` and parses the JSON
|
|
623
|
+
* output from stderr. Returns the measured values for use in
|
|
624
|
+
* the second pass, or null if measurement failed.
|
|
625
|
+
*/
|
|
626
|
+
private async measureLoudness(
|
|
627
|
+
ffmpegPath: string,
|
|
628
|
+
inputPath: string,
|
|
629
|
+
): Promise<{
|
|
630
|
+
inputI: string;
|
|
631
|
+
inputLra: string;
|
|
632
|
+
inputTp: string;
|
|
633
|
+
inputThresh: string;
|
|
634
|
+
offset: string;
|
|
635
|
+
} | null> {
|
|
636
|
+
return new Promise((resolve, reject) => {
|
|
637
|
+
const proc = spawn(
|
|
638
|
+
ffmpegPath,
|
|
639
|
+
[
|
|
640
|
+
"-hide_banner",
|
|
641
|
+
"-y",
|
|
642
|
+
"-i",
|
|
643
|
+
inputPath,
|
|
644
|
+
"-af",
|
|
645
|
+
"loudnorm=I=-14:LRA=7:TP=-2:print_format=json",
|
|
646
|
+
"-f",
|
|
647
|
+
"null",
|
|
648
|
+
"-",
|
|
649
|
+
],
|
|
650
|
+
{
|
|
651
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
652
|
+
timeout: 300_000, // 5 min to measure a 20min segment
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
let stderr = "";
|
|
657
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
658
|
+
stderr += chunk.toString("utf-8");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
proc.on("error", (err) => reject(err));
|
|
662
|
+
proc.on("close", (code) => {
|
|
663
|
+
if (code !== 0) {
|
|
664
|
+
resolve(null);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
const parsed = parseLoudnormJson(stderr);
|
|
670
|
+
resolve(parsed);
|
|
671
|
+
} catch {
|
|
672
|
+
resolve(null);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Parse the JSON output from ffmpeg's loudnorm print_format=json.
|
|
681
|
+
*
|
|
682
|
+
* Handles two output styles:
|
|
683
|
+
* Multi-line:
|
|
684
|
+
* [Parsed_loudnorm_0 @ 0x...]
|
|
685
|
+
* { "input_i": "-23.5", ... }
|
|
686
|
+
* Single-line:
|
|
687
|
+
* [Parsed_loudnorm_0 @ 0x...] { "input_i": "-23.5", ... }
|
|
688
|
+
*/
|
|
689
|
+
function parseLoudnormJson(stderr: string): {
|
|
690
|
+
inputI: string;
|
|
691
|
+
inputLra: string;
|
|
692
|
+
inputTp: string;
|
|
693
|
+
inputThresh: string;
|
|
694
|
+
offset: string;
|
|
695
|
+
} | null {
|
|
696
|
+
try {
|
|
697
|
+
// Find the first `{...}` JSON object in stderr using a simple depth counter
|
|
698
|
+
let start = -1;
|
|
699
|
+
let depth = 0;
|
|
700
|
+
|
|
701
|
+
for (let i = 0; i < stderr.length; i++) {
|
|
702
|
+
const ch = stderr[i];
|
|
703
|
+
if (ch === "{") {
|
|
704
|
+
if (start === -1) {
|
|
705
|
+
start = i;
|
|
706
|
+
}
|
|
707
|
+
depth++;
|
|
708
|
+
} else if (ch === "}") {
|
|
709
|
+
depth--;
|
|
710
|
+
if (depth === 0 && start !== -1) {
|
|
711
|
+
const jsonStr = stderr.slice(start, i + 1);
|
|
712
|
+
const data = JSON.parse(jsonStr);
|
|
713
|
+
|
|
714
|
+
if (
|
|
715
|
+
typeof data.input_i === "string" &&
|
|
716
|
+
typeof data.input_lra === "string" &&
|
|
717
|
+
typeof data.input_tp === "string" &&
|
|
718
|
+
typeof data.input_thresh === "string" &&
|
|
719
|
+
typeof data.target_offset === "string"
|
|
720
|
+
) {
|
|
721
|
+
return {
|
|
722
|
+
inputI: data.input_i,
|
|
723
|
+
inputLra: data.input_lra,
|
|
724
|
+
inputTp: data.input_tp,
|
|
725
|
+
inputThresh: data.input_thresh,
|
|
726
|
+
offset: data.target_offset,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
// JSON parse failure
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Detect whether `ffmpeg` is available on the system PATH.
|
|
742
|
+
*
|
|
743
|
+
* Returns "ffmpeg" (or the resolved path) if found, or null if not.
|
|
744
|
+
*/
|
|
745
|
+
function detectSystemFfmpeg(): string | null {
|
|
746
|
+
try {
|
|
747
|
+
const result = spawnSync("ffmpeg", ["-version"], {
|
|
748
|
+
stdio: "pipe",
|
|
749
|
+
timeout: 5000,
|
|
750
|
+
});
|
|
751
|
+
return result.status === 0 ? "ffmpeg" : null;
|
|
752
|
+
} catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function sleep(ms: number): Promise<void> {
|
|
758
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ─── Functional API ──────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
export interface DownloadFunctionOptions extends TikTokLiveDownloaderOptions {
|
|
764
|
+
/** Override the target username (defaults to the one passed to download()). */
|
|
765
|
+
username?: string;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Download a TikTok livestream.
|
|
770
|
+
*
|
|
771
|
+
* Simplest one-shot API. Resolves when the stream ends.
|
|
772
|
+
*
|
|
773
|
+
* @param username - TikTok username (with or without @).
|
|
774
|
+
* @param options - Download options and callbacks.
|
|
775
|
+
*/
|
|
776
|
+
export async function download(
|
|
777
|
+
username: string,
|
|
778
|
+
options: DownloadFunctionOptions = {},
|
|
779
|
+
): Promise<DownloadResult> {
|
|
780
|
+
const downloader = new TikTokLiveDownloader(username, options);
|
|
781
|
+
|
|
782
|
+
// Wire callbacks as events
|
|
783
|
+
if (options.onStart) {
|
|
784
|
+
downloader.on("start", options.onStart);
|
|
785
|
+
}
|
|
786
|
+
if (options.onProgress) {
|
|
787
|
+
downloader.on("progress", options.onProgress);
|
|
788
|
+
}
|
|
789
|
+
if (options.onError) {
|
|
790
|
+
downloader.on("error", options.onError);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return downloader.start();
|
|
794
|
+
}
|