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 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?.removeEventListener("abort", onAbort);
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: 3e4,
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, 3e4);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokwatchr",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Download TikTok livestreams. Given a username, download the livestream.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -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 { UserNotFoundError, UserOfflineError } from "./errors.js";
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: 30_000,
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 = await this.fetchStreamInfo(roomId);
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, 30_000);
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.
@@ -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
- // Don't reject — resolve with partial data instead, same
207
- // as the close handler does for aborted downloads.
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
- // ffmpeg receives SIGTERM first, which tells it to flush
229
- // its output and exit cleanly, producing a playable file.
230
- if (aborted) {
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,