@zfadhli/tokrec 0.7.1 → 0.9.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.
Files changed (44) hide show
  1. package/README.md +129 -76
  2. package/dist/api/client.mjs +45 -19
  3. package/dist/api/client.mjs.map +1 -1
  4. package/dist/api/rate-limiter.mjs +29 -0
  5. package/dist/api/rate-limiter.mjs.map +1 -0
  6. package/dist/api/tiktok.mjs +180 -51
  7. package/dist/api/tiktok.mjs.map +1 -1
  8. package/dist/browser-cookies.mjs +8 -11
  9. package/dist/browser-cookies.mjs.map +1 -1
  10. package/dist/cli.mjs +10 -2
  11. package/dist/cli.mjs.map +1 -1
  12. package/dist/config.d.mts +18 -6
  13. package/dist/config.d.mts.map +1 -1
  14. package/dist/config.mjs +8 -4
  15. package/dist/config.mjs.map +1 -1
  16. package/dist/index.mjs +22 -4
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/monitor.mjs +12 -13
  19. package/dist/monitor.mjs.map +1 -1
  20. package/dist/package.mjs +1 -1
  21. package/dist/recorder/convert.mjs +25 -25
  22. package/dist/recorder/convert.mjs.map +1 -1
  23. package/dist/recorder/download-hls.mjs +77 -0
  24. package/dist/recorder/download-hls.mjs.map +1 -0
  25. package/dist/recorder/download-stream.mjs +78 -0
  26. package/dist/recorder/download-stream.mjs.map +1 -0
  27. package/dist/recorder/ffmpeg-utils.mjs +122 -0
  28. package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
  29. package/dist/recorder/index.d.mts.map +1 -1
  30. package/dist/recorder/index.mjs +88 -176
  31. package/dist/recorder/index.mjs.map +1 -1
  32. package/dist/recorder/post-processing.mjs +150 -0
  33. package/dist/recorder/post-processing.mjs.map +1 -0
  34. package/dist/recorder/recorder-events.mjs +34 -0
  35. package/dist/recorder/recorder-events.mjs.map +1 -0
  36. package/dist/recorder/recorder-state.mjs +38 -0
  37. package/dist/recorder/recorder-state.mjs.map +1 -0
  38. package/dist/recorder/stream.mjs +5 -293
  39. package/dist/recorder/stream.mjs.map +1 -1
  40. package/dist/ui.mjs +16 -25
  41. package/dist/ui.mjs.map +1 -1
  42. package/dist/utils.mjs +19 -3
  43. package/dist/utils.mjs.map +1 -1
  44. package/package.json +6 -6
@@ -0,0 +1,122 @@
1
+ import { TikTokError } from "../config.mjs";
2
+ import { spawn } from "node:child_process";
3
+ import { statSync } from "node:fs";
4
+ //#region src/recorder/ffmpeg-utils.ts
5
+ /**
6
+ * Shared FFmpeg utilities — binary resolution, duration formatting, constants,
7
+ * and stream-download primitives.
8
+ */
9
+ /** Kill FFmpeg if no output is received within this window after spawn. */
10
+ const FFMPEG_STARTUP_TIMEOUT = 3e4;
11
+ /** Standard FFmpeg reconnect + mpegts pipe arguments. */
12
+ const FFMPEG_BASE_ARGS = [
13
+ "-reconnect",
14
+ "1",
15
+ "-reconnect_at_eof",
16
+ "1",
17
+ "-reconnect_streamed",
18
+ "1",
19
+ "-reconnect_delay_max",
20
+ "5",
21
+ "-c",
22
+ "copy",
23
+ "-f",
24
+ "mpegts",
25
+ "pipe:1"
26
+ ];
27
+ /** Find FFmpeg binary via Bun.which() or PATH search. */
28
+ function findFfmpegPath() {
29
+ const bunWhich = Bun?.which;
30
+ if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
31
+ const paths = process.env.PATH?.split(":") ?? [];
32
+ for (const dir of paths) try {
33
+ const full = `${dir}/ffmpeg`;
34
+ statSync(full);
35
+ return full;
36
+ } catch {}
37
+ return null;
38
+ }
39
+ /** Format seconds into a compact human-readable duration. */
40
+ function formatDuration(seconds) {
41
+ return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
42
+ }
43
+ /**
44
+ * Spawn an FFmpeg process to download from `url` and pipe its stdout to
45
+ * `writer` with backpressure handling.
46
+ *
47
+ * Features:
48
+ * - Transparent HTTP reconnection via FFmpeg `-reconnect` flags
49
+ * - `FFMPEG_STARTUP_TIMEOUT` (30s) startup guard with SIGTERM → SIGKILL
50
+ * escalation to prevent orphaned processes
51
+ * - Bounded stderr capture (10k chars) for error diagnostics
52
+ * - Automatic cleanup when `signal` fires (uses spawn `signal` option)
53
+ * - AbortError is swallowed — the promise resolves instead of rejecting,
54
+ * allowing the caller to check `signal.aborted` and react accordingly
55
+ */
56
+ async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration) {
57
+ const proc = spawn(ffmpegPath, [
58
+ "-i",
59
+ url,
60
+ ...maxDuration ? ["-t", String(maxDuration)] : [],
61
+ ...FFMPEG_BASE_ARGS
62
+ ], {
63
+ stdio: [
64
+ "ignore",
65
+ "pipe",
66
+ "pipe"
67
+ ],
68
+ signal
69
+ });
70
+ let totalBytes = 0;
71
+ let stderr = "";
72
+ let firstDataTimer = null;
73
+ proc.stderr?.on("data", (chunk) => {
74
+ if (firstDataTimer) clearTimeout(firstDataTimer);
75
+ firstDataTimer = null;
76
+ stderr += chunk.toString();
77
+ if (stderr.length > 1e4) stderr = stderr.slice(-5e3);
78
+ });
79
+ proc.stdout.on("data", (chunk) => {
80
+ if (firstDataTimer) clearTimeout(firstDataTimer);
81
+ firstDataTimer = null;
82
+ const canContinue = writer.write(chunk);
83
+ totalBytes += chunk.length;
84
+ if (!canContinue) {
85
+ proc.stdout.pause();
86
+ writer.once("drain", () => proc.stdout.resume());
87
+ }
88
+ });
89
+ await new Promise((resolve, reject) => {
90
+ firstDataTimer = setTimeout(() => {
91
+ if (!proc.killed) {
92
+ proc.kill("SIGTERM");
93
+ setTimeout(() => {
94
+ if (!proc.killed) proc.kill("SIGKILL");
95
+ }, 2e3);
96
+ }
97
+ reject(new TikTokError("ffmpeg-error", `FFmpeg startup timed out after ${FFMPEG_STARTUP_TIMEOUT / 1e3}s\n${stderr.slice(-500)}`));
98
+ }, FFMPEG_STARTUP_TIMEOUT);
99
+ proc.on("close", (code) => {
100
+ if (firstDataTimer) clearTimeout(firstDataTimer);
101
+ if (signal.aborted) {
102
+ resolve();
103
+ return;
104
+ }
105
+ if (code === 0 || code === null) {
106
+ resolve();
107
+ return;
108
+ }
109
+ reject(new TikTokError("ffmpeg-error", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
110
+ });
111
+ proc.on("error", (err) => {
112
+ if (firstDataTimer) clearTimeout(firstDataTimer);
113
+ if (err instanceof Error && err.name === "AbortError") return;
114
+ reject(err);
115
+ });
116
+ });
117
+ return { bytes: totalBytes };
118
+ }
119
+ //#endregion
120
+ export { findFfmpegPath, formatDuration, pipeFfmpegSegment };
121
+
122
+ //# sourceMappingURL=ffmpeg-utils.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg-utils.mjs","names":[],"sources":["../../src/recorder/ffmpeg-utils.ts"],"sourcesContent":["/**\n * Shared FFmpeg utilities — binary resolution, duration formatting, constants,\n * and stream-download primitives.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { statSync, type WriteStream } from \"node:fs\"\nimport { TikTokError } from \"../config\"\n\n/** Kill FFmpeg if no output is received within this window after spawn. */\nexport const FFMPEG_STARTUP_TIMEOUT = 30_000\n\n/** Standard FFmpeg reconnect + mpegts pipe arguments. */\nconst FFMPEG_BASE_ARGS = [\n \"-reconnect\",\n \"1\",\n \"-reconnect_at_eof\",\n \"1\",\n \"-reconnect_streamed\",\n \"1\",\n \"-reconnect_delay_max\",\n \"5\",\n \"-c\",\n \"copy\",\n \"-f\",\n \"mpegts\",\n \"pipe:1\",\n]\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nexport function findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n\n/** Format seconds into a compact human-readable duration. */\nexport function formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Result from a single FFmpeg segment download. */\nexport interface FfmpegSegmentResult {\n bytes: number\n}\n\n/**\n * Spawn an FFmpeg process to download from `url` and pipe its stdout to\n * `writer` with backpressure handling.\n *\n * Features:\n * - Transparent HTTP reconnection via FFmpeg `-reconnect` flags\n * - `FFMPEG_STARTUP_TIMEOUT` (30s) startup guard with SIGTERM → SIGKILL\n * escalation to prevent orphaned processes\n * - Bounded stderr capture (10k chars) for error diagnostics\n * - Automatic cleanup when `signal` fires (uses spawn `signal` option)\n * - AbortError is swallowed — the promise resolves instead of rejecting,\n * allowing the caller to check `signal.aborted` and react accordingly\n */\nexport async function pipeFfmpegSegment(\n ffmpegPath: string,\n url: string,\n writer: WriteStream,\n signal: AbortSignal,\n maxDuration?: number,\n): Promise<FfmpegSegmentResult> {\n const durationArgs = maxDuration ? [\"-t\", String(maxDuration)] : []\n const proc = spawn(ffmpegPath, [\"-i\", url, ...durationArgs, ...FFMPEG_BASE_ARGS], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let totalBytes = 0\n let stderr = \"\"\n let firstDataTimer: ReturnType<typeof setTimeout> | null = null\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n if (firstDataTimer) clearTimeout(firstDataTimer)\n firstDataTimer = null\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.stdout.on(\"data\", (chunk: Buffer) => {\n if (firstDataTimer) clearTimeout(firstDataTimer)\n firstDataTimer = null\n const canContinue = writer.write(chunk)\n totalBytes += chunk.length\n if (!canContinue) {\n proc.stdout.pause()\n writer.once(\"drain\", () => proc.stdout.resume())\n }\n })\n\n await new Promise<void>((resolve, reject) => {\n firstDataTimer = setTimeout(() => {\n if (!proc.killed) {\n proc.kill(\"SIGTERM\")\n setTimeout(() => {\n if (!proc.killed) proc.kill(\"SIGKILL\")\n }, 2000)\n }\n reject(\n new TikTokError(\n \"ffmpeg-error\",\n `FFmpeg startup timed out after ${FFMPEG_STARTUP_TIMEOUT / 1000}s\\n${stderr.slice(-500)}`,\n ),\n )\n }, FFMPEG_STARTUP_TIMEOUT)\n\n proc.on(\"close\", (code) => {\n if (firstDataTimer) clearTimeout(firstDataTimer)\n if (signal.aborted) {\n resolve()\n return\n }\n if (code === 0 || code === null) {\n resolve()\n return\n }\n reject(\n new TikTokError(\"ffmpeg-error\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n })\n\n proc.on(\"error\", (err) => {\n if (firstDataTimer) clearTimeout(firstDataTimer)\n if (err instanceof Error && err.name === \"AbortError\") return\n reject(err)\n })\n })\n\n return { bytes: totalBytes }\n}\n"],"mappings":";;;;;;;;;AAUA,MAAa,yBAAyB;;AAGtC,MAAM,mBAAmB;CACvB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,SAAgB,iBAAgC;CAC9C,MAAM,WAAY,KAAa;CAC/B,IAAI,OAAO,aAAa,YACtB,OAAQ,SAAS,QAAQ,KAAgB;CAE3C,MAAM,QAAQ,QAAQ,IAAI,MAAM,MAAM,GAAG,KAAK,CAAC;CAC/C,KAAK,MAAM,OAAO,OAChB,IAAI;EACF,MAAM,OAAO,GAAG,IAAI;EACpB,SAAS,IAAI;EACb,OAAO;CACT,QAAQ,CAER;CAEF,OAAO;AACT;;AAGA,SAAgB,eAAe,SAAyB;CAGtD,OAAO,GAFG,KAAK,MAAM,UAAU,EAErB,EAAE,IADF,KAAK,MAAM,UAAU,EACf,EAAE;AACpB;;;;;;;;;;;;;;AAoBA,eAAsB,kBACpB,YACA,KACA,QACA,QACA,aAC8B;CAE9B,MAAM,OAAO,MAAM,YAAY;EAAC;EAAM;EAAK,GADtB,cAAc,CAAC,MAAM,OAAO,WAAW,CAAC,IAAI,CAAC;EACN,GAAG;CAAgB,GAAG;EAChF,OAAO;GAAC;GAAU;GAAQ;EAAM;EAChC;CACF,CAAC;CAED,IAAI,aAAa;CACjB,IAAI,SAAS;CACb,IAAI,iBAAuD;CAE3D,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,IAAI,gBAAgB,aAAa,cAAc;EAC/C,iBAAiB;EACjB,UAAU,MAAM,SAAS;EACzB,IAAI,OAAO,SAAS,KAAO,SAAS,OAAO,MAAM,IAAK;CACxD,CAAC;CAED,KAAK,OAAO,GAAG,SAAS,UAAkB;EACxC,IAAI,gBAAgB,aAAa,cAAc;EAC/C,iBAAiB;EACjB,MAAM,cAAc,OAAO,MAAM,KAAK;EACtC,cAAc,MAAM;EACpB,IAAI,CAAC,aAAa;GAChB,KAAK,OAAO,MAAM;GAClB,OAAO,KAAK,eAAe,KAAK,OAAO,OAAO,CAAC;EACjD;CACF,CAAC;CAED,MAAM,IAAI,SAAe,SAAS,WAAW;EAC3C,iBAAiB,iBAAiB;GAChC,IAAI,CAAC,KAAK,QAAQ;IAChB,KAAK,KAAK,SAAS;IACnB,iBAAiB;KACf,IAAI,CAAC,KAAK,QAAQ,KAAK,KAAK,SAAS;IACvC,GAAG,GAAI;GACT;GACA,OACE,IAAI,YACF,gBACA,kCAAkC,yBAAyB,IAAK,KAAK,OAAO,MAAM,IAAI,GACxF,CACF;EACF,GAAG,sBAAsB;EAEzB,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,gBAAgB,aAAa,cAAc;GAC/C,IAAI,OAAO,SAAS;IAClB,QAAQ;IACR;GACF;GACA,IAAI,SAAS,KAAK,SAAS,MAAM;IAC/B,QAAQ;IACR;GACF;GACA,OACE,IAAI,YAAY,gBAAgB,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAC1F;EACF,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,IAAI,gBAAgB,aAAa,cAAc;GAC/C,IAAI,eAAe,SAAS,IAAI,SAAS,cAAc;GACvD,OAAO,GAAG;EACZ,CAAC;CACH,CAAC;CAED,OAAO,EAAE,OAAO,WAAW;AAC7B"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/recorder/index.ts"],"mappings":";;;iBA2BgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAkB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/recorder/index.ts"],"mappings":";;;iBAsBgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAkB"}
@@ -2,20 +2,20 @@ import { createHttpClient } from "../api/client.mjs";
2
2
  import { createTikTokApi } from "../api/tiktok.mjs";
3
3
  import { TikTokError, normalizeConfig } from "../config.mjs";
4
4
  import { createLogger } from "../logger.mjs";
5
+ import { sleep } from "../utils.mjs";
5
6
  import { createPollingMonitor } from "../monitor.mjs";
7
+ import { findFfmpegPath } from "./ffmpeg-utils.mjs";
6
8
  import { createConverter } from "./convert.mjs";
7
9
  import { createAudioNormalizer } from "./normalize.mjs";
10
+ import { processRecording } from "./post-processing.mjs";
11
+ import { createEventEmitter } from "./recorder-events.mjs";
12
+ import { createRecorderState } from "./recorder-state.mjs";
8
13
  import { createStreamDownloader } from "./stream.mjs";
9
14
  import { spawn } from "node:child_process";
10
- import { readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
11
- import { join, parse } from "node:path";
12
15
  //#region src/recorder/index.ts
13
16
  /**
14
17
  * Recorder orchestrator — wires together the API, monitor, stream downloader,
15
18
  * and converter into a single RecorderController.
16
- *
17
- * Downloads the full stream (FLV via fetch, HLS via FFmpeg), then uses FFmpeg
18
- * to split it into time-aligned MP4 segments.
19
19
  */
20
20
  function createRecorder(config) {
21
21
  const cfg = normalizeConfig(config);
@@ -24,11 +24,11 @@ function createRecorder(config) {
24
24
  logFile: "tiktok-recorder.log",
25
25
  console: cfg.logConsole
26
26
  });
27
- const eventHandlers = /* @__PURE__ */ new Map();
28
- let state = {
27
+ const emitter = createEventEmitter();
28
+ const stateManager = createRecorderState({
29
29
  state: "idle",
30
30
  user: cfg.user
31
- };
31
+ }, logger);
32
32
  let httpClient = null;
33
33
  let api = null;
34
34
  let monitor = null;
@@ -36,76 +36,70 @@ function createRecorder(config) {
36
36
  let converter = null;
37
37
  let audioNormalizer = null;
38
38
  let stopRequested = false;
39
- function setState(partial) {
40
- state = {
41
- ...state,
42
- ...partial
43
- };
44
- }
45
- function getStatus() {
46
- return { ...state };
47
- }
48
- function on(event, handler) {
49
- const list = eventHandlers.get(event) ?? [];
50
- list.push(handler);
51
- eventHandlers.set(event, list);
52
- }
53
- function emit(event, ...args) {
54
- const list = eventHandlers.get(event);
55
- if (list) for (const h of list) try {
56
- h(...args);
57
- } catch {}
58
- }
39
+ let stopAbortController = new AbortController();
40
+ let pendingRemuxes = [];
59
41
  function getFfmpegPath() {
60
- const bunWhich = Bun?.which;
61
- if (typeof bunWhich === "function") {
62
- const path = bunWhich("ffmpeg");
63
- if (path) return path;
64
- }
65
- throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
42
+ const path = findFfmpegPath();
43
+ if (!path) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
44
+ return path;
66
45
  }
67
- function runFfmpeg(args) {
46
+ function runFfmpeg(args, signal) {
68
47
  return new Promise((resolve, reject) => {
69
- const proc = spawn(getFfmpegPath(), args, { stdio: [
70
- "ignore",
71
- "pipe",
72
- "pipe"
73
- ] });
48
+ const proc = spawn(getFfmpegPath(), args, {
49
+ stdio: [
50
+ "ignore",
51
+ "pipe",
52
+ "pipe"
53
+ ],
54
+ signal
55
+ });
74
56
  let stderr = "";
75
57
  proc.stderr?.on("data", (chunk) => {
76
58
  stderr += chunk.toString();
59
+ if (stderr.length > 1e4) stderr = stderr.slice(-5e3);
77
60
  });
78
61
  proc.on("close", (code) => {
62
+ if (signal?.aborted) {
63
+ reject(new TikTokError("unknown", "Aborted by user"));
64
+ return;
65
+ }
79
66
  if (code === 0) resolve();
80
67
  else reject(new TikTokError("unknown", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
81
68
  });
82
69
  proc.on("error", (err) => {
70
+ if (err instanceof Error && err.name === "AbortError") return;
83
71
  reject(new TikTokError("unknown", `Failed to spawn FFmpeg: ${err.message}`));
84
72
  });
85
73
  });
86
74
  }
87
75
  async function start() {
76
+ if (stateManager.getStatus().state !== "idle") {
77
+ logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`);
78
+ return;
79
+ }
88
80
  stopRequested = false;
81
+ pendingRemuxes = [];
82
+ stopAbortController = new AbortController();
89
83
  logger.info(`Starting recorder for @${cfg.user}`);
90
84
  httpClient = await createHttpClient(cfg);
91
- api = createTikTokApi(httpClient);
85
+ api = createTikTokApi(httpClient, logger, cfg.debug);
92
86
  downloader = createStreamDownloader(logger);
93
- converter = createConverter(logger);
87
+ converter = createConverter(logger, stopAbortController.signal);
94
88
  audioNormalizer = cfg.normalizeAudio ? createAudioNormalizer({
95
89
  loudness: cfg.normalizeLoudness,
96
90
  audioCodec: cfg.normalizeCodec,
97
91
  audioBitrate: cfg.normalizeBitrate,
98
- onStart: (file) => emit("normalize:start", { file }),
99
- onProgress: (file, percent, phase) => emit("normalize:progress", {
92
+ onStart: (file) => emitter.emit("normalize:start", { file }),
93
+ onProgress: (file, percent, phase) => emitter.emit("normalize:progress", {
100
94
  file,
101
95
  percent,
102
96
  phase
103
97
  }),
104
- onComplete: (result) => emit("normalize:end", {
98
+ onComplete: (result) => emitter.emit("normalize:end", {
105
99
  input: result.input,
106
100
  output: result.output
107
101
  }),
108
- onError: (file, err) => emit("normalize:error", {
102
+ onError: (file, err) => emitter.emit("normalize:error", {
109
103
  input: file,
110
104
  error: err.message
111
105
  })
@@ -117,12 +111,12 @@ function createRecorder(config) {
117
111
  if (stopRequested) return;
118
112
  const user = cfg.user;
119
113
  api.invalidateCache();
120
- emit("checking", { user });
114
+ emitter.emit("checking", { user });
121
115
  logger.info(`Checking @${user}...`);
122
116
  const roomId = await api.getRoomId(user);
123
117
  if (!roomId) {
124
118
  logger.info(`@${user} is offline`);
125
- emit("tick", {
119
+ emitter.emit("tick", {
126
120
  user,
127
121
  isLive: false
128
122
  });
@@ -131,7 +125,7 @@ function createRecorder(config) {
131
125
  const liveUrl = await api.getLiveUrl(roomId);
132
126
  if (!liveUrl) {
133
127
  logger.warn(`@${user} is live but no stream URL found`);
134
- emit("tick", {
128
+ emitter.emit("tick", {
135
129
  user,
136
130
  isLive: false,
137
131
  roomId
@@ -139,167 +133,85 @@ function createRecorder(config) {
139
133
  return;
140
134
  }
141
135
  logger.info(`@${user} is LIVE! (room: ${roomId})`);
142
- emit("tick", {
136
+ emitter.emit("tick", {
143
137
  user,
144
138
  isLive: true,
145
139
  roomId
146
140
  });
147
- setState({ state: "recording" });
148
- emit("recording:start", {
141
+ stateManager.setState({ state: "recording" });
142
+ emitter.emit("recording:start", {
149
143
  user,
150
144
  file: ""
151
145
  });
152
- let reconnectCount = 0;
153
146
  const getNextUrl = async () => {
154
- reconnectCount++;
155
- if (reconnectCount > 100) {
156
- logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`);
157
- return null;
158
- }
159
- api.invalidateCache();
160
- const stillLive = await api.getRoomId(user);
161
- if (!stillLive) {
147
+ if (!await api.isRoomAlive(roomId)) {
162
148
  logger.info(`@${user} is no longer live — stopping`);
163
149
  return null;
164
150
  }
165
- return api.getLiveUrl(stillLive);
151
+ api.invalidateCache();
152
+ return api.getLiveUrl(roomId);
166
153
  };
167
- const result = await downloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emit("download:progress", info), getNextUrl);
168
- emit("download:end", {
154
+ const result = await downloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("download:progress", info), getNextUrl);
155
+ emitter.emit("download:end", {
169
156
  file: result.file,
170
157
  duration: result.duration,
171
158
  size: result.size
172
159
  });
173
- setState({ state: "converting" });
174
- if (result.size > 0 && cfg.segmentMinutes >= 1) {
175
- const parsed = parse(result.file);
176
- const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`);
177
- const segmentTime = Math.max(1, cfg.segmentMinutes * 60);
178
- logger.info(`Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`);
179
- emit("segmenting:start", {
180
- input: result.file,
181
- outputPattern
182
- });
183
- try {
184
- await runFfmpeg([
185
- "-i",
186
- result.file,
187
- "-c",
188
- "copy",
189
- "-f",
190
- "segment",
191
- "-segment_time",
192
- String(segmentTime),
193
- "-reset_timestamps",
194
- "1",
195
- "-segment_start_number",
196
- "1",
197
- outputPattern
198
- ]);
199
- const recStartMs = statSync(result.file).mtimeMs - (result.duration ?? 0) * 1e3;
200
- try {
201
- unlinkSync(result.file);
202
- logger.info(`Deleted original: ${parsed.base}`);
203
- } catch {
204
- logger?.warn(`Could not delete original: ${parsed.base}`);
205
- }
206
- const allFiles = readdirSync(parsed.dir);
207
- const prefix = `${parsed.name}_part`;
208
- const segmentFiles = allFiles.filter((f) => f.startsWith(prefix) && f.endsWith(".mp4")).sort((a, b) => {
209
- return Number.parseInt(a.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10) - Number.parseInt(b.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10);
210
- });
211
- const segmentDurationSec = cfg.segmentMinutes * 60;
212
- const totalDuration = result.duration;
213
- for (let i = 0; i < segmentFiles.length; i++) {
214
- const filePath = join(parsed.dir, segmentFiles[i]);
215
- const stats = statSync(filePath);
216
- emit("recording:end", {
217
- file: filePath,
218
- duration: i === segmentFiles.length - 1 ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1)) : segmentDurationSec,
219
- size: stats.size
220
- });
221
- emit("converted", {
222
- input: result.file,
223
- output: filePath
224
- });
225
- }
226
- for (let i = 0; i < segmentFiles.length; i++) {
227
- const fp = join(parsed.dir, segmentFiles[i]);
228
- const segStart = recStartMs + i * segmentDurationSec * 1e3;
229
- utimesSync(fp, new Date(segStart), new Date(segStart));
230
- }
231
- emit("segmenting:end", { segments: segmentFiles.length });
232
- if (audioNormalizer) for (const segFile of segmentFiles) {
233
- const segPath = join(parsed.dir, segFile);
234
- try {
235
- await audioNormalizer.normalize(segPath);
236
- } catch {}
237
- }
238
- setState({
239
- currentFile: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)) : result.file,
240
- sessionDuration: result.duration
241
- });
242
- } catch (err) {
243
- const msg = err instanceof Error ? err.message : String(err);
244
- logger.error(`Segmenting failed: ${msg}`);
245
- emit("converting:start", { input: result.file });
246
- try {
247
- const mp4File = await converter.convert(result.file);
248
- emit("converted", {
249
- input: result.file,
250
- output: mp4File
251
- });
252
- if (audioNormalizer) try {
253
- await audioNormalizer.normalize(mp4File);
254
- } catch {}
255
- } catch (convErr) {
256
- const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
257
- logger.error(`Conversion failed: ${tkErr.message}`);
258
- emit("error", tkErr);
259
- }
260
- }
261
- } else if (result.size > 0) {
262
- emit("converting:start", { input: result.file });
263
- try {
264
- const mp4File = await converter.convert(result.file);
265
- if (audioNormalizer) try {
266
- await audioNormalizer.normalize(mp4File);
267
- } catch {}
268
- emit("recording:end", {
269
- file: mp4File,
270
- duration: result.duration,
271
- size: result.size
272
- });
273
- emit("converted", {
274
- input: result.file,
275
- output: mp4File
160
+ await processRecording(result, cfg, {
161
+ runFfmpeg,
162
+ converter,
163
+ audioNormalizer,
164
+ stopAbortController,
165
+ pendingRemuxes,
166
+ emit: (event, ...args) => emitter.emit(event, ...args),
167
+ setState: stateManager.setState,
168
+ stopRequested: () => stopRequested,
169
+ logger
170
+ });
171
+ if (cfg.duration > 0) {
172
+ logger.info("Duration limit reached — exiting");
173
+ monitor.stopAfterCurrentTick();
174
+ } else if (!stopRequested && roomId) {
175
+ if (!await api.isRoomAlive(roomId)) {
176
+ logger.info(`@${user} is offline`);
177
+ emitter.emit("tick", {
178
+ user,
179
+ isLive: false
276
180
  });
277
- } catch (convErr) {
278
- const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
279
- logger.error(`Conversion failed: ${tkErr.message}`);
280
- emit("error", tkErr);
281
181
  }
282
182
  }
283
- setState({ state: "polling" });
284
183
  }
285
184
  });
286
- setState({ state: "polling" });
185
+ stateManager.setState({ state: "polling" });
287
186
  await monitor.start();
288
187
  }
289
188
  async function stop() {
189
+ if (stateManager.getStatus().state === "stopped") {
190
+ logger.warn(`stop() ignored: already stopped`);
191
+ return;
192
+ }
290
193
  stopRequested = true;
291
194
  logger.info("Stopping recorder...");
195
+ if (pendingRemuxes.length > 0) {
196
+ logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`);
197
+ const abortTimeout = new AbortController();
198
+ await Promise.race([Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()), sleep(6e4, abortTimeout.signal)]);
199
+ pendingRemuxes = [];
200
+ }
292
201
  if (downloader) downloader.abort();
293
202
  if (monitor) await monitor.stop();
203
+ stopAbortController.abort();
294
204
  if (httpClient) await httpClient.close();
295
- setState({ state: "stopped" });
205
+ emitter.clear();
206
+ stateManager.setState({ state: "stopped" });
296
207
  logger.info("Recorder stopped");
297
208
  }
298
209
  return {
299
210
  start,
300
211
  stop,
301
- getStatus,
302
- on
212
+ getStatus: stateManager.getStatus,
213
+ on: emitter.on,
214
+ off: emitter.off
303
215
  };
304
216
  }
305
217
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n *\n * Downloads the full stream (FLV via fetch, HLS via FFmpeg), then uses FFmpeg\n * to split it into time-aligned MP4 segments.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { createStreamDownloader, type StreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n // Internal event registry\n const eventHandlers = new Map<string, Array<(...args: any[]) => void>>()\n\n let state: RecorderStatus = {\n state: \"idle\",\n user: cfg.user,\n }\n\n // State machine\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n\n function setState(partial: Partial<RecorderStatus>): void {\n state = { ...state, ...partial }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = eventHandlers.get(event) ?? []\n list.push(handler as (...args: any[]) => void)\n eventHandlers.set(event, list)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = eventHandlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function getFfmpegPath(): string {\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === \"function\") {\n const path = bunWhich(\"ffmpeg\") as string | undefined\n if (path) return path\n }\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function runFfmpeg(args: string[]): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n proc.on(\"close\", (code) => {\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n stopRequested = false\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) => emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emit(\"tick\", { user, isLive: true, roomId })\n\n // Record the stream\n setState({ state: \"recording\" })\n emit(\"recording:start\", { user, file: \"\" })\n\n // Create a reconnection callback that fetches a fresh stream URL\n // when the current segment ends. This handles TikTok's short-lived\n // stream URLs (typically 30-60s per segment).\n let reconnectCount = 0\n const getNextUrl = async (): Promise<string | null> => {\n reconnectCount++\n if (reconnectCount > 100) {\n // Safety valve: don't reconnect indefinitely\n logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`)\n return null\n }\n // Invalidate cache so we get fresh data from TikTok\n api!.invalidateCache()\n // Check if the user is still live with the same room\n const stillLive = await api!.getRoomId(user)\n if (!stillLive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Get a fresh stream URL for the continuing stream\n return api!.getLiveUrl(stillLive)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n setState({ state: \"converting\" })\n\n if (result.size > 0 && cfg.segmentMinutes >= 1) {\n // Split the FLV into timed MP4 segments using FFmpeg\n const parsed = parse(result.file)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n const segmentTime = Math.max(1, cfg.segmentMinutes * 60)\n\n logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`,\n )\n\n emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n await runFfmpeg([\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ])\n\n // Capture FLV timestamp before deletion (used to timestamp segments)\n const flvMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = flvMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original FLV\n try {\n unlinkSync(result.file)\n logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n logger?.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = cfg.segmentMinutes * 60\n const totalDuration = result.duration\n\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i]!)\n const stats = statSync(filePath)\n // Estimate duration: first N-1 are full segments, last is remainder\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n emit(\"recording:end\", {\n file: filePath,\n duration: estDuration,\n size: stats.size,\n })\n emit(\"converted\", {\n input: result.file,\n output: filePath,\n })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i]!)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (audioNormalizer) {\n for (const segFile of segmentFiles) {\n const segPath = join(parsed.dir, segFile)\n try {\n await audioNormalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback — continue to next segment\n }\n }\n }\n\n setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)!) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n logger.error(`Segmenting failed: ${msg}`)\n // Fallback: try simple conversion of the whole FLV\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n emit(\"converted\", { input: result.file, output: mp4File })\n if (audioNormalizer) {\n try {\n await audioNormalizer.normalize(mp4File)\n } catch {\n // Error already emitted via onError callback\n }\n }\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError\n ? convErr\n : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n } else if (result.size > 0) {\n // No segmenting — simple FLV → MP4 conversion\n emit(\"converting:start\", { input: result.file })\n try {\n const mp4File = await converter!.convert(result.file)\n if (audioNormalizer) {\n try {\n await audioNormalizer.normalize(mp4File)\n } catch {\n // Error already emitted via onError callback\n }\n }\n emit(\"recording:end\", {\n file: mp4File,\n duration: result.duration,\n size: result.size,\n })\n emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n logger.error(`Conversion failed: ${tkErr.message}`)\n emit(\"error\", tkErr)\n }\n }\n\n setState({ state: \"polling\" })\n },\n })\n\n setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n if (httpClient) await httpClient.close()\n setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus,\n on,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { spawn } from \"node:child_process\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport type { RecorderConfig, RecorderController } from \"../config\"\nimport { normalizeConfig, TikTokError } from \"../config\"\nimport { createLogger } from \"../logger\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { sleep } from \"../utils\"\nimport { type Converter, createConverter } from \"./convert\"\nimport { findFfmpegPath } from \"./ffmpeg-utils\"\nimport { type AudioNormalizer, createAudioNormalizer } from \"./normalize\"\nimport { processRecording } from \"./post-processing\"\nimport { createEventEmitter } from \"./recorder-events\"\nimport { createRecorderState } from \"./recorder-state\"\nimport type { DownloadResult } from \"./stream\"\nimport { createStreamDownloader, type StreamDownloader } from \"./stream\"\n\nexport function createRecorder(config: RecorderConfig): RecorderController {\n const cfg = normalizeConfig(config)\n const logger = createLogger({\n level: cfg.logLevel,\n logFile: \"tiktok-recorder.log\",\n console: cfg.logConsole,\n })\n\n const emitter = createEventEmitter()\n const stateManager = createRecorderState({ state: \"idle\" as const, user: cfg.user }, logger)\n\n // State variables\n let httpClient: Awaited<ReturnType<typeof createHttpClient>> | null = null\n let api: TikTokApi | null = null\n let monitor: PollingMonitor | null = null\n let downloader: StreamDownloader | null = null\n let converter: Converter | null = null\n let audioNormalizer: AudioNormalizer | null = null\n let stopRequested = false\n let stopAbortController = new AbortController()\n let pendingRemuxes: Promise<unknown>[] = []\n\n function getFfmpegPath(): string {\n const path = findFfmpegPath()\n if (!path) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n return path\n }\n\n function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = getFfmpegPath()\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.on(\"close\", (code) => {\n if (signal?.aborted) {\n reject(new TikTokError(\"unknown\", \"Aborted by user\"))\n return\n }\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n if (err instanceof Error && err.name === \"AbortError\") return\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n const user = cfg.user\n api!.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await api!.getRoomId(user)\n if (!roomId) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n return\n }\n\n const liveUrl = await api!.getLiveUrl(roomId)\n if (!liveUrl) {\n logger.warn(`@${user} is live but no stream URL found`)\n emitter.emit(\"tick\", { user, isLive: false, roomId })\n return\n }\n\n logger.info(`@${user} is LIVE! (room: ${roomId})`)\n emitter.emit(\"tick\", { user, isLive: true, roomId })\n\n stateManager.setState({ state: \"recording\" })\n emitter.emit(\"recording:start\", { user, file: \"\" })\n\n const getNextUrl = async (): Promise<string | null> => {\n // Use the lightweight check_alive endpoint first — avoids a full\n // page re-scan + room/info call when the stream is still active.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is no longer live — stopping`)\n return null\n }\n // Still alive — invalidate cache and get a fresh stream URL.\n api!.invalidateCache()\n return api!.getLiveUrl(roomId)\n }\n\n const result = await downloader!.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n )\n\n emitter.emit(\"download:end\", {\n file: result.file,\n duration: result.duration,\n size: result.size,\n })\n\n await processRecording(result as DownloadResult, cfg, {\n runFfmpeg,\n converter: converter!,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (event: string, ...args: any[]) => (emitter as any).emit(event, ...args),\n setState: stateManager.setState,\n stopRequested: () => stopRequested,\n logger,\n })\n\n // One-shot mode: if a duration was set, stop after this recording\n if (cfg.duration > 0) {\n logger.info(\"Duration limit reached — exiting\")\n monitor!.stopAfterCurrentTick()\n } else if (!stopRequested && roomId) {\n // Recording ended because the stream went offline (not user-initiated stop).\n // Immediately re-check via lightweight check_alive endpoint instead of\n // waiting for the next poll interval.\n const alive = await api!.isRoomAlive(roomId)\n if (!alive) {\n logger.info(`@${user} is offline`)\n emitter.emit(\"tick\", { user, isLive: false })\n }\n }\n },\n })\n\n stateManager.setState({ state: \"polling\" })\n await monitor.start()\n }\n\n async function stop(): Promise<void> {\n if (stateManager.getStatus().state === \"stopped\") {\n logger.warn(`stop() ignored: already stopped`)\n return\n }\n stopRequested = true\n logger.info(\"Stopping recorder...\")\n\n if (pendingRemuxes.length > 0) {\n logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(60_000, abortTimeout.signal),\n ])\n pendingRemuxes = []\n }\n\n if (downloader) downloader.abort()\n if (monitor) await monitor.stop()\n stopAbortController.abort()\n if (httpClient) await httpClient.close()\n emitter.clear()\n stateManager.setState({ state: \"stopped\" })\n logger.info(\"Recorder stopped\")\n }\n\n return {\n start,\n stop,\n getStatus: stateManager.getStatus,\n on: emitter.on,\n off: emitter.off,\n }\n}\n"],"mappings":"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"}