@zfadhli/tokrec 0.7.1 → 0.8.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/README.md +129 -76
- package/dist/api/client.mjs +45 -19
- package/dist/api/client.mjs.map +1 -1
- package/dist/api/rate-limiter.mjs +29 -0
- package/dist/api/rate-limiter.mjs.map +1 -0
- package/dist/api/tiktok.mjs +180 -51
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +8 -11
- package/dist/browser-cookies.mjs.map +1 -1
- package/dist/cli.mjs +10 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/config.d.mts +18 -6
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +7 -3
- package/dist/config.mjs.map +1 -1
- package/dist/index.mjs +22 -4
- package/dist/index.mjs.map +1 -1
- package/dist/monitor.mjs +12 -13
- package/dist/monitor.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs +25 -25
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/download-hls.mjs +77 -0
- package/dist/recorder/download-hls.mjs.map +1 -0
- package/dist/recorder/download-stream.mjs +78 -0
- package/dist/recorder/download-stream.mjs.map +1 -0
- package/dist/recorder/ffmpeg-utils.mjs +122 -0
- package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/recorder/index.d.mts.map +1 -1
- package/dist/recorder/index.mjs +82 -178
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/post-processing.mjs +150 -0
- package/dist/recorder/post-processing.mjs.map +1 -0
- package/dist/recorder/recorder-events.mjs +34 -0
- package/dist/recorder/recorder-events.mjs.map +1 -0
- package/dist/recorder/recorder-state.mjs +38 -0
- package/dist/recorder/recorder-state.mjs.map +1 -0
- package/dist/recorder/stream.mjs +5 -293
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/utils.mjs +19 -3
- package/dist/utils.mjs.map +1 -1
- 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":";;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/recorder/index.ts"],"mappings":";;;iBAsBgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAkB"}
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -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
|
|
28
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
61
|
-
if (
|
|
62
|
-
|
|
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, {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,77 @@ 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
emit(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
276
|
-
});
|
|
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
|
-
}
|
|
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();
|
|
282
174
|
}
|
|
283
|
-
setState({ state: "polling" });
|
|
284
175
|
}
|
|
285
176
|
});
|
|
286
|
-
setState({ state: "polling" });
|
|
177
|
+
stateManager.setState({ state: "polling" });
|
|
287
178
|
await monitor.start();
|
|
288
179
|
}
|
|
289
180
|
async function stop() {
|
|
181
|
+
if (stateManager.getStatus().state === "stopped") {
|
|
182
|
+
logger.warn(`stop() ignored: already stopped`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
290
185
|
stopRequested = true;
|
|
291
186
|
logger.info("Stopping recorder...");
|
|
187
|
+
if (pendingRemuxes.length > 0) {
|
|
188
|
+
logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 60s)...`);
|
|
189
|
+
const abortTimeout = new AbortController();
|
|
190
|
+
await Promise.race([Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()), sleep(6e4, abortTimeout.signal)]);
|
|
191
|
+
pendingRemuxes = [];
|
|
192
|
+
}
|
|
292
193
|
if (downloader) downloader.abort();
|
|
293
194
|
if (monitor) await monitor.stop();
|
|
195
|
+
stopAbortController.abort();
|
|
294
196
|
if (httpClient) await httpClient.close();
|
|
295
|
-
|
|
197
|
+
emitter.clear();
|
|
198
|
+
stateManager.setState({ state: "stopped" });
|
|
296
199
|
logger.info("Recorder stopped");
|
|
297
200
|
}
|
|
298
201
|
return {
|
|
299
202
|
start,
|
|
300
203
|
stop,
|
|
301
|
-
getStatus,
|
|
302
|
-
on
|
|
204
|
+
getStatus: stateManager.getStatus,
|
|
205
|
+
on: emitter.on,
|
|
206
|
+
off: emitter.off
|
|
303
207
|
};
|
|
304
208
|
}
|
|
305
209
|
//#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 }\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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"}
|