@zfadhli/tokrec 0.11.5 → 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.mjs +79 -5
- package/dist/bin.mjs.map +1 -1
- package/dist/core/recorder/convert.mjs +19 -31
- package/dist/core/recorder/convert.mjs.map +1 -1
- package/dist/core/recorder/ffmpeg-utils.mjs +2 -5
- package/dist/core/recorder/ffmpeg-utils.mjs.map +1 -1
- package/dist/core/recorder/index.mjs +12 -29
- package/dist/core/recorder/index.mjs.map +1 -1
- package/dist/lib/cli.mjs +119 -65
- package/dist/lib/cli.mjs.map +1 -1
- package/dist/lib/config.mjs +1 -6
- package/dist/lib/config.mjs.map +1 -1
- package/dist/lib/tmux.mjs +70 -0
- package/dist/lib/tmux.mjs.map +1 -0
- package/dist/package.mjs +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](https://bun.sh)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
|
|
13
|
-
[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [CLI options](#cli-options) · [Authentication](#authentication) · [How it works](#how-it-works) · [Library API](#library-api) · [FAQ](#faq)
|
|
13
|
+
[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [CLI options](#cli-options) · [Background recording](#background-recording-tmux) · [Authentication](#authentication) · [How it works](#how-it-works) · [Library API](#library-api) · [FAQ](#faq)
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
- **Typed event system** — 15 events with typed payloads for lifecycle tracking
|
|
35
35
|
- **Proxy support** — route traffic through an HTTP proxy for regional access
|
|
36
36
|
- **FLV + HLS support** — automatically detects and handles both stream formats
|
|
37
|
-
- **
|
|
37
|
+
- **Background recording** — tmux integration keeps recordings alive after terminal close, with per-user session management
|
|
38
|
+
- **Multi-user** — record multiple users simultaneously, each in its own tmux session
|
|
39
|
+
- **71+ passing tests** — unit tests with real TikTok HTML/API fixtures
|
|
38
40
|
|
|
39
41
|
---
|
|
40
42
|
|
|
@@ -106,6 +108,31 @@ tokrec -u username -o ./videos -p http://127.0.0.1:8080
|
|
|
106
108
|
tokrec -u username --normalize
|
|
107
109
|
```
|
|
108
110
|
|
|
111
|
+
### Background recording (tmux)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Start in background — survives terminal close
|
|
115
|
+
tokrec start -u username
|
|
116
|
+
|
|
117
|
+
# Record multiple users simultaneously
|
|
118
|
+
tokrec start -u user1
|
|
119
|
+
tokrec start -u user2
|
|
120
|
+
|
|
121
|
+
# Check active sessions
|
|
122
|
+
tokrec status
|
|
123
|
+
|
|
124
|
+
# Reattach to see live output
|
|
125
|
+
tmux attach -t tokrec-user1
|
|
126
|
+
|
|
127
|
+
# Detach without killing (Ctrl+B, D)
|
|
128
|
+
|
|
129
|
+
# Stop a specific user
|
|
130
|
+
tokrec stop -u user1
|
|
131
|
+
|
|
132
|
+
# Stop all sessions
|
|
133
|
+
tokrec stop
|
|
134
|
+
```
|
|
135
|
+
|
|
109
136
|
> [!TIP]
|
|
110
137
|
> Both `-d` and `-s` accept values in **minutes** (the tool converts them internally).
|
|
111
138
|
|
|
@@ -130,12 +157,29 @@ tokrec -u username --normalize
|
|
|
130
157
|
| `--rate <n>` | _(none)_ | `5` | Max API requests/sec (0 = unlimited). Prevents WAF triggering |
|
|
131
158
|
| `--debug` | _(none)_ | off | Show API debug logging on stderr |
|
|
132
159
|
|
|
160
|
+
### Subcommands
|
|
161
|
+
|
|
162
|
+
| Command | Description | Example |
|
|
163
|
+
| -------- | ---------------------------------------- | ------------------------------------------- |
|
|
164
|
+
| `start` | Start recorder in a tmux session | `tokrec start -u username` |
|
|
165
|
+
| `stop` | Stop tmux session(s) | `tokrec stop -u username` (or `tokrec stop` to stop all) |
|
|
166
|
+
| `status` | Show active tmux sessions | `tokrec status` |
|
|
167
|
+
| `setup` | Check cookie status or create cookies.json | `tokrec setup` |
|
|
168
|
+
|
|
133
169
|
---
|
|
134
170
|
|
|
135
171
|
## Authentication
|
|
136
172
|
|
|
137
173
|
TikTok's Slardar WAF blocks unauthenticated requests. You must provide a valid `sessionid_ss` cookie to bypass it.
|
|
138
174
|
|
|
175
|
+
### Quick setup
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
tokrec setup
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This checks for Firefox cookies (auto-detected) or `cookies.json`. If neither is found, it prompts you to enter your cookie value interactively.
|
|
182
|
+
|
|
139
183
|
### Option 1: Firefox (auto-detected)
|
|
140
184
|
|
|
141
185
|
If you're logged into TikTok in Firefox, `tokrec` automatically reads the cookie from Firefox's SQLite store. No config file needed.
|
|
@@ -338,7 +382,15 @@ TikTok serves live streams as short FLV segments (~30-60s). `tokrec` handles rec
|
|
|
338
382
|
|
|
339
383
|
### Can I record multiple users at once?
|
|
340
384
|
|
|
341
|
-
|
|
385
|
+
Yes. Use tmux background sessions:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
tokrec start -u user1
|
|
389
|
+
tokrec start -u user2
|
|
390
|
+
tokrec status # shows all active sessions
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Each user gets an independent tmux session (`tokrec-{user}`), so they run in parallel without interfering.
|
|
342
394
|
|
|
343
395
|
### Can I use this with Node.js instead of Bun?
|
|
344
396
|
|
|
@@ -350,7 +402,12 @@ Yes. The package is built to ESM for Node.js 20+. Bun is optional for developmen
|
|
|
350
402
|
|
|
351
403
|
### Does it work if the user goes live while I'm not running it?
|
|
352
404
|
|
|
353
|
-
No. `tokrec` only polls while it's running.
|
|
405
|
+
No. `tokrec` only polls while it's running. Use tmux background sessions for always-on monitoring:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
tokrec start -u username # runs in tmux, survives terminal close
|
|
409
|
+
tmux attach -t tokrec-username # reattach to view output
|
|
410
|
+
```
|
|
354
411
|
|
|
355
412
|
---
|
|
356
413
|
|
package/dist/bin.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { TikTokError
|
|
2
|
+
import { TikTokError } from "./lib/config.mjs";
|
|
3
3
|
import { bytesToHuman, relativeTime } from "./lib/utils.mjs";
|
|
4
4
|
import { createRecorder } from "./core/recorder/index.mjs";
|
|
5
5
|
import "./index.mjs";
|
|
6
6
|
import { extractTikTokCookiesFromFirefox } from "./lib/browser-cookies.mjs";
|
|
7
7
|
import { parseArgs } from "./lib/cli.mjs";
|
|
8
|
+
import { tmuxStart, tmuxStatus, tmuxStop } from "./lib/tmux.mjs";
|
|
8
9
|
import { createDisplay } from "./lib/ui.mjs";
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
11
|
import { join } from "node:path";
|
|
11
12
|
import { color } from "@zfadhli/koko-cli";
|
|
12
13
|
//#region src/bin.ts
|
|
@@ -27,10 +28,9 @@ async function loadCookies(cookiesPath) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
async function main() {
|
|
30
|
-
let
|
|
31
|
+
let result;
|
|
31
32
|
try {
|
|
32
|
-
|
|
33
|
-
validateConfig(config);
|
|
33
|
+
result = parseArgs();
|
|
34
34
|
} catch (err) {
|
|
35
35
|
if (err instanceof TikTokError && err.kind === "config-error") {
|
|
36
36
|
console.error(`${color.red("Error:")} ${err.message}`);
|
|
@@ -38,6 +38,80 @@ async function main() {
|
|
|
38
38
|
}
|
|
39
39
|
throw err;
|
|
40
40
|
}
|
|
41
|
+
if (result.command === "start") {
|
|
42
|
+
const userMatch = result.args.find((_, i, a) => a[i - 1] === "--user" || a[i - 1] === "-u");
|
|
43
|
+
if (!userMatch) {
|
|
44
|
+
console.error(`${color.red("Error:")} --user is required for "start" command`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const cmd = `tokrec ${result.args.join(" ")}`;
|
|
48
|
+
try {
|
|
49
|
+
await tmuxStart(userMatch, cmd);
|
|
50
|
+
console.log(`${color.green("✓")} Session "tokrec-${userMatch}" started in background`);
|
|
51
|
+
console.log(` tmux attach -t tokrec-${userMatch} — view live output`);
|
|
52
|
+
console.log(` tokrec stop -u ${userMatch} — stop recording`);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`${color.red("Error:")} ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (result.command === "stop") {
|
|
60
|
+
try {
|
|
61
|
+
await tmuxStop(result.user);
|
|
62
|
+
if (result.user) console.log(`${color.green("✓")} Session "tokrec-${result.user}" stopped`);
|
|
63
|
+
else console.log(`${color.green("✓")} All tokrec sessions stopped`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`${color.red("Error:")} ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (result.command === "status") {
|
|
71
|
+
const sessions = await tmuxStatus(result.user);
|
|
72
|
+
if (sessions.length === 0) console.log(`${color.gray("○")} No active session${result.user ? ` for "${result.user}"` : ""}`);
|
|
73
|
+
else for (const s of sessions) {
|
|
74
|
+
console.log(`${color.green("●")} tokrec-${s.user}`);
|
|
75
|
+
console.log(` tmux attach -t ${s.session} — view live output`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (result.command === "setup") {
|
|
80
|
+
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
81
|
+
if (firefoxCookies) {
|
|
82
|
+
const names = Object.keys(firefoxCookies);
|
|
83
|
+
const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
|
|
84
|
+
console.log(`${color.green("✓")} Firefox cookies detected${detail}`);
|
|
85
|
+
console.log(` No setup needed — tokrec auto-detects Firefox cookies.`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const cookiesPath = join(process.cwd(), "cookies.json");
|
|
89
|
+
if (existsSync(cookiesPath)) try {
|
|
90
|
+
if (JSON.parse(readFileSync(cookiesPath, "utf-8")).sessionid_ss) {
|
|
91
|
+
console.log(`${color.green("✓")} cookies.json found at ./cookies.json`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
console.log(`${color.yellow("⚠")} No TikTok cookies found.`);
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(`You need a valid sessionid_ss cookie to bypass TikTok's WAF.`);
|
|
98
|
+
console.log(`Either log in to TikTok in Firefox (auto-detected), or enter manually:`);
|
|
99
|
+
console.log();
|
|
100
|
+
const sessionid = prompt(" sessionid_ss cookie value:");
|
|
101
|
+
if (!sessionid || sessionid.trim().length === 0) {
|
|
102
|
+
console.log(`${color.red("✗")} No cookie provided. Setup cancelled.`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const cookies = { sessionid_ss: sessionid.trim() };
|
|
106
|
+
const idc = prompt(" tt-target-idc (optional, press Enter to skip):");
|
|
107
|
+
if (idc && idc.trim().length > 0) cookies["tt-target-idc"] = idc.trim();
|
|
108
|
+
writeFileSync(cookiesPath, `${JSON.stringify(cookies, null, 2)}\n`);
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(`${color.green("✓")} cookies.json created at ./cookies.json`);
|
|
111
|
+
console.log(` You can now run: tokrec -u <username>`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const config = result.config;
|
|
41
115
|
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
42
116
|
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
43
117
|
config.logConsole = false;
|
package/dist/bin.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bin.mjs","names":[],"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { createRecorder } from \"./index\"\nimport { extractTikTokCookiesFromFirefox } from \"./lib/browser-cookies\"\nimport { parseArgs } from \"./lib/cli\"\nimport type { RecorderConfig } from \"./lib/config\"\nimport { TikTokError, validateConfig } from \"./lib/config\"\nimport { createDisplay } from \"./lib/ui\"\nimport { bytesToHuman, relativeTime } from \"./lib/utils\"\n\nasync function loadCookies(cookiesPath?: string): Promise<Record<string, string> | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as Record<string, string | undefined>\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as Record<string, string>\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let config: RecorderConfig\n\n try {\n config = parseArgs()\n validateConfig(config)\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n config.cookies = firefoxCookies ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (config.cookies) {\n const source = firefoxCookies ? \"Firefox\" : \"cookies.json\"\n const names = Object.keys(config.cookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n display.showInfo(`${source} cookies loaded${detail}`)\n } else {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", (info) => {\n const parsed = info.output.split(\"/\").pop() ?? info.output\n display.conversionDone(parsed)\n })\n\n recorder.on(\"recording:end\", () => {\n // For simple conversion (no segmenting), recording:end fires once.\n // For segmenting path, segmenting:end already acknowledges the result.\n // This is a no-op since we handle the terminal output through download:end\n // and segmenting:end / converted events.\n })\n\n recorder.on(\"error\", (err) => {\n display.showError(`[${err.kind}] ${err.message}`)\n })\n\n recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n // Remove signal handlers so they don't accumulate if main() is called again\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n display.showInfo(\"Shutting down gracefully...\")\n await recorder.stop()\n display.stop()\n process.exit(0)\n }\n\n process.on(\"SIGINT\", handleSignal)\n process.on(\"SIGTERM\", handleSignal)\n process.on(\"SIGHUP\", handleSignal)\n\n // Show initial status and start the recorder\n display.pollingStarted(config.interval ?? 3)\n try {\n await recorder.start()\n } catch (err) {\n if (err instanceof TikTokError) {\n display.showError(`[${err.kind}] ${err.message}`)\n } else {\n display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)\n }\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain().catch((err) => {\n console.error(\"Fatal:\", err instanceof Error ? err.message : String(err))\n process.exit(1)\n})\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bin.mjs","names":[],"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { createRecorder } from \"./index\"\nimport { extractTikTokCookiesFromFirefox } from \"./lib/browser-cookies\"\nimport { type CLIResult, parseArgs } from \"./lib/cli\"\nimport type { RecorderConfig } from \"./lib/config\"\nimport { TikTokError } from \"./lib/config\"\nimport { tmuxStart, tmuxStatus, tmuxStop } from \"./lib/tmux\"\nimport { createDisplay } from \"./lib/ui\"\nimport { bytesToHuman, relativeTime } from \"./lib/utils\"\n\nasync function loadCookies(cookiesPath?: string): Promise<Record<string, string> | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as Record<string, string | undefined>\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as Record<string, string>\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let result: CLIResult\n\n try {\n result = parseArgs()\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Handle tmux subcommands\n if (result.command === \"start\") {\n // Extract user from args to build session name\n const userMatch = result.args.find((_, i, a) => a[i - 1] === \"--user\" || a[i - 1] === \"-u\")\n if (!userMatch) {\n console.error(`${color.red(\"Error:\")} --user is required for \"start\" command`)\n process.exit(1)\n }\n const cmd = `tokrec ${result.args.join(\" \")}`\n try {\n await tmuxStart(userMatch, cmd)\n console.log(`${color.green(\"✓\")} Session \"tokrec-${userMatch}\" started in background`)\n console.log(` tmux attach -t tokrec-${userMatch} — view live output`)\n console.log(` tokrec stop -u ${userMatch} — stop recording`)\n } catch (err) {\n console.error(`${color.red(\"Error:\")} ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n return\n }\n\n if (result.command === \"stop\") {\n try {\n await tmuxStop(result.user)\n if (result.user) {\n console.log(`${color.green(\"✓\")} Session \"tokrec-${result.user}\" stopped`)\n } else {\n console.log(`${color.green(\"✓\")} All tokrec sessions stopped`)\n }\n } catch (err) {\n console.error(`${color.red(\"Error:\")} ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n return\n }\n\n if (result.command === \"status\") {\n const sessions = await tmuxStatus(result.user)\n if (sessions.length === 0) {\n console.log(\n `${color.gray(\"○\")} No active session${result.user ? ` for \"${result.user}\"` : \"\"}`,\n )\n } else {\n for (const s of sessions) {\n console.log(`${color.green(\"●\")} tokrec-${s.user}`)\n console.log(` tmux attach -t ${s.session} — view live output`)\n }\n }\n return\n }\n\n if (result.command === \"setup\") {\n // 1. Check Firefox cookies\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n if (firefoxCookies) {\n const names = Object.keys(firefoxCookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n console.log(`${color.green(\"✓\")} Firefox cookies detected${detail}`)\n console.log(` No setup needed — tokrec auto-detects Firefox cookies.`)\n return\n }\n\n // 2. Check cookies.json\n const cookiesPath = join(process.cwd(), \"cookies.json\")\n if (existsSync(cookiesPath)) {\n try {\n const data = JSON.parse(readFileSync(cookiesPath, \"utf-8\"))\n if (data.sessionid_ss) {\n console.log(`${color.green(\"✓\")} cookies.json found at ./cookies.json`)\n return\n }\n } catch {\n // invalid JSON, fall through to setup\n }\n }\n\n // 3. No cookies found — interactive setup\n console.log(`${color.yellow(\"⚠\")} No TikTok cookies found.`)\n console.log()\n console.log(`You need a valid sessionid_ss cookie to bypass TikTok's WAF.`)\n console.log(`Either log in to TikTok in Firefox (auto-detected), or enter manually:`)\n console.log()\n\n const sessionid = prompt(\" sessionid_ss cookie value:\")\n if (!sessionid || sessionid.trim().length === 0) {\n console.log(`${color.red(\"✗\")} No cookie provided. Setup cancelled.`)\n return\n }\n\n const cookies: Record<string, string> = { sessionid_ss: sessionid.trim() }\n\n const idc = prompt(\" tt-target-idc (optional, press Enter to skip):\")\n if (idc && idc.trim().length > 0) {\n cookies[\"tt-target-idc\"] = idc.trim()\n }\n\n writeFileSync(cookiesPath, `${JSON.stringify(cookies, null, 2)}\\n`)\n console.log()\n console.log(`${color.green(\"✓\")} cookies.json created at ./cookies.json`)\n console.log(` You can now run: tokrec -u <username>`)\n return\n }\n\n // Record command — full recorder flow\n const config: RecorderConfig = result.config\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n config.cookies = firefoxCookies ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (config.cookies) {\n const source = firefoxCookies ? \"Firefox\" : \"cookies.json\"\n const names = Object.keys(config.cookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n display.showInfo(`${source} cookies loaded${detail}`)\n } else {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", (info) => {\n const parsed = info.output.split(\"/\").pop() ?? info.output\n display.conversionDone(parsed)\n })\n\n recorder.on(\"recording:end\", () => {\n // For simple conversion (no segmenting), recording:end fires once.\n // For segmenting path, segmenting:end already acknowledges the result.\n // This is a no-op since we handle the terminal output through download:end\n // and segmenting:end / converted events.\n })\n\n recorder.on(\"error\", (err) => {\n display.showError(`[${err.kind}] ${err.message}`)\n })\n\n recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n // Remove signal handlers so they don't accumulate if main() is called again\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n display.showInfo(\"Shutting down gracefully...\")\n await recorder.stop()\n display.stop()\n process.exit(0)\n }\n\n process.on(\"SIGINT\", handleSignal)\n process.on(\"SIGTERM\", handleSignal)\n process.on(\"SIGHUP\", handleSignal)\n\n // Show initial status and start the recorder\n display.pollingStarted(config.interval ?? 3)\n try {\n await recorder.start()\n } catch (err) {\n if (err instanceof TikTokError) {\n display.showError(`[${err.kind}] ${err.message}`)\n } else {\n display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)\n }\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain().catch((err) => {\n console.error(\"Fatal:\", err instanceof Error ? err.message : String(err))\n process.exit(1)\n})\n"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TikTokError } from "../../lib/config.mjs";
|
|
2
2
|
import { findFfmpegPath } from "../../lib/utils.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { execa } from "execa";
|
|
4
4
|
import { unlinkSync } from "node:fs";
|
|
5
5
|
//#region src/core/recorder/convert.ts
|
|
6
6
|
/**
|
|
@@ -25,41 +25,29 @@ function createConverter(logger, signal) {
|
|
|
25
25
|
}
|
|
26
26
|
return { convert };
|
|
27
27
|
}
|
|
28
|
-
function runFfmpeg(ffmpegPath, input, output, signal) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
async function runFfmpeg(ffmpegPath, input, output, signal) {
|
|
29
|
+
const args = [
|
|
30
|
+
"-i",
|
|
31
|
+
input,
|
|
32
|
+
"-c",
|
|
33
|
+
"copy",
|
|
34
|
+
"-y",
|
|
35
|
+
output
|
|
36
|
+
];
|
|
37
|
+
try {
|
|
38
|
+
await execa(ffmpegPath, args, {
|
|
39
|
+
signal,
|
|
38
40
|
stdio: [
|
|
39
41
|
"ignore",
|
|
40
42
|
"pipe",
|
|
41
43
|
"pipe"
|
|
42
|
-
]
|
|
43
|
-
signal
|
|
44
|
+
]
|
|
44
45
|
});
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
proc.on("close", (code) => {
|
|
51
|
-
if (signal?.aborted) {
|
|
52
|
-
reject(new TikTokError("aborted", "Aborted by user"));
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
if (code === 0) resolve();
|
|
56
|
-
else reject(new TikTokError("ffmpeg-error", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
|
|
57
|
-
});
|
|
58
|
-
proc.on("error", (err) => {
|
|
59
|
-
if (err instanceof Error && err.name === "AbortError") return;
|
|
60
|
-
reject(new TikTokError("ffmpeg-error", `Failed to spawn FFmpeg: ${err.message}`));
|
|
61
|
-
});
|
|
62
|
-
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (signal?.aborted) throw new TikTokError("aborted", "Aborted by user");
|
|
48
|
+
const stderr = err.stderr ?? "";
|
|
49
|
+
throw new TikTokError("ffmpeg-error", `FFmpeg exited with code ${err.exitCode}\n${stderr.slice(-500)}`);
|
|
50
|
+
}
|
|
63
51
|
}
|
|
64
52
|
//#endregion
|
|
65
53
|
export { createConverter };
|
|
@@ -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 {
|
|
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;CAC7E,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"}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { execa } from "execa";
|
|
3
3
|
//#region src/core/recorder/ffmpeg-utils.ts
|
|
4
|
-
/**
|
|
5
|
-
* Shared FFmpeg utilities — stream-download pipe primitive and constants.
|
|
6
|
-
*/
|
|
7
4
|
/** Kill FFmpeg if no output is received within this window after spawn. */
|
|
8
5
|
const FFMPEG_STARTUP_TIMEOUT = 3e4;
|
|
9
6
|
/** Standard FFmpeg reconnect + mpegts pipe arguments. */
|
|
@@ -40,7 +37,7 @@ const FFMPEG_BASE_ARGS = [
|
|
|
40
37
|
* divergence between the in-memory count and actual disk writes.
|
|
41
38
|
*/
|
|
42
39
|
async function pipeFfmpegSegment(ffmpegPath, url, writer, signal, maxDuration) {
|
|
43
|
-
const proc =
|
|
40
|
+
const proc = execa(ffmpegPath, [
|
|
44
41
|
"-i",
|
|
45
42
|
url,
|
|
46
43
|
...maxDuration ? ["-t", String(maxDuration)] : [],
|
|
@@ -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 {
|
|
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 { WriteStream } from \"node:fs\"\nimport { execa } from \"execa\"\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/** 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 * Features:\n * - Transparent HTTP reconnection via FFmpeg `-reconnect` flags\n * - `FFMPEG_STARTUP_TIMEOUT` (30s) startup guard with SIGTERM → SIGKILL\n * escalation to prevent orphaned processes\n * - Bounded stderr capture (10k chars) for error diagnostics\n * - Automatic cleanup when `signal` fires (uses spawn `signal` option)\n * - AbortError is swallowed — the promise resolves instead of rejecting,\n * allowing the caller to check `signal.aborted` and react accordingly\n *\n * The caller is responsible for tracking file size via `statSync` — this\n * function deliberately avoids maintaining its own byte counter to prevent\n * divergence between the in-memory count and actual disk writes.\n */\nexport async function pipeFfmpegSegment(\n ffmpegPath: string,\n url: string,\n writer: WriteStream,\n signal: AbortSignal,\n maxDuration?: number,\n): Promise<void> {\n const durationArgs = maxDuration ? [\"-t\", String(maxDuration)] : []\n const proc = execa(ffmpegPath, [\"-i\", url, ...durationArgs, ...FFMPEG_BASE_ARGS], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\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 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 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 if (err instanceof Error && err.name === \"AbortError\") return\n reject(err)\n })\n })\n}\n"],"mappings":";;;;AASA,MAAa,yBAAyB;;AAGtC,MAAM,mBAAmB;CACvB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;;;;;;;;;;AAmBA,eAAsB,kBACpB,YACA,KACA,QACA,QACA,aACe;CAEf,MAAM,OAAO,MAAM,YAAY;EAAC;EAAM;EAAK,GADtB,cAAc,CAAC,MAAM,OAAO,WAAW,CAAC,IAAI,CAAC;EACN,GAAG;CAAgB,GAAG;EAChF,OAAO;GAAC;GAAU;GAAQ;EAAM;EAChC;CACF,CAAC;CAED,IAAI,SAAS;CACb,IAAI,iBAAuD;CAE3D,MAAM,0BAA0B;EAC9B,IAAI,gBAAgB;GAClB,aAAa,cAAc;GAC3B,iBAAiB;EACnB;CACF;CAEA,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,kBAAkB;EAClB,UAAU,MAAM,SAAS;EACzB,IAAI,OAAO,SAAS,KAAO,SAAS,OAAO,MAAM,IAAK;CACxD,CAAC;CAED,KAAK,OAAO,GAAG,SAAS,UAAkB;EACxC,kBAAkB;EAElB,IAAI,CADgB,OAAO,MAAM,KAClB,GAAG;GAChB,KAAK,OAAO,MAAM;GAClB,OAAO,KAAK,eAAe,KAAK,OAAO,OAAO,CAAC;EACjD;CACF,CAAC;CAED,MAAM,IAAI,SAAe,SAAS,WAAW;EAC3C,iBAAiB,iBAAiB;GAChC,IAAI,CAAC,KAAK,QAAQ;IAChB,KAAK,KAAK,SAAS;IACnB,iBAAiB;KACf,IAAI,CAAC,KAAK,QAAQ,KAAK,KAAK,SAAS;IACvC,GAAG,GAAI;GACT;GACA,OACE,IAAI,YACF,gBACA,kCAAkC,yBAAyB,IAAK,KAAK,OAAO,MAAM,IAAI,GACxF,CACF;EACF,GAAG,sBAAsB;EAEzB,KAAK,GAAG,UAAU,SAAS;GACzB,kBAAkB;GAClB,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,IAAI,eAAe,SAAS,IAAI,SAAS,cAAc;GACvD,OAAO,GAAG;EACZ,CAAC;CACH,CAAC;AACH"}
|
|
@@ -10,7 +10,7 @@ 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 {
|
|
13
|
+
import { execa } from "execa";
|
|
14
14
|
//#region src/core/recorder/index.ts
|
|
15
15
|
/**
|
|
16
16
|
* Recorder orchestrator — wires together the API, monitor, stream downloader,
|
|
@@ -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") {
|
|
@@ -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 { spawn } from \"node:child_process\"\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 function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n reject(\n new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n ),\n )\n return\n }\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n if (stderr.length > 10000) stderr = stderr.slice(-5000)\n })\n\n proc.on(\"close\", (code) => {\n if (signal?.aborted) {\n reject(new TikTokError(\"unknown\", \"Aborted by user\"))\n return\n }\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"unknown\", `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`),\n )\n }\n })\n\n proc.on(\"error\", (err) => {\n if (err instanceof Error && err.name === \"AbortError\") return\n reject(new TikTokError(\"unknown\", `Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n async function start(): Promise<void> {\n if (stateManager.getStatus().state !== \"idle\") {\n logger.warn(`start() ignored: already in state ${stateManager.getStatus().state}`)\n return\n }\n stopRequested = false\n pendingRemuxes = []\n stopAbortController = new AbortController()\n\n logger.info(`Starting recorder for @${cfg.user}`)\n httpClient = await createHttpClient(cfg)\n api = createTikTokApi(httpClient, logger, cfg.debug)\n downloader = createStreamDownloader(logger)\n converter = createConverter(logger, stopAbortController.signal)\n audioNormalizer = cfg.normalizeAudio\n ? createAudioNormalizer(\n {\n loudness: cfg.normalizeLoudness,\n audioCodec: cfg.normalizeCodec,\n audioBitrate: cfg.normalizeBitrate,\n onStart: (file) => emitter.emit(\"normalize:start\", { file }),\n onProgress: (file, percent, phase) =>\n emitter.emit(\"normalize:progress\", {\n file,\n percent,\n phase: phase as \"analyzing\" | \"normalizing\",\n }),\n onComplete: (result) =>\n emitter.emit(\"normalize:end\", { input: result.input, output: result.output }),\n onError: (file, err) =>\n emitter.emit(\"normalize:error\", { input: file, error: err.message }),\n },\n logger,\n )\n : null\n\n monitor = createPollingMonitor({\n intervalMinutes: cfg.interval,\n logger,\n onTick: async () => {\n if (stopRequested) return\n\n // 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, { 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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"}
|
package/dist/lib/cli.mjs
CHANGED
|
@@ -7,84 +7,138 @@ import { createCLI } from "@zfadhli/koko-cli";
|
|
|
7
7
|
* CLI argument parser — uses koko-cli's createCLI (wraps cac).
|
|
8
8
|
* Exports a single parseArgs() function.
|
|
9
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
|
+
}
|
|
10
75
|
function parseArgs(argv = process.argv) {
|
|
11
|
-
let
|
|
76
|
+
let result;
|
|
12
77
|
let error;
|
|
13
78
|
const cli = createCLI("tokrec", version).description("Minimal TikTok live stream recorder — Bun + TypeScript");
|
|
14
79
|
cli.command("", "Record a TikTok live stream", (cmd) => {
|
|
15
|
-
cmd
|
|
16
|
-
cmd.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
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);
|
|
29
94
|
cmd.action((opts) => {
|
|
30
95
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const n = Number(opts.interval);
|
|
37
|
-
if (!Number.isFinite(n) || n < 1) throw new TikTokError("config-error", "--interval must be a number >= 1");
|
|
38
|
-
parsed.interval = n;
|
|
39
|
-
}
|
|
40
|
-
if (opts.duration !== void 0) {
|
|
41
|
-
const n = Number(opts.duration);
|
|
42
|
-
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--duration must be a number >= 0 (minutes)");
|
|
43
|
-
parsed.duration = n * 60;
|
|
44
|
-
}
|
|
45
|
-
if (opts.proxy) parsed.proxy = opts.proxy;
|
|
46
|
-
if (opts.logLevel) {
|
|
47
|
-
const levels = [
|
|
48
|
-
"debug",
|
|
49
|
-
"info",
|
|
50
|
-
"warn",
|
|
51
|
-
"error"
|
|
52
|
-
];
|
|
53
|
-
if (!levels.includes(opts.logLevel)) throw new TikTokError("config-error", `--log-level must be one of: ${levels.join(", ")}`);
|
|
54
|
-
parsed.logLevel = opts.logLevel;
|
|
55
|
-
}
|
|
56
|
-
if (opts.cookies) parsed.cookiesPath = opts.cookies;
|
|
57
|
-
if (opts.segmentMinutes !== void 0) {
|
|
58
|
-
const n = Number(opts.segmentMinutes);
|
|
59
|
-
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--segment-minutes must be a number >= 0");
|
|
60
|
-
parsed.segmentMinutes = n;
|
|
61
|
-
}
|
|
62
|
-
if (opts.normalize !== void 0) parsed.normalizeAudio = opts.normalize;
|
|
63
|
-
if (opts.normalizeLoudness !== void 0) {
|
|
64
|
-
const n = Number(opts.normalizeLoudness);
|
|
65
|
-
if (!Number.isFinite(n)) throw new TikTokError("config-error", "--normalize-loudness must be a number");
|
|
66
|
-
parsed.normalizeLoudness = n;
|
|
67
|
-
}
|
|
68
|
-
if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec;
|
|
69
|
-
if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate;
|
|
70
|
-
if (opts.rate !== void 0) {
|
|
71
|
-
const n = Number(opts.rate);
|
|
72
|
-
if (!Number.isFinite(n) || n < 0) throw new TikTokError("config-error", "--rate must be a number >= 0");
|
|
73
|
-
parsed.ratePerSecond = n;
|
|
74
|
-
}
|
|
75
|
-
if (opts.debug) parsed.debug = true;
|
|
76
|
-
config = parsed;
|
|
96
|
+
parseRecorderOpts(opts);
|
|
97
|
+
result = {
|
|
98
|
+
command: "start",
|
|
99
|
+
args: argv.slice(2).filter((a) => a !== "start")
|
|
100
|
+
};
|
|
77
101
|
} catch (err) {
|
|
78
102
|
error = err instanceof Error ? err.message : String(err);
|
|
79
103
|
}
|
|
80
104
|
});
|
|
81
105
|
});
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
138
|
cli.parse(argv);
|
|
85
139
|
if (error) throw new TikTokError("config-error", error);
|
|
86
|
-
if (!
|
|
87
|
-
return
|
|
140
|
+
if (!result) throw new TikTokError("config-error", "<user> is required (positional or --user). Use --help for usage.");
|
|
141
|
+
return result;
|
|
88
142
|
}
|
|
89
143
|
/**
|
|
90
144
|
* Scan argv for the first bare argument that is neither a flag nor a flag's value.
|
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 { 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 function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | 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 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 cmd.action((opts: Record<string, unknown>) => {\n try {\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(\n \"config-error\",\n `--log-level must be one of: ${levels.join(\", \")}`,\n )\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\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 config = parsed\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\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 const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push(\"--user\", positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError(\"config-error\", error)\n }\n\n if (!config) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (positional or --user). Use --help for usage.\",\n )\n }\n\n return config\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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"}
|
|
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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"}
|
package/dist/lib/config.mjs
CHANGED
|
@@ -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
|
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: \"./recordings\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 0,\n normalizeAudio: true,\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
|
|
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: \"./recordings\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 0,\n normalizeAudio: true,\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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
//#region src/lib/tmux.ts
|
|
3
|
+
/**
|
|
4
|
+
* tmux session helpers — per-user start/stop/status for background recording.
|
|
5
|
+
*
|
|
6
|
+
* Session naming: "tokrec-{user}" — each user gets an independent tmux session.
|
|
7
|
+
*/
|
|
8
|
+
function sessionName(user) {
|
|
9
|
+
return `tokrec-${user}`;
|
|
10
|
+
}
|
|
11
|
+
async function hasSession(name) {
|
|
12
|
+
const { exitCode } = await execa("tmux", [
|
|
13
|
+
"has-session",
|
|
14
|
+
"-t",
|
|
15
|
+
name
|
|
16
|
+
], { reject: false });
|
|
17
|
+
return exitCode === 0;
|
|
18
|
+
}
|
|
19
|
+
async function tmuxStart(user, command) {
|
|
20
|
+
const name = sessionName(user);
|
|
21
|
+
if (await hasSession(name)) throw new Error(`Session "${name}" already exists. Run "tokrec stop -u ${user}" first.`);
|
|
22
|
+
await execa("tmux", [
|
|
23
|
+
"new-session",
|
|
24
|
+
"-d",
|
|
25
|
+
"-s",
|
|
26
|
+
name,
|
|
27
|
+
command
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
async function tmuxStop(user) {
|
|
31
|
+
if (user) {
|
|
32
|
+
const name = sessionName(user);
|
|
33
|
+
if (!await hasSession(name)) throw new Error(`No session "${name}" found.`);
|
|
34
|
+
await execa("tmux", [
|
|
35
|
+
"kill-session",
|
|
36
|
+
"-t",
|
|
37
|
+
name
|
|
38
|
+
]);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const { stdout } = await execa("tmux", [
|
|
42
|
+
"list-sessions",
|
|
43
|
+
"-F",
|
|
44
|
+
"#{session_name}"
|
|
45
|
+
], { reject: false });
|
|
46
|
+
const sessions = stdout.split("\n").filter((s) => s.startsWith("tokrec-"));
|
|
47
|
+
if (sessions.length === 0) throw new Error("No active tokrec sessions found.");
|
|
48
|
+
for (const s of sessions) await execa("tmux", [
|
|
49
|
+
"kill-session",
|
|
50
|
+
"-t",
|
|
51
|
+
s
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
async function tmuxStatus(user) {
|
|
55
|
+
const { stdout } = await execa("tmux", [
|
|
56
|
+
"list-sessions",
|
|
57
|
+
"-F",
|
|
58
|
+
"#{session_name}"
|
|
59
|
+
], { reject: false });
|
|
60
|
+
const all = stdout.split("\n").filter((s) => s.startsWith("tokrec-")).map((s) => ({
|
|
61
|
+
user: s.slice(7),
|
|
62
|
+
session: s
|
|
63
|
+
}));
|
|
64
|
+
if (user) return all.filter((s) => s.user === user);
|
|
65
|
+
return all;
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
export { tmuxStart, tmuxStatus, tmuxStop };
|
|
69
|
+
|
|
70
|
+
//# sourceMappingURL=tmux.mjs.map
|
|
@@ -0,0 +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 execa(\"tmux\", [\"kill-session\", \"-t\", 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 execa(\"tmux\", [\"kill-session\", \"-t\", s])\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,MAAM,QAAQ;GAAC;GAAgB;GAAM;EAAI,CAAC;EAChD;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,MAAM,QAAQ;EAAC;EAAgB;EAAM;CAAC,CAAC;AAEjD;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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zfadhli/tokrec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Minimal TikTok live stream recorder — Bun + TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@zfadhli/koko-cli": "^0.2.1",
|
|
35
|
+
"execa": "^9.6.1",
|
|
35
36
|
"peaknorm": "^0.7.0",
|
|
36
37
|
"wreq-js": "^2.3.1"
|
|
37
38
|
},
|