@zfadhli/tokrec 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/recorder/convert.mjs +1 -1
- package/dist/core/recorder/convert.mjs.map +1 -1
- package/dist/core/recorder/download.mjs +2 -2
- package/dist/core/recorder/download.mjs.map +1 -1
- package/dist/core/recorder/ffmpeg-utils.mjs +39 -28
- package/dist/core/recorder/ffmpeg-utils.mjs.map +1 -1
- package/dist/core/recorder/index.mjs +4 -4
- package/dist/core/recorder/index.mjs.map +1 -1
- package/dist/core/recorder/stream.mjs +2 -2
- package/dist/core/recorder/stream.mjs.map +1 -1
- package/dist/lib/cli.mjs +1 -1
- package/dist/lib/cli.mjs.map +1 -1
- package/dist/lib/config.mjs +2 -2
- package/dist/lib/config.mjs.map +1 -1
- package/dist/lib/tmux.mjs +21 -7
- package/dist/lib/tmux.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convert.mjs","names":[],"sources":["../../../src/core/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).\n * Deletes the original file on success.\n */\n\nimport { unlinkSync } from \"node:fs\"\nimport { execa } from \"execa\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport { findFfmpegPath } from \"../../lib/utils\"\n\nexport interface Converter {\n /** Convert a TS file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger, signal?: AbortSignal): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\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, signal)\n\n // Delete original TS\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\nasync function runFfmpeg(\n ffmpegPath: string,\n input: string,\n output: string,\n signal?: AbortSignal,\n): Promise<void> {\n const args = [\"-i\", input, \"-c\", \"copy\", \"-y\", output]\n\n try {\n await execa(ffmpegPath, args, { signal, stdio: [\"ignore\", \"pipe\", \"pipe\"] })\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"aborted\", \"Aborted by user\")\n }\n const stderr = (err as { stderr?: string }).stderr ?? \"\"\n throw new TikTokError(\n \"ffmpeg-error\",\n `FFmpeg exited with code ${(err as { exitCode?: number }).exitCode}\\n${stderr.slice(-500)}`,\n )\n }\n}\n"],"mappings":";;;;;;;;;AAgBA,SAAgB,gBAAgB,QAAiB,QAAiC;CAChF,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,eAAe;EAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,QAAQ,MAAM;EAGjD,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,eAAe,UACb,YACA,OACA,QACA,QACe;CACf,MAAM,OAAO;EAAC;EAAM;EAAO;EAAM;EAAQ;EAAM;CAAM;CAErD,IAAI;EACF,MAAM,MAAM,YAAY,MAAM;GAAE;GAAQ,OAAO;IAAC;IAAU;IAAQ;GAAM;EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"convert.mjs","names":[],"sources":["../../../src/core/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).\n * Deletes the original file on success.\n */\n\nimport { unlinkSync } from \"node:fs\"\nimport { execa } from \"execa\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport { findFfmpegPath } from \"../../lib/utils\"\n\nexport interface Converter {\n /** Convert a TS file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger, signal?: AbortSignal): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\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, signal)\n\n // Delete original TS\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\nasync function runFfmpeg(\n ffmpegPath: string,\n input: string,\n output: string,\n signal?: AbortSignal,\n): Promise<void> {\n const args = [\"-i\", input, \"-c\", \"copy\", \"-y\", output]\n\n try {\n await execa(ffmpegPath, args, { cancelSignal: signal, stdio: [\"ignore\", \"pipe\", \"pipe\"] })\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"aborted\", \"Aborted by user\")\n }\n const stderr = (err as { stderr?: string }).stderr ?? \"\"\n throw new TikTokError(\n \"ffmpeg-error\",\n `FFmpeg exited with code ${(err as { exitCode?: number }).exitCode}\\n${stderr.slice(-500)}`,\n )\n }\n}\n"],"mappings":";;;;;;;;;AAgBA,SAAgB,gBAAgB,QAAiB,QAAiC;CAChF,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,eAAe;EAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,QAAQ,MAAM;EAGjD,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,eAAe,UACb,YACA,OACA,QACA,QACe;CACf,MAAM,OAAO;EAAC;EAAM;EAAO;EAAM;EAAQ;EAAM;CAAM;CAErD,IAAI;EACF,MAAM,MAAM,YAAY,MAAM;GAAE,cAAc;GAAQ,OAAO;IAAC;IAAU;IAAQ;GAAM;EAAE,CAAC;CAC3F,SAAS,KAAc;EACrB,IAAI,QAAQ,SACV,MAAM,IAAI,YAAY,WAAW,iBAAiB;EAEpD,MAAM,SAAU,IAA4B,UAAU;EACtD,MAAM,IAAI,YACR,gBACA,2BAA4B,IAA8B,SAAS,IAAI,OAAO,MAAM,IAAI,GAC1F;CACF;AACF"}
|
|
@@ -15,7 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
* Both FLV and HLS streams are handled by the same download function —
|
|
16
16
|
* the only difference is an optional label in the log messages.
|
|
17
17
|
*/
|
|
18
|
-
async function downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger, label) {
|
|
18
|
+
async function downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger, label, cookies) {
|
|
19
19
|
const filename = formatFilename(user, "ts");
|
|
20
20
|
const filepath = join(outputDir, filename);
|
|
21
21
|
const prefix = label ? ` (${label})` : "";
|
|
@@ -33,7 +33,7 @@ async function downloadStream(liveUrl, user, outputDir, maxDuration, onProgress,
|
|
|
33
33
|
try {
|
|
34
34
|
while (!signal.aborted) {
|
|
35
35
|
const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : void 0;
|
|
36
|
-
await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining);
|
|
36
|
+
await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining, cookies);
|
|
37
37
|
try {
|
|
38
38
|
fileSize = statSync(filepath).size;
|
|
39
39
|
} catch {}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"download.mjs","names":[],"sources":["../../../src/core/recorder/download.ts"],"sourcesContent":["/**\n * Live stream downloader — downloads a live stream via FFmpeg stdout pipe.\n *\n * Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.\n * FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's\n * short-lived stream URL expires, FFmpeg exits and the outer loop fetches\n * a fresh URL via getNextUrl.\n *\n * Both FLV and HLS streams are handled by the same download function —\n * the only difference is an optional label in the log messages.\n */\n\nimport { createWriteStream, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport { bytesToHuman, findFfmpegPath, formatDuration, formatFilename } from \"../../lib/utils\"\nimport { pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadStream(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress: ((info: ProgressInfo) => void) | undefined,\n getNextUrl: (() => Promise<string | null>) | undefined,\n signal: AbortSignal,\n logger?: Logger,\n label?: string,\n): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n const prefix = label ? ` (${label})` : \"\"\n logger?.info(`Recording${prefix}: ${filename}`)\n\n const startTime = Date.now()\n\n /** File size read directly from disk — authoritative, avoids in-memory counter drift. */\n let fileSize = 0\n let lastProgressTime = 0\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found — required for stream download. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const writer = createWriteStream(filepath)\n\n try {\n while (!signal.aborted) {\n const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined\n await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining)\n\n // Read actual file size from disk — always authoritative\n try {\n fileSize = statSync(filepath).size\n } catch {\n // File may not be accessible; leave fileSize at last known value\n }\n\n // Report progress after each segment\n const now = Date.now()\n const e = elapsed()\n if (onProgress && now - lastProgressTime >= 1000) {\n lastProgressTime = now\n const speed = e > 0 ? fileSize / e : 0\n onProgress({ bytes: fileSize, elapsed: e, speed })\n }\n\n if (signal.aborted) break\n if (maxDuration > 0 && e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n if (!getNextUrl) break\n\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n break\n }\n liveUrl = nextUrl\n }\n } catch (err) {\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n } finally {\n await new Promise<void>((resolve) => writer.end(resolve))\n }\n\n const duration = elapsed()\n // Final authoritative size after flush\n try {\n fileSize = statSync(filepath).size\n } catch {\n // keep last known value\n }\n if (onProgress) {\n onProgress({\n bytes: fileSize,\n elapsed: duration,\n speed: duration > 0 ? fileSize / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(fileSize)})`,\n )\n\n return { file: filepath, duration, size: fileSize }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAoBA,eAAsB,eACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACA,
|
|
1
|
+
{"version":3,"file":"download.mjs","names":[],"sources":["../../../src/core/recorder/download.ts"],"sourcesContent":["/**\n * Live stream downloader — downloads a live stream via FFmpeg stdout pipe.\n *\n * Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.\n * FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's\n * short-lived stream URL expires, FFmpeg exits and the outer loop fetches\n * a fresh URL via getNextUrl.\n *\n * Both FLV and HLS streams are handled by the same download function —\n * the only difference is an optional label in the log messages.\n */\n\nimport { createWriteStream, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport { bytesToHuman, findFfmpegPath, formatDuration, formatFilename } from \"../../lib/utils\"\nimport { pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadStream(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress: ((info: ProgressInfo) => void) | undefined,\n getNextUrl: (() => Promise<string | null>) | undefined,\n signal: AbortSignal,\n logger?: Logger,\n label?: string,\n cookies?: Record<string, string>,\n): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n const prefix = label ? ` (${label})` : \"\"\n logger?.info(`Recording${prefix}: ${filename}`)\n\n const startTime = Date.now()\n\n /** File size read directly from disk — authoritative, avoids in-memory counter drift. */\n let fileSize = 0\n let lastProgressTime = 0\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found — required for stream download. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const writer = createWriteStream(filepath)\n\n try {\n while (!signal.aborted) {\n const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined\n await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining, cookies)\n\n // Read actual file size from disk — always authoritative\n try {\n fileSize = statSync(filepath).size\n } catch {\n // File may not be accessible; leave fileSize at last known value\n }\n\n // Report progress after each segment\n const now = Date.now()\n const e = elapsed()\n if (onProgress && now - lastProgressTime >= 1000) {\n lastProgressTime = now\n const speed = e > 0 ? fileSize / e : 0\n onProgress({ bytes: fileSize, elapsed: e, speed })\n }\n\n if (signal.aborted) break\n if (maxDuration > 0 && e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n if (!getNextUrl) break\n\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n break\n }\n liveUrl = nextUrl\n }\n } catch (err) {\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n } finally {\n await new Promise<void>((resolve) => writer.end(resolve))\n }\n\n const duration = elapsed()\n // Final authoritative size after flush\n try {\n fileSize = statSync(filepath).size\n } catch {\n // keep last known value\n }\n if (onProgress) {\n onProgress({\n bytes: fileSize,\n elapsed: duration,\n speed: duration > 0 ? fileSize / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(fileSize)})`,\n )\n\n return { file: filepath, duration, size: fileSize }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAoBA,eAAsB,eACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACA,OACA,SACyB;CACzB,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CACzC,MAAM,SAAS,QAAQ,KAAK,MAAM,KAAK;CACvC,QAAQ,KAAK,YAAY,OAAO,IAAI,UAAU;CAE9C,MAAM,YAAY,KAAK,IAAI;;CAG3B,IAAI,WAAW;CACf,IAAI,mBAAmB;CAEvB,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,2HAEF;CAGF,SAAS,UAAkB;EACzB,QAAQ,KAAK,IAAI,IAAI,aAAa;CACpC;CAEA,MAAM,SAAS,kBAAkB,QAAQ;CAEzC,IAAI;EACF,OAAO,CAAC,OAAO,SAAS;GACtB,MAAM,YAAY,cAAc,IAAI,KAAK,IAAI,GAAG,cAAc,QAAQ,CAAC,IAAI,KAAA;GAC3E,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,WAAW,OAAO;GAG/E,IAAI;IACF,WAAW,SAAS,QAAQ,CAAC,CAAC;GAChC,QAAQ,CAER;GAGA,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,WAAW,IAAI;IACrC,WAAW;KAAE,OAAO;KAAU,SAAS;KAAG;IAAM,CAAC;GACnD;GAEA,IAAI,OAAO,SAAS;GACpB,IAAI,cAAc,KAAK,KAAK,aAAa;IACvC,QAAQ,KAAK,2BAA2B,YAAY,GAAG;IACvD;GACF;GACA,IAAI,CAAC,YAAY;GAEjB,MAAM,UAAU,MAAM,WAAW;GACjC,IAAI,CAAC,SAAS;IACZ,QAAQ,KAAK,wCAAwC;IACrD;GACF;GACA,UAAU;EACZ;CACF,SAAS,KAAK;EACZ,QAAQ,MAAM,oBAAoB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;CACtF,UAAU;EACR,MAAM,IAAI,SAAe,YAAY,OAAO,IAAI,OAAO,CAAC;CAC1D;CAEA,MAAM,WAAW,QAAQ;CAEzB,IAAI;EACF,WAAW,SAAS,QAAQ,CAAC,CAAC;CAChC,QAAQ,CAER;CACA,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,WAAW,WAAW;CAC9C,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,QAAQ,EAAE,EAC1F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAS;AACpD"}
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
3
|
//#region src/core/recorder/ffmpeg-utils.ts
|
|
4
|
+
/**
|
|
5
|
+
* Shared FFmpeg utilities — stream-download pipe primitive and constants.
|
|
6
|
+
*/
|
|
4
7
|
/** Kill FFmpeg if no output is received within this window after spawn. */
|
|
5
8
|
const FFMPEG_STARTUP_TIMEOUT = 3e4;
|
|
9
|
+
/**
|
|
10
|
+
* Build FFmpeg HTTP option arguments for cookies and headers.
|
|
11
|
+
* TikTok's CDN requires session cookies and a Referer to serve the stream.
|
|
12
|
+
*/
|
|
13
|
+
function buildHttpArgs(cookies) {
|
|
14
|
+
const args = [];
|
|
15
|
+
args.push("-user_agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0");
|
|
16
|
+
args.push("-headers", "Referer: https://www.tiktok.com/\r\n");
|
|
17
|
+
if (cookies && Object.keys(cookies).length > 0) {
|
|
18
|
+
const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("\r\n");
|
|
19
|
+
args.push("-cookies", cookieStr);
|
|
20
|
+
}
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
6
23
|
/** Standard FFmpeg reconnect + mpegts pipe arguments. */
|
|
7
24
|
const FFMPEG_BASE_ARGS = [
|
|
8
25
|
"-reconnect",
|
|
@@ -23,33 +40,22 @@ const FFMPEG_BASE_ARGS = [
|
|
|
23
40
|
* Spawn an FFmpeg process to download from `url` and pipe its stdout to
|
|
24
41
|
* `writer` with backpressure handling.
|
|
25
42
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - `FFMPEG_STARTUP_TIMEOUT` (30s) startup guard with SIGTERM → SIGKILL
|
|
29
|
-
* escalation to prevent orphaned processes
|
|
30
|
-
* - Bounded stderr capture (10k chars) for error diagnostics
|
|
31
|
-
* - Automatic cleanup when `signal` fires (uses spawn `signal` option)
|
|
32
|
-
* - AbortError is swallowed — the promise resolves instead of rejecting,
|
|
33
|
-
* allowing the caller to check `signal.aborted` and react accordingly
|
|
34
|
-
*
|
|
35
|
-
* The caller is responsible for tracking file size via `statSync` — this
|
|
36
|
-
* function deliberately avoids maintaining its own byte counter to prevent
|
|
37
|
-
* divergence between the in-memory count and actual disk writes.
|
|
43
|
+
* Uses child_process.spawn directly (not execa) because execa v9 defaults
|
|
44
|
+
* to encoding stdout as UTF-8, which corrupts binary MPEG-TS data.
|
|
38
45
|
*/
|
|
39
|
-
async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration) {
|
|
40
|
-
const
|
|
46
|
+
async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration, cookies) {
|
|
47
|
+
const durationArgs = maxDuration ? ["-t", String(maxDuration)] : [];
|
|
48
|
+
const proc = spawn(ffmpegPath, [
|
|
49
|
+
...buildHttpArgs(cookies),
|
|
41
50
|
"-i",
|
|
42
51
|
url,
|
|
43
|
-
...
|
|
52
|
+
...durationArgs,
|
|
44
53
|
...FFMPEG_BASE_ARGS
|
|
45
|
-
], {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
],
|
|
51
|
-
signal
|
|
52
|
-
});
|
|
54
|
+
], { stdio: [
|
|
55
|
+
"ignore",
|
|
56
|
+
"pipe",
|
|
57
|
+
"pipe"
|
|
58
|
+
] });
|
|
53
59
|
let stderr = "";
|
|
54
60
|
let firstDataTimer = null;
|
|
55
61
|
const clearStartupTimer = () => {
|
|
@@ -58,16 +64,20 @@ async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration) {
|
|
|
58
64
|
firstDataTimer = null;
|
|
59
65
|
}
|
|
60
66
|
};
|
|
67
|
+
const onAbort = () => {
|
|
68
|
+
if (!proc.killed) proc.kill("SIGTERM");
|
|
69
|
+
};
|
|
70
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
61
71
|
proc.stderr?.on("data", (chunk) => {
|
|
62
72
|
clearStartupTimer();
|
|
63
73
|
stderr += chunk.toString();
|
|
64
74
|
if (stderr.length > 1e4) stderr = stderr.slice(-5e3);
|
|
65
75
|
});
|
|
66
|
-
proc.stdout
|
|
76
|
+
proc.stdout?.on("data", (chunk) => {
|
|
67
77
|
clearStartupTimer();
|
|
68
78
|
if (!writer.write(chunk)) {
|
|
69
|
-
proc.stdout
|
|
70
|
-
writer.once("drain", () => proc.stdout
|
|
79
|
+
proc.stdout?.pause();
|
|
80
|
+
writer.once("drain", () => proc.stdout?.resume());
|
|
71
81
|
}
|
|
72
82
|
});
|
|
73
83
|
await new Promise((resolve, reject) => {
|
|
@@ -82,6 +92,7 @@ async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration) {
|
|
|
82
92
|
}, FFMPEG_STARTUP_TIMEOUT);
|
|
83
93
|
proc.on("close", (code) => {
|
|
84
94
|
clearStartupTimer();
|
|
95
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
96
|
if (signal.aborted) {
|
|
86
97
|
resolve();
|
|
87
98
|
return;
|
|
@@ -94,7 +105,7 @@ async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration) {
|
|
|
94
105
|
});
|
|
95
106
|
proc.on("error", (err) => {
|
|
96
107
|
clearStartupTimer();
|
|
97
|
-
|
|
108
|
+
signal.removeEventListener("abort", onAbort);
|
|
98
109
|
reject(err);
|
|
99
110
|
});
|
|
100
111
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ffmpeg-utils.mjs","names":[],"sources":["../../../src/core/recorder/ffmpeg-utils.ts"],"sourcesContent":["/**\n * Shared FFmpeg utilities — stream-download pipe primitive and constants.\n */\n\nimport type
|
|
1
|
+
{"version":3,"file":"ffmpeg-utils.mjs","names":[],"sources":["../../../src/core/recorder/ffmpeg-utils.ts"],"sourcesContent":["/**\n * Shared FFmpeg utilities — stream-download pipe primitive and constants.\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport type { WriteStream } from \"node:fs\"\nimport { TikTokError } from \"../../lib/config\"\n\n/** Kill FFmpeg if no output is received within this window after spawn. */\nexport const FFMPEG_STARTUP_TIMEOUT = 30_000\n\n/**\n * Build FFmpeg HTTP option arguments for cookies and headers.\n * TikTok's CDN requires session cookies and a Referer to serve the stream.\n */\nfunction buildHttpArgs(cookies?: Record<string, string>): string[] {\n const args: string[] = []\n\n // ponytail: user-agent header, CDNs tend to be picky\n args.push(\n \"-user_agent\",\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n )\n args.push(\"-headers\", \"Referer: https://www.tiktok.com/\\r\\n\")\n\n if (cookies && Object.keys(cookies).length > 0) {\n const cookieStr = Object.entries(cookies)\n .map(([k, v]) => `${k}=${v}`)\n .join(\"\\r\\n\")\n args.push(\"-cookies\", cookieStr)\n }\n\n return args\n}\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/**\n * Spawn an FFmpeg process to download from `url` and pipe its stdout to\n * `writer` with backpressure handling.\n *\n * Uses child_process.spawn directly (not execa) because execa v9 defaults\n * to encoding stdout as UTF-8, which corrupts binary MPEG-TS data.\n */\nexport async function pipeFfmpegSegment(\n ffmpegPath: string,\n url: string,\n writer: WriteStream,\n signal: AbortSignal,\n maxDuration?: number,\n cookies?: Record<string, string>,\n): Promise<void> {\n const durationArgs = maxDuration ? [\"-t\", String(maxDuration)] : []\n const httpArgs = buildHttpArgs(cookies)\n // ponytail: input options (-headers, -cookies, -user_agent) must come before -i\n const args = [...httpArgs, \"-i\", url, ...durationArgs, ...FFMPEG_BASE_ARGS]\n const proc: ChildProcess = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n\n let stderr = \"\"\n let firstDataTimer: ReturnType<typeof setTimeout> | null = null\n\n const clearStartupTimer = () => {\n if (firstDataTimer) {\n clearTimeout(firstDataTimer)\n firstDataTimer = null\n }\n }\n\n // Forward abort signal to process\n const onAbort = () => {\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }\n signal.addEventListener(\"abort\", onAbort, { once: true })\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n clearStartupTimer()\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.stdout?.on(\"data\", (chunk: Buffer) => {\n clearStartupTimer()\n const canContinue = writer.write(chunk)\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 clearStartupTimer()\n signal.removeEventListener(\"abort\", onAbort)\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 clearStartupTimer()\n signal.removeEventListener(\"abort\", onAbort)\n reject(err)\n })\n })\n}\n"],"mappings":";;;;;;;AASA,MAAa,yBAAyB;;;;;AAMtC,SAAS,cAAc,SAA4C;CACjE,MAAM,OAAiB,CAAC;CAGxB,KAAK,KACH,eACA,kFACF;CACA,KAAK,KAAK,YAAY,sCAAsC;CAE5D,IAAI,WAAW,OAAO,KAAK,OAAO,CAAC,CAAC,SAAS,GAAG;EAC9C,MAAM,YAAY,OAAO,QAAQ,OAAO,CAAC,CACtC,KAAK,CAAC,GAAG,OAAO,GAAG,EAAE,GAAG,GAAG,CAAC,CAC5B,KAAK,MAAM;EACd,KAAK,KAAK,YAAY,SAAS;CACjC;CAEA,OAAO;AACT;;AAGA,MAAM,mBAAmB;CACvB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;AASA,eAAsB,kBACpB,YACA,KACA,QACA,QACA,aACA,SACe;CACf,MAAM,eAAe,cAAc,CAAC,MAAM,OAAO,WAAW,CAAC,IAAI,CAAC;CAIlE,MAAM,OAAqB,MAAM,YAAY;EAD/B,GAFG,cAAc,OAEP;EAAG;EAAM;EAAK,GAAG;EAAc,GAAG;CACV,GAAG,EACjD,OAAO;EAAC;EAAU;EAAQ;CAAM,EAClC,CAAC;CAED,IAAI,SAAS;CACb,IAAI,iBAAuD;CAE3D,MAAM,0BAA0B;EAC9B,IAAI,gBAAgB;GAClB,aAAa,cAAc;GAC3B,iBAAiB;EACnB;CACF;CAGA,MAAM,gBAAgB;EACpB,IAAI,CAAC,KAAK,QAAQ,KAAK,KAAK,SAAS;CACvC;CACA,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;CAExD,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,kBAAkB;EAClB,UAAU,MAAM,SAAS;EACzB,IAAI,OAAO,SAAS,KAAO,SAAS,OAAO,MAAM,IAAK;CACxD,CAAC;CAED,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,kBAAkB;EAElB,IAAI,CADgB,OAAO,MAAM,KAClB,GAAG;GAChB,KAAK,QAAQ,MAAM;GACnB,OAAO,KAAK,eAAe,KAAK,QAAQ,OAAO,CAAC;EAClD;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,kBAAkB;GAClB,OAAO,oBAAoB,SAAS,OAAO;GAC3C,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,kBAAkB;GAClB,OAAO,oBAAoB,SAAS,OAAO;GAC3C,OAAO,GAAG;EACZ,CAAC;CACH,CAAC;AACH"}
|
|
@@ -42,7 +42,7 @@ function createRecorder(config) {
|
|
|
42
42
|
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
|
|
43
43
|
try {
|
|
44
44
|
await execa(ffmpegPath, args, {
|
|
45
|
-
signal,
|
|
45
|
+
cancelSignal: signal,
|
|
46
46
|
stdio: [
|
|
47
47
|
"ignore",
|
|
48
48
|
"pipe",
|
|
@@ -136,7 +136,7 @@ function createRecorder(config) {
|
|
|
136
136
|
tikTokApi.invalidateCache();
|
|
137
137
|
return tikTokApi.getLiveUrl(roomId);
|
|
138
138
|
};
|
|
139
|
-
const result = await streamDownloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("download:progress", info), getNextUrl);
|
|
139
|
+
const result = await streamDownloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("download:progress", info), getNextUrl, cfg.cookies);
|
|
140
140
|
emitter.emit("download:end", {
|
|
141
141
|
file: result.file,
|
|
142
142
|
duration: result.duration,
|
|
@@ -178,9 +178,9 @@ function createRecorder(config) {
|
|
|
178
178
|
stopRequested = true;
|
|
179
179
|
logger.info("Stopping recorder...");
|
|
180
180
|
if (pendingRemuxes.length > 0) {
|
|
181
|
-
logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max
|
|
181
|
+
logger.info(`Waiting for ${pendingRemuxes.length} pending conversion(s) (max 10 min)...`);
|
|
182
182
|
const abortTimeout = new AbortController();
|
|
183
|
-
await Promise.race([Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()), sleep(
|
|
183
|
+
await Promise.race([Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()), sleep(6e5, abortTimeout.signal)]);
|
|
184
184
|
pendingRemuxes = [];
|
|
185
185
|
}
|
|
186
186
|
if (downloader) downloader.abort();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../../src/core/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { execa } from \"execa\"\nimport type { RecorderConfig, RecorderController } from \"../../lib/config\"\nimport { normalizeConfig, TikTokError } from \"../../lib/config\"\nimport { createLogger } from \"../../lib/logger\"\nimport { findFfmpegPath, sleep } from \"../../lib/utils\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\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 async function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\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 try {\n await execa(ffmpegPath, args, { signal, stdio: [\"ignore\", \"pipe\", \"pipe\"] })\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"unknown\", \"Aborted by user\")\n }\n const message = err instanceof Error ? err.message : String(err)\n throw new TikTokError(\"unknown\", `FFmpeg failed: ${message}`)\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 // All dependencies are initialized by start() before onTick runs\n const tikTokApi = api as TikTokApi\n const streamDownloader = downloader as StreamDownloader\n const pollingMonitor = monitor as PollingMonitor\n\n const user = cfg.user\n tikTokApi.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await tikTokApi.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 tikTokApi.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 tikTokApi.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 tikTokApi.invalidateCache()\n return tikTokApi.getLiveUrl(roomId)\n }\n\n const result = await streamDownloader.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 as Converter,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (emitter.emit as (event: string, ...args: unknown[]) => void).bind(emitter),\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 pollingMonitor.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 tikTokApi.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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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../../src/core/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { execa } from \"execa\"\nimport type { RecorderConfig, RecorderController } from \"../../lib/config\"\nimport { normalizeConfig, TikTokError } from \"../../lib/config\"\nimport { createLogger } from \"../../lib/logger\"\nimport { findFfmpegPath, sleep } from \"../../lib/utils\"\nimport { createHttpClient } from \"../api/client\"\nimport { createTikTokApi, type TikTokApi } from \"../api/tiktok\"\nimport { createPollingMonitor, type PollingMonitor } from \"../monitor\"\nimport { type Converter, createConverter } from \"./convert\"\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 async function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\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 try {\n await execa(ffmpegPath, args, { cancelSignal: signal, stdio: [\"ignore\", \"pipe\", \"pipe\"] })\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"unknown\", \"Aborted by user\")\n }\n const message = err instanceof Error ? err.message : String(err)\n throw new TikTokError(\"unknown\", `FFmpeg failed: ${message}`)\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 // All dependencies are initialized by start() before onTick runs\n const tikTokApi = api as TikTokApi\n const streamDownloader = downloader as StreamDownloader\n const pollingMonitor = monitor as PollingMonitor\n\n const user = cfg.user\n tikTokApi.invalidateCache()\n\n emitter.emit(\"checking\", { user })\n logger.info(`Checking @${user}...`)\n\n const roomId = await tikTokApi.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 tikTokApi.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 tikTokApi.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 tikTokApi.invalidateCache()\n return tikTokApi.getLiveUrl(roomId)\n }\n\n const result = await streamDownloader.download(\n liveUrl,\n user,\n cfg.outputDir,\n cfg.duration,\n (info) => emitter.emit(\"download:progress\", info),\n getNextUrl,\n cfg.cookies,\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 as Converter,\n audioNormalizer,\n stopAbortController,\n pendingRemuxes,\n emit: (emitter.emit as (event: string, ...args: unknown[]) => void).bind(emitter),\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 pollingMonitor.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 tikTokApi.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 10 min)...`)\n const abortTimeout = new AbortController()\n await Promise.race([\n Promise.allSettled(pendingRemuxes).finally(() => abortTimeout.abort()),\n sleep(600_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":";;;;;;;;;;;;;;;;;;AAqBA,SAAgB,eAAe,QAA4C;CACzE,MAAM,MAAM,gBAAgB,MAAM;CAClC,MAAM,SAAS,aAAa;EAC1B,OAAO,IAAI;EACX,SAAS;EACT,SAAS,IAAI;CACf,CAAC;CAED,MAAM,UAAU,mBAAmB;CACnC,MAAM,eAAe,oBAAoB;EAAE,OAAO;EAAiB,MAAM,IAAI;CAAK,GAAG,MAAM;CAG3F,IAAI,aAAkE;CACtE,IAAI,MAAwB;CAC5B,IAAI,UAAiC;CACrC,IAAI,aAAsC;CAC1C,IAAI,YAA8B;CAClC,IAAI,kBAA0C;CAC9C,IAAI,gBAAgB;CACpB,IAAI,sBAAsB,IAAI,gBAAgB;CAC9C,IAAI,iBAAqC,CAAC;CAE1C,eAAe,UAAU,MAAgB,QAAqC;EAC5E,MAAM,aAAa,eAAe;EAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,0FACF;EAEF,IAAI;GACF,MAAM,MAAM,YAAY,MAAM;IAAE,cAAc;IAAQ,OAAO;KAAC;KAAU;KAAQ;IAAM;GAAE,CAAC;EAC3F,SAAS,KAAc;GACrB,IAAI,QAAQ,SACV,MAAM,IAAI,YAAY,WAAW,iBAAiB;GAGpD,MAAM,IAAI,YAAY,WAAW,kBADjB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GACH;EAC9D;CACF;CAEA,eAAe,QAAuB;EACpC,IAAI,aAAa,UAAU,CAAC,CAAC,UAAU,QAAQ;GAC7C,OAAO,KAAK,qCAAqC,aAAa,UAAU,CAAC,CAAC,OAAO;GACjF;EACF;EACA,gBAAgB;EAChB,iBAAiB,CAAC;EAClB,sBAAsB,IAAI,gBAAgB;EAE1C,OAAO,KAAK,0BAA0B,IAAI,MAAM;EAChD,aAAa,MAAM,iBAAiB,GAAG;EACvC,MAAM,gBAAgB,YAAY,QAAQ,IAAI,KAAK;EACnD,aAAa,uBAAuB,MAAM;EAC1C,YAAY,gBAAgB,QAAQ,oBAAoB,MAAM;EAC9D,kBAAkB,IAAI,iBAClB,sBACE;GACE,UAAU,IAAI;GACd,YAAY,IAAI;GAChB,cAAc,IAAI;GAClB,UAAU,SAAS,QAAQ,KAAK,mBAAmB,EAAE,KAAK,CAAC;GAC3D,aAAa,MAAM,SAAS,UAC1B,QAAQ,KAAK,sBAAsB;IACjC;IACA;IACO;GACT,CAAC;GACH,aAAa,WACX,QAAQ,KAAK,iBAAiB;IAAE,OAAO,OAAO;IAAO,QAAQ,OAAO;GAAO,CAAC;GAC9E,UAAU,MAAM,QACd,QAAQ,KAAK,mBAAmB;IAAE,OAAO;IAAM,OAAO,IAAI;GAAQ,CAAC;EACvE,GACA,MACF,IACA;EAEJ,UAAU,qBAAqB;GAC7B,iBAAiB,IAAI;GACrB;GACA,QAAQ,YAAY;IAClB,IAAI,eAAe;IAGnB,MAAM,YAAY;IAClB,MAAM,mBAAmB;IACzB,MAAM,iBAAiB;IAEvB,MAAM,OAAO,IAAI;IACjB,UAAU,gBAAgB;IAE1B,QAAQ,KAAK,YAAY,EAAE,KAAK,CAAC;IACjC,OAAO,KAAK,aAAa,KAAK,IAAI;IAElC,MAAM,SAAS,MAAM,UAAU,UAAU,IAAI;IAC7C,IAAI,CAAC,QAAQ;KACX,OAAO,KAAK,IAAI,KAAK,YAAY;KACjC,QAAQ,KAAK,QAAQ;MAAE;MAAM,QAAQ;KAAM,CAAC;KAC5C;IACF;IAEA,MAAM,UAAU,MAAM,UAAU,WAAW,MAAM;IACjD,IAAI,CAAC,SAAS;KACZ,OAAO,KAAK,IAAI,KAAK,iCAAiC;KACtD,QAAQ,KAAK,QAAQ;MAAE;MAAM,QAAQ;MAAO;KAAO,CAAC;KACpD;IACF;IAEA,OAAO,KAAK,IAAI,KAAK,mBAAmB,OAAO,EAAE;IACjD,QAAQ,KAAK,QAAQ;KAAE;KAAM,QAAQ;KAAM;IAAO,CAAC;IAEnD,aAAa,SAAS,EAAE,OAAO,YAAY,CAAC;IAC5C,QAAQ,KAAK,mBAAmB;KAAE;KAAM,MAAM;IAAG,CAAC;IAElD,MAAM,aAAa,YAAoC;KAIrD,IAAI,CAAC,MADe,UAAU,YAAY,MAAM,GACpC;MACV,OAAO,KAAK,IAAI,KAAK,8BAA8B;MACnD,OAAO;KACT;KAEA,UAAU,gBAAgB;KAC1B,OAAO,UAAU,WAAW,MAAM;IACpC;IAEA,MAAM,SAAS,MAAM,iBAAiB,SACpC,SACA,MACA,IAAI,WACJ,IAAI,WACH,SAAS,QAAQ,KAAK,qBAAqB,IAAI,GAChD,YACA,IAAI,OACN;IAEA,QAAQ,KAAK,gBAAgB;KAC3B,MAAM,OAAO;KACb,UAAU,OAAO;KACjB,MAAM,OAAO;IACf,CAAC;IAED,MAAM,iBAAiB,QAA0B,KAAK;KACpD;KACW;KACX;KACA;KACA;KACA,MAAO,QAAQ,KAAqD,KAAK,OAAO;KAChF,UAAU,aAAa;KACvB,qBAAqB;KACrB;IACF,CAAC;IAGD,IAAI,IAAI,WAAW,GAAG;KACpB,OAAO,KAAK,kCAAkC;KAC9C,eAAe,qBAAqB;IACtC,OAAO,IAAI,CAAC,iBAAiB;SAKvB,CAAC,MADe,UAAU,YAAY,MAAM,GACpC;MACV,OAAO,KAAK,IAAI,KAAK,YAAY;MACjC,QAAQ,KAAK,QAAQ;OAAE;OAAM,QAAQ;MAAM,CAAC;KAC9C;;GAEJ;EACF,CAAC;EAED,aAAa,SAAS,EAAE,OAAO,UAAU,CAAC;EAC1C,MAAM,QAAQ,MAAM;CACtB;CAEA,eAAe,OAAsB;EACnC,IAAI,aAAa,UAAU,CAAC,CAAC,UAAU,WAAW;GAChD,OAAO,KAAK,iCAAiC;GAC7C;EACF;EACA,gBAAgB;EAChB,OAAO,KAAK,sBAAsB;EAElC,IAAI,eAAe,SAAS,GAAG;GAC7B,OAAO,KAAK,eAAe,eAAe,OAAO,uCAAuC;GACxF,MAAM,eAAe,IAAI,gBAAgB;GACzC,MAAM,QAAQ,KAAK,CACjB,QAAQ,WAAW,cAAc,CAAC,CAAC,cAAc,aAAa,MAAM,CAAC,GACrE,MAAM,KAAS,aAAa,MAAM,CACpC,CAAC;GACD,iBAAiB,CAAC;EACpB;EAEA,IAAI,YAAY,WAAW,MAAM;EACjC,IAAI,SAAS,MAAM,QAAQ,KAAK;EAChC,oBAAoB,MAAM;EAC1B,IAAI,YAAY,MAAM,WAAW,MAAM;EACvC,QAAQ,MAAM;EACd,aAAa,SAAS,EAAE,OAAO,UAAU,CAAC;EAC1C,OAAO,KAAK,kBAAkB;CAChC;CAEA,OAAO;EACL;EACA;EACA,WAAW,aAAa;EACxB,IAAI,QAAQ;EACZ,KAAK,QAAQ;CACf;AACF"}
|
|
@@ -9,10 +9,10 @@ function createStreamDownloader(logger) {
|
|
|
9
9
|
function isHlsUrl(url) {
|
|
10
10
|
return url.includes(".m3u8");
|
|
11
11
|
}
|
|
12
|
-
async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl) {
|
|
12
|
+
async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl, cookies) {
|
|
13
13
|
abortController = new AbortController();
|
|
14
14
|
ensureDir(outputDir);
|
|
15
|
-
return downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, abortController.signal, logger, isHlsUrl(liveUrl) ? "HLS" : void 0);
|
|
15
|
+
return downloadStream(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, abortController.signal, logger, isHlsUrl(liveUrl) ? "HLS" : void 0, cookies);
|
|
16
16
|
}
|
|
17
17
|
return {
|
|
18
18
|
download,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.mjs","names":[],"sources":["../../../src/core/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — downloads a live stream to disk using FFmpeg.\n *\n * Both FLV and HLS streams are downloaded via FFmpeg subprocess with\n * transparent reconnection for transient network drops. Routes to the\n * appropriate downloader based on URL format.\n */\n\nimport type { Logger } from \"../../lib/logger\"\nimport { ensureDir } from \"../../lib/utils\"\nimport { downloadStream } from \"./download\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortController = new AbortController()\n\n function abort(): void {\n abortController.abort()\n }\n\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortController = new AbortController()\n ensureDir(outputDir)\n\n return downloadStream(\n liveUrl,\n user,\n outputDir,\n maxDuration,\n onProgress,\n getNextUrl,\n abortController.signal,\n logger,\n isHlsUrl(liveUrl) ? \"HLS\" : undefined,\n )\n }\n\n return { download, abort }\n}\n"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"stream.mjs","names":[],"sources":["../../../src/core/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — downloads a live stream to disk using FFmpeg.\n *\n * Both FLV and HLS streams are downloaded via FFmpeg subprocess with\n * transparent reconnection for transient network drops. Routes to the\n * appropriate downloader based on URL format.\n */\n\nimport type { Logger } from \"../../lib/logger\"\nimport { ensureDir } from \"../../lib/utils\"\nimport { downloadStream } from \"./download\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n /** Cookies to pass to FFmpeg for CDN auth. */\n cookies?: Record<string, string>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortController = new AbortController()\n\n function abort(): void {\n abortController.abort()\n }\n\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n cookies?: Record<string, string>,\n ): Promise<DownloadResult> {\n abortController = new AbortController()\n ensureDir(outputDir)\n\n return downloadStream(\n liveUrl,\n user,\n outputDir,\n maxDuration,\n onProgress,\n getNextUrl,\n abortController.signal,\n logger,\n isHlsUrl(liveUrl) ? \"HLS\" : undefined,\n cookies,\n )\n }\n\n return { download, abort }\n}\n"],"mappings":";;;AAyCA,SAAgB,uBAAuB,QAAmC;CACxE,IAAI,kBAAkB,IAAI,gBAAgB;CAE1C,SAAS,QAAc;EACrB,gBAAgB,MAAM;CACxB;CAEA,SAAS,SAAS,KAAsB;EACtC,OAAO,IAAI,SAAS,OAAO;CAC7B;CAEA,eAAe,SACb,SACA,MACA,WACA,cAAc,GACd,YACA,YACA,SACyB;EACzB,kBAAkB,IAAI,gBAAgB;EACtC,UAAU,SAAS;EAEnB,OAAO,eACL,SACA,MACA,WACA,aACA,YACA,YACA,gBAAgB,QAChB,QACA,SAAS,OAAO,IAAI,QAAQ,KAAA,GAC5B,OACF;CACF;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
|
package/dist/lib/cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { createCLI } from "@zfadhli/koko-cli";
|
|
|
10
10
|
/** Shared recorder options for both default and start commands. */
|
|
11
11
|
function registerRecorderOptions(cmd) {
|
|
12
12
|
cmd.option("-u, --user <name>", "TikTok username (or pass as first argument)");
|
|
13
|
-
cmd.option("-o, --output <dir>", "Output directory", { default: "
|
|
13
|
+
cmd.option("-o, --output <dir>", "Output directory", { default: "." });
|
|
14
14
|
cmd.option("-i, --interval <minutes>", "Polling interval in minutes", { default: "3" });
|
|
15
15
|
cmd.option("-d, --duration <minutes>", "Max recording duration in minutes");
|
|
16
16
|
cmd.option("-p, --proxy <url>", "HTTP proxy (e.g. http://127.0.0.1:8080)");
|
package/dist/lib/cli.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../../src/lib/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { type CommandBuilder, createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport type CLIResult =\n | { command: \"record\"; config: RecorderConfig }\n | { command: \"start\"; args: string[] }\n | { command: \"stop\"; user?: string }\n | { command: \"status\"; user?: string }\n | { command: \"setup\" }\n\n/** Shared recorder options for both default and start commands. */\nfunction registerRecorderOptions(cmd: CommandBuilder) {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \"
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../../src/lib/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { type CommandBuilder, createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport type CLIResult =\n | { command: \"record\"; config: RecorderConfig }\n | { command: \"start\"; args: string[] }\n | { command: \"stop\"; user?: string }\n | { command: \"status\"; user?: string }\n | { command: \"setup\" }\n\n/** Shared recorder options for both default and start commands. */\nfunction registerRecorderOptions(cmd: CommandBuilder) {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \".\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\n \"-s, --segment-minutes <minutes>\",\n \"Segment duration in minutes (default: disabled). Split recording into segments of this length.\",\n )\n cmd.option(\"--normalize\", \"Normalize audio loudness (EBU R128)\")\n cmd.option(\"--normalize-loudness <num>\", \"Target loudness in LUFS (default: -14)\")\n cmd.option(\"--normalize-codec <name>\", \"Audio codec for normalized output (default: libopus)\")\n cmd.option(\"--normalize-bitrate <str>\", \"Audio bitrate for normalized output (default: 96k)\")\n cmd.option(\"--rate <n>\", \"Max API requests per second (default: 5). Set 0 for unlimited.\")\n cmd.option(\"--debug\", \"Show API debug logging on stderr for troubleshooting\")\n}\n\nfunction parseRecorderOpts(opts: Record<string, unknown>): RecorderConfig {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError(\"config-error\", \"<user> is required (--user or -u)\")\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be a number >= 1\")\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be a number >= 0 (minutes)\")\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = [\"debug\", \"info\", \"warn\", \"error\"]\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\"config-error\", `--log-level must be one of: ${levels.join(\", \")}`)\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--segment-minutes must be a number >= 0\")\n }\n parsed.segmentMinutes = n\n }\n if (opts.normalize !== undefined) parsed.normalizeAudio = opts.normalize as boolean\n if (opts.normalizeLoudness !== undefined) {\n const n = Number(opts.normalizeLoudness)\n if (!Number.isFinite(n)) {\n throw new TikTokError(\"config-error\", \"--normalize-loudness must be a number\")\n }\n parsed.normalizeLoudness = n\n }\n if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec as string\n if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate as string\n if (opts.rate !== undefined) {\n const n = Number(opts.rate)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--rate must be a number >= 0\")\n }\n parsed.ratePerSecond = n\n }\n if (opts.debug) parsed.debug = true\n\n return parsed\n}\n\nexport function parseArgs(argv: string[] = process.argv): CLIResult {\n let result: CLIResult | undefined\n let error: string | undefined\n\n const cli = createCLI(\"tokrec\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n )\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n registerRecorderOptions(cmd)\n cmd.action((opts: Record<string, unknown>) => {\n try {\n result = { command: \"record\", config: parseRecorderOpts(opts) }\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n cli.command(\"start\", \"Start recorder in a tmux session (survives terminal close)\", (cmd) => {\n registerRecorderOptions(cmd)\n cmd.action((opts: Record<string, unknown>) => {\n try {\n parseRecorderOpts(opts)\n // Pass through original args (minus 'start') to the inner tokrec process\n const args = argv.slice(2).filter((a) => a !== \"start\")\n result = { command: \"start\", args }\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n cli.command(\"stop\", \"Stop background tmux session(s)\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"Stop a specific user's session (omit to stop all)\")\n cmd.action((opts: Record<string, unknown>) => {\n result = { command: \"stop\", user: opts.user as string | undefined }\n })\n })\n\n cli.command(\"status\", \"Check tmux session(s)\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"Check a specific user's session (omit to show all)\")\n cmd.action((opts: Record<string, unknown>) => {\n result = { command: \"status\", user: opts.user as string | undefined }\n })\n })\n\n cli.command(\"setup\", \"Check cookie status or create cookies.json interactively\", (cmd) => {\n cmd.action(() => {\n result = { command: \"setup\" }\n })\n })\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError(\"config-error\", error)\n }\n\n if (!result) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (--user or -u). Use --help for usage.\",\n )\n }\n\n return result\n}\n"],"mappings":"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"}
|
package/dist/lib/config.mjs
CHANGED
|
@@ -10,13 +10,13 @@ var TikTokError = class extends Error {
|
|
|
10
10
|
}
|
|
11
11
|
};
|
|
12
12
|
const DEFAULTS = {
|
|
13
|
-
outputDir: "
|
|
13
|
+
outputDir: ".",
|
|
14
14
|
interval: 3,
|
|
15
15
|
duration: 0,
|
|
16
16
|
logLevel: "info",
|
|
17
17
|
logConsole: true,
|
|
18
18
|
segmentMinutes: 0,
|
|
19
|
-
normalizeAudio:
|
|
19
|
+
normalizeAudio: false,
|
|
20
20
|
normalizeLoudness: -14,
|
|
21
21
|
ratePerSecond: 5,
|
|
22
22
|
debug: false
|
package/dist/lib/config.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/lib/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\n/**\n * TikTok cookie key-value pairs extracted from Firefox or cookies.json.\n * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF\n * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.\n */\nexport type CookieAuth = Record<string, string>\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nexport interface RecorderConfig {\n /** TikTok username (required) */\n user: string\n /** Output directory for recordings (default: ./recordings) */\n outputDir?: string\n /** Polling interval in minutes (default: 3) */\n interval?: number\n /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */\n duration?: number\n /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */\n proxy?: string\n /** Path to cookies.json (default: ./cookies.json) */\n cookiesPath?: string\n /** Cookie auth loaded from cookies.json */\n cookies?: CookieAuth\n /** Log level (default: info) */\n logLevel?: LogLevel\n /** Whether to print logs to console (default: true). Set false when using Display-based UI. */\n logConsole?: boolean\n /** Segment duration in minutes (default: 0 — disabled). When >= 1, the recording is split into segments of this length. */\n segmentMinutes?: number\n /** Normalize audio loudness via EBU R128 (default: false). */\n normalizeAudio?: boolean\n /** Target loudness in LUFS (default: -14). */\n normalizeLoudness?: number\n /** Audio codec for normalized output (default: peaknorm's default, \"libopus\"). */\n normalizeCodec?: string\n /** Audio bitrate for normalized output (default: peaknorm's default, \"96k\"). */\n normalizeBitrate?: string\n /**\n * Max API requests per second (default: 5).\n * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.\n * Set to 0 for unlimited (not recommended).\n */\n ratePerSecond?: number\n /** Show API debug logging on stderr (default: false). */\n debug?: boolean\n}\n\nexport interface RecorderController {\n /** Start the polling loop */\n start(): Promise<void>\n /** Gracefully stop (joins active recording, then exits) */\n stop(): Promise<void>\n /** Snapshot of current state */\n getStatus(): RecorderStatus\n /** Subscribe to events */\n on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n /** Unsubscribe a previously registered event handler */\n off<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n}\n\nexport interface RecorderStatus {\n state: \"idle\" | \"polling\" | \"recording\" | \"converting\" | \"stopped\"\n user: string\n currentFile?: string\n sessionDuration?: number\n lastPollTime?: string\n lastError?: string\n}\n\nexport type RecorderEvent = keyof RecorderEventHandler\n\nexport interface RecorderEventHandler {\n checking: (info: { user: string }) => void\n tick: (info: { user: string; isLive: boolean; roomId?: string }) => void\n \"recording:start\": (info: { user: string; file: string }) => void\n \"download:progress\": (info: { bytes: number; elapsed: number; speed: number }) => void\n \"download:end\": (info: { file: string; duration: number; size: number }) => void\n \"recording:end\": (info: { file: string; duration: number; size: number }) => void\n \"segmenting:start\": (info: { input: string; outputPattern: string }) => void\n \"segmenting:end\": (info: { segments: number }) => void\n \"converting:start\": (info: { input: string }) => void\n converted: (info: { input: string; output: string }) => void\n \"normalize:start\": (info: { file: string }) => void\n \"normalize:progress\": (info: {\n file: string\n percent: number\n phase: \"analyzing\" | \"normalizing\"\n }) => void\n \"normalize:end\": (info: { input: string; output: string }) => void\n \"normalize:error\": (info: { input: string; error: string }) => void\n error: (err: TikTokError) => void\n}\n\nexport type AppErrorKind =\n | \"user-not-live\"\n | \"room-not-found\"\n | \"stream-url-not-found\"\n | \"waf-blocked\"\n | \"country-blocked\"\n | \"network-error\"\n | \"ffmpeg-not-found\"\n | \"ffmpeg-error\"\n | \"aborted\"\n | \"config-error\"\n | \"unknown\"\n\nexport class TikTokError extends Error {\n constructor(\n public readonly kind: AppErrorKind,\n message: string,\n public override readonly cause?: unknown,\n ) {\n super(message)\n this.name = \"TikTokError\"\n }\n}\n\nconst DEFAULTS = {\n outputDir: \"
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/lib/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\n/**\n * TikTok cookie key-value pairs extracted from Firefox or cookies.json.\n * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF\n * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.\n */\nexport type CookieAuth = Record<string, string>\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nexport interface RecorderConfig {\n /** TikTok username (required) */\n user: string\n /** Output directory for recordings (default: ./recordings) */\n outputDir?: string\n /** Polling interval in minutes (default: 3) */\n interval?: number\n /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */\n duration?: number\n /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */\n proxy?: string\n /** Path to cookies.json (default: ./cookies.json) */\n cookiesPath?: string\n /** Cookie auth loaded from cookies.json */\n cookies?: CookieAuth\n /** Log level (default: info) */\n logLevel?: LogLevel\n /** Whether to print logs to console (default: true). Set false when using Display-based UI. */\n logConsole?: boolean\n /** Segment duration in minutes (default: 0 — disabled). When >= 1, the recording is split into segments of this length. */\n segmentMinutes?: number\n /** Normalize audio loudness via EBU R128 (default: false). */\n normalizeAudio?: boolean\n /** Target loudness in LUFS (default: -14). */\n normalizeLoudness?: number\n /** Audio codec for normalized output (default: peaknorm's default, \"libopus\"). */\n normalizeCodec?: string\n /** Audio bitrate for normalized output (default: peaknorm's default, \"96k\"). */\n normalizeBitrate?: string\n /**\n * Max API requests per second (default: 5).\n * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.\n * Set to 0 for unlimited (not recommended).\n */\n ratePerSecond?: number\n /** Show API debug logging on stderr (default: false). */\n debug?: boolean\n}\n\nexport interface RecorderController {\n /** Start the polling loop */\n start(): Promise<void>\n /** Gracefully stop (joins active recording, then exits) */\n stop(): Promise<void>\n /** Snapshot of current state */\n getStatus(): RecorderStatus\n /** Subscribe to events */\n on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n /** Unsubscribe a previously registered event handler */\n off<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n}\n\nexport interface RecorderStatus {\n state: \"idle\" | \"polling\" | \"recording\" | \"converting\" | \"stopped\"\n user: string\n currentFile?: string\n sessionDuration?: number\n lastPollTime?: string\n lastError?: string\n}\n\nexport type RecorderEvent = keyof RecorderEventHandler\n\nexport interface RecorderEventHandler {\n checking: (info: { user: string }) => void\n tick: (info: { user: string; isLive: boolean; roomId?: string }) => void\n \"recording:start\": (info: { user: string; file: string }) => void\n \"download:progress\": (info: { bytes: number; elapsed: number; speed: number }) => void\n \"download:end\": (info: { file: string; duration: number; size: number }) => void\n \"recording:end\": (info: { file: string; duration: number; size: number }) => void\n \"segmenting:start\": (info: { input: string; outputPattern: string }) => void\n \"segmenting:end\": (info: { segments: number }) => void\n \"converting:start\": (info: { input: string }) => void\n converted: (info: { input: string; output: string }) => void\n \"normalize:start\": (info: { file: string }) => void\n \"normalize:progress\": (info: {\n file: string\n percent: number\n phase: \"analyzing\" | \"normalizing\"\n }) => void\n \"normalize:end\": (info: { input: string; output: string }) => void\n \"normalize:error\": (info: { input: string; error: string }) => void\n error: (err: TikTokError) => void\n}\n\nexport type AppErrorKind =\n | \"user-not-live\"\n | \"room-not-found\"\n | \"stream-url-not-found\"\n | \"waf-blocked\"\n | \"country-blocked\"\n | \"network-error\"\n | \"ffmpeg-not-found\"\n | \"ffmpeg-error\"\n | \"aborted\"\n | \"config-error\"\n | \"unknown\"\n\nexport class TikTokError extends Error {\n constructor(\n public readonly kind: AppErrorKind,\n message: string,\n public override readonly cause?: unknown,\n ) {\n super(message)\n this.name = \"TikTokError\"\n }\n}\n\nconst DEFAULTS = {\n outputDir: \".\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 0,\n normalizeAudio: false,\n normalizeLoudness: -14,\n ratePerSecond: 5,\n debug: false,\n}\n\nexport function normalizeConfig(\n config: RecorderConfig,\n): Required<\n Omit<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\" | \"normalizeCodec\" | \"normalizeBitrate\">\n> &\n Pick<\n RecorderConfig,\n \"cookies\" | \"cookiesPath\" | \"proxy\" | \"normalizeCodec\" | \"normalizeBitrate\"\n > {\n return {\n user: config.user,\n outputDir: config.outputDir ?? DEFAULTS.outputDir,\n interval: config.interval ?? DEFAULTS.interval,\n duration: config.duration ?? DEFAULTS.duration,\n logLevel: config.logLevel ?? DEFAULTS.logLevel,\n logConsole: config.logConsole ?? DEFAULTS.logConsole,\n proxy: config.proxy,\n cookies: config.cookies,\n cookiesPath: config.cookiesPath,\n segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes,\n normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,\n normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,\n normalizeCodec: config.normalizeCodec,\n normalizeBitrate: config.normalizeBitrate,\n ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,\n debug: config.debug ?? DEFAULTS.debug,\n }\n}\n\nexport function validateConfig(config: RecorderConfig): void {\n if (!config.user || config.user.trim().length === 0) {\n throw new TikTokError(\"config-error\", \"--user is required\")\n }\n if (config.interval !== undefined && config.interval < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be >= 1 minute\")\n }\n if (config.duration !== undefined && config.duration < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be >= 0 seconds\")\n }\n}\n"],"mappings":";AAgHA,IAAa,cAAb,cAAiC,MAAM;CAEnB;CAES;CAH3B,YACE,MACA,SACA,OACA;EACA,MAAM,OAAO;EAJG,KAAA,OAAA;EAES,KAAA,QAAA;EAGzB,KAAK,OAAO;CACd;AACF;AAEA,MAAM,WAAW;CACf,WAAW;CACX,UAAU;CACV,UAAU;CACV,UAAU;CACV,YAAY;CACZ,gBAAgB;CAChB,gBAAgB;CAChB,mBAAmB;CACnB,eAAe;CACf,OAAO;AACT;AAEA,SAAgB,gBACd,QAOE;CACF,OAAO;EACL,MAAM,OAAO;EACb,WAAW,OAAO,aAAa,SAAS;EACxC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,YAAY,OAAO,cAAc,SAAS;EAC1C,OAAO,OAAO;EACd,SAAS,OAAO;EAChB,aAAa,OAAO;EACpB,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,mBAAmB,OAAO,qBAAqB,SAAS;EACxD,gBAAgB,OAAO;EACvB,kBAAkB,OAAO;EACzB,eAAe,OAAO,iBAAiB,SAAS;EAChD,OAAO,OAAO,SAAS,SAAS;CAClC;AACF"}
|
package/dist/lib/tmux.mjs
CHANGED
|
@@ -31,11 +31,7 @@ async function tmuxStop(user) {
|
|
|
31
31
|
if (user) {
|
|
32
32
|
const name = sessionName(user);
|
|
33
33
|
if (!await hasSession(name)) throw new Error(`No session "${name}" found.`);
|
|
34
|
-
await
|
|
35
|
-
"kill-session",
|
|
36
|
-
"-t",
|
|
37
|
-
name
|
|
38
|
-
]);
|
|
34
|
+
await gracefulStop(name);
|
|
39
35
|
return;
|
|
40
36
|
}
|
|
41
37
|
const { stdout } = await execa("tmux", [
|
|
@@ -45,10 +41,28 @@ async function tmuxStop(user) {
|
|
|
45
41
|
], { reject: false });
|
|
46
42
|
const sessions = stdout.split("\n").filter((s) => s.startsWith("tokrec-"));
|
|
47
43
|
if (sessions.length === 0) throw new Error("No active tokrec sessions found.");
|
|
48
|
-
for (const s of sessions) await
|
|
44
|
+
for (const s of sessions) await gracefulStop(s);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Send Ctrl-C to the tmux session and wait for it to exit.
|
|
48
|
+
* Falls back to kill-session after the timeout.
|
|
49
|
+
*/
|
|
50
|
+
async function gracefulStop(name) {
|
|
51
|
+
await execa("tmux", [
|
|
52
|
+
"send-keys",
|
|
53
|
+
"-t",
|
|
54
|
+
name,
|
|
55
|
+
"C-c"
|
|
56
|
+
]);
|
|
57
|
+
const deadline = Date.now() + 66e4;
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
if (!await hasSession(name)) return;
|
|
60
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
61
|
+
}
|
|
62
|
+
if (await hasSession(name)) await execa("tmux", [
|
|
49
63
|
"kill-session",
|
|
50
64
|
"-t",
|
|
51
|
-
|
|
65
|
+
name
|
|
52
66
|
]);
|
|
53
67
|
}
|
|
54
68
|
async function tmuxStatus(user) {
|
package/dist/lib/tmux.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tmux.mjs","names":[],"sources":["../../src/lib/tmux.ts"],"sourcesContent":["/**\n * tmux session helpers — per-user start/stop/status for background recording.\n *\n * Session naming: \"tokrec-{user}\" — each user gets an independent tmux session.\n */\n\nimport { execa } from \"execa\"\n\nfunction sessionName(user: string): string {\n return `tokrec-${user}`\n}\n\nasync function hasSession(name: string): Promise<boolean> {\n const { exitCode } = await execa(\"tmux\", [\"has-session\", \"-t\", name], { reject: false })\n return exitCode === 0\n}\n\nexport async function tmuxStart(user: string, command: string): Promise<void> {\n const name = sessionName(user)\n if (await hasSession(name)) {\n throw new Error(`Session \"${name}\" already exists. Run \"tokrec stop -u ${user}\" first.`)\n }\n\n await execa(\"tmux\", [\"new-session\", \"-d\", \"-s\", name, command])\n}\n\nexport async function tmuxStop(user?: string): Promise<void> {\n if (user) {\n const name = sessionName(user)\n if (!(await hasSession(name))) {\n throw new Error(`No session \"${name}\" found.`)\n }\n await
|
|
1
|
+
{"version":3,"file":"tmux.mjs","names":[],"sources":["../../src/lib/tmux.ts"],"sourcesContent":["/**\n * tmux session helpers — per-user start/stop/status for background recording.\n *\n * Session naming: \"tokrec-{user}\" — each user gets an independent tmux session.\n */\n\nimport { execa } from \"execa\"\n\nfunction sessionName(user: string): string {\n return `tokrec-${user}`\n}\n\nasync function hasSession(name: string): Promise<boolean> {\n const { exitCode } = await execa(\"tmux\", [\"has-session\", \"-t\", name], { reject: false })\n return exitCode === 0\n}\n\nexport async function tmuxStart(user: string, command: string): Promise<void> {\n const name = sessionName(user)\n if (await hasSession(name)) {\n throw new Error(`Session \"${name}\" already exists. Run \"tokrec stop -u ${user}\" first.`)\n }\n\n await execa(\"tmux\", [\"new-session\", \"-d\", \"-s\", name, command])\n}\n\nexport async function tmuxStop(user?: string): Promise<void> {\n if (user) {\n const name = sessionName(user)\n if (!(await hasSession(name))) {\n throw new Error(`No session \"${name}\" found.`)\n }\n await gracefulStop(name)\n return\n }\n\n // Stop all tokrec-* sessions\n const { stdout } = await execa(\"tmux\", [\"list-sessions\", \"-F\", \"#{session_name}\"], {\n reject: false,\n })\n const sessions = stdout.split(\"\\n\").filter((s) => s.startsWith(\"tokrec-\"))\n\n if (sessions.length === 0) {\n throw new Error(\"No active tokrec sessions found.\")\n }\n\n for (const s of sessions) {\n await gracefulStop(s)\n }\n}\n\n/**\n * Send Ctrl-C to the tmux session and wait for it to exit.\n * Falls back to kill-session after the timeout.\n */\nasync function gracefulStop(name: string): Promise<void> {\n // Send SIGINT via Ctrl-C\n await execa(\"tmux\", [\"send-keys\", \"-t\", name, \"C-c\"])\n\n // Wait up to 11 min for graceful shutdown (10 min pending conversion timeout + margin)\n const deadline = Date.now() + 660_000\n while (Date.now() < deadline) {\n if (!(await hasSession(name))) return\n await new Promise((r) => setTimeout(r, 500))\n }\n\n // Force kill if still alive\n if (await hasSession(name)) {\n await execa(\"tmux\", [\"kill-session\", \"-t\", name])\n }\n}\n\nexport interface SessionInfo {\n user: string\n session: string\n}\n\nexport async function tmuxStatus(user?: string): Promise<SessionInfo[]> {\n const { stdout } = await execa(\"tmux\", [\"list-sessions\", \"-F\", \"#{session_name}\"], {\n reject: false,\n })\n\n const all = stdout\n .split(\"\\n\")\n .filter((s) => s.startsWith(\"tokrec-\"))\n .map((s) => ({ user: s.slice(\"tokrec-\".length), session: s }))\n\n if (user) {\n return all.filter((s) => s.user === user)\n }\n\n return all\n}\n"],"mappings":";;;;;;;AAQA,SAAS,YAAY,MAAsB;CACzC,OAAO,UAAU;AACnB;AAEA,eAAe,WAAW,MAAgC;CACxD,MAAM,EAAE,aAAa,MAAM,MAAM,QAAQ;EAAC;EAAe;EAAM;CAAI,GAAG,EAAE,QAAQ,MAAM,CAAC;CACvF,OAAO,aAAa;AACtB;AAEA,eAAsB,UAAU,MAAc,SAAgC;CAC5E,MAAM,OAAO,YAAY,IAAI;CAC7B,IAAI,MAAM,WAAW,IAAI,GACvB,MAAM,IAAI,MAAM,YAAY,KAAK,wCAAwC,KAAK,SAAS;CAGzF,MAAM,MAAM,QAAQ;EAAC;EAAe;EAAM;EAAM;EAAM;CAAO,CAAC;AAChE;AAEA,eAAsB,SAAS,MAA8B;CAC3D,IAAI,MAAM;EACR,MAAM,OAAO,YAAY,IAAI;EAC7B,IAAI,CAAE,MAAM,WAAW,IAAI,GACzB,MAAM,IAAI,MAAM,eAAe,KAAK,SAAS;EAE/C,MAAM,aAAa,IAAI;EACvB;CACF;CAGA,MAAM,EAAE,WAAW,MAAM,MAAM,QAAQ;EAAC;EAAiB;EAAM;CAAiB,GAAG,EACjF,QAAQ,MACV,CAAC;CACD,MAAM,WAAW,OAAO,MAAM,IAAI,CAAC,CAAC,QAAQ,MAAM,EAAE,WAAW,SAAS,CAAC;CAEzE,IAAI,SAAS,WAAW,GACtB,MAAM,IAAI,MAAM,kCAAkC;CAGpD,KAAK,MAAM,KAAK,UACd,MAAM,aAAa,CAAC;AAExB;;;;;AAMA,eAAe,aAAa,MAA6B;CAEvD,MAAM,MAAM,QAAQ;EAAC;EAAa;EAAM;EAAM;CAAK,CAAC;CAGpD,MAAM,WAAW,KAAK,IAAI,IAAI;CAC9B,OAAO,KAAK,IAAI,IAAI,UAAU;EAC5B,IAAI,CAAE,MAAM,WAAW,IAAI,GAAI;EAC/B,MAAM,IAAI,SAAS,MAAM,WAAW,GAAG,GAAG,CAAC;CAC7C;CAGA,IAAI,MAAM,WAAW,IAAI,GACvB,MAAM,MAAM,QAAQ;EAAC;EAAgB;EAAM;CAAI,CAAC;AAEpD;AAOA,eAAsB,WAAW,MAAuC;CACtE,MAAM,EAAE,WAAW,MAAM,MAAM,QAAQ;EAAC;EAAiB;EAAM;CAAiB,GAAG,EACjF,QAAQ,MACV,CAAC;CAED,MAAM,MAAM,OACT,MAAM,IAAI,CAAC,CACX,QAAQ,MAAM,EAAE,WAAW,SAAS,CAAC,CAAC,CACtC,KAAK,OAAO;EAAE,MAAM,EAAE,MAAM,CAAgB;EAAG,SAAS;CAAE,EAAE;CAE/D,IAAI,MACF,OAAO,IAAI,QAAQ,MAAM,EAAE,SAAS,IAAI;CAG1C,OAAO;AACT"}
|
package/dist/package.mjs
CHANGED