@zfadhli/tokrec 0.2.1

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.
@@ -0,0 +1,84 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ //#region src/logger.ts
4
+ /**
5
+ * Logger — rotating file + console with levels.
6
+ * Zero-dependency minimal implementation (~60 lines).
7
+ */
8
+ const LEVEL_ORDER = {
9
+ debug: 0,
10
+ info: 1,
11
+ warn: 2,
12
+ error: 3
13
+ };
14
+ const LEVEL_COLORS = {
15
+ debug: "\x1B[90m",
16
+ info: "\x1B[36m",
17
+ warn: "\x1B[33m",
18
+ error: "\x1B[31m"
19
+ };
20
+ const RESET = "\x1B[0m";
21
+ function createLogger(opts) {
22
+ const level = opts.level ?? "info";
23
+ const logFile = opts.logFile ?? "tiktok-recorder.log";
24
+ const maxSize = opts.maxSize ?? 5 * 1024 * 1024;
25
+ const backups = opts.backups ?? 3;
26
+ const showConsole = opts.console !== false;
27
+ const logDir = dirname(logFile);
28
+ if (logDir !== ".") mkdirSync(logDir, { recursive: true });
29
+ function rotateIfNeeded() {
30
+ try {
31
+ if (existsSync(logFile)) {
32
+ if (statSync(logFile).size >= maxSize) {
33
+ const lastBackup = `${logFile}.${backups}`;
34
+ if (existsSync(lastBackup)) try {
35
+ renameSync(lastBackup, `${lastBackup}.old`);
36
+ } catch {}
37
+ for (let i = backups - 1; i >= 1; i--) {
38
+ const src = `${logFile}.${i}`;
39
+ const dst = `${logFile}.${i + 1}`;
40
+ if (existsSync(src)) try {
41
+ renameSync(src, dst);
42
+ } catch {}
43
+ }
44
+ try {
45
+ renameSync(logFile, `${logFile}.1`);
46
+ } catch {}
47
+ }
48
+ }
49
+ } catch {}
50
+ }
51
+ try {
52
+ if (!existsSync(logFile)) writeFileSync(logFile, "", "utf-8");
53
+ } catch {}
54
+ function write(level, msg, args) {
55
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
56
+ const suffix = args.length > 0 ? ` ${args.map((a) => String(a)).join(" ")}` : "";
57
+ const line = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`;
58
+ const formatted = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`;
59
+ if (showConsole) if (level === "error") process.stderr.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\n`);
60
+ else process.stdout.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\n`);
61
+ try {
62
+ rotateIfNeeded();
63
+ appendFileSync(logFile, `${line}\n`, "utf-8");
64
+ } catch {}
65
+ }
66
+ return {
67
+ debug: (msg, ...args) => {
68
+ if (LEVEL_ORDER.debug >= LEVEL_ORDER[level]) write("debug", msg, args);
69
+ },
70
+ info: (msg, ...args) => {
71
+ if (LEVEL_ORDER.info >= LEVEL_ORDER[level]) write("info", msg, args);
72
+ },
73
+ warn: (msg, ...args) => {
74
+ if (LEVEL_ORDER.warn >= LEVEL_ORDER[level]) write("warn", msg, args);
75
+ },
76
+ error: (msg, ...args) => {
77
+ if (LEVEL_ORDER.error >= LEVEL_ORDER[level]) write("error", msg, args);
78
+ }
79
+ };
80
+ }
81
+ //#endregion
82
+ export { createLogger };
83
+
84
+ //# sourceMappingURL=logger.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.mjs","names":[],"sources":["../src/logger.ts"],"sourcesContent":["/**\n * Logger — rotating file + console with levels.\n * Zero-dependency minimal implementation (~60 lines).\n */\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from 'node:fs'\nimport { dirname } from 'node:path'\n\nexport type LogLevel = 'debug' | 'info' | 'warn' | 'error'\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n}\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: '\\x1b[90m', // gray\n info: '\\x1b[36m', // cyan\n warn: '\\x1b[33m', // yellow\n error: '\\x1b[31m', // red\n}\n\nconst RESET = '\\x1b[0m'\n\nexport interface Logger {\n debug: (msg: string, ...args: unknown[]) => void\n info: (msg: string, ...args: unknown[]) => void\n warn: (msg: string, ...args: unknown[]) => void\n error: (msg: string, ...args: unknown[]) => void\n}\n\nexport function createLogger(opts: {\n level?: LogLevel\n logFile?: string\n maxSize?: number\n backups?: number\n /** When true (default), writes to stdout/stderr. Set false for file-only logging. */\n console?: boolean\n}): Logger {\n const level = opts.level ?? 'info'\n const logFile = opts.logFile ?? 'tiktok-recorder.log'\n const maxSize = opts.maxSize ?? 5 * 1024 * 1024 // 5 MB\n const backups = opts.backups ?? 3\n const showConsole = opts.console !== false // default true\n\n // Ensure parent directory exists\n const logDir = dirname(logFile)\n if (logDir !== '.') {\n mkdirSync(logDir, { recursive: true })\n }\n\n function rotateIfNeeded(): void {\n try {\n if (existsSync(logFile)) {\n const size = statSync(logFile).size\n if (size >= maxSize) {\n // Remove the oldest backup\n const lastBackup = `${logFile}.${backups}`\n if (existsSync(lastBackup)) {\n try {\n renameSync(lastBackup, `${lastBackup}.old`)\n } catch {\n // Best-effort cleanup\n }\n }\n // Shift backups\n for (let i = backups - 1; i >= 1; i--) {\n const src = `${logFile}.${i}`\n const dst = `${logFile}.${i + 1}`\n if (existsSync(src)) {\n try {\n renameSync(src, dst)\n } catch {\n // Best-effort\n }\n }\n }\n // Rename current → .1\n try {\n renameSync(logFile, `${logFile}.1`)\n } catch {\n // Best-effort\n }\n }\n }\n } catch {\n // Silent — never throw in logger\n }\n }\n\n // Touch the file\n try {\n if (!existsSync(logFile)) {\n writeFileSync(logFile, '', 'utf-8')\n }\n } catch {\n // Best-effort\n }\n\n function write(level: LogLevel, msg: string, args: unknown[]): void {\n const ts = new Date().toISOString()\n const suffix = args.length > 0 ? ` ${args.map((a) => String(a)).join(' ')}` : ''\n const line = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`\n const formatted = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`\n\n // Console (optional — can be suppressed for file-only logging)\n if (showConsole) {\n if (level === 'error') {\n process.stderr.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\\n`)\n } else {\n process.stdout.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\\n`)\n }\n }\n\n // File\n try {\n rotateIfNeeded()\n appendFileSync(logFile, `${line}\\n`, 'utf-8')\n } catch {\n // Best-effort\n }\n }\n\n return {\n debug: (msg, ...args) => {\n if (LEVEL_ORDER.debug >= LEVEL_ORDER[level]) write('debug', msg, args)\n },\n info: (msg, ...args) => {\n if (LEVEL_ORDER.info >= LEVEL_ORDER[level]) write('info', msg, args)\n },\n warn: (msg, ...args) => {\n if (LEVEL_ORDER.warn >= LEVEL_ORDER[level]) write('warn', msg, args)\n },\n error: (msg, ...args) => {\n if (LEVEL_ORDER.error >= LEVEL_ORDER[level]) write('error', msg, args)\n },\n }\n}\n"],"mappings":";;;;;;;AAUA,MAAM,cAAwC;CAC5C,OAAO;CACP,MAAM;CACN,MAAM;CACN,OAAO;AACT;AAEA,MAAM,eAAyC;CAC7C,OAAO;CACP,MAAM;CACN,MAAM;CACN,OAAO;AACT;AAEA,MAAM,QAAQ;AASd,SAAgB,aAAa,MAOlB;CACT,MAAM,QAAQ,KAAK,SAAS;CAC5B,MAAM,UAAU,KAAK,WAAW;CAChC,MAAM,UAAU,KAAK,WAAW,IAAI,OAAO;CAC3C,MAAM,UAAU,KAAK,WAAW;CAChC,MAAM,cAAc,KAAK,YAAY;CAGrC,MAAM,SAAS,QAAQ,OAAO;CAC9B,IAAI,WAAW,KACb,UAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;CAGvC,SAAS,iBAAuB;EAC9B,IAAI;GACF,IAAI,WAAW,OAAO;QACP,SAAS,OAAO,CAAC,CAAC,QACnB,SAAS;KAEnB,MAAM,aAAa,GAAG,QAAQ,GAAG;KACjC,IAAI,WAAW,UAAU,GACvB,IAAI;MACF,WAAW,YAAY,GAAG,WAAW,KAAK;KAC5C,QAAQ,CAER;KAGF,KAAK,IAAI,IAAI,UAAU,GAAG,KAAK,GAAG,KAAK;MACrC,MAAM,MAAM,GAAG,QAAQ,GAAG;MAC1B,MAAM,MAAM,GAAG,QAAQ,GAAG,IAAI;MAC9B,IAAI,WAAW,GAAG,GAChB,IAAI;OACF,WAAW,KAAK,GAAG;MACrB,QAAQ,CAER;KAEJ;KAEA,IAAI;MACF,WAAW,SAAS,GAAG,QAAQ,GAAG;KACpC,QAAQ,CAER;IACF;;EAEJ,QAAQ,CAER;CACF;CAGA,IAAI;EACF,IAAI,CAAC,WAAW,OAAO,GACrB,cAAc,SAAS,IAAI,OAAO;CAEtC,QAAQ,CAER;CAEA,SAAS,MAAM,OAAiB,KAAa,MAAuB;EAClE,MAAM,sBAAK,IAAI,KAAK,EAAA,CAAE,YAAY;EAClC,MAAM,SAAS,KAAK,SAAS,IAAI,IAAI,KAAK,KAAK,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM;EAC9E,MAAM,OAAO,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,IAAI,MAAM;EACvD,MAAM,YAAY,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,IAAI,MAAM;EAG5D,IAAI,aACF,IAAI,UAAU,SACZ,QAAQ,OAAO,MAAM,GAAG,aAAa,SAAS,YAAY,MAAM,GAAG;OAEnE,QAAQ,OAAO,MAAM,GAAG,aAAa,SAAS,YAAY,MAAM,GAAG;EAKvE,IAAI;GACF,eAAe;GACf,eAAe,SAAS,GAAG,KAAK,KAAK,OAAO;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EACL,QAAQ,KAAK,GAAG,SAAS;GACvB,IAAI,YAAY,SAAS,YAAY,QAAQ,MAAM,SAAS,KAAK,IAAI;EACvE;EACA,OAAO,KAAK,GAAG,SAAS;GACtB,IAAI,YAAY,QAAQ,YAAY,QAAQ,MAAM,QAAQ,KAAK,IAAI;EACrE;EACA,OAAO,KAAK,GAAG,SAAS;GACtB,IAAI,YAAY,QAAQ,YAAY,QAAQ,MAAM,QAAQ,KAAK,IAAI;EACrE;EACA,QAAQ,KAAK,GAAG,SAAS;GACvB,IAAI,YAAY,SAAS,YAAY,QAAQ,MAAM,SAAS,KAAK,IAAI;EACvE;CACF;AACF"}
@@ -0,0 +1,43 @@
1
+ import { sleep } from "./utils.mjs";
2
+ //#region src/monitor.ts
3
+ function createPollingMonitor(opts) {
4
+ const intervalMs = opts.intervalMinutes * 60 * 1e3;
5
+ let active = true;
6
+ let currentTick = null;
7
+ async function start() {
8
+ active = true;
9
+ opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`);
10
+ while (active) {
11
+ currentTick = opts.onTick();
12
+ try {
13
+ await currentTick;
14
+ } catch (err) {
15
+ const msg = err instanceof Error ? err.message : String(err);
16
+ opts.logger?.error(`Tick error: ${msg}`);
17
+ } finally {
18
+ currentTick = null;
19
+ }
20
+ if (!active) break;
21
+ const checkInterval = 1e3;
22
+ let waited = 0;
23
+ while (waited < intervalMs) {
24
+ if (!active) break;
25
+ await sleep(Math.min(checkInterval, intervalMs - waited));
26
+ waited += checkInterval;
27
+ }
28
+ }
29
+ opts.logger?.info("Polling stopped");
30
+ }
31
+ async function stop() {
32
+ active = false;
33
+ if (currentTick) await currentTick;
34
+ }
35
+ return {
36
+ start,
37
+ stop
38
+ };
39
+ }
40
+ //#endregion
41
+ export { createPollingMonitor };
42
+
43
+ //# sourceMappingURL=monitor.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"monitor.mjs","names":[],"sources":["../src/monitor.ts"],"sourcesContent":["/**\n * Polling monitor — calls a tick function on a configurable interval.\n * Fires the first tick immediately, then every N minutes.\n */\n\nimport type { Logger } from './logger'\nimport { sleep } from './utils'\n\nexport interface PollingMonitor {\n start: () => Promise<void>\n stop: () => Promise<void>\n}\n\nexport function createPollingMonitor(opts: {\n intervalMinutes: number\n onTick: () => Promise<void>\n logger?: Logger\n}): PollingMonitor {\n const intervalMs = opts.intervalMinutes * 60 * 1000\n let active = true\n let currentTick: Promise<void> | null = null\n\n async function start(): Promise<void> {\n active = true\n opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`)\n\n while (active) {\n currentTick = opts.onTick()\n try {\n await currentTick\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n opts.logger?.error(`Tick error: ${msg}`)\n } finally {\n currentTick = null\n }\n\n if (!active) break\n\n // Wait for the interval, checking periodically if we should stop\n const checkInterval = 1000 // check every second\n let waited = 0\n while (waited < intervalMs) {\n if (!active) break\n await sleep(Math.min(checkInterval, intervalMs - waited))\n waited += checkInterval\n }\n }\n\n opts.logger?.info('Polling stopped')\n }\n\n async function stop(): Promise<void> {\n active = false\n // Wait for in-flight tick to finish (e.g. recording → converting → MP4)\n if (currentTick) {\n await currentTick\n }\n }\n\n return { start, stop }\n}\n"],"mappings":";;AAaA,SAAgB,qBAAqB,MAIlB;CACjB,MAAM,aAAa,KAAK,kBAAkB,KAAK;CAC/C,IAAI,SAAS;CACb,IAAI,cAAoC;CAExC,eAAe,QAAuB;EACpC,SAAS;EACT,KAAK,QAAQ,KAAK,8BAA8B,KAAK,gBAAgB,MAAM;EAE3E,OAAO,QAAQ;GACb,cAAc,KAAK,OAAO;GAC1B,IAAI;IACF,MAAM;GACR,SAAS,KAAK;IACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IAC3D,KAAK,QAAQ,MAAM,eAAe,KAAK;GACzC,UAAU;IACR,cAAc;GAChB;GAEA,IAAI,CAAC,QAAQ;GAGb,MAAM,gBAAgB;GACtB,IAAI,SAAS;GACb,OAAO,SAAS,YAAY;IAC1B,IAAI,CAAC,QAAQ;IACb,MAAM,MAAM,KAAK,IAAI,eAAe,aAAa,MAAM,CAAC;IACxD,UAAU;GACZ;EACF;EAEA,KAAK,QAAQ,KAAK,iBAAiB;CACrC;CAEA,eAAe,OAAsB;EACnC,SAAS;EAET,IAAI,aACF,MAAM;CAEV;CAEA,OAAO;EAAE;EAAO;CAAK;AACvB"}
@@ -0,0 +1,67 @@
1
+ import { spawn } from "node:child_process";
2
+ import { statSync, unlinkSync } from "node:fs";
3
+ //#region src/recorder/convert.ts
4
+ /**
5
+ * Converter — spawns FFmpeg to convert FLV → MP4 (stream copy by default).
6
+ * Deletes the original FLV on success.
7
+ */
8
+ function createConverter(logger) {
9
+ async function convert(input) {
10
+ const output = input.replace(/\.flv$/i, ".mp4");
11
+ logger?.info(`Converting: ${input} → ${output}`);
12
+ const ffmpegPath = findFfmpeg();
13
+ if (!ffmpegPath) throw new Error("FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
14
+ await runFfmpeg(ffmpegPath, input, output);
15
+ try {
16
+ unlinkSync(input);
17
+ logger?.info(`Deleted original: ${input}`);
18
+ } catch {
19
+ logger?.warn(`Could not delete original: ${input}`);
20
+ }
21
+ logger?.info(`Converted: ${output}`);
22
+ return output;
23
+ }
24
+ return { convert };
25
+ }
26
+ function findFfmpeg() {
27
+ const bunWhich = Bun?.which;
28
+ if (typeof bunWhich === "function") return bunWhich("ffmpeg");
29
+ const paths = process.env.PATH?.split(":") ?? [];
30
+ for (const dir of paths) try {
31
+ const full = `${dir}/ffmpeg`;
32
+ statSync(full);
33
+ return full;
34
+ } catch {}
35
+ return null;
36
+ }
37
+ function runFfmpeg(ffmpegPath, input, output) {
38
+ return new Promise((resolve, reject) => {
39
+ const proc = spawn(ffmpegPath, [
40
+ "-i",
41
+ input,
42
+ "-c",
43
+ "copy",
44
+ "-y",
45
+ output
46
+ ], { stdio: [
47
+ "ignore",
48
+ "pipe",
49
+ "pipe"
50
+ ] });
51
+ let stderr = "";
52
+ proc.stderr?.on("data", (chunk) => {
53
+ stderr += chunk.toString();
54
+ });
55
+ proc.on("close", (code) => {
56
+ if (code === 0) resolve();
57
+ else reject(/* @__PURE__ */ new Error(`FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
58
+ });
59
+ proc.on("error", (err) => {
60
+ reject(/* @__PURE__ */ new Error(`Failed to spawn FFmpeg: ${err.message}`));
61
+ });
62
+ });
63
+ }
64
+ //#endregion
65
+ export { createConverter };
66
+
67
+ //# sourceMappingURL=convert.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert FLV → MP4 (stream copy by default).\n * Deletes the original FLV on success.\n */\n\nimport { spawn } from 'node:child_process'\nimport { statSync, unlinkSync } from 'node:fs'\nimport type { Logger } from '../logger'\n\nexport interface Converter {\n /** Convert a FLV file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.flv$/i, '.mp4')\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpeg()\n if (!ffmpegPath) {\n throw new Error(\n 'FFmpeg not found. Install it:\\n' +\n ' Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n' +\n ' macOS: brew install ffmpeg\\n' +\n ' Windows: choco install ffmpeg',\n )\n }\n\n await runFfmpeg(ffmpegPath, input, output)\n\n // Delete original FLV\n try {\n unlinkSync(input)\n logger?.info(`Deleted original: ${input}`)\n } catch {\n logger?.warn(`Could not delete original: ${input}`)\n }\n\n logger?.info(`Converted: ${output}`)\n return output\n }\n\n return { convert }\n}\n\nfunction findFfmpeg(): string | null {\n // Bun.which searches PATH like `which`\n const bunWhich = (Bun as any)?.which\n if (typeof bunWhich === 'function') {\n return bunWhich('ffmpeg') as string | null\n }\n\n // Fallback: manual PATH search\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 // eslint-disable-line no-empty\n }\n }\n return null\n}\n\nfunction runFfmpeg(ffmpegPath: string, input: string, output: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const args = [\n '-i',\n input,\n '-c',\n 'copy', // stream copy — no re-encode, fast\n '-y', // overwrite output\n output,\n ]\n\n const proc = spawn(ffmpegPath, args, {\n stdio: ['ignore', 'pipe', 'pipe'],\n })\n\n let stderr = ''\n\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(new Error(`FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`))\n }\n })\n\n proc.on('error', (err) => {\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n}\n"],"mappings":";;;;;;;AAcA,SAAgB,gBAAgB,QAA4B;CAC1D,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,WAAW,MAAM;EAE9C,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,WAAW;EAC9B,IAAI,CAAC,YACH,MAAM,IAAI,MACR,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,MAAM;EAGzC,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,OAAO,EAAE,QAAQ;AACnB;AAEA,SAAS,aAA4B;CAEnC,MAAM,WAAY,KAAa;CAC/B,IAAI,OAAO,aAAa,YACtB,OAAO,SAAS,QAAQ;CAI1B,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;AAEA,SAAS,UAAU,YAAoB,OAAe,QAA+B;CACnF,OAAO,IAAI,SAAS,SAAS,WAAW;EAUtC,MAAM,OAAO,MAAM,YAAY;GAR7B;GACA;GACA;GACA;GACA;GACA;EAGgC,GAAG,EACnC,OAAO;GAAC;GAAU;GAAQ;EAAM,EAClC,CAAC;EAED,IAAI,SAAS;EAEb,KAAK,QAAQ,GAAG,SAAS,UAAkB;GACzC,UAAU,MAAM,SAAS;EAC3B,CAAC;EAED,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,SAAS,GACX,QAAQ;QAER,uBAAO,IAAI,MAAM,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAAC;EAE9E,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,uBAAO,IAAI,MAAM,2BAA2B,IAAI,SAAS,CAAC;EAC5D,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,7 @@
1
+ import { RecorderConfig, RecorderController } from "../config.mjs";
2
+
3
+ //#region src/recorder/index.d.ts
4
+ declare function createRecorder(config: RecorderConfig): RecorderController;
5
+ //#endregion
6
+ export { createRecorder };
7
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/recorder/index.ts"],"mappings":";;;iBA0BgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAkB"}
@@ -0,0 +1,267 @@
1
+ import { createHttpClient } from "../api/client.mjs";
2
+ import { createTikTokApi } from "../api/tiktok.mjs";
3
+ import { TikTokError, normalizeConfig } from "../config.mjs";
4
+ import { createLogger } from "../logger.mjs";
5
+ import { createPollingMonitor } from "../monitor.mjs";
6
+ import { createConverter } from "./convert.mjs";
7
+ import { createStreamDownloader } from "./stream.mjs";
8
+ import { spawn } from "node:child_process";
9
+ import { readdirSync, statSync, unlinkSync } from "node:fs";
10
+ import { join, parse } from "node:path";
11
+ //#region src/recorder/index.ts
12
+ /**
13
+ * Recorder orchestrator — wires together the API, monitor, stream downloader,
14
+ * and converter into a single RecorderController.
15
+ *
16
+ * Downloads the full stream as a single FLV, then uses FFmpeg to split it
17
+ * into time-aligned MP4 segments.
18
+ */
19
+ function createRecorder(config) {
20
+ const cfg = normalizeConfig(config);
21
+ const logger = createLogger({
22
+ level: cfg.logLevel,
23
+ logFile: "tiktok-recorder.log",
24
+ console: cfg.logConsole
25
+ });
26
+ const eventHandlers = /* @__PURE__ */ new Map();
27
+ let state = {
28
+ state: "idle",
29
+ user: cfg.user
30
+ };
31
+ let httpClient = null;
32
+ let api = null;
33
+ let monitor = null;
34
+ let downloader = null;
35
+ let converter = null;
36
+ let stopRequested = false;
37
+ function setState(partial) {
38
+ state = {
39
+ ...state,
40
+ ...partial
41
+ };
42
+ }
43
+ function getStatus() {
44
+ return { ...state };
45
+ }
46
+ function on(event, handler) {
47
+ const list = eventHandlers.get(event) ?? [];
48
+ list.push(handler);
49
+ eventHandlers.set(event, list);
50
+ }
51
+ function emit(event, ...args) {
52
+ const list = eventHandlers.get(event);
53
+ if (list) for (const h of list) try {
54
+ h(...args);
55
+ } catch {}
56
+ }
57
+ function getFfmpegPath() {
58
+ const bunWhich = Bun?.which;
59
+ if (typeof bunWhich === "function") {
60
+ const path = bunWhich("ffmpeg");
61
+ if (path) return path;
62
+ }
63
+ throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
64
+ }
65
+ function runFfmpeg(args) {
66
+ return new Promise((resolve, reject) => {
67
+ const proc = spawn(getFfmpegPath(), args, { stdio: [
68
+ "ignore",
69
+ "pipe",
70
+ "pipe"
71
+ ] });
72
+ let stderr = "";
73
+ proc.stderr?.on("data", (chunk) => {
74
+ stderr += chunk.toString();
75
+ });
76
+ proc.on("close", (code) => {
77
+ if (code === 0) resolve();
78
+ else reject(new TikTokError("unknown", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
79
+ });
80
+ proc.on("error", (err) => {
81
+ reject(new TikTokError("unknown", `Failed to spawn FFmpeg: ${err.message}`));
82
+ });
83
+ });
84
+ }
85
+ async function start() {
86
+ stopRequested = false;
87
+ logger.info(`Starting recorder for @${cfg.user}`);
88
+ httpClient = await createHttpClient(cfg);
89
+ api = createTikTokApi(httpClient);
90
+ downloader = createStreamDownloader(logger);
91
+ converter = createConverter(logger);
92
+ monitor = createPollingMonitor({
93
+ intervalMinutes: cfg.interval,
94
+ logger,
95
+ onTick: async () => {
96
+ if (stopRequested) return;
97
+ const user = cfg.user;
98
+ api.invalidateCache();
99
+ emit("checking", { user });
100
+ logger.info(`Checking @${user}...`);
101
+ const roomId = await api.getRoomId(user);
102
+ if (!roomId) {
103
+ logger.info(`@${user} is offline`);
104
+ emit("tick", {
105
+ user,
106
+ isLive: false
107
+ });
108
+ return;
109
+ }
110
+ const liveUrl = await api.getLiveUrl(roomId);
111
+ if (!liveUrl) {
112
+ logger.warn(`@${user} is live but no stream URL found`);
113
+ emit("tick", {
114
+ user,
115
+ isLive: false,
116
+ roomId
117
+ });
118
+ return;
119
+ }
120
+ logger.info(`@${user} is LIVE! (room: ${roomId})`);
121
+ emit("tick", {
122
+ user,
123
+ isLive: true,
124
+ roomId
125
+ });
126
+ setState({ state: "recording" });
127
+ emit("recording:start", {
128
+ user,
129
+ file: ""
130
+ });
131
+ let reconnectCount = 0;
132
+ const getNextUrl = async () => {
133
+ reconnectCount++;
134
+ if (reconnectCount > 100) {
135
+ logger.warn(`Reconnection limit reached (${reconnectCount} attempts)`);
136
+ return null;
137
+ }
138
+ api.invalidateCache();
139
+ const stillLive = await api.getRoomId(user);
140
+ if (!stillLive) {
141
+ logger.info(`@${user} is no longer live — stopping`);
142
+ return null;
143
+ }
144
+ return api.getLiveUrl(stillLive);
145
+ };
146
+ const result = await downloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emit("download:progress", info), getNextUrl);
147
+ emit("download:end", {
148
+ file: result.file,
149
+ duration: result.duration,
150
+ size: result.size
151
+ });
152
+ setState({ state: "converting" });
153
+ if (result.size > 0 && cfg.segmentMinutes >= 1) {
154
+ const parsed = parse(result.file);
155
+ const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`);
156
+ const segmentTime = Math.max(1, cfg.segmentMinutes * 60);
157
+ logger.info(`Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes} min each)`);
158
+ emit("segmenting:start", {
159
+ input: result.file,
160
+ outputPattern
161
+ });
162
+ try {
163
+ await runFfmpeg([
164
+ "-i",
165
+ result.file,
166
+ "-c",
167
+ "copy",
168
+ "-f",
169
+ "segment",
170
+ "-segment_time",
171
+ String(segmentTime),
172
+ "-reset_timestamps",
173
+ "1",
174
+ outputPattern
175
+ ]);
176
+ try {
177
+ unlinkSync(result.file);
178
+ logger.info(`Deleted original: ${parsed.base}`);
179
+ } catch {
180
+ logger?.warn(`Could not delete original: ${parsed.base}`);
181
+ }
182
+ const allFiles = readdirSync(parsed.dir);
183
+ const prefix = `${parsed.name}_part`;
184
+ const segmentFiles = allFiles.filter((f) => f.startsWith(prefix) && f.endsWith(".mp4")).sort((a, b) => {
185
+ return Number.parseInt(a.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10) - Number.parseInt(b.match(/_part(\d+)\.mp4$/)?.[1] ?? "0", 10);
186
+ });
187
+ const segmentDurationSec = cfg.segmentMinutes * 60;
188
+ const totalDuration = result.duration;
189
+ for (let i = 0; i < segmentFiles.length; i++) {
190
+ const filePath = join(parsed.dir, segmentFiles[i]);
191
+ const stats = statSync(filePath);
192
+ emit("recording:end", {
193
+ file: filePath,
194
+ duration: i === segmentFiles.length - 1 ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1)) : segmentDurationSec,
195
+ size: stats.size
196
+ });
197
+ emit("converted", {
198
+ input: result.file,
199
+ output: filePath
200
+ });
201
+ }
202
+ emit("segmenting:end", { segments: segmentFiles.length });
203
+ setState({
204
+ currentFile: segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1)) : result.file,
205
+ sessionDuration: result.duration
206
+ });
207
+ } catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ logger.error(`Segmenting failed: ${msg}`);
210
+ emit("converting:start", { input: result.file });
211
+ try {
212
+ const mp4File = await converter.convert(result.file);
213
+ emit("converted", {
214
+ input: result.file,
215
+ output: mp4File
216
+ });
217
+ } catch (convErr) {
218
+ const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
219
+ logger.error(`Conversion failed: ${tkErr.message}`);
220
+ emit("error", tkErr);
221
+ }
222
+ }
223
+ } else if (result.size > 0) {
224
+ emit("converting:start", { input: result.file });
225
+ try {
226
+ const mp4File = await converter.convert(result.file);
227
+ emit("recording:end", {
228
+ file: mp4File,
229
+ duration: result.duration,
230
+ size: result.size
231
+ });
232
+ emit("converted", {
233
+ input: result.file,
234
+ output: mp4File
235
+ });
236
+ } catch (convErr) {
237
+ const tkErr = convErr instanceof TikTokError ? convErr : new TikTokError("unknown", String(convErr));
238
+ logger.error(`Conversion failed: ${tkErr.message}`);
239
+ emit("error", tkErr);
240
+ }
241
+ }
242
+ setState({ state: "polling" });
243
+ }
244
+ });
245
+ setState({ state: "polling" });
246
+ await monitor.start();
247
+ }
248
+ async function stop() {
249
+ stopRequested = true;
250
+ logger.info("Stopping recorder...");
251
+ if (downloader) downloader.abort();
252
+ if (monitor) await monitor.stop();
253
+ if (httpClient) await httpClient.close();
254
+ setState({ state: "stopped" });
255
+ logger.info("Recorder stopped");
256
+ }
257
+ return {
258
+ start,
259
+ stop,
260
+ getStatus,
261
+ on
262
+ };
263
+ }
264
+ //#endregion
265
+ export { createRecorder };
266
+
267
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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 as a single FLV, then uses FFmpeg to split it\n * into time-aligned MP4 segments.\n */\n\nimport { spawn } from 'node:child_process'\nimport { readdirSync, statSync, unlinkSync } from 'node:fs'\nimport { join, parse } from 'node:path'\nimport { createHttpClient } from '../api/client'\nimport { type TikTokApi, createTikTokApi } from '../api/tiktok'\nimport type {\n RecorderConfig,\n RecorderController,\n RecorderEvent,\n RecorderEventHandler,\n RecorderStatus,\n} from '../config'\nimport { TikTokError, normalizeConfig } from '../config'\nimport { createLogger } from '../logger'\nimport { type PollingMonitor, createPollingMonitor } from '../monitor'\nimport { type Converter, createConverter } from './convert'\nimport { type StreamDownloader, createStreamDownloader } 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 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\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', { file: result.file, duration: result.duration, size: result.size })\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 outputPattern,\n ])\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 emit('segmenting:end', { segments: segmentFiles.length })\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 } 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 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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"}