tokwatchr 0.4.4 → 0.6.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
@@ -63,7 +63,7 @@ interface TikTokLiveDownloaderOptions {
63
63
  maxDuration?: number;
64
64
  /** Split recording into segments of this many seconds (default: Infinity = single file). */
65
65
  maxSegmentDuration?: number;
66
- /** Polling interval in ms when waiting for live (default: 30_000). */
66
+ /** Polling interval in seconds when waiting for live (default: 180). */
67
67
  checkInterval?: number;
68
68
  /** HTTP proxy URL (supports http, https, socks4, socks5). */
69
69
  proxyUrl?: string;
@@ -71,7 +71,7 @@ interface TikTokLiveDownloaderOptions {
71
71
  cookieJar?: CookieJarLike;
72
72
  /** Browser to emulate (default: "chrome"). */
73
73
  browser?: Browser;
74
- /** Request timeout in ms (default: 30_000). */
74
+ /** Request timeout in seconds (default: 30). */
75
75
  timeout?: number;
76
76
  /** Extra headers to send with every request. */
77
77
  headers?: Record<string, string>;
@@ -88,7 +88,29 @@ 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
+ }
99
+ interface RemuxInfo {
100
+ /** Input .ts file path. */
101
+ filePath: string;
102
+ /** Input file size in MB. */
103
+ inputSizeMB: number;
104
+ /** Remux status: started → completed (with outputPath) or failed (keeping .ts). */
105
+ status: "started" | "completed" | "failed";
106
+ /** Output file path. Only set when status is "completed". */
107
+ outputPath?: string;
108
+ }
91
109
  interface TikTokLiveDownloaderEvents {
110
+ /** Emitted periodically while waiting for a room or stream to become active. */
111
+ waiting: [info: WaitingInfo];
112
+ /** Emitted when a remux operation starts, completes, or fails. */
113
+ remux: [info: RemuxInfo];
92
114
  /** Emitted when a live stream is detected and we're about to start recording. */
93
115
  start: [info: StreamInfo];
94
116
  /** Emitted periodically (every ~1s) during recording with current stats. */
@@ -330,4 +352,4 @@ declare function renderFilename(template: string, values: {
330
352
  part?: number;
331
353
  }): string;
332
354
  //#endregion
333
- 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 };
355
+ export { AbortError, type CookieJarLike, type CreateClientOptions, DownloadFailedError, type DownloadFunctionOptions, type DownloadResult, type DownloadStats, type DownloaderState, FfmpegError, type OutputFormat, type QualityOption, type RemuxInfo, 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
@@ -368,7 +368,7 @@ function parseFfmpegProgress(line) {
368
368
  * - Better handling of stream interruptions
369
369
  */
370
370
  async function downloadWithFfmpeg(options) {
371
- const { ffmpegPath, url, outputPath, quality, args: extraArgs, bitrate, signal, onProgress, maxDuration, timeout = 3e4 } = options;
371
+ const { ffmpegPath, url, outputPath, quality, args: extraArgs, bitrate, signal, onProgress, maxDuration, timeout = 30 } = options;
372
372
  const ffmpegArgs = [
373
373
  "-y",
374
374
  "-i",
@@ -394,8 +394,8 @@ async function downloadWithFfmpeg(options) {
394
394
  let firstDataTimer = setTimeout(() => {
395
395
  firstDataTimer = null;
396
396
  proc.kill("SIGTERM");
397
- reject(new FfmpegError(`ffmpeg produced no output within ${timeout}ms — check the stream URL`));
398
- }, timeout);
397
+ reject(new FfmpegError(`ffmpeg produced no output within ${timeout}s — check the stream URL`));
398
+ }, timeout * 1e3);
399
399
  proc.stderr?.on("data", (chunk) => {
400
400
  if (firstDataTimer) {
401
401
  clearTimeout(firstDataTimer);
@@ -620,11 +620,11 @@ const DEFAULT_OPTIONS = {
620
620
  bitrate: null,
621
621
  maxDuration: Infinity,
622
622
  maxSegmentDuration: Infinity,
623
- checkInterval: 18e4,
623
+ checkInterval: 180,
624
624
  proxyUrl: null,
625
625
  cookieJar: null,
626
626
  browser: "chrome",
627
- timeout: 3e4,
627
+ timeout: 30,
628
628
  headers: {},
629
629
  onProgress: null,
630
630
  onStart: null,
@@ -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,12 +839,18 @@ var TikTokLiveDownloader = class {
834
839
  }
835
840
  async resolveRoomIdWithRetry() {
836
841
  const interval = this.options.checkInterval;
837
- const maxInterval = Math.max(interval, 18e4);
842
+ const maxInterval = Math.max(interval, 180);
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;
842
- await sleep(maxInterval);
848
+ this.emit("waiting", {
849
+ username: this.username,
850
+ phase: "room",
851
+ elapsed: (Date.now() - waitStart) / 1e3
852
+ });
853
+ await sleep(maxInterval * 1e3);
843
854
  }
844
855
  }
845
856
  async fetchStreamInfo(roomId) {
@@ -858,14 +869,20 @@ var TikTokLiveDownloader = class {
858
869
  */
859
870
  async pollStreamInfo(roomId) {
860
871
  const interval = this.options.checkInterval;
861
- const maxInterval = Math.max(interval, 18e4);
872
+ const maxInterval = Math.max(interval, 180);
873
+ const waitStart = Date.now();
862
874
  while (true) {
863
875
  this.abortController.signal?.throwIfAborted();
864
876
  try {
865
877
  return await this.fetchStreamInfo(roomId);
866
878
  } catch (err) {
867
879
  if (err instanceof StreamFetchError) {
868
- await sleep(maxInterval);
880
+ this.emit("waiting", {
881
+ username: this.username,
882
+ phase: "stream",
883
+ elapsed: (Date.now() - waitStart) / 1e3
884
+ });
885
+ await sleep(maxInterval * 1e3);
869
886
  continue;
870
887
  }
871
888
  throw err;
@@ -964,12 +981,23 @@ var TikTokLiveDownloader = class {
964
981
  const finalExt = targetFormat === "mkv" ? "mkv" : "mp4";
965
982
  const finalPath = inputPath.replace(/\.ts$/, `.${finalExt}`);
966
983
  if (finalPath === inputPath) return tsResult;
984
+ this.emit("remux", {
985
+ filePath: inputPath,
986
+ inputSizeMB: tsResult.sizeMB,
987
+ status: "started"
988
+ });
967
989
  try {
968
990
  await this.remuxAndNormalize(this.options.ffmpegPath, inputPath, finalPath);
969
991
  try {
970
992
  const { unlinkSync } = await import("node:fs");
971
993
  unlinkSync(inputPath);
972
994
  } catch {}
995
+ this.emit("remux", {
996
+ filePath: inputPath,
997
+ outputPath: finalPath,
998
+ inputSizeMB: tsResult.sizeMB,
999
+ status: "completed"
1000
+ });
973
1001
  return {
974
1002
  ...tsResult,
975
1003
  filePath: finalPath,
@@ -978,6 +1006,11 @@ var TikTokLiveDownloader = class {
978
1006
  } catch (err) {
979
1007
  const msg = err instanceof Error ? err.message : String(err);
980
1008
  console.warn(`tokwatchr: remux failed for ${inputPath}, keeping .ts as fallback: ${msg}`);
1009
+ this.emit("remux", {
1010
+ filePath: inputPath,
1011
+ inputSizeMB: tsResult.sizeMB,
1012
+ status: "failed"
1013
+ });
981
1014
  return tsResult;
982
1015
  }
983
1016
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokwatchr",
3
- "version": "0.4.4",
3
+ "version": "0.6.0",
4
4
  "description": "Download TikTok livestreams. Given a username, download the livestream.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -36,11 +36,11 @@ const DEFAULT_OPTIONS: ResolvedOptions = {
36
36
  bitrate: null,
37
37
  maxDuration: Infinity,
38
38
  maxSegmentDuration: Infinity,
39
- checkInterval: 180_000,
39
+ checkInterval: 180,
40
40
  proxyUrl: null,
41
41
  cookieJar: null,
42
42
  browser: "chrome",
43
- timeout: 30_000,
43
+ timeout: 30,
44
44
  headers: {},
45
45
  onProgress: null,
46
46
  onStart: null,
@@ -320,6 +320,16 @@ export class TikTokLiveDownloader {
320
320
  // biome-ignore lint/style/noNonNullAssertion: at least one result
321
321
  return finalResults[finalResults.length - 1]!;
322
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
+
323
333
  // Await any background remuxes before shutting down,
324
334
  // so they can finish producing .mp4 and delete temp .ts files.
325
335
  if (pendingRemuxes.length > 0) {
@@ -367,7 +377,8 @@ export class TikTokLiveDownloader {
367
377
 
368
378
  private async resolveRoomIdWithRetry(): Promise<string> {
369
379
  const interval = this.options.checkInterval;
370
- const maxInterval = Math.max(interval, 180_000);
380
+ const maxInterval = Math.max(interval, 180);
381
+ const waitStart = Date.now();
371
382
 
372
383
  while (true) {
373
384
  this.abortController.signal?.throwIfAborted();
@@ -377,7 +388,12 @@ export class TikTokLiveDownloader {
377
388
  return roomId;
378
389
  }
379
390
 
380
- await sleep(maxInterval);
391
+ this.emit("waiting", {
392
+ username: this.username,
393
+ phase: "room",
394
+ elapsed: (Date.now() - waitStart) / 1_000,
395
+ });
396
+ await sleep(maxInterval * 1_000);
381
397
  }
382
398
  }
383
399
 
@@ -400,7 +416,8 @@ export class TikTokLiveDownloader {
400
416
  */
401
417
  private async pollStreamInfo(roomId: string): Promise<StreamInfo> {
402
418
  const interval = this.options.checkInterval;
403
- const maxInterval = Math.max(interval, 180_000);
419
+ const maxInterval = Math.max(interval, 180);
420
+ const waitStart = Date.now();
404
421
 
405
422
  while (true) {
406
423
  this.abortController.signal?.throwIfAborted();
@@ -409,7 +426,12 @@ export class TikTokLiveDownloader {
409
426
  return await this.fetchStreamInfo(roomId);
410
427
  } catch (err) {
411
428
  if (err instanceof StreamFetchError) {
412
- await sleep(maxInterval);
429
+ this.emit("waiting", {
430
+ username: this.username,
431
+ phase: "stream",
432
+ elapsed: (Date.now() - waitStart) / 1_000,
433
+ });
434
+ await sleep(maxInterval * 1_000);
413
435
  continue;
414
436
  }
415
437
  throw err;
@@ -550,6 +572,12 @@ export class TikTokLiveDownloader {
550
572
 
551
573
  if (finalPath === inputPath) return tsResult;
552
574
 
575
+ this.emit("remux", {
576
+ filePath: inputPath,
577
+ inputSizeMB: tsResult.sizeMB,
578
+ status: "started",
579
+ });
580
+
553
581
  try {
554
582
  await this.remuxAndNormalize(
555
583
  this.options.ffmpegPath,
@@ -563,6 +591,14 @@ export class TikTokLiveDownloader {
563
591
  } catch {
564
592
  // ignore cleanup failure
565
593
  }
594
+
595
+ this.emit("remux", {
596
+ filePath: inputPath,
597
+ outputPath: finalPath,
598
+ inputSizeMB: tsResult.sizeMB,
599
+ status: "completed",
600
+ });
601
+
566
602
  return {
567
603
  ...tsResult,
568
604
  filePath: finalPath,
@@ -573,6 +609,13 @@ export class TikTokLiveDownloader {
573
609
  console.warn(
574
610
  `tokwatchr: remux failed for ${inputPath}, keeping .ts as fallback: ${msg}`,
575
611
  );
612
+
613
+ this.emit("remux", {
614
+ filePath: inputPath,
615
+ inputSizeMB: tsResult.sizeMB,
616
+ status: "failed",
617
+ });
618
+
576
619
  return tsResult;
577
620
  }
578
621
  }
@@ -13,10 +13,10 @@ export interface FfmpegDownloadOptions {
13
13
  onProgress?: (stats: DownloadStats) => void;
14
14
  maxDuration?: number;
15
15
  /**
16
- * Startup timeout in ms. If ffmpeg produces no stderr output within
16
+ * Startup timeout in seconds. If ffmpeg produces no stderr output within
17
17
  * this window, it is killed and the promise rejects. Prevents
18
18
  * indefinite hangs on bad/ stalled stream URLs.
19
- * @default 30_000
19
+ * @default 30
20
20
  */
21
21
  timeout?: number;
22
22
  }
@@ -89,7 +89,7 @@ export async function downloadWithFfmpeg(
89
89
  signal,
90
90
  onProgress,
91
91
  maxDuration,
92
- timeout = 30_000,
92
+ timeout = 30,
93
93
  } = options;
94
94
 
95
95
  const ffmpegArgs: string[] = [
@@ -123,7 +123,7 @@ export async function downloadWithFfmpeg(
123
123
  let duration = 0;
124
124
 
125
125
  // Startup timeout — if ffmpeg produces no stderr output within
126
- // `timeout` ms, it likely stalled on a bad URL. Kill it so the
126
+ // `timeout` seconds, it likely stalled on a bad URL. Kill it so the
127
127
  // caller doesn't hang indefinitely.
128
128
  let firstDataTimer: ReturnType<typeof setTimeout> | null = setTimeout(
129
129
  () => {
@@ -131,11 +131,11 @@ export async function downloadWithFfmpeg(
131
131
  proc.kill("SIGTERM");
132
132
  reject(
133
133
  new FfmpegError(
134
- `ffmpeg produced no output within ${timeout}ms — check the stream URL`,
134
+ `ffmpeg produced no output within ${timeout}s — check the stream URL`,
135
135
  ),
136
136
  );
137
137
  },
138
- timeout,
138
+ timeout * 1_000,
139
139
  );
140
140
 
141
141
  // Parse stderr for progress
package/src/index.ts CHANGED
@@ -28,10 +28,12 @@ export type {
28
28
  DownloadStats,
29
29
  OutputFormat,
30
30
  QualityOption,
31
+ RemuxInfo,
31
32
  StreamInfo,
32
33
  StreamQualityKey,
33
34
  TikTokLiveDownloaderEvents,
34
35
  TikTokLiveDownloaderOptions,
36
+ WaitingInfo,
35
37
  } from "./types.js";
36
38
 
37
39
  // ─── Quality utilities ────────────────────────────────────
package/src/types.ts CHANGED
@@ -85,7 +85,7 @@ export interface TikTokLiveDownloaderOptions {
85
85
  maxDuration?: number;
86
86
  /** Split recording into segments of this many seconds (default: Infinity = single file). */
87
87
  maxSegmentDuration?: number;
88
- /** Polling interval in ms when waiting for live (default: 30_000). */
88
+ /** Polling interval in seconds when waiting for live (default: 180). */
89
89
  checkInterval?: number;
90
90
  /** HTTP proxy URL (supports http, https, socks4, socks5). */
91
91
  proxyUrl?: string;
@@ -93,7 +93,7 @@ export interface TikTokLiveDownloaderOptions {
93
93
  cookieJar?: CookieJarLike;
94
94
  /** Browser to emulate (default: "chrome"). */
95
95
  browser?: Browser;
96
- /** Request timeout in ms (default: 30_000). */
96
+ /** Request timeout in seconds (default: 30). */
97
97
  timeout?: number;
98
98
  /** Extra headers to send with every request. */
99
99
  headers?: Record<string, string>;
@@ -114,7 +114,31 @@ 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
+
126
+ export interface RemuxInfo {
127
+ /** Input .ts file path. */
128
+ filePath: string;
129
+ /** Input file size in MB. */
130
+ inputSizeMB: number;
131
+ /** Remux status: started → completed (with outputPath) or failed (keeping .ts). */
132
+ status: "started" | "completed" | "failed";
133
+ /** Output file path. Only set when status is "completed". */
134
+ outputPath?: string;
135
+ }
136
+
117
137
  export interface TikTokLiveDownloaderEvents {
138
+ /** Emitted periodically while waiting for a room or stream to become active. */
139
+ waiting: [info: WaitingInfo];
140
+ /** Emitted when a remux operation starts, completes, or fails. */
141
+ remux: [info: RemuxInfo];
118
142
  /** Emitted when a live stream is detected and we're about to start recording. */
119
143
  start: [info: StreamInfo];
120
144
  /** Emitted periodically (every ~1s) during recording with current stats. */