tokwatchr 0.4.2 → 0.4.4
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/index.d.mts +6 -0
- package/dist/index.mjs +25 -20
- package/package.json +1 -1
- package/src/TikTokLiveDownloader.ts +34 -4
- package/src/download/ffmpeg.ts +8 -31
package/dist/index.d.mts
CHANGED
|
@@ -237,6 +237,12 @@ declare class TikTokLiveDownloader {
|
|
|
237
237
|
private resolveRoomIdOnce;
|
|
238
238
|
private resolveRoomIdWithRetry;
|
|
239
239
|
private fetchStreamInfo;
|
|
240
|
+
/**
|
|
241
|
+
* Poll for stream info until the stream becomes active.
|
|
242
|
+
* Used in waitForLive mode when the room exists but the stream
|
|
243
|
+
* has not yet started broadcasting.
|
|
244
|
+
*/
|
|
245
|
+
private pollStreamInfo;
|
|
240
246
|
/**
|
|
241
247
|
* Download a single segment. Always downloads to .ts (crash-safe).
|
|
242
248
|
* Does NOT remux — that is handled by remuxSegment() running in the background.
|
package/dist/index.mjs
CHANGED
|
@@ -391,21 +391,8 @@ async function downloadWithFfmpeg(options) {
|
|
|
391
391
|
let stderrBuffer = "";
|
|
392
392
|
let sizeBytes = 0;
|
|
393
393
|
let duration = 0;
|
|
394
|
-
const abortHandler = () => {
|
|
395
|
-
proc.kill("SIGTERM");
|
|
396
|
-
setTimeout(() => {
|
|
397
|
-
if (proc.exitCode === null) proc.kill("SIGKILL");
|
|
398
|
-
}, 15e3);
|
|
399
|
-
};
|
|
400
|
-
let aborted = false;
|
|
401
|
-
const onAbort = () => {
|
|
402
|
-
aborted = true;
|
|
403
|
-
abortHandler();
|
|
404
|
-
};
|
|
405
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
406
394
|
let firstDataTimer = setTimeout(() => {
|
|
407
395
|
firstDataTimer = null;
|
|
408
|
-
signal?.removeEventListener("abort", onAbort);
|
|
409
396
|
proc.kill("SIGTERM");
|
|
410
397
|
reject(new FfmpegError(`ffmpeg produced no output within ${timeout}ms — check the stream URL`));
|
|
411
398
|
}, timeout);
|
|
@@ -439,9 +426,7 @@ async function downloadWithFfmpeg(options) {
|
|
|
439
426
|
clearTimeout(firstDataTimer);
|
|
440
427
|
firstDataTimer = null;
|
|
441
428
|
}
|
|
442
|
-
signal?.removeEventListener("abort", onAbort);
|
|
443
429
|
if (err.name === "AbortError") {
|
|
444
|
-
aborted = true;
|
|
445
430
|
resolve({
|
|
446
431
|
sizeBytes,
|
|
447
432
|
duration,
|
|
@@ -456,8 +441,7 @@ async function downloadWithFfmpeg(options) {
|
|
|
456
441
|
clearTimeout(firstDataTimer);
|
|
457
442
|
firstDataTimer = null;
|
|
458
443
|
}
|
|
459
|
-
signal?.
|
|
460
|
-
if (aborted) {
|
|
444
|
+
if (signal?.aborted) {
|
|
461
445
|
resolve({
|
|
462
446
|
sizeBytes,
|
|
463
447
|
duration,
|
|
@@ -636,7 +620,7 @@ const DEFAULT_OPTIONS = {
|
|
|
636
620
|
bitrate: null,
|
|
637
621
|
maxDuration: Infinity,
|
|
638
622
|
maxSegmentDuration: Infinity,
|
|
639
|
-
checkInterval:
|
|
623
|
+
checkInterval: 18e4,
|
|
640
624
|
proxyUrl: null,
|
|
641
625
|
cookieJar: null,
|
|
642
626
|
browser: "chrome",
|
|
@@ -789,7 +773,7 @@ var TikTokLiveDownloader = class {
|
|
|
789
773
|
if (!roomId && waitForLive) roomId = await this.resolveRoomIdWithRetry();
|
|
790
774
|
if (!roomId) throw new UserOfflineError(this.username);
|
|
791
775
|
this.setState("waiting");
|
|
792
|
-
const firstInfo = await this.fetchStreamInfo(roomId);
|
|
776
|
+
const firstInfo = waitForLive ? await this.pollStreamInfo(roomId) : await this.fetchStreamInfo(roomId);
|
|
793
777
|
if (!(this.options.maxSegmentDuration > 0 && this.options.maxSegmentDuration < Infinity)) {
|
|
794
778
|
const tsResult = await this.downloadSegment(firstInfo, 1);
|
|
795
779
|
const finalResult = await this.remuxSegment(tsResult);
|
|
@@ -850,7 +834,7 @@ var TikTokLiveDownloader = class {
|
|
|
850
834
|
}
|
|
851
835
|
async resolveRoomIdWithRetry() {
|
|
852
836
|
const interval = this.options.checkInterval;
|
|
853
|
-
const maxInterval = Math.max(interval,
|
|
837
|
+
const maxInterval = Math.max(interval, 18e4);
|
|
854
838
|
while (true) {
|
|
855
839
|
this.abortController.signal?.throwIfAborted();
|
|
856
840
|
const roomId = await this.resolveRoomIdOnce();
|
|
@@ -868,6 +852,27 @@ var TikTokLiveDownloader = class {
|
|
|
868
852
|
return info;
|
|
869
853
|
}
|
|
870
854
|
/**
|
|
855
|
+
* Poll for stream info until the stream becomes active.
|
|
856
|
+
* Used in waitForLive mode when the room exists but the stream
|
|
857
|
+
* has not yet started broadcasting.
|
|
858
|
+
*/
|
|
859
|
+
async pollStreamInfo(roomId) {
|
|
860
|
+
const interval = this.options.checkInterval;
|
|
861
|
+
const maxInterval = Math.max(interval, 18e4);
|
|
862
|
+
while (true) {
|
|
863
|
+
this.abortController.signal?.throwIfAborted();
|
|
864
|
+
try {
|
|
865
|
+
return await this.fetchStreamInfo(roomId);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
if (err instanceof StreamFetchError) {
|
|
868
|
+
await sleep(maxInterval);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
throw err;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
871
876
|
* Download a single segment. Always downloads to .ts (crash-safe).
|
|
872
877
|
* Does NOT remux — that is handled by remuxSegment() running in the background.
|
|
873
878
|
*/
|
package/package.json
CHANGED
|
@@ -9,7 +9,11 @@ import { resolveRoomId } from "./api/room.js";
|
|
|
9
9
|
import { fetchStreamInfo } from "./api/stream.js";
|
|
10
10
|
import { downloadWithFfmpeg } from "./download/ffmpeg.js";
|
|
11
11
|
import { downloadRawHttp } from "./download/raw-http.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
StreamFetchError,
|
|
14
|
+
UserNotFoundError,
|
|
15
|
+
UserOfflineError,
|
|
16
|
+
} from "./errors.js";
|
|
13
17
|
import type {
|
|
14
18
|
DownloaderState,
|
|
15
19
|
DownloadResult,
|
|
@@ -32,7 +36,7 @@ const DEFAULT_OPTIONS: ResolvedOptions = {
|
|
|
32
36
|
bitrate: null,
|
|
33
37
|
maxDuration: Infinity,
|
|
34
38
|
maxSegmentDuration: Infinity,
|
|
35
|
-
checkInterval:
|
|
39
|
+
checkInterval: 180_000,
|
|
36
40
|
proxyUrl: null,
|
|
37
41
|
cookieJar: null,
|
|
38
42
|
browser: "chrome",
|
|
@@ -259,7 +263,9 @@ export class TikTokLiveDownloader {
|
|
|
259
263
|
|
|
260
264
|
// Phase 2: Get stream info (refreshed per segment to avoid stale URLs)
|
|
261
265
|
this.setState("waiting");
|
|
262
|
-
const firstInfo =
|
|
266
|
+
const firstInfo = waitForLive
|
|
267
|
+
? await this.pollStreamInfo(roomId)
|
|
268
|
+
: await this.fetchStreamInfo(roomId);
|
|
263
269
|
|
|
264
270
|
const segmentEnabled =
|
|
265
271
|
this.options.maxSegmentDuration > 0 &&
|
|
@@ -361,7 +367,7 @@ export class TikTokLiveDownloader {
|
|
|
361
367
|
|
|
362
368
|
private async resolveRoomIdWithRetry(): Promise<string> {
|
|
363
369
|
const interval = this.options.checkInterval;
|
|
364
|
-
const maxInterval = Math.max(interval,
|
|
370
|
+
const maxInterval = Math.max(interval, 180_000);
|
|
365
371
|
|
|
366
372
|
while (true) {
|
|
367
373
|
this.abortController.signal?.throwIfAborted();
|
|
@@ -387,6 +393,30 @@ export class TikTokLiveDownloader {
|
|
|
387
393
|
return info;
|
|
388
394
|
}
|
|
389
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Poll for stream info until the stream becomes active.
|
|
398
|
+
* Used in waitForLive mode when the room exists but the stream
|
|
399
|
+
* has not yet started broadcasting.
|
|
400
|
+
*/
|
|
401
|
+
private async pollStreamInfo(roomId: string): Promise<StreamInfo> {
|
|
402
|
+
const interval = this.options.checkInterval;
|
|
403
|
+
const maxInterval = Math.max(interval, 180_000);
|
|
404
|
+
|
|
405
|
+
while (true) {
|
|
406
|
+
this.abortController.signal?.throwIfAborted();
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
return await this.fetchStreamInfo(roomId);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (err instanceof StreamFetchError) {
|
|
412
|
+
await sleep(maxInterval);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
390
420
|
/**
|
|
391
421
|
* Download a single segment. Always downloads to .ts (crash-safe).
|
|
392
422
|
* Does NOT remux — that is handled by remuxSegment() running in the background.
|
package/src/download/ffmpeg.ts
CHANGED
|
@@ -122,35 +122,12 @@ export async function downloadWithFfmpeg(
|
|
|
122
122
|
let sizeBytes = 0;
|
|
123
123
|
let duration = 0;
|
|
124
124
|
|
|
125
|
-
const abortHandler = () => {
|
|
126
|
-
// SIGTERM tells ffmpeg to flush its output (moov atom, etc.)
|
|
127
|
-
// and exit cleanly. This produces a playable file even when
|
|
128
|
-
// stopped mid-recording.
|
|
129
|
-
proc.kill("SIGTERM");
|
|
130
|
-
// Safety net: if ffmpeg doesn't respond, force-kill after 15s.
|
|
131
|
-
// The close handler will still fire and resolve with partial data.
|
|
132
|
-
setTimeout(() => {
|
|
133
|
-
if (proc.exitCode === null) {
|
|
134
|
-
proc.kill("SIGKILL");
|
|
135
|
-
}
|
|
136
|
-
}, 15_000);
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
let aborted = false;
|
|
140
|
-
const onAbort = () => {
|
|
141
|
-
aborted = true;
|
|
142
|
-
abortHandler();
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
146
|
-
|
|
147
125
|
// Startup timeout — if ffmpeg produces no stderr output within
|
|
148
126
|
// `timeout` ms, it likely stalled on a bad URL. Kill it so the
|
|
149
127
|
// caller doesn't hang indefinitely.
|
|
150
128
|
let firstDataTimer: ReturnType<typeof setTimeout> | null = setTimeout(
|
|
151
129
|
() => {
|
|
152
130
|
firstDataTimer = null;
|
|
153
|
-
signal?.removeEventListener("abort", onAbort);
|
|
154
131
|
proc.kill("SIGTERM");
|
|
155
132
|
reject(
|
|
156
133
|
new FfmpegError(
|
|
@@ -200,13 +177,11 @@ export async function downloadWithFfmpeg(
|
|
|
200
177
|
clearTimeout(firstDataTimer);
|
|
201
178
|
firstDataTimer = null;
|
|
202
179
|
}
|
|
203
|
-
signal?.removeEventListener("abort", onAbort);
|
|
204
180
|
// spawn's signal option emits AbortError when the signal
|
|
205
181
|
// is already aborted at spawn time or fires mid-flight.
|
|
206
|
-
//
|
|
207
|
-
//
|
|
182
|
+
// Resolve with partial data instead of rejecting — the
|
|
183
|
+
// close handler will also fire and see signal?.aborted.
|
|
208
184
|
if (err.name === "AbortError") {
|
|
209
|
-
aborted = true;
|
|
210
185
|
resolve({
|
|
211
186
|
sizeBytes,
|
|
212
187
|
duration,
|
|
@@ -222,12 +197,14 @@ export async function downloadWithFfmpeg(
|
|
|
222
197
|
clearTimeout(firstDataTimer);
|
|
223
198
|
firstDataTimer = null;
|
|
224
199
|
}
|
|
225
|
-
signal?.removeEventListener("abort", onAbort);
|
|
226
200
|
|
|
227
201
|
// When the user aborted, resolve with whatever we got.
|
|
228
|
-
//
|
|
229
|
-
// its output and exit cleanly
|
|
230
|
-
|
|
202
|
+
// spawn's signal option sends SIGTERM, which tells ffmpeg
|
|
203
|
+
// to flush its output and exit cleanly.
|
|
204
|
+
// Use signal?.aborted directly instead of a listener flag
|
|
205
|
+
// to avoid a race between listener registration order and
|
|
206
|
+
// the async close event.
|
|
207
|
+
if (signal?.aborted) {
|
|
231
208
|
resolve({
|
|
232
209
|
sizeBytes,
|
|
233
210
|
duration,
|