@zfadhli/tokrec 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +215 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/{api → core/api}/client.mjs +1 -1
- package/dist/core/api/client.mjs.map +1 -0
- package/dist/{api → core/api}/rate-limiter.mjs +1 -1
- package/dist/core/api/rate-limiter.mjs.map +1 -0
- package/dist/{api → core/api}/tiktok.mjs +1 -1
- package/dist/core/api/tiktok.mjs.map +1 -0
- package/dist/{monitor.mjs → core/monitor.mjs} +2 -2
- package/dist/core/monitor.mjs.map +1 -0
- package/dist/core/recorder/convert.mjs +55 -0
- package/dist/core/recorder/convert.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/download.mjs +3 -3
- package/dist/core/recorder/download.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/ffmpeg-utils.mjs +4 -7
- package/dist/core/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/core/recorder/index.d.mts +7 -0
- package/dist/core/recorder/index.d.mts.map +1 -0
- package/dist/{recorder → core/recorder}/index.mjs +16 -33
- package/dist/core/recorder/index.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/normalize.mjs +1 -1
- package/dist/core/recorder/normalize.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/post-processing.mjs +2 -2
- package/dist/core/recorder/post-processing.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/recorder-events.mjs +1 -1
- package/dist/core/recorder/recorder-events.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/recorder-state.mjs +1 -1
- package/dist/core/recorder/recorder-state.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/stream.mjs +2 -2
- package/dist/core/recorder/stream.mjs.map +1 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +2 -141
- package/dist/{browser-cookies.mjs → lib/browser-cookies.mjs} +1 -1
- package/dist/lib/browser-cookies.mjs.map +1 -0
- package/dist/lib/cli.mjs +164 -0
- package/dist/lib/cli.mjs.map +1 -0
- package/dist/{config.d.mts → lib/config.d.mts} +1 -1
- package/dist/lib/config.d.mts.map +1 -0
- package/dist/{config.mjs → lib/config.mjs} +2 -7
- package/dist/lib/config.mjs.map +1 -0
- package/dist/{logger.mjs → lib/logger.mjs} +1 -1
- package/dist/lib/logger.mjs.map +1 -0
- package/dist/lib/tmux.mjs +70 -0
- package/dist/lib/tmux.mjs.map +1 -0
- package/dist/{ui.mjs → lib/ui.mjs} +1 -1
- package/dist/lib/ui.mjs.map +1 -0
- package/dist/{utils.mjs → lib/utils.mjs} +1 -1
- package/dist/lib/utils.mjs.map +1 -0
- package/dist/package.mjs +1 -1
- package/package.json +10 -9
- package/dist/api/client.mjs.map +0 -1
- package/dist/api/rate-limiter.mjs.map +0 -1
- package/dist/api/tiktok.mjs.map +0 -1
- package/dist/browser-cookies.mjs.map +0 -1
- package/dist/cli.mjs +0 -110
- package/dist/cli.mjs.map +0 -1
- package/dist/config.d.mts.map +0 -1
- package/dist/config.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/lib.d.mts +0 -3
- package/dist/lib.mjs +0 -2
- package/dist/logger.mjs.map +0 -1
- package/dist/monitor.mjs.map +0 -1
- package/dist/recorder/convert.mjs +0 -67
- package/dist/recorder/convert.mjs.map +0 -1
- package/dist/recorder/download.mjs.map +0 -1
- package/dist/recorder/ffmpeg-utils.mjs.map +0 -1
- package/dist/recorder/index.d.mts +0 -7
- package/dist/recorder/index.d.mts.map +0 -1
- package/dist/recorder/index.mjs.map +0 -1
- package/dist/recorder/normalize.mjs.map +0 -1
- package/dist/recorder/post-processing.mjs.map +0 -1
- package/dist/recorder/recorder-events.mjs.map +0 -1
- package/dist/recorder/recorder-state.mjs.map +0 -1
- package/dist/recorder/stream.mjs.map +0 -1
- package/dist/ui.mjs.map +0 -1
- package/dist/utils.mjs.map +0 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { RecorderConfig, RecorderController } from "../../lib/config.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/core/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/core/recorder/index.ts"],"mappings":";;;iBAqBgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAA"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { TikTokError, normalizeConfig } from "../../lib/config.mjs";
|
|
2
|
+
import { createLogger } from "../../lib/logger.mjs";
|
|
3
|
+
import { findFfmpegPath, sleep } from "../../lib/utils.mjs";
|
|
1
4
|
import { createHttpClient } from "../api/client.mjs";
|
|
2
5
|
import { createTikTokApi } from "../api/tiktok.mjs";
|
|
3
|
-
import { TikTokError, normalizeConfig } from "../config.mjs";
|
|
4
|
-
import { createLogger } from "../logger.mjs";
|
|
5
|
-
import { findFfmpegPath, sleep } from "../utils.mjs";
|
|
6
6
|
import { createPollingMonitor } from "../monitor.mjs";
|
|
7
7
|
import { createConverter } from "./convert.mjs";
|
|
8
8
|
import { createAudioNormalizer } from "./normalize.mjs";
|
|
@@ -10,8 +10,8 @@ import { processRecording } from "./post-processing.mjs";
|
|
|
10
10
|
import { createEventEmitter } from "./recorder-events.mjs";
|
|
11
11
|
import { createRecorderState } from "./recorder-state.mjs";
|
|
12
12
|
import { createStreamDownloader } from "./stream.mjs";
|
|
13
|
-
import {
|
|
14
|
-
//#region src/recorder/index.ts
|
|
13
|
+
import { execa } from "execa";
|
|
14
|
+
//#region src/core/recorder/index.ts
|
|
15
15
|
/**
|
|
16
16
|
* Recorder orchestrator — wires together the API, monitor, stream downloader,
|
|
17
17
|
* and converter into a single RecorderController.
|
|
@@ -37,39 +37,22 @@ function createRecorder(config) {
|
|
|
37
37
|
let stopRequested = false;
|
|
38
38
|
let stopAbortController = new AbortController();
|
|
39
39
|
let pendingRemuxes = [];
|
|
40
|
-
function runFfmpeg(args, signal) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
const proc = spawn(ffmpegPath, args, {
|
|
40
|
+
async function runFfmpeg(args, signal) {
|
|
41
|
+
const ffmpegPath = findFfmpegPath();
|
|
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
|
+
try {
|
|
44
|
+
await execa(ffmpegPath, args, {
|
|
45
|
+
signal,
|
|
48
46
|
stdio: [
|
|
49
47
|
"ignore",
|
|
50
48
|
"pipe",
|
|
51
49
|
"pipe"
|
|
52
|
-
]
|
|
53
|
-
signal
|
|
54
|
-
});
|
|
55
|
-
let stderr = "";
|
|
56
|
-
proc.stderr?.on("data", (chunk) => {
|
|
57
|
-
stderr += chunk.toString();
|
|
58
|
-
if (stderr.length > 1e4) stderr = stderr.slice(-5e3);
|
|
59
|
-
});
|
|
60
|
-
proc.on("close", (code) => {
|
|
61
|
-
if (signal?.aborted) {
|
|
62
|
-
reject(new TikTokError("unknown", "Aborted by user"));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (code === 0) resolve();
|
|
66
|
-
else reject(new TikTokError("unknown", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
|
|
67
|
-
});
|
|
68
|
-
proc.on("error", (err) => {
|
|
69
|
-
if (err instanceof Error && err.name === "AbortError") return;
|
|
70
|
-
reject(new TikTokError("unknown", `Failed to spawn FFmpeg: ${err.message}`));
|
|
50
|
+
]
|
|
71
51
|
});
|
|
72
|
-
})
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (signal?.aborted) throw new TikTokError("unknown", "Aborted by user");
|
|
54
|
+
throw new TikTokError("unknown", `FFmpeg failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
+
}
|
|
73
56
|
}
|
|
74
57
|
async function start() {
|
|
75
58
|
if (stateManager.getStatus().state !== "idle") {
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.mjs","names":[],"sources":["../../../src/core/recorder/normalize.ts"],"sourcesContent":["/**\n * Audio normalizer — wraps peaknorm's EBU R128 loudness normalization.\n * Runs after conversion/segmenting to normalize the final MP4 audio.\n */\n\nimport type { NormalizeOptions, NormalizeResult } from \"peaknorm\"\nimport { normalizeFile } from \"peaknorm\"\nimport type { Logger } from \"../../lib/logger\"\n\nexport interface AudioNormalizer {\n /** Normalize a single audio/video file. Returns the normalize result. */\n normalize(file: string): Promise<NormalizeResult>\n}\n\nexport interface AudioNormalizerOptions {\n /** Target loudness in LUFS (default: -14). */\n loudness: number\n /** Audio codec for output (default: peaknorm's default, \"libopus\"). */\n audioCodec?: string\n /** Audio bitrate for output (default: peaknorm's default, \"96k\"). */\n audioBitrate?: string\n /** Called when a file starts normalization. */\n onStart?: (file: string) => void\n /** Called with progress updates (percent 0-100, phase \"analyzing\"|\"normalizing\"). */\n onProgress?: (file: string, percent: number, phase: string) => void\n /** Called when a file completes normalization successfully. */\n onComplete?: (result: NormalizeResult) => void\n /** Called when a file errors during normalization. */\n onError?: (file: string, error: Error) => void\n}\n\nexport function createAudioNormalizer(\n opts: AudioNormalizerOptions,\n logger?: Logger,\n): AudioNormalizer {\n return {\n async normalize(file: string): Promise<NormalizeResult> {\n logger?.info(`Normalizing audio: ${file}`)\n opts.onStart?.(file)\n\n try {\n const normalizeOpts: NormalizeOptions = {\n loudness: opts.loudness,\n backup: false,\n onFileProgress: (f, percent, phase) => {\n opts.onProgress?.(f, percent, phase)\n },\n }\n if (opts.audioCodec !== undefined) normalizeOpts.audioCodec = opts.audioCodec\n if (opts.audioBitrate !== undefined) normalizeOpts.audioBitrate = opts.audioBitrate\n const result = await normalizeFile(file, normalizeOpts)\n\n logger?.info(`Audio normalized: ${result.output}`)\n opts.onComplete?.(result)\n return result\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err))\n logger?.error(`Audio normalization failed: ${error.message}`)\n opts.onError?.(file, error)\n throw error\n }\n },\n }\n}\n"],"mappings":";;AA+BA,SAAgB,sBACd,MACA,QACiB;CACjB,OAAO,EACL,MAAM,UAAU,MAAwC;EACtD,QAAQ,KAAK,sBAAsB,MAAM;EACzC,KAAK,UAAU,IAAI;EAEnB,IAAI;GACF,MAAM,gBAAkC;IACtC,UAAU,KAAK;IACf,QAAQ;IACR,iBAAiB,GAAG,SAAS,UAAU;KACrC,KAAK,aAAa,GAAG,SAAS,KAAK;IACrC;GACF;GACA,IAAI,KAAK,eAAe,KAAA,GAAW,cAAc,aAAa,KAAK;GACnE,IAAI,KAAK,iBAAiB,KAAA,GAAW,cAAc,eAAe,KAAK;GACvE,MAAM,SAAS,MAAM,cAAc,MAAM,aAAa;GAEtD,QAAQ,KAAK,qBAAqB,OAAO,QAAQ;GACjD,KAAK,aAAa,MAAM;GACxB,OAAO;EACT,SAAS,KAAK;GACZ,MAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;GAChE,QAAQ,MAAM,+BAA+B,MAAM,SAAS;GAC5D,KAAK,UAAU,MAAM,KAAK;GAC1B,MAAM;EACR;CACF,EACF;AACF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { TikTokError } from "
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
2
|
import { readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
3
3
|
import { join, parse } from "node:path";
|
|
4
|
-
//#region src/recorder/post-processing.ts
|
|
4
|
+
//#region src/core/recorder/post-processing.ts
|
|
5
5
|
/**
|
|
6
6
|
* Post-recording processing — segmenting, conversion, and audio normalization.
|
|
7
7
|
*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"post-processing.mjs","names":[],"sources":["../../../src/core/recorder/post-processing.ts"],"sourcesContent":["/**\n * Post-recording processing — segmenting, conversion, and audio normalization.\n *\n * After a stream download completes, this module handles:\n * - Splitting the downloaded file into timed MP4 segments\n * - Converting TS to MP4 (fallback or direct)\n * - Audio normalization via EBU R128\n * - File management (deleting originals, setting timestamps)\n * - Event emissions for each step\n */\n\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport type { RecorderConfig } from \"../../lib/config\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport type { Converter } from \"./convert\"\nimport type { AudioNormalizer } from \"./normalize\"\nimport type { DownloadResult } from \"./stream\"\n\nexport interface ProcessingDeps {\n runFfmpeg: (args: string[], signal?: AbortSignal) => Promise<void>\n converter: Converter\n audioNormalizer: AudioNormalizer | null\n stopAbortController: AbortController\n pendingRemuxes: Promise<unknown>[]\n emit: (event: string, ...args: unknown[]) => void\n setState: (partial: Record<string, unknown>) => void\n stopRequested: () => boolean\n logger: Logger\n}\n\nexport async function processRecording(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n try {\n if (statSync(result.file).size === 0) return\n } catch {\n return // file doesn't exist or can't be accessed\n }\n\n deps.setState({ state: \"converting\" })\n\n if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) {\n await processSegmenting(result, cfg, deps)\n } else {\n await processConversion(result, cfg, deps)\n }\n\n deps.setState({ state: \"polling\" })\n}\n\n// ─── Segmenting path ──────────────────────────────────────────────\n\nasync function processSegmenting(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n const parsed = parse(result.file)\n const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n\n deps.logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`,\n )\n deps.emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n const segmentPromise = deps.runFfmpeg(\n [\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ],\n deps.stopAbortController.signal,\n )\n deps.pendingRemuxes.push(segmentPromise)\n await segmentPromise\n\n // Capture file timestamp before deletion\n const fileMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = fileMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original TS\n try {\n unlinkSync(result.file)\n deps.logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n deps.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 = segmentTime\n const totalDuration = result.duration\n\n // Emit events for each segment\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i] as string)\n const stats = statSync(filePath)\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n deps.emit(\"recording:end\", { file: filePath, duration: estDuration, size: stats.size })\n deps.emit(\"converted\", { input: result.file, output: filePath })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i] as string)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n deps.emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (deps.audioNormalizer) {\n const normalizer = deps.audioNormalizer\n const normalizePromises = segmentFiles.map(async (segFile) => {\n const segPath = join(parsed.dir, segFile)\n try {\n await normalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback\n }\n })\n deps.pendingRemuxes.push(...normalizePromises)\n await Promise.allSettled(normalizePromises)\n }\n\n deps.setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1) as string) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n if (deps.stopRequested()) return\n const msg = err instanceof Error ? err.message : String(err)\n deps.logger.error(`Segmenting failed: ${msg}`)\n deps.setState({ lastError: `Segmenting failed: ${msg}` })\n // Fallback: try simple conversion of the whole TS\n await runFallbackConversion(result, deps)\n }\n}\n\n// ─── Shared converter helper ────────────────────────────────────\n\n/**\n * Convert a downloaded file to MP4 and optionally normalize audio.\n * Shared by the direct conversion and fallback paths.\n *\n * @param emitRecordingEnd When true, also emit a `recording:end` event\n * (used by the direct conversion path; the segmenting path emits its\n * own `recording:end` events per segment).\n */\nasync function convertFile(\n result: DownloadResult,\n deps: ProcessingDeps,\n emitRecordingEnd?: boolean,\n): Promise<void> {\n deps.emit(\"converting:start\", { input: result.file })\n try {\n const convertPromise = deps.converter.convert(result.file)\n deps.pendingRemuxes.push(convertPromise)\n const mp4File = await convertPromise\n\n if (deps.audioNormalizer) {\n const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {})\n deps.pendingRemuxes.push(normalizePromise)\n await normalizePromise\n }\n\n if (emitRecordingEnd) {\n deps.emit(\"recording:end\", { file: mp4File, duration: result.duration, size: result.size })\n }\n deps.emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n deps.logger.error(`Conversion failed: ${tkErr.message}`)\n deps.setState({ lastError: `Conversion failed: ${tkErr.message}` })\n deps.emit(\"error\", tkErr)\n }\n}\n\n// ─── Conversion path (no segmenting) ─────────────────────────────\n\nasync function processConversion(\n result: DownloadResult,\n _cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n return convertFile(result, deps, true)\n}\n\n// ─── Fallback conversion (when segmenting fails) ─────────────────\n\nasync function runFallbackConversion(result: DownloadResult, deps: ProcessingDeps): Promise<void> {\n return convertFile(result, deps)\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-events.mjs","names":[],"sources":["../../../src/core/recorder/recorder-events.ts"],"sourcesContent":["/**\n * Typed event emitter for recorder lifecycle events.\n */\n\nimport type { RecorderEvent, RecorderEventHandler } from \"../../lib/config\"\n\nexport interface RecorderEventEmitter {\n on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n emit<E extends RecorderEvent>(event: E, ...args: Parameters<RecorderEventHandler[E]>): void\n clear(): void\n}\n\nexport function createEventEmitter(): RecorderEventEmitter {\n const handlers = new Map<string, Array<(...args: unknown[]) => void>>()\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event) ?? []\n list.push(handler as (...args: unknown[]) => void)\n handlers.set(event, list)\n }\n\n function off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event)\n if (!list) return\n const idx = list.indexOf(handler as (...args: unknown[]) => void)\n if (idx !== -1) list.splice(idx, 1)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = handlers.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 clear(): void {\n handlers.clear()\n }\n\n return { on, off, emit, clear }\n}\n"],"mappings":";AAaA,SAAgB,qBAA2C;CACzD,MAAM,2BAAW,IAAI,IAAiD;CAEtE,SAAS,GAA4B,OAAU,SAAwC;EACrF,MAAM,OAAO,SAAS,IAAI,KAAK,KAAK,CAAC;EACrC,KAAK,KAAK,OAAuC;EACjD,SAAS,IAAI,OAAO,IAAI;CAC1B;CAEA,SAAS,IAA6B,OAAU,SAAwC;EACtF,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,CAAC,MAAM;EACX,MAAM,MAAM,KAAK,QAAQ,OAAuC;EAChE,IAAI,QAAQ,IAAI,KAAK,OAAO,KAAK,CAAC;CACpC;CAEA,SAAS,KACP,OACA,GAAG,MACG;EACN,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,MACF,KAAK,MAAM,KAAK,MACd,IAAI;GACF,EAAE,GAAG,IAAI;EACX,QAAQ,CAER;CAGN;CAEA,SAAS,QAAc;EACrB,SAAS,MAAM;CACjB;CAEA,OAAO;EAAE;EAAI;EAAK;EAAM;CAAM;AAChC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-state.mjs","names":[],"sources":["../../../src/core/recorder/recorder-state.ts"],"sourcesContent":["/**\n * State machine for the recorder lifecycle.\n */\n\nimport type { RecorderStatus } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\n\n// Valid state transitions: [from] → [to, to, ...]\nconst TRANSITIONS: Record<string, string[]> = {\n idle: [\"polling\"],\n polling: [\"recording\", \"stopped\"],\n recording: [\"converting\", \"stopped\"],\n converting: [\"polling\", \"stopped\"],\n stopped: [],\n}\n\nexport interface RecorderStateManager {\n setState(partial: Partial<RecorderStatus>): void\n getStatus(): RecorderStatus\n}\n\nexport function createRecorderState(initial: RecorderStatus, logger: Logger): RecorderStateManager {\n let state: RecorderStatus = { ...initial }\n\n function setState(partial: Partial<RecorderStatus>): void {\n const prevState = state.state\n const newState = partial.state\n\n // Validate state transitions\n if (newState && newState !== prevState) {\n const allowed = TRANSITIONS[prevState]\n if (!allowed?.includes(newState)) {\n logger.error(`Invalid state transition: ${prevState} → ${newState} (ignored)`)\n return\n }\n logger.info(`State: ${prevState} → ${newState}`)\n }\n\n state = { ...state, ...partial }\n\n // Auto-track timestamps on state entry\n if (newState === \"polling\" && newState !== prevState) {\n state.lastPollTime = new Date().toISOString()\n }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n return { setState, getStatus }\n}\n"],"mappings":";AAQA,MAAM,cAAwC;CAC5C,MAAM,CAAC,SAAS;CAChB,SAAS,CAAC,aAAa,SAAS;CAChC,WAAW,CAAC,cAAc,SAAS;CACnC,YAAY,CAAC,WAAW,SAAS;CACjC,SAAS,CAAC;AACZ;AAOA,SAAgB,oBAAoB,SAAyB,QAAsC;CACjG,IAAI,QAAwB,EAAE,GAAG,QAAQ;CAEzC,SAAS,SAAS,SAAwC;EACxD,MAAM,YAAY,MAAM;EACxB,MAAM,WAAW,QAAQ;EAGzB,IAAI,YAAY,aAAa,WAAW;GAEtC,IAAI,CADY,YAAY,UAChB,EAAE,SAAS,QAAQ,GAAG;IAChC,OAAO,MAAM,6BAA6B,UAAU,KAAK,SAAS,WAAW;IAC7E;GACF;GACA,OAAO,KAAK,UAAU,UAAU,KAAK,UAAU;EACjD;EAEA,QAAQ;GAAE,GAAG;GAAO,GAAG;EAAQ;EAG/B,IAAI,aAAa,aAAa,aAAa,WACzC,MAAM,gCAAe,IAAI,KAAK,EAAA,CAAE,YAAY;CAEhD;CAEA,SAAS,YAA4B;EACnC,OAAO,EAAE,GAAG,MAAM;CACpB;CAEA,OAAO;EAAE;EAAU;CAAU;AAC/B"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ensureDir } from "
|
|
1
|
+
import { ensureDir } from "../../lib/utils.mjs";
|
|
2
2
|
import { downloadStream } from "./download.mjs";
|
|
3
|
-
//#region src/recorder/stream.ts
|
|
3
|
+
//#region src/core/recorder/stream.ts
|
|
4
4
|
function createStreamDownloader(logger) {
|
|
5
5
|
let abortController = new AbortController();
|
|
6
6
|
function abort() {
|
|
@@ -0,0 +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":";;;AAuCA,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,YACyB;EACzB,kBAAkB,IAAI,gBAAgB;EACtC,UAAU,SAAS;EAEnB,OAAO,eACL,SACA,MACA,WACA,aACA,YACA,YACA,gBAAgB,QAChB,QACA,SAAS,OAAO,IAAI,QAAQ,KAAA,CAC9B;CACF;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
import { AppErrorKind, RecorderConfig, RecorderController, RecorderEvent, RecorderEventHandler, RecorderStatus, TikTokError } from "./lib/config.mjs";
|
|
2
|
+
import { createRecorder } from "./core/recorder/index.mjs";
|
|
3
|
+
export { type AppErrorKind, type RecorderConfig, type RecorderController, type RecorderEvent, type RecorderEventHandler, type RecorderStatus, type TikTokError, createRecorder };
|
package/dist/index.mjs
CHANGED
|
@@ -1,141 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { bytesToHuman, relativeTime } from "./utils.mjs";
|
|
4
|
-
import { createRecorder } from "./recorder/index.mjs";
|
|
5
|
-
import "./lib.mjs";
|
|
6
|
-
import { extractTikTokCookiesFromFirefox } from "./browser-cookies.mjs";
|
|
7
|
-
import { parseArgs } from "./cli.mjs";
|
|
8
|
-
import { createDisplay } from "./ui.mjs";
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { color } from "@zfadhli/koko-cli";
|
|
12
|
-
//#region src/index.ts
|
|
13
|
-
/**
|
|
14
|
-
* Entry point — parses CLI args, creates the recorder, and handles signals.
|
|
15
|
-
* Uses the terminal Display manager for beautiful console output.
|
|
16
|
-
*/
|
|
17
|
-
async function loadCookies(cookiesPath) {
|
|
18
|
-
const path = cookiesPath ?? join(process.cwd(), "cookies.json");
|
|
19
|
-
if (!existsSync(path)) return;
|
|
20
|
-
try {
|
|
21
|
-
const content = readFileSync(path, "utf-8");
|
|
22
|
-
const data = JSON.parse(content);
|
|
23
|
-
if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
|
|
24
|
-
return;
|
|
25
|
-
} catch {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async function main() {
|
|
30
|
-
let config;
|
|
31
|
-
try {
|
|
32
|
-
config = parseArgs();
|
|
33
|
-
validateConfig(config);
|
|
34
|
-
} catch (err) {
|
|
35
|
-
if (err instanceof TikTokError && err.kind === "config-error") {
|
|
36
|
-
console.error(`${color.red("Error:")} ${err.message}`);
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
throw err;
|
|
40
|
-
}
|
|
41
|
-
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
42
|
-
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
43
|
-
config.logConsole = false;
|
|
44
|
-
const display = createDisplay();
|
|
45
|
-
if (config.cookies) {
|
|
46
|
-
const source = firefoxCookies ? "Firefox" : "cookies.json";
|
|
47
|
-
const names = Object.keys(config.cookies);
|
|
48
|
-
const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
|
|
49
|
-
display.showInfo(`${source} cookies loaded${detail}`);
|
|
50
|
-
} else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
|
|
51
|
-
const recorder = createRecorder(config);
|
|
52
|
-
let lastWasLive = null;
|
|
53
|
-
let firstOfflineTime = 0;
|
|
54
|
-
recorder.on("checking", (info) => {
|
|
55
|
-
display.checkingUser(info.user);
|
|
56
|
-
});
|
|
57
|
-
recorder.on("tick", (info) => {
|
|
58
|
-
if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
|
|
59
|
-
else if (lastWasLive === null || lastWasLive === true) {
|
|
60
|
-
display.userOffline(info.user);
|
|
61
|
-
firstOfflineTime = Date.now();
|
|
62
|
-
} else {
|
|
63
|
-
const elapsed = Date.now() - firstOfflineTime;
|
|
64
|
-
display.userOfflineRepeat(info.user, relativeTime(elapsed));
|
|
65
|
-
}
|
|
66
|
-
lastWasLive = info.isLive;
|
|
67
|
-
});
|
|
68
|
-
recorder.on("recording:start", () => {
|
|
69
|
-
display.startRecording();
|
|
70
|
-
});
|
|
71
|
-
recorder.on("download:end", (info) => {
|
|
72
|
-
const parsed = info.file.split("/").pop() ?? info.file;
|
|
73
|
-
const sizeStr = bytesToHuman(info.size);
|
|
74
|
-
display.finishRecording(parsed, info.duration, sizeStr);
|
|
75
|
-
});
|
|
76
|
-
recorder.on("segmenting:start", () => {
|
|
77
|
-
display.startSegmenting();
|
|
78
|
-
});
|
|
79
|
-
recorder.on("segmenting:end", (info) => {
|
|
80
|
-
display.segmentsCreated(info.segments);
|
|
81
|
-
});
|
|
82
|
-
recorder.on("converting:start", () => {
|
|
83
|
-
display.startConverting();
|
|
84
|
-
});
|
|
85
|
-
recorder.on("converted", (info) => {
|
|
86
|
-
const parsed = info.output.split("/").pop() ?? info.output;
|
|
87
|
-
display.conversionDone(parsed);
|
|
88
|
-
});
|
|
89
|
-
recorder.on("recording:end", () => {});
|
|
90
|
-
recorder.on("error", (err) => {
|
|
91
|
-
display.showError(`[${err.kind}] ${err.message}`);
|
|
92
|
-
});
|
|
93
|
-
recorder.on("normalize:start", () => {
|
|
94
|
-
display.normalizeStart();
|
|
95
|
-
});
|
|
96
|
-
recorder.on("normalize:progress", (info) => {
|
|
97
|
-
display.normalizeProgress(info.percent, info.phase);
|
|
98
|
-
});
|
|
99
|
-
recorder.on("normalize:end", () => {
|
|
100
|
-
display.normalizeComplete();
|
|
101
|
-
});
|
|
102
|
-
recorder.on("normalize:error", (info) => {
|
|
103
|
-
display.normalizeError(info.error);
|
|
104
|
-
});
|
|
105
|
-
let stopping = false;
|
|
106
|
-
const handleSignal = async () => {
|
|
107
|
-
if (stopping) return;
|
|
108
|
-
stopping = true;
|
|
109
|
-
process.off("SIGINT", handleSignal);
|
|
110
|
-
process.off("SIGTERM", handleSignal);
|
|
111
|
-
process.off("SIGHUP", handleSignal);
|
|
112
|
-
display.showInfo("Shutting down gracefully...");
|
|
113
|
-
await recorder.stop();
|
|
114
|
-
display.stop();
|
|
115
|
-
process.exit(0);
|
|
116
|
-
};
|
|
117
|
-
process.on("SIGINT", handleSignal);
|
|
118
|
-
process.on("SIGTERM", handleSignal);
|
|
119
|
-
process.on("SIGHUP", handleSignal);
|
|
120
|
-
display.pollingStarted(config.interval ?? 3);
|
|
121
|
-
try {
|
|
122
|
-
await recorder.start();
|
|
123
|
-
} catch (err) {
|
|
124
|
-
if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
|
|
125
|
-
else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
-
process.off("SIGINT", handleSignal);
|
|
127
|
-
process.off("SIGTERM", handleSignal);
|
|
128
|
-
process.off("SIGHUP", handleSignal);
|
|
129
|
-
await recorder.stop();
|
|
130
|
-
display.stop();
|
|
131
|
-
process.exit(1);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
main().catch((err) => {
|
|
135
|
-
console.error("Fatal:", err instanceof Error ? err.message : String(err));
|
|
136
|
-
process.exit(1);
|
|
137
|
-
});
|
|
138
|
-
//#endregion
|
|
139
|
-
export {};
|
|
140
|
-
|
|
141
|
-
//# sourceMappingURL=index.mjs.map
|
|
1
|
+
import { createRecorder } from "./core/recorder/index.mjs";
|
|
2
|
+
export { createRecorder };
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
//#region src/browser-cookies.ts
|
|
5
|
+
//#region src/lib/browser-cookies.ts
|
|
6
6
|
/**
|
|
7
7
|
* Browser cookie extraction — reads TikTok session cookies from Firefox's
|
|
8
8
|
* cookie store using bun:sqlite (no external dependencies).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../../src/lib/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Queries ALL cookies for TikTok domains from Firefox, including the\n * Slardar WAF challenge cookie which is needed to bypass bot detection.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * sessionid_ss cookie exists (without which the session is useless).\n */\nexport function extractTikTokCookiesFromFirefox(): Record<string, string> | null {\n const dbPath = findFirefoxCookieDb()\n if (!dbPath) return null\n\n try {\n const db = new Database(dbPath, { readonly: true })\n try {\n const rows = db\n .query(\n `SELECT name, value FROM moz_cookies\n WHERE host LIKE '%.tiktok.com' OR host LIKE '%.tiktokv.com'`,\n )\n .all() as Array<{ name: string; value: string }>\n\n // Require sessionid_ss — without it the session is useless\n const hasSession = rows.some((r) => r.name === \"sessionid_ss\")\n if (!hasSession) return null\n\n const cookies: Record<string, string> = {}\n for (const row of rows) {\n cookies[row.name] = row.value\n }\n return cookies\n } finally {\n db.close()\n }\n } catch {\n return null\n }\n}\n\n/**\n * Locate the Firefox cookies.sqlite by scanning profile directories.\n *\n * Strategy:\n * 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.\n * 2. Fall back to scanning directory entries that look like Firefox profiles\n * (contain a cookies.sqlite file).\n */\n/** @public Exported for testing */\nexport function findFirefoxCookieDb(): string | null {\n const firefoxDir = join(homedir(), \".mozilla\", \"firefox\")\n if (!existsSync(firefoxDir)) return null\n\n // Try profiles.ini first for a reliable default-profile path\n const iniPath = join(firefoxDir, \"profiles.ini\")\n if (existsSync(iniPath)) {\n try {\n const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir)\n if (profilePath) {\n const dbPath = join(profilePath, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n } catch {\n // fall through to directory scan\n }\n }\n\n // Fallback: scan for any directory containing cookies.sqlite\n const entries = readdirSync(firefoxDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && !entry.isSymbolicLink()) continue\n const dbPath = join(firefoxDir, entry.name, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n\n return null\n}\n\n/**\n * Parse Firefox's profiles.ini to find the default profile path.\n *\n * The INI format looks like:\n * [Profile0]\n * Name=default\n * IsRelative=1\n * Path=1pese0rl.default-release\n * Default=1\n */\n/** @public Exported for testing */\nexport function parseFirefoxProfilesIni(iniPath: string, firefoxDir: string): string | null {\n const text = readFileSync(iniPath, \"utf-8\")\n\n let defaultPath: string | null = null\n let isRelative = true\n let inProfile = false\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim()\n\n // Section header\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n if (inProfile && defaultPath) break // found it\n inProfile = trimmed.toLowerCase().startsWith(\"[profile\")\n if (inProfile) {\n defaultPath = null\n isRelative = true\n }\n continue\n }\n\n if (!inProfile) continue\n\n const lower = trimmed.toLowerCase()\n if (lower.startsWith(\"path=\")) {\n defaultPath = trimmed.slice(5)\n } else if (lower.startsWith(\"isrelative=\")) {\n isRelative = trimmed.slice(11).trim() !== \"0\"\n } else if (lower.startsWith(\"default=\")) {\n if (trimmed.slice(8).trim() !== \"1\") {\n // Not the default profile — skip\n inProfile = false\n defaultPath = null\n }\n }\n }\n\n if (!defaultPath) return null\n return isRelative ? join(firefoxDir, defaultPath) : defaultPath\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,kCAAiE;CAC/E,MAAM,SAAS,oBAAoB;CACnC,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI;EACF,MAAM,KAAK,IAAI,SAAS,QAAQ,EAAE,UAAU,KAAK,CAAC;EAClD,IAAI;GACF,MAAM,OAAO,GACV,MACC;uEAEF,CAAC,CACA,IAAI;GAIP,IAAI,CADe,KAAK,MAAM,MAAM,EAAE,SAAS,cACjC,GAAG,OAAO;GAExB,MAAM,UAAkC,CAAC;GACzC,KAAK,MAAM,OAAO,MAChB,QAAQ,IAAI,QAAQ,IAAI;GAE1B,OAAO;EACT,UAAU;GACR,GAAG,MAAM;EACX;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAgB,sBAAqC;CACnD,MAAM,aAAa,KAAK,QAAQ,GAAG,YAAY,SAAS;CACxD,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;CAGpC,MAAM,UAAU,KAAK,YAAY,cAAc;CAC/C,IAAI,WAAW,OAAO,GACpB,IAAI;EACF,MAAM,cAAc,wBAAwB,SAAS,UAAU;EAC/D,IAAI,aAAa;GACf,MAAM,SAAS,KAAK,aAAa,gBAAgB;GACjD,IAAI,WAAW,MAAM,GAAG,OAAO;EACjC;CACF,QAAQ,CAER;CAIF,MAAM,UAAU,YAAY,YAAY,EAAE,eAAe,KAAK,CAAC;CAC/D,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,MAAM,eAAe,GAAG;EACrD,MAAM,SAAS,KAAK,YAAY,MAAM,MAAM,gBAAgB;EAC5D,IAAI,WAAW,MAAM,GAAG,OAAO;CACjC;CAEA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,wBAAwB,SAAiB,YAAmC;CAC1F,MAAM,OAAO,aAAa,SAAS,OAAO;CAE1C,IAAI,cAA6B;CACjC,IAAI,aAAa;CACjB,IAAI,YAAY;CAEhB,KAAK,MAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;EACnC,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GAAG;GACpD,IAAI,aAAa,aAAa;GAC9B,YAAY,QAAQ,YAAY,CAAC,CAAC,WAAW,UAAU;GACvD,IAAI,WAAW;IACb,cAAc;IACd,aAAa;GACf;GACA;EACF;EAEA,IAAI,CAAC,WAAW;EAEhB,MAAM,QAAQ,QAAQ,YAAY;EAClC,IAAI,MAAM,WAAW,OAAO,GAC1B,cAAc,QAAQ,MAAM,CAAC;OACxB,IAAI,MAAM,WAAW,aAAa,GACvC,aAAa,QAAQ,MAAM,EAAE,CAAC,CAAC,KAAK,MAAM;OACrC,IAAI,MAAM,WAAW,UAAU;OAChC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,KAAK;IAEnC,YAAY;IACZ,cAAc;GAChB;;CAEJ;CAEA,IAAI,CAAC,aAAa,OAAO;CACzB,OAAO,aAAa,KAAK,YAAY,WAAW,IAAI;AACtD"}
|
package/dist/lib/cli.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { TikTokError } from "./config.mjs";
|
|
2
|
+
import { sanitizeUser } from "./utils.mjs";
|
|
3
|
+
import { version } from "../package.mjs";
|
|
4
|
+
import { createCLI } from "@zfadhli/koko-cli";
|
|
5
|
+
//#region src/lib/cli.ts
|
|
6
|
+
/**
|
|
7
|
+
* CLI argument parser — uses koko-cli's createCLI (wraps cac).
|
|
8
|
+
* Exports a single parseArgs() function.
|
|
9
|
+
*/
|
|
10
|
+
/** Shared recorder options for both default and start commands. */
|
|
11
|
+
function registerRecorderOptions(cmd) {
|
|
12
|
+
cmd.option("-u, --user <name>", "TikTok username (or pass as first argument)");
|
|
13
|
+
cmd.option("-o, --output <dir>", "Output directory", { default: "./recordings" });
|
|
14
|
+
cmd.option("-i, --interval <minutes>", "Polling interval in minutes", { default: "3" });
|
|
15
|
+
cmd.option("-d, --duration <minutes>", "Max recording duration in minutes");
|
|
16
|
+
cmd.option("-p, --proxy <url>", "HTTP proxy (e.g. http://127.0.0.1:8080)");
|
|
17
|
+
cmd.option("-l, --log-level <level>", "Log level: debug | info | warn | error");
|
|
18
|
+
cmd.option("-c, --cookies <path>", "Path to cookies.json");
|
|
19
|
+
cmd.option("-s, --segment-minutes <minutes>", "Segment duration in minutes (default: disabled). Split recording into segments of this length.");
|
|
20
|
+
cmd.option("--normalize", "Normalize audio loudness (EBU R128)");
|
|
21
|
+
cmd.option("--normalize-loudness <num>", "Target loudness in LUFS (default: -14)");
|
|
22
|
+
cmd.option("--normalize-codec <name>", "Audio codec for normalized output (default: libopus)");
|
|
23
|
+
cmd.option("--normalize-bitrate <str>", "Audio bitrate for normalized output (default: 96k)");
|
|
24
|
+
cmd.option("--rate <n>", "Max API requests per second (default: 5). Set 0 for unlimited.");
|
|
25
|
+
cmd.option("--debug", "Show API debug logging on stderr for troubleshooting");
|
|
26
|
+
}
|
|
27
|
+
function parseRecorderOpts(opts) {
|
|
28
|
+
const user = opts.user;
|
|
29
|
+
if (!user) throw new TikTokError("config-error", "<user> is required (positional or --user)");
|
|
30
|
+
const parsed = { user: sanitizeUser(user) };
|
|
31
|
+
if (opts.output) parsed.outputDir = opts.output;
|
|
32
|
+
if (opts.interval !== void 0) {
|
|
33
|
+
const n = Number(opts.interval);
|
|
34
|
+
if (!Number.isFinite(n) || n < 1) throw new TikTokError("config-error", "--interval must be a number >= 1");
|
|
35
|
+
parsed.interval = n;
|
|
36
|
+
}
|
|
37
|
+
if (opts.duration !== void 0) {
|
|
38
|
+
const n = Number(opts.duration);
|
|
39
|
+
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--duration must be a number >= 0 (minutes)");
|
|
40
|
+
parsed.duration = n * 60;
|
|
41
|
+
}
|
|
42
|
+
if (opts.proxy) parsed.proxy = opts.proxy;
|
|
43
|
+
if (opts.logLevel) {
|
|
44
|
+
const levels = [
|
|
45
|
+
"debug",
|
|
46
|
+
"info",
|
|
47
|
+
"warn",
|
|
48
|
+
"error"
|
|
49
|
+
];
|
|
50
|
+
if (!levels.includes(opts.logLevel)) throw new TikTokError("config-error", `--log-level must be one of: ${levels.join(", ")}`);
|
|
51
|
+
parsed.logLevel = opts.logLevel;
|
|
52
|
+
}
|
|
53
|
+
if (opts.cookies) parsed.cookiesPath = opts.cookies;
|
|
54
|
+
if (opts.segmentMinutes !== void 0) {
|
|
55
|
+
const n = Number(opts.segmentMinutes);
|
|
56
|
+
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--segment-minutes must be a number >= 0");
|
|
57
|
+
parsed.segmentMinutes = n;
|
|
58
|
+
}
|
|
59
|
+
if (opts.normalize !== void 0) parsed.normalizeAudio = opts.normalize;
|
|
60
|
+
if (opts.normalizeLoudness !== void 0) {
|
|
61
|
+
const n = Number(opts.normalizeLoudness);
|
|
62
|
+
if (!Number.isFinite(n)) throw new TikTokError("config-error", "--normalize-loudness must be a number");
|
|
63
|
+
parsed.normalizeLoudness = n;
|
|
64
|
+
}
|
|
65
|
+
if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec;
|
|
66
|
+
if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate;
|
|
67
|
+
if (opts.rate !== void 0) {
|
|
68
|
+
const n = Number(opts.rate);
|
|
69
|
+
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--rate must be a number >= 0");
|
|
70
|
+
parsed.ratePerSecond = n;
|
|
71
|
+
}
|
|
72
|
+
if (opts.debug) parsed.debug = true;
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
function parseArgs(argv = process.argv) {
|
|
76
|
+
let result;
|
|
77
|
+
let error;
|
|
78
|
+
const cli = createCLI("tokrec", version).description("Minimal TikTok live stream recorder — Bun + TypeScript");
|
|
79
|
+
cli.command("", "Record a TikTok live stream", (cmd) => {
|
|
80
|
+
registerRecorderOptions(cmd);
|
|
81
|
+
cmd.action((opts) => {
|
|
82
|
+
try {
|
|
83
|
+
result = {
|
|
84
|
+
command: "record",
|
|
85
|
+
config: parseRecorderOpts(opts)
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
error = err instanceof Error ? err.message : String(err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
cli.command("start", "Start recorder in a tmux session (survives terminal close)", (cmd) => {
|
|
93
|
+
registerRecorderOptions(cmd);
|
|
94
|
+
cmd.action((opts) => {
|
|
95
|
+
try {
|
|
96
|
+
parseRecorderOpts(opts);
|
|
97
|
+
result = {
|
|
98
|
+
command: "start",
|
|
99
|
+
args: argv.slice(2).filter((a) => a !== "start")
|
|
100
|
+
};
|
|
101
|
+
} catch (err) {
|
|
102
|
+
error = err instanceof Error ? err.message : String(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
cli.command("stop", "Stop background tmux session(s)", (cmd) => {
|
|
107
|
+
cmd.option("-u, --user <name>", "Stop a specific user's session (omit to stop all)");
|
|
108
|
+
cmd.action((opts) => {
|
|
109
|
+
result = {
|
|
110
|
+
command: "stop",
|
|
111
|
+
user: opts.user
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
cli.command("status", "Check tmux session(s)", (cmd) => {
|
|
116
|
+
cmd.option("-u, --user <name>", "Check a specific user's session (omit to show all)");
|
|
117
|
+
cmd.action((opts) => {
|
|
118
|
+
result = {
|
|
119
|
+
command: "status",
|
|
120
|
+
user: opts.user
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
cli.command("setup", "Check cookie status or create cookies.json interactively", (cmd) => {
|
|
125
|
+
cmd.action(() => {
|
|
126
|
+
result = { command: "setup" };
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
if (![
|
|
130
|
+
"start",
|
|
131
|
+
"stop",
|
|
132
|
+
"status",
|
|
133
|
+
"setup"
|
|
134
|
+
].includes(argv[2] ?? "")) {
|
|
135
|
+
const positionalUser = extractPositional(argv);
|
|
136
|
+
if (positionalUser) argv.push("--user", positionalUser);
|
|
137
|
+
}
|
|
138
|
+
cli.parse(argv);
|
|
139
|
+
if (error) throw new TikTokError("config-error", error);
|
|
140
|
+
if (!result) throw new TikTokError("config-error", "<user> is required (positional or --user). Use --help for usage.");
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Scan argv for the first bare argument that is neither a flag nor a flag's value.
|
|
145
|
+
* Returns the value and removes it from the array, or null if none found.
|
|
146
|
+
*/
|
|
147
|
+
function extractPositional(argv) {
|
|
148
|
+
for (let i = 2; i < argv.length; i++) {
|
|
149
|
+
const arg = argv[i];
|
|
150
|
+
if (arg === "--") return argv.splice(i, 1)[0];
|
|
151
|
+
if (arg.startsWith("-")) {
|
|
152
|
+
const next = argv[i + 1];
|
|
153
|
+
if (next && !next.startsWith("-") && arg !== "--help" && arg !== "--version") i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
argv.splice(i, 1);
|
|
157
|
+
return arg;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
export { parseArgs };
|
|
163
|
+
|
|
164
|
+
//# sourceMappingURL=cli.mjs.map
|
|
@@ -0,0 +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: \"./recordings\",\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 (positional or --user)\")\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 // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n // Skip for subcommands — they have their own option parsing.\n const subcommands = [\"start\", \"stop\", \"status\", \"setup\"]\n if (!subcommands.includes(argv[2] ?? \"\")) {\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push(\"--user\", positionalUser)\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 (positional or --user). Use --help for usage.\",\n )\n }\n\n return result\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i] as string\n // -- signals end of options; anything after is a positional\n if (arg === \"--\") {\n const pos = argv.splice(i, 1)[0] as string\n return pos\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith(\"-\")) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1]\n if (next && !next.startsWith(\"-\") && arg !== \"--help\" && arg !== \"--version\") {\n i++ // skip the value\n }\n continue\n }\n // Found a bare positional — remove it and return\n argv.splice(i, 1)\n return arg\n }\n return null\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.mts","names":[],"sources":["../../src/lib/config.ts"],"mappings":";;AAUA;;;;AAAyB;AAEzB;;;KAFY,UAAA,GAAa,MAAA;AAAA,KAEb,QAAA;AAAA,UAEK,cAAA;EAAA;EAEf,IAAA;EAcW;EAZX,SAAA;;EAEA,QAAA;;EAEA,QAAA;;EAEA,KAAA;;EAEA,WAAA;;EAEA,OAAA,GAAU,UAAA;;EAEV,QAAA,GAAW,QAAA;;EAEX,UAAA;;EAEA,cAAA;;EAEA,cAAA;EAcA;EAZA,iBAAA;EAee;EAbf,cAAA;;EAEA,gBAAA;;;;;;EAMA,aAAA;;EAEA,KAAA;AAAA;AAAA,UAGe,kBAAA;EAUmE;EARlF,KAAA,IAAS,OAAA;;EAET,IAAA,IAAQ,OAAA;;EAER,SAAA,IAAa,cAAA;;EAEb,EAAA,iBAAmB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;;EAEjF,GAAA,iBAAoB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;AAAA;AAAA,UAGnE,cAAA;EACf,KAAA;EACA,IAAA;EACA,WAAA;EACA,eAAA;EACA,YAAA;EACA,SAAA;AAAA;AAAA,KAGU,aAAA,SAAsB,oBAAA;AAAA,UAEjB,oBAAA;EACf,QAAA,GAAW,IAAA;IAAQ,IAAA;EAAA;EACnB,IAAA,GAAO,IAAA;IAAQ,IAAA;IAAc,MAAA;IAAiB,MAAA;EAAA;EAC9C,iBAAA,GAAoB,IAAA;IAAQ,IAAA;IAAc,IAAA;EAAA;EAC1C,mBAAA,GAAsB,IAAA;IAAQ,KAAA;IAAe,OAAA;IAAiB,KAAA;EAAA;EAC9D,cAAA,GAAiB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EACzD,eAAA,GAAkB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EAC1D,kBAAA,GAAqB,IAAA;IAAQ,KAAA;IAAe,aAAA;EAAA;EAC5C,gBAAA,GAAmB,IAAA;IAAQ,QAAA;EAAA;EAC3B,kBAAA,GAAqB,IAAA;IAAQ,KAAA;EAAA;EAC7B,SAAA,GAAY,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACnC,iBAAA,GAAoB,IAAA;IAAQ,IAAA;EAAA;EAC5B,oBAAA,GAAuB,IAAA;IACrB,IAAA;IACA,OAAA;IACA,KAAA;EAAA;EAEF,eAAA,GAAkB,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACzC,iBAAA,GAAoB,IAAA;IAAQ,KAAA;IAAe,KAAA;EAAA;EAC3C,KAAA,GAAQ,GAAA,EAAK,WAAA;AAAA;AAAA,KAGH,YAAA;AAAA,cAaC,WAAA,SAAoB,KAAA;EAAA,SAE7B,IAAA,EAAsB,YAAA;EAAA,kBAEtB,KAAA;EAHF,WAAA,CACE,IAAA,EAAsB,YAAA,EACtB,OAAA,UACA,KAAA;AAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region src/config.ts
|
|
1
|
+
//#region src/lib/config.ts
|
|
2
2
|
var TikTokError = class extends Error {
|
|
3
3
|
kind;
|
|
4
4
|
cause;
|
|
@@ -41,12 +41,7 @@ function normalizeConfig(config) {
|
|
|
41
41
|
debug: config.debug ?? DEFAULTS.debug
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
-
function validateConfig(config) {
|
|
45
|
-
if (!config.user || config.user.trim().length === 0) throw new TikTokError("config-error", "--user is required");
|
|
46
|
-
if (config.interval !== void 0 && config.interval < 1) throw new TikTokError("config-error", "--interval must be >= 1 minute");
|
|
47
|
-
if (config.duration !== void 0 && config.duration < 0) throw new TikTokError("config-error", "--duration must be >= 0 seconds");
|
|
48
|
-
}
|
|
49
44
|
//#endregion
|
|
50
|
-
export { TikTokError, normalizeConfig
|
|
45
|
+
export { TikTokError, normalizeConfig };
|
|
51
46
|
|
|
52
47
|
//# sourceMappingURL=config.mjs.map
|