tokwatchr 0.4.3 → 0.5.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/dist/index.d.mts CHANGED
@@ -88,7 +88,17 @@ interface CookieJarLike {
88
88
  setCookie(raw: string, url: string): Promise<void>;
89
89
  getCookieString(url: string): Promise<string>;
90
90
  }
91
+ interface WaitingInfo {
92
+ /** The username being waited on. */
93
+ username: string;
94
+ /** Which phase we're waiting in: "room" = looking for a room, "stream" = stream not active. */
95
+ phase: "room" | "stream";
96
+ /** Seconds elapsed since this phase started. */
97
+ elapsed: number;
98
+ }
91
99
  interface TikTokLiveDownloaderEvents {
100
+ /** Emitted periodically while waiting for a room or stream to become active. */
101
+ waiting: [info: WaitingInfo];
92
102
  /** Emitted when a live stream is detected and we're about to start recording. */
93
103
  start: [info: StreamInfo];
94
104
  /** Emitted periodically (every ~1s) during recording with current stats. */
@@ -237,6 +247,12 @@ declare class TikTokLiveDownloader {
237
247
  private resolveRoomIdOnce;
238
248
  private resolveRoomIdWithRetry;
239
249
  private fetchStreamInfo;
250
+ /**
251
+ * Poll for stream info until the stream becomes active.
252
+ * Used in waitForLive mode when the room exists but the stream
253
+ * has not yet started broadcasting.
254
+ */
255
+ private pollStreamInfo;
240
256
  /**
241
257
  * Download a single segment. Always downloads to .ts (crash-safe).
242
258
  * Does NOT remux — that is handled by remuxSegment() running in the background.
@@ -324,4 +340,4 @@ declare function renderFilename(template: string, values: {
324
340
  part?: number;
325
341
  }): string;
326
342
  //#endregion
327
- export { AbortError, type CookieJarLike, type CreateClientOptions, DownloadFailedError, type DownloadFunctionOptions, type DownloadResult, type DownloadStats, type DownloaderState, FfmpegError, type OutputFormat, type QualityOption, RoomResolveError, type RoomResolveOptions, StreamFetchError, type StreamInfo, type StreamInfoOptions, type StreamQualityKey, TikTokLiveDownloader, type TikTokLiveDownloaderEvents, type TikTokLiveDownloaderOptions, TikTokLiveError, UserNotFoundError, UserOfflineError, buildQualityOption, createClient, download, fetchStreamInfo, parseQualities, renderFilename, resolveRoomId, selectQuality };
343
+ export { AbortError, type CookieJarLike, type CreateClientOptions, DownloadFailedError, type DownloadFunctionOptions, type DownloadResult, type DownloadStats, type DownloaderState, FfmpegError, type OutputFormat, type QualityOption, RoomResolveError, type RoomResolveOptions, StreamFetchError, type StreamInfo, type StreamInfoOptions, type StreamQualityKey, TikTokLiveDownloader, type TikTokLiveDownloaderEvents, type TikTokLiveDownloaderOptions, TikTokLiveError, UserNotFoundError, UserOfflineError, type WaitingInfo, buildQualityOption, createClient, download, fetchStreamInfo, parseQualities, renderFilename, resolveRoomId, selectQuality };
package/dist/index.mjs CHANGED
@@ -620,7 +620,7 @@ const DEFAULT_OPTIONS = {
620
620
  bitrate: null,
621
621
  maxDuration: Infinity,
622
622
  maxSegmentDuration: Infinity,
623
- checkInterval: 3e4,
623
+ checkInterval: 18e4,
624
624
  proxyUrl: null,
625
625
  cookieJar: null,
626
626
  browser: "chrome",
@@ -773,7 +773,7 @@ var TikTokLiveDownloader = class {
773
773
  if (!roomId && waitForLive) roomId = await this.resolveRoomIdWithRetry();
774
774
  if (!roomId) throw new UserOfflineError(this.username);
775
775
  this.setState("waiting");
776
- const firstInfo = await this.fetchStreamInfo(roomId);
776
+ const firstInfo = waitForLive ? await this.pollStreamInfo(roomId) : await this.fetchStreamInfo(roomId);
777
777
  if (!(this.options.maxSegmentDuration > 0 && this.options.maxSegmentDuration < Infinity)) {
778
778
  const tsResult = await this.downloadSegment(firstInfo, 1);
779
779
  const finalResult = await this.remuxSegment(tsResult);
@@ -808,6 +808,11 @@ var TikTokLiveDownloader = class {
808
808
  this.emit("complete", finalResults);
809
809
  return finalResults[finalResults.length - 1];
810
810
  } catch (err) {
811
+ if (err instanceof StreamFetchError) this.emit("waiting", {
812
+ username: this.username,
813
+ phase: "stream",
814
+ elapsed: 0
815
+ });
811
816
  if (pendingRemuxes.length > 0) await Promise.allSettled(pendingRemuxes);
812
817
  this.setState("done");
813
818
  const error = err instanceof Error ? err : new Error(String(err));
@@ -834,11 +839,17 @@ var TikTokLiveDownloader = class {
834
839
  }
835
840
  async resolveRoomIdWithRetry() {
836
841
  const interval = this.options.checkInterval;
837
- const maxInterval = Math.max(interval, 3e4);
842
+ const maxInterval = Math.max(interval, 18e4);
843
+ const waitStart = Date.now();
838
844
  while (true) {
839
845
  this.abortController.signal?.throwIfAborted();
840
846
  const roomId = await this.resolveRoomIdOnce();
841
847
  if (roomId) return roomId;
848
+ this.emit("waiting", {
849
+ username: this.username,
850
+ phase: "room",
851
+ elapsed: (Date.now() - waitStart) / 1e3
852
+ });
842
853
  await sleep(maxInterval);
843
854
  }
844
855
  }
@@ -852,6 +863,33 @@ var TikTokLiveDownloader = class {
852
863
  return info;
853
864
  }
854
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, 18e4);
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);
886
+ continue;
887
+ }
888
+ throw err;
889
+ }
890
+ }
891
+ }
892
+ /**
855
893
  * Download a single segment. Always downloads to .ts (crash-safe).
856
894
  * Does NOT remux — that is handled by remuxSegment() running in the background.
857
895
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokwatchr",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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 &&
@@ -314,6 +320,16 @@ export class TikTokLiveDownloader {
314
320
  // biome-ignore lint/style/noNonNullAssertion: at least one result
315
321
  return finalResults[finalResults.length - 1]!;
316
322
  } catch (err) {
323
+ // One-shot waiting feedback for startRecording() users
324
+ // when the stream isn't active yet.
325
+ if (err instanceof StreamFetchError) {
326
+ this.emit("waiting", {
327
+ username: this.username,
328
+ phase: "stream",
329
+ elapsed: 0,
330
+ });
331
+ }
332
+
317
333
  // Await any background remuxes before shutting down,
318
334
  // so they can finish producing .mp4 and delete temp .ts files.
319
335
  if (pendingRemuxes.length > 0) {
@@ -361,7 +377,8 @@ export class TikTokLiveDownloader {
361
377
 
362
378
  private async resolveRoomIdWithRetry(): Promise<string> {
363
379
  const interval = this.options.checkInterval;
364
- const maxInterval = Math.max(interval, 30_000);
380
+ const maxInterval = Math.max(interval, 180_000);
381
+ const waitStart = Date.now();
365
382
 
366
383
  while (true) {
367
384
  this.abortController.signal?.throwIfAborted();
@@ -371,6 +388,11 @@ export class TikTokLiveDownloader {
371
388
  return roomId;
372
389
  }
373
390
 
391
+ this.emit("waiting", {
392
+ username: this.username,
393
+ phase: "room",
394
+ elapsed: (Date.now() - waitStart) / 1_000,
395
+ });
374
396
  await sleep(maxInterval);
375
397
  }
376
398
  }
@@ -387,6 +409,36 @@ export class TikTokLiveDownloader {
387
409
  return info;
388
410
  }
389
411
 
412
+ /**
413
+ * Poll for stream info until the stream becomes active.
414
+ * Used in waitForLive mode when the room exists but the stream
415
+ * has not yet started broadcasting.
416
+ */
417
+ private async pollStreamInfo(roomId: string): Promise<StreamInfo> {
418
+ const interval = this.options.checkInterval;
419
+ const maxInterval = Math.max(interval, 180_000);
420
+ const waitStart = Date.now();
421
+
422
+ while (true) {
423
+ this.abortController.signal?.throwIfAborted();
424
+
425
+ try {
426
+ return await this.fetchStreamInfo(roomId);
427
+ } catch (err) {
428
+ if (err instanceof StreamFetchError) {
429
+ this.emit("waiting", {
430
+ username: this.username,
431
+ phase: "stream",
432
+ elapsed: (Date.now() - waitStart) / 1_000,
433
+ });
434
+ await sleep(maxInterval);
435
+ continue;
436
+ }
437
+ throw err;
438
+ }
439
+ }
440
+ }
441
+
390
442
  /**
391
443
  * Download a single segment. Always downloads to .ts (crash-safe).
392
444
  * Does NOT remux — that is handled by remuxSegment() running in the background.
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type {
32
32
  StreamQualityKey,
33
33
  TikTokLiveDownloaderEvents,
34
34
  TikTokLiveDownloaderOptions,
35
+ WaitingInfo,
35
36
  } from "./types.js";
36
37
 
37
38
  // ─── Quality utilities ────────────────────────────────────
package/src/types.ts CHANGED
@@ -114,7 +114,18 @@ export interface CookieJarLike {
114
114
 
115
115
  // ─── Events ───────────────────────────────────────────────
116
116
 
117
+ export interface WaitingInfo {
118
+ /** The username being waited on. */
119
+ username: string;
120
+ /** Which phase we're waiting in: "room" = looking for a room, "stream" = stream not active. */
121
+ phase: "room" | "stream";
122
+ /** Seconds elapsed since this phase started. */
123
+ elapsed: number;
124
+ }
125
+
117
126
  export interface TikTokLiveDownloaderEvents {
127
+ /** Emitted periodically while waiting for a room or stream to become active. */
128
+ waiting: [info: WaitingInfo];
118
129
  /** Emitted when a live stream is detected and we're about to start recording. */
119
130
  start: [info: StreamInfo];
120
131
  /** Emitted periodically (every ~1s) during recording with current stats. */