@zfadhli/tokrec 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +215 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/{api → core/api}/client.mjs +1 -1
- package/dist/core/api/client.mjs.map +1 -0
- package/dist/{api → core/api}/rate-limiter.mjs +1 -1
- package/dist/core/api/rate-limiter.mjs.map +1 -0
- package/dist/{api → core/api}/tiktok.mjs +1 -1
- package/dist/core/api/tiktok.mjs.map +1 -0
- package/dist/{monitor.mjs → core/monitor.mjs} +2 -2
- package/dist/core/monitor.mjs.map +1 -0
- package/dist/core/recorder/convert.mjs +55 -0
- package/dist/core/recorder/convert.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/download.mjs +3 -3
- package/dist/core/recorder/download.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/ffmpeg-utils.mjs +4 -7
- package/dist/core/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/core/recorder/index.d.mts +7 -0
- package/dist/core/recorder/index.d.mts.map +1 -0
- package/dist/{recorder → core/recorder}/index.mjs +16 -33
- package/dist/core/recorder/index.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/normalize.mjs +1 -1
- package/dist/core/recorder/normalize.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/post-processing.mjs +2 -2
- package/dist/core/recorder/post-processing.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/recorder-events.mjs +1 -1
- package/dist/core/recorder/recorder-events.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/recorder-state.mjs +1 -1
- package/dist/core/recorder/recorder-state.mjs.map +1 -0
- package/dist/{recorder → core/recorder}/stream.mjs +2 -2
- package/dist/core/recorder/stream.mjs.map +1 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +2 -141
- package/dist/{browser-cookies.mjs → lib/browser-cookies.mjs} +1 -1
- package/dist/lib/browser-cookies.mjs.map +1 -0
- package/dist/lib/cli.mjs +164 -0
- package/dist/lib/cli.mjs.map +1 -0
- package/dist/{config.d.mts → lib/config.d.mts} +1 -1
- package/dist/lib/config.d.mts.map +1 -0
- package/dist/{config.mjs → lib/config.mjs} +2 -7
- package/dist/lib/config.mjs.map +1 -0
- package/dist/{logger.mjs → lib/logger.mjs} +1 -1
- package/dist/lib/logger.mjs.map +1 -0
- package/dist/lib/tmux.mjs +70 -0
- package/dist/lib/tmux.mjs.map +1 -0
- package/dist/{ui.mjs → lib/ui.mjs} +1 -1
- package/dist/lib/ui.mjs.map +1 -0
- package/dist/{utils.mjs → lib/utils.mjs} +1 -1
- package/dist/lib/utils.mjs.map +1 -0
- package/dist/package.mjs +1 -1
- package/package.json +10 -9
- package/dist/api/client.mjs.map +0 -1
- package/dist/api/rate-limiter.mjs.map +0 -1
- package/dist/api/tiktok.mjs.map +0 -1
- package/dist/browser-cookies.mjs.map +0 -1
- package/dist/cli.mjs +0 -110
- package/dist/cli.mjs.map +0 -1
- package/dist/config.d.mts.map +0 -1
- package/dist/config.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/lib.d.mts +0 -3
- package/dist/lib.mjs +0 -2
- package/dist/logger.mjs.map +0 -1
- package/dist/monitor.mjs.map +0 -1
- package/dist/recorder/convert.mjs +0 -67
- package/dist/recorder/convert.mjs.map +0 -1
- package/dist/recorder/download.mjs.map +0 -1
- package/dist/recorder/ffmpeg-utils.mjs.map +0 -1
- package/dist/recorder/index.d.mts +0 -7
- package/dist/recorder/index.d.mts.map +0 -1
- package/dist/recorder/index.mjs.map +0 -1
- package/dist/recorder/normalize.mjs.map +0 -1
- package/dist/recorder/post-processing.mjs.map +0 -1
- package/dist/recorder/recorder-events.mjs.map +0 -1
- package/dist/recorder/recorder-state.mjs.map +0 -1
- package/dist/recorder/stream.mjs.map +0 -1
- package/dist/ui.mjs.map +0 -1
- package/dist/utils.mjs.map +0 -1
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.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { TikTokError } from "./lib/config.mjs";
|
|
3
|
+
import { bytesToHuman, relativeTime } from "./lib/utils.mjs";
|
|
4
|
+
import { createRecorder } from "./core/recorder/index.mjs";
|
|
5
|
+
import "./index.mjs";
|
|
6
|
+
import { extractTikTokCookiesFromFirefox } from "./lib/browser-cookies.mjs";
|
|
7
|
+
import { parseArgs } from "./lib/cli.mjs";
|
|
8
|
+
import { tmuxStart, tmuxStatus, tmuxStop } from "./lib/tmux.mjs";
|
|
9
|
+
import { createDisplay } from "./lib/ui.mjs";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { color } from "@zfadhli/koko-cli";
|
|
13
|
+
//#region src/bin.ts
|
|
14
|
+
/**
|
|
15
|
+
* Entry point — parses CLI args, creates the recorder, and handles signals.
|
|
16
|
+
* Uses the terminal Display manager for beautiful console output.
|
|
17
|
+
*/
|
|
18
|
+
async function loadCookies(cookiesPath) {
|
|
19
|
+
const path = cookiesPath ?? join(process.cwd(), "cookies.json");
|
|
20
|
+
if (!existsSync(path)) return;
|
|
21
|
+
try {
|
|
22
|
+
const content = readFileSync(path, "utf-8");
|
|
23
|
+
const data = JSON.parse(content);
|
|
24
|
+
if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
|
|
25
|
+
return;
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function main() {
|
|
31
|
+
let result;
|
|
32
|
+
try {
|
|
33
|
+
result = parseArgs();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof TikTokError && err.kind === "config-error") {
|
|
36
|
+
console.error(`${color.red("Error:")} ${err.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
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;
|
|
115
|
+
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
116
|
+
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
117
|
+
config.logConsole = false;
|
|
118
|
+
const display = createDisplay();
|
|
119
|
+
if (config.cookies) {
|
|
120
|
+
const source = firefoxCookies ? "Firefox" : "cookies.json";
|
|
121
|
+
const names = Object.keys(config.cookies);
|
|
122
|
+
const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
|
|
123
|
+
display.showInfo(`${source} cookies loaded${detail}`);
|
|
124
|
+
} else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
|
|
125
|
+
const recorder = createRecorder(config);
|
|
126
|
+
let lastWasLive = null;
|
|
127
|
+
let firstOfflineTime = 0;
|
|
128
|
+
recorder.on("checking", (info) => {
|
|
129
|
+
display.checkingUser(info.user);
|
|
130
|
+
});
|
|
131
|
+
recorder.on("tick", (info) => {
|
|
132
|
+
if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
|
|
133
|
+
else if (lastWasLive === null || lastWasLive === true) {
|
|
134
|
+
display.userOffline(info.user);
|
|
135
|
+
firstOfflineTime = Date.now();
|
|
136
|
+
} else {
|
|
137
|
+
const elapsed = Date.now() - firstOfflineTime;
|
|
138
|
+
display.userOfflineRepeat(info.user, relativeTime(elapsed));
|
|
139
|
+
}
|
|
140
|
+
lastWasLive = info.isLive;
|
|
141
|
+
});
|
|
142
|
+
recorder.on("recording:start", () => {
|
|
143
|
+
display.startRecording();
|
|
144
|
+
});
|
|
145
|
+
recorder.on("download:end", (info) => {
|
|
146
|
+
const parsed = info.file.split("/").pop() ?? info.file;
|
|
147
|
+
const sizeStr = bytesToHuman(info.size);
|
|
148
|
+
display.finishRecording(parsed, info.duration, sizeStr);
|
|
149
|
+
});
|
|
150
|
+
recorder.on("segmenting:start", () => {
|
|
151
|
+
display.startSegmenting();
|
|
152
|
+
});
|
|
153
|
+
recorder.on("segmenting:end", (info) => {
|
|
154
|
+
display.segmentsCreated(info.segments);
|
|
155
|
+
});
|
|
156
|
+
recorder.on("converting:start", () => {
|
|
157
|
+
display.startConverting();
|
|
158
|
+
});
|
|
159
|
+
recorder.on("converted", (info) => {
|
|
160
|
+
const parsed = info.output.split("/").pop() ?? info.output;
|
|
161
|
+
display.conversionDone(parsed);
|
|
162
|
+
});
|
|
163
|
+
recorder.on("recording:end", () => {});
|
|
164
|
+
recorder.on("error", (err) => {
|
|
165
|
+
display.showError(`[${err.kind}] ${err.message}`);
|
|
166
|
+
});
|
|
167
|
+
recorder.on("normalize:start", () => {
|
|
168
|
+
display.normalizeStart();
|
|
169
|
+
});
|
|
170
|
+
recorder.on("normalize:progress", (info) => {
|
|
171
|
+
display.normalizeProgress(info.percent, info.phase);
|
|
172
|
+
});
|
|
173
|
+
recorder.on("normalize:end", () => {
|
|
174
|
+
display.normalizeComplete();
|
|
175
|
+
});
|
|
176
|
+
recorder.on("normalize:error", (info) => {
|
|
177
|
+
display.normalizeError(info.error);
|
|
178
|
+
});
|
|
179
|
+
let stopping = false;
|
|
180
|
+
const handleSignal = async () => {
|
|
181
|
+
if (stopping) return;
|
|
182
|
+
stopping = true;
|
|
183
|
+
process.off("SIGINT", handleSignal);
|
|
184
|
+
process.off("SIGTERM", handleSignal);
|
|
185
|
+
process.off("SIGHUP", handleSignal);
|
|
186
|
+
display.showInfo("Shutting down gracefully...");
|
|
187
|
+
await recorder.stop();
|
|
188
|
+
display.stop();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
};
|
|
191
|
+
process.on("SIGINT", handleSignal);
|
|
192
|
+
process.on("SIGTERM", handleSignal);
|
|
193
|
+
process.on("SIGHUP", handleSignal);
|
|
194
|
+
display.pollingStarted(config.interval ?? 3);
|
|
195
|
+
try {
|
|
196
|
+
await recorder.start();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
|
|
199
|
+
else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
process.off("SIGINT", handleSignal);
|
|
201
|
+
process.off("SIGTERM", handleSignal);
|
|
202
|
+
process.off("SIGHUP", handleSignal);
|
|
203
|
+
await recorder.stop();
|
|
204
|
+
display.stop();
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
main().catch((err) => {
|
|
209
|
+
console.error("Fatal:", err instanceof Error ? err.message : String(err));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
212
|
+
//#endregion
|
|
213
|
+
export {};
|
|
214
|
+
|
|
215
|
+
//# sourceMappingURL=bin.mjs.map
|
package/dist/bin.mjs.map
ADDED
|
@@ -0,0 +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, 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 { createRateLimiter } from "./rate-limiter.mjs";
|
|
2
2
|
import { createSession } from "wreq-js";
|
|
3
|
-
//#region src/api/client.ts
|
|
3
|
+
//#region src/core/api/client.ts
|
|
4
4
|
/**
|
|
5
5
|
* HTTP client — thin wrapper around wreq-js createSession.
|
|
6
6
|
* Provides TLS fingerprint impersonation, cookie jar, proxy support,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../../../src/core/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, proxy support,\n * and configurable rate limiting to prevent WAF triggering.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../../lib/config\"\nimport { createRateLimiter } from \"./rate-limiter\"\n\n// BodyInit is only available inside declare module \"bun\" in bun-types, not as a global\n// under the ES2022 lib. We explicitly import it to keep lib focused on ES2022.\ntype _BodyInit = import(\"bun\").BodyInit\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: _BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nconst REQUEST_TIMEOUT_MS = 15_000\n\n/** Standard browser headers to avoid TikTok WAF detection. */\nconst BROWSER_HEADERS: Record<string, string> = {\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.5\",\n \"Accept-Encoding\": \"gzip, deflate, br\",\n \"Upgrade-Insecure-Requests\": \"1\",\n \"Sec-Fetch-Dest\": \"document\",\n \"Sec-Fetch-Mode\": \"navigate\",\n \"Sec-Fetch-Site\": \"none\",\n \"Sec-Fetch-User\": \"?1\",\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"firefox_149\",\n os: \"windows\",\n proxy: config.proxy,\n })\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies && \"sessionid_ss\" in config.cookies) {\n const domains = [\"https://www.tiktok.com\", \"https://webcast.tiktok.com\", \"https://m.tiktok.com\"]\n for (const domain of domains) {\n for (const [name, value] of Object.entries(config.cookies)) {\n session.setCookie(name, value, domain)\n }\n }\n }\n\n const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)\n\n async function rateLimitedFetch(\n url: string,\n options?: { method?: string; body?: unknown; headers?: Record<string, string> },\n ): Promise<Response> {\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n await rateLimiter.acquire(controller.signal)\n const res = await session.fetch(url, {\n method: options?.method,\n body: options?.body as never,\n headers: {\n ...BROWSER_HEADERS,\n ...options?.headers,\n },\n signal: controller.signal,\n })\n return res as unknown as Response\n } finally {\n clearTimeout(timeout)\n }\n }\n\n return {\n get: async (url: string) => rateLimitedFetch(url),\n post: async (url: string, body?: _BodyInit, headers?: Record<string, string>) =>\n rateLimitedFetch(url, {\n method: \"POST\",\n body: body as never,\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n }),\n close: async () => {\n await session.close()\n },\n }\n}\n"],"mappings":";;;;;;;;AAoBA,MAAM,qBAAqB;;AAG3B,MAAM,kBAA0C;CAC9C,cAAc;CACd,QAAQ;CACR,mBAAmB;CACnB,mBAAmB;CACnB,6BAA6B;CAC7B,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;AACpB;AAEA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAC;CAOD,IAAI,OAAO,WAAW,kBAAkB,OAAO,SAE7C,KAAK,MAAM,UAAU;EADJ;EAA0B;EAA8B;CAC9C,GACzB,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,OAAO,GACvD,QAAQ,UAAU,MAAM,OAAO,MAAM;CAK3C,MAAM,cAAc,kBAAkB,OAAO,iBAAiB,CAAC;CAE/D,eAAe,iBACb,KACA,SACmB;EACnB,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;EACvE,IAAI;GACF,MAAM,YAAY,QAAQ,WAAW,MAAM;GAU3C,OAAO,MATW,QAAQ,MAAM,KAAK;IACnC,QAAQ,SAAS;IACjB,MAAM,SAAS;IACf,SAAS;KACP,GAAG;KACH,GAAG,SAAS;IACd;IACA,QAAQ,WAAW;GACrB,CAAC;EAEH,UAAU;GACR,aAAa,OAAO;EACtB;CACF;CAEA,OAAO;EACL,KAAK,OAAO,QAAgB,iBAAiB,GAAG;EAChD,MAAM,OAAO,KAAa,MAAkB,YAC1C,iBAAiB,KAAK;GACpB,QAAQ;GACF;GACN,SAAS;IACP,gBAAgB;IAChB,GAAG;GACL;EACF,CAAC;EACH,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.mjs","names":[],"sources":["../../../src/core/api/rate-limiter.ts"],"sourcesContent":["/**\n * Token-bucket rate limiter — limits API request frequency to prevent\n * TikTok WAF triggering on rapid polling or URL refresh loops.\n *\n * Design:\n * - Token bucket with configurable refill rate (tokens/sec)\n * - AbortSignal-aware wait so cancellation works through the queue\n * - Thread-safe for cooperative JS concurrency (all awaiters share\n * the same monotonically-increasing last-token timestamp)\n */\n\nexport interface RateLimiter {\n /** Wait until a rate-limit token is available, then consume it. */\n acquire(signal?: AbortSignal): Promise<void>\n}\n\n/**\n * Create a token-bucket rate limiter.\n *\n * @param tokensPerSecond Max sustained requests per second.\n * 0 or negative = unlimited (no waiting).\n */\nexport function createRateLimiter(tokensPerSecond: number): RateLimiter {\n // Unlimited mode — no-op acquire\n if (tokensPerSecond <= 0) {\n return { acquire: async () => {} }\n }\n\n const MIN_INTERVAL_MS = 1000 / tokensPerSecond\n let lastTokenTime = 0\n\n return {\n async acquire(signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) return\n\n const now = Date.now()\n const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now)\n\n if (waitTime > 0) {\n await new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, waitTime)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n }\n\n lastTokenTime = Date.now()\n },\n }\n}\n"],"mappings":";;;;;;;AAsBA,SAAgB,kBAAkB,iBAAsC;CAEtE,IAAI,mBAAmB,GACrB,OAAO,EAAE,SAAS,YAAY,CAAC,EAAE;CAGnC,MAAM,kBAAkB,MAAO;CAC/B,IAAI,gBAAgB;CAEpB,OAAO,EACL,MAAM,QAAQ,QAAqC;EACjD,IAAI,QAAQ,SAAS;EAErB,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,WAAW,KAAK,IAAI,GAAG,gBAAgB,kBAAkB,GAAG;EAElE,IAAI,WAAW,GACb,MAAM,IAAI,SAAe,YAAY;GACnC,MAAM,QAAQ,WAAW,SAAS,QAAQ;GAC1C,IAAI,QACF,OAAO,iBACL,eACM;IACJ,aAAa,KAAK;IAClB,QAAQ;GACV,GACA,EAAE,MAAM,KAAK,CACf;EAEJ,CAAC;EAGH,gBAAgB,KAAK,IAAI;CAC3B,EACF;AACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../../src/core/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * Primary path: parse SIGI_STATE from the user's /live page (fast path for\n * older TikTok pages). When SIGI_STATE is absent (TikTok's new unified page\n * structure), falls back to __UNIVERSAL_DATA_FOR_REHYDRATION__ on the profile\n * page to extract the room ID, then calls the Webcast room/info API for live\n * status and stream URL.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n *\n * Note: The room/enter Webcast endpoint has been deprecated due to frequent\n * 403 errors. All lookups go through room/info (reliable for live + offline).\n */\n\nimport type { Logger } from \"../../lib/logger\"\nimport type { HttpClient } from \"./client\"\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASES = [\"https://www.tiktok.com\", \"https://m.tiktok.com\"]\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\"\n\nexport function createTikTokApi(http: HttpClient, logger?: Logger, showDebug?: boolean): TikTokApi {\n // Debug logging helper — writes to stderr only when --debug is passed.\n const debug = (msg: string) => {\n logger?.debug(msg)\n if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\\n`)\n }\n let cached: LiveInfo | null = null\n let cachedUser = \"\"\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = \"\"\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n // Use the lightweight check_alive endpoint instead of re-fetching\n // the full room/info response.\n return fetchCheckAlive(roomId)\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE / Universal Data ──\n\n /**\n * Try to fetch a TikTok user page from multiple domains.\n * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com\n * often has different bot protection and may succeed.\n * Returns the HTML and the base URL that worked, or null if all fail.\n */\n async function tryFetchPage(\n user: string,\n path: string,\n ): Promise<{ html: string; baseUrl: string } | null> {\n for (const base of TIKTOK_BASES) {\n const url = `${base}/@${user}${path}`\n const res = await http.get(url)\n debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) continue\n\n const html = await res.text()\n // Reject Slardar WAF challenge pages (small + keyword)\n if (html.length < 5000 || html.includes(\"SlardarWAF\")) {\n debug(\n `tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes(\"SlardarWAF\")})`,\n )\n continue\n }\n\n debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`)\n return { html, baseUrl: base }\n }\n return null\n }\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n let sigi: SigiState | null = null\n let html = \"\"\n\n try {\n const page = await tryFetchPage(user, \"/live\")\n if (!page) {\n debug(\"fetchLiveInfo: all domains blocked for /live page\")\n // Fall through to universal data / profile page fallback\n html = \"\"\n } else {\n html = page.html\n debug(\n `fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n\n sigi = extractSigiState(html)\n debug(`fetchLiveInfo: SIGI_STATE ${sigi ? \"found\" : \"not found\"}`)\n\n if (sigi) {\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n debug(\n `fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? \"null\"}`,\n )\n\n // ── Primary path: liveRoom is populated (server-rendered) ──\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n debug(\n `fetchLiveInfo: primary path — roomId=${roomId}, status=${liveRoom.status}, isLive=${isLive}`,\n )\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi)\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n }\n\n // ── Fallback: userInfo has roomId, but liveRoom is async-loaded ──\n // TikTok may omit liveRoom from the server-rendered page and load it\n // via JavaScript. Query the Webcast API directly — it's authoritative.\n if (userInfo?.roomId) {\n const roomId = String(userInfo.roomId)\n debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`)\n const roomInfo = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: Webcast API ${roomInfo ? \"returned room info\" : \"returned null\"}`)\n if (roomInfo) return roomInfo\n }\n\n // ── Legacy fallback: async-loaded SIGI_STATE ──\n debug(\"fetchLiveInfo: trying legacy fallback (UserModule)\")\n const legacy = await fetchLiveInfoFallback(sigi, user)\n debug(`fetchLiveInfo: legacy fallback ${legacy ? \"succeeded\" : \"returned null\"}`)\n if (legacy) return legacy\n } // end if (sigi)\n } // end else (page loaded)\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n debug(`fetchLiveInfo: caught error — ${_err instanceof Error ? _err.message : String(_err)}`)\n return null\n }\n\n // ── API fallback: when page scraping fails (WAF), use TikTok API directly ──\n // The /api-live/user/room/ endpoint returns roomId + live status without\n // the Slardar WAF that blocks www.tiktok.com HTML pages.\n if (!html || html.length < 5000) {\n debug(\"fetchLiveInfo: trying API fallback (api-live/user/room)\")\n const apiResult = await fetchLiveInfoFromApi(user)\n if (apiResult) return apiResult\n }\n\n // ── New fallback: TikTok's unified page structure ──\n // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.\n // The /live page may not have it, so try the main profile page.\n debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`)\n const { roomId } = extractUserFromUniversalData(html)\n debug(`fetchLiveInfo: universal data roomId=${roomId ?? \"null\"}`)\n if (roomId) {\n // If the /live page has the roomId, use room/info (reliable)\n const result = await fetchRoomInfoFromRoomApi(roomId)\n debug(`fetchLiveInfo: universal data Webcast API ${result ? \"succeeded\" : \"returned null\"}`)\n if (result) return result\n }\n // No roomId available — fall through to profile page to check for\n // __UNIVERSAL_DATA_FOR_REHYDRATION__ which may have the roomId when live.\n\n // Final fallback: fetch the main profile page for webapp.user-detail\n debug(\"fetchLiveInfo: trying profile page fallback\")\n const profileResult = await fetchLiveInfoFromProfile(user)\n debug(`fetchLiveInfo: profile page ${profileResult ? \"succeeded\" : \"returned null\"}`)\n return profileResult\n }\n\n /**\n * Fetch the main profile page to extract user info from\n * __UNIVERSAL_DATA_FOR_REHYDRATION__. When the user is live, their\n * roomId is embedded in the data, allowing us to fetch room info and\n * stream URL via the /webcast/room/info/ endpoint (which works\n * reliably). If no roomId is present the user is offline.\n */\n async function fetchLiveInfoFromProfile(user: string): Promise<LiveInfo | null> {\n try {\n const page = await tryFetchPage(user, \"\")\n if (!page) {\n debug(\"fetchLiveInfoFromProfile: all domains blocked for profile page\")\n return null\n }\n debug(\n `fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\\n/g, \"\\\\n\")}`,\n )\n const { userId, roomId } = extractUserFromUniversalData(page.html)\n if (!userId) return null\n\n // If roomId is embedded in the profile page, the user is live.\n // Use room/info which works reliably for both live and offline rooms.\n if (roomId) {\n return fetchRoomInfoFromRoomApi(roomId)\n }\n\n // No roomId means the user is offline (room/enter was unreliable).\n return null\n } catch {\n return null\n }\n }\n\n /**\n * Fetch live info from TikTok's internal API endpoint.\n * This bypasses the Slardar WAF that blocks www.tiktok.com HTML pages.\n *\n * Endpoint: /api-live/user/room/?aid=1988&uniqueId=${user}&sourceType=54\n * Returns the roomId, user status (2 = live), and other user metadata.\n * When the user is live, also queries the Webcast API for a stream URL.\n */\n async function fetchLiveInfoFromApi(user: string): Promise<LiveInfo | null> {\n try {\n const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(user)}&sourceType=54`\n const res = await http.get(url)\n debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n const userData = (data?.data as Record<string, unknown> | undefined)?.user as\n | { roomId?: string; status?: number; uniqueId?: string }\n | undefined\n\n if (!userData?.roomId) {\n debug(\"fetchLiveInfoFromApi: no roomId in API response\")\n return null\n }\n\n const roomId = String(userData.roomId)\n // status: 2 = live, 4 = offline, 1 = ...\n const isLive = userData.status === 2\n debug(`fetchLiveInfoFromApi: roomId=${roomId}, status=${userData.status}, isLive=${isLive}`)\n\n if (!isLive) {\n return { roomId, isLive: false, streamUrl: null, title: null }\n }\n\n // Query Webcast API for stream URL\n const streamUrl = await fetchStreamUrlFromApi(roomId)\n return { roomId, isLive: true, streamUrl, title: null }\n } catch {\n return null\n }\n }\n\n /**\n * Shared boilerplate for Webcast room/info API calls.\n * Handles URL construction, HTTP GET, JSON parsing, status code checks,\n * and error handling. The caller provides an extractor for the response data.\n */\n async function fetchFromRoomApi<T>(\n roomId: string,\n extract: (data: Record<string, unknown>) => T | null,\n ): Promise<T | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return null\n\n const text = await res.text()\n debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as { status_code?: unknown }).status_code === 4003110) return null\n\n return extract(data)\n } catch {\n return null\n }\n }\n\n /**\n * Check if a room is currently alive using the lightweight\n * /webcast/room/check_alive/ endpoint.\n *\n * This is faster and cheaper than fetching the full room/info response.\n * Returns `true` if the room is live, `false` otherwise (including errors).\n *\n * Endpoint: /webcast/room/check_alive/?aid=1988®ion=CH&room_ids={id}&user_is_login=true\n * Response: { \"data\": [{ \"alive\": true/false, \"room_id\": \"...\" }] }\n */\n async function fetchCheckAlive(roomId: string): Promise<boolean> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988®ion=CH&room_ids=${roomId}&user_is_login=true`\n const res = await http.get(url)\n debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)\n if (!res.ok) return false\n\n const text = await res.text()\n debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)\n if (text.length === 0) return false\n\n const data = JSON.parse(text) as Record<string, unknown>\n const items = data?.data as Array<Record<string, unknown>> | undefined\n const alive = items?.[0]?.alive === true\n debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)\n return alive\n } catch (err) {\n debug(\n `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,\n )\n return false\n }\n }\n\n /**\n * Fetch room info from the Webcast room/info API. Requires a known roomId.\n */\n async function fetchRoomInfoFromRoomApi(roomId: string): Promise<LiveInfo | null> {\n return fetchFromRoomApi(roomId, (data) => {\n const roomData = (data as { data?: Record<string, unknown> }).data\n if (!roomData) return null\n\n const status = roomData.status ?? 4\n const isLive = status === 2\n const streamUrl = isLive ? findStreamUrlRecursively(roomData) : null\n\n return { roomId, isLive, streamUrl, title: (roomData.title as string | undefined) ?? null }\n })\n }\n\n /**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL is obtained\n * from the Webcast API (room/info/) as a final fallback.\n */\n async function fetchLiveInfoFallback(sigi: SigiState, user: string): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus\n const isLive = liveRoomStatus === 2 // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user]\n let roomId: string | null = null\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId)\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null\n return { roomId, isLive, streamUrl, title: null }\n }\n\n // No roomId available — treat as offline (room/enter was unreliable).\n return null\n }\n\n /**\n * Fetch stream URL from the Webcast API.\n * Covers cases where SIGI_STATE doesn't include stream data.\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n id?: string | number\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string // numeric user ID\n uniqueId?: string\n roomId?: string | { id: string }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\n/**\n * @public Exported for testing.\n */\nexport function extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n/**\n * Extract the numeric user ID and optional room ID from TikTok's new\n * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).\n *\n * The profile page (/@user) includes \"webapp.user-detail\" which\n * contains the user's numeric ID. When the user is live, the roomId\n * is also embedded here.\n *\n * Returns { userId, roomId } where userId is always set when the data\n * is found, and roomId is set only when the user is currently live.\n *\n * @public Exported for testing and fixture capture.\n */\nexport function extractUserFromUniversalData(html: string): {\n userId: string | null\n roomId: string | null\n} {\n const match = html.match(\n /<script\\s+id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n )\n if (!match?.[1]) return { userId: null, roomId: null }\n try {\n const data = JSON.parse(match[1])\n const scope = (data as Record<string, unknown>).__DEFAULT_SCOPE__ ?? data\n const userDetail = scope?.[\"webapp.user-detail\"]\n const user = userDetail?.userInfo?.user\n const userId = user?.id ? String(user.id) : null\n const roomId = user?.roomId ? String(user.roomId) : null\n return { userId, roomId }\n } catch {\n return { userId: null, roomId: null }\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n *\n * Prefers FLV (lower latency) over HLS/other protocols.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]>[\"liveRoom\"],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality.\n // FLV is preferred (lower latency); HLS is the fallback.\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"]\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined\n // Prefer FLV over HLS when both are available\n const url = entry?.main?.flv ?? entry?.main?.hls\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Prefers FLV over HLS when both are present in the response.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Collect all candidate URLs with their preference\n const flvUrls: string[] = []\n const m3u8Urls: string[] = []\n\n function walk(value: unknown): void {\n if (typeof value === \"string\") {\n // Direct URL match\n if (\n (value.startsWith(\"http://\") || value.startsWith(\"https://\")) &&\n (value.includes(\".flv\") || value.includes(\".m3u8\"))\n ) {\n if (value.includes(\".flv\")) {\n flvUrls.push(value)\n } else {\n m3u8Urls.push(value)\n }\n return\n }\n // JSON-encoded string — parse and recurse\n if (value.startsWith(\"{\") || value.startsWith(\"[\")) {\n try {\n walk(JSON.parse(value))\n } catch {\n // skip\n }\n }\n return\n }\n\n if (value && typeof value === \"object\") {\n if (Array.isArray(value)) {\n for (const item of value) {\n walk(item)\n }\n } else {\n for (const val of Object.values(value as Record<string, unknown>)) {\n walk(val)\n }\n }\n }\n }\n\n walk(obj)\n\n // Prefer FLV (lower latency) over HLS\n if (flvUrls.length > 0) return flvUrls[0] as string\n if (m3u8Urls.length > 0) return m3u8Urls[0] as string\n return null\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { sleep } from "
|
|
2
|
-
//#region src/monitor.ts
|
|
1
|
+
import { sleep } from "../lib/utils.mjs";
|
|
2
|
+
//#region src/core/monitor.ts
|
|
3
3
|
function createPollingMonitor(opts) {
|
|
4
4
|
const intervalMs = opts.intervalMinutes * 60 * 1e3;
|
|
5
5
|
const stopSignal = new AbortController();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"monitor.mjs","names":[],"sources":["../../src/core/monitor.ts"],"sourcesContent":["/**\n * Polling monitor — calls a tick function on a configurable interval.\n * Fires the first tick immediately, then every N minutes.\n *\n * Uses an AbortController so stop() can interrupt the sleep between\n * ticks immediately, avoiding a busy 1-second polling loop.\n */\n\nimport type { Logger } from \"../lib/logger\"\nimport { sleep } from \"../lib/utils\"\n\nexport interface PollingMonitor {\n start: () => Promise<void>\n stop: () => Promise<void>\n /** Schedule the monitor to stop after the current tick completes. */\n stopAfterCurrentTick: () => void\n}\n\nexport function createPollingMonitor(opts: {\n intervalMinutes: number\n onTick: () => Promise<void>\n logger?: Logger\n}): PollingMonitor {\n const intervalMs = opts.intervalMinutes * 60 * 1000\n const stopSignal = new AbortController()\n let currentTick: Promise<void> | null = null\n let stopAfterTick = false\n\n async function start(): Promise<void> {\n opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`)\n\n while (!stopSignal.signal.aborted) {\n currentTick = opts.onTick()\n try {\n await currentTick\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n opts.logger?.error(`Tick error: ${msg}`)\n } finally {\n currentTick = null\n }\n\n if (stopAfterTick) {\n stopSignal.abort()\n }\n if (stopSignal.signal.aborted) break\n\n // Sleep for the full interval — stop() aborts the signal to wake us up early\n await sleep(intervalMs, stopSignal.signal)\n }\n\n opts.logger?.info(\"Polling stopped\")\n }\n\n async function stop(): Promise<void> {\n // Signal start() to exit the loop immediately\n stopSignal.abort()\n // Wait for in-flight tick to finish (e.g. recording → converting → MP4)\n if (currentTick) {\n await currentTick\n }\n }\n\n function stopAfterCurrentTick(): void {\n stopAfterTick = true\n }\n\n return { start, stop, stopAfterCurrentTick }\n}\n"],"mappings":";;AAkBA,SAAgB,qBAAqB,MAIlB;CACjB,MAAM,aAAa,KAAK,kBAAkB,KAAK;CAC/C,MAAM,aAAa,IAAI,gBAAgB;CACvC,IAAI,cAAoC;CACxC,IAAI,gBAAgB;CAEpB,eAAe,QAAuB;EACpC,KAAK,QAAQ,KAAK,8BAA8B,KAAK,gBAAgB,MAAM;EAE3E,OAAO,CAAC,WAAW,OAAO,SAAS;GACjC,cAAc,KAAK,OAAO;GAC1B,IAAI;IACF,MAAM;GACR,SAAS,KAAK;IACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IAC3D,KAAK,QAAQ,MAAM,eAAe,KAAK;GACzC,UAAU;IACR,cAAc;GAChB;GAEA,IAAI,eACF,WAAW,MAAM;GAEnB,IAAI,WAAW,OAAO,SAAS;GAG/B,MAAM,MAAM,YAAY,WAAW,MAAM;EAC3C;EAEA,KAAK,QAAQ,KAAK,iBAAiB;CACrC;CAEA,eAAe,OAAsB;EAEnC,WAAW,MAAM;EAEjB,IAAI,aACF,MAAM;CAEV;CAEA,SAAS,uBAA6B;EACpC,gBAAgB;CAClB;CAEA,OAAO;EAAE;EAAO;EAAM;CAAqB;AAC7C"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
+
import { findFfmpegPath } from "../../lib/utils.mjs";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import { unlinkSync } from "node:fs";
|
|
5
|
+
//#region src/core/recorder/convert.ts
|
|
6
|
+
/**
|
|
7
|
+
* Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).
|
|
8
|
+
* Deletes the original file on success.
|
|
9
|
+
*/
|
|
10
|
+
function createConverter(logger, signal) {
|
|
11
|
+
async function convert(input) {
|
|
12
|
+
const output = input.replace(/\.(flv|ts)$/i, ".mp4");
|
|
13
|
+
logger?.info(`Converting: ${input} → ${output}`);
|
|
14
|
+
const ffmpegPath = findFfmpegPath();
|
|
15
|
+
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
|
|
16
|
+
await runFfmpeg(ffmpegPath, input, output, signal);
|
|
17
|
+
try {
|
|
18
|
+
unlinkSync(input);
|
|
19
|
+
logger?.info(`Deleted original: ${input}`);
|
|
20
|
+
} catch {
|
|
21
|
+
logger?.warn(`Could not delete original: ${input}`);
|
|
22
|
+
}
|
|
23
|
+
logger?.info(`Converted: ${output}`);
|
|
24
|
+
return output;
|
|
25
|
+
}
|
|
26
|
+
return { convert };
|
|
27
|
+
}
|
|
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,
|
|
40
|
+
stdio: [
|
|
41
|
+
"ignore",
|
|
42
|
+
"pipe",
|
|
43
|
+
"pipe"
|
|
44
|
+
]
|
|
45
|
+
});
|
|
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
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { createConverter };
|
|
54
|
+
|
|
55
|
+
//# sourceMappingURL=convert.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"convert.mjs","names":[],"sources":["../../../src/core/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).\n * Deletes the original file on success.\n */\n\nimport { unlinkSync } from \"node:fs\"\nimport { execa } from \"execa\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport { findFfmpegPath } from \"../../lib/utils\"\n\nexport interface Converter {\n /** Convert a TS file to MP4. Returns the output filepath. */\n convert: (input: string) => Promise<string>\n}\n\nexport function createConverter(logger?: Logger, signal?: AbortSignal): Converter {\n async function convert(input: string): Promise<string> {\n const output = input.replace(/\\.(flv|ts)$/i, \".mp4\")\n\n logger?.info(`Converting: ${input} → ${output}`)\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found. Install it:\\n\" +\n \" Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\\n\" +\n \" macOS: brew install ffmpeg\\n\" +\n \" Windows: choco install ffmpeg\",\n )\n }\n\n await runFfmpeg(ffmpegPath, input, output, signal)\n\n // Delete original TS\n try {\n unlinkSync(input)\n logger?.info(`Deleted original: ${input}`)\n } catch {\n logger?.warn(`Could not delete original: ${input}`)\n }\n\n logger?.info(`Converted: ${output}`)\n return output\n }\n\n return { convert }\n}\n\nasync function runFfmpeg(\n ffmpegPath: string,\n input: string,\n output: string,\n signal?: AbortSignal,\n): Promise<void> {\n const args = [\"-i\", input, \"-c\", \"copy\", \"-y\", output]\n\n try {\n await execa(ffmpegPath, args, { signal, stdio: [\"ignore\", \"pipe\", \"pipe\"] })\n } catch (err: unknown) {\n if (signal?.aborted) {\n throw new TikTokError(\"aborted\", \"Aborted by user\")\n }\n const stderr = (err as { stderr?: string }).stderr ?? \"\"\n throw new TikTokError(\n \"ffmpeg-error\",\n `FFmpeg exited with code ${(err as { exitCode?: number }).exitCode}\\n${stderr.slice(-500)}`,\n )\n }\n}\n"],"mappings":";;;;;;;;;AAgBA,SAAgB,gBAAgB,QAAiB,QAAiC;CAChF,eAAe,QAAQ,OAAgC;EACrD,MAAM,SAAS,MAAM,QAAQ,gBAAgB,MAAM;EAEnD,QAAQ,KAAK,eAAe,MAAM,KAAK,QAAQ;EAE/C,MAAM,aAAa,eAAe;EAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,sKAIF;EAGF,MAAM,UAAU,YAAY,OAAO,QAAQ,MAAM;EAGjD,IAAI;GACF,WAAW,KAAK;GAChB,QAAQ,KAAK,qBAAqB,OAAO;EAC3C,QAAQ;GACN,QAAQ,KAAK,8BAA8B,OAAO;EACpD;EAEA,QAAQ,KAAK,cAAc,QAAQ;EACnC,OAAO;CACT;CAEA,OAAO,EAAE,QAAQ;AACnB;AAEA,eAAe,UACb,YACA,OACA,QACA,QACe;CACf,MAAM,OAAO;EAAC;EAAM;EAAO;EAAM;EAAQ;EAAM;CAAM;CAErD,IAAI;EACF,MAAM,MAAM,YAAY,MAAM;GAAE;GAAQ,OAAO;IAAC;IAAU;IAAQ;GAAM;EAAE,CAAC;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,9 @@
|
|
|
1
|
-
import { TikTokError } from "
|
|
2
|
-
import { bytesToHuman, findFfmpegPath, formatDuration, formatFilename } from "
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
+
import { bytesToHuman, findFfmpegPath, formatDuration, formatFilename } from "../../lib/utils.mjs";
|
|
3
3
|
import { pipeFfmpegSegment } from "./ffmpeg-utils.mjs";
|
|
4
4
|
import { createWriteStream, statSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
//#region src/recorder/download.ts
|
|
6
|
+
//#region src/core/recorder/download.ts
|
|
7
7
|
/**
|
|
8
8
|
* Live stream downloader — downloads a live stream via FFmpeg stdout pipe.
|
|
9
9
|
*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download.mjs","names":[],"sources":["../../../src/core/recorder/download.ts"],"sourcesContent":["/**\n * Live stream downloader — downloads a live stream via FFmpeg stdout pipe.\n *\n * Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.\n * FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's\n * short-lived stream URL expires, FFmpeg exits and the outer loop fetches\n * a fresh URL via getNextUrl.\n *\n * Both FLV and HLS streams are handled by the same download function —\n * the only difference is an optional label in the log messages.\n */\n\nimport { createWriteStream, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport { bytesToHuman, findFfmpegPath, formatDuration, formatFilename } from \"../../lib/utils\"\nimport { pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadStream(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress: ((info: ProgressInfo) => void) | undefined,\n getNextUrl: (() => Promise<string | null>) | undefined,\n signal: AbortSignal,\n logger?: Logger,\n label?: string,\n): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n const prefix = label ? ` (${label})` : \"\"\n logger?.info(`Recording${prefix}: ${filename}`)\n\n const startTime = Date.now()\n\n /** File size read directly from disk — authoritative, avoids in-memory counter drift. */\n let fileSize = 0\n let lastProgressTime = 0\n\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new TikTokError(\n \"ffmpeg-not-found\",\n \"FFmpeg not found — required for stream download. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const writer = createWriteStream(filepath)\n\n try {\n while (!signal.aborted) {\n const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : undefined\n await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining)\n\n // Read actual file size from disk — always authoritative\n try {\n fileSize = statSync(filepath).size\n } catch {\n // File may not be accessible; leave fileSize at last known value\n }\n\n // Report progress after each segment\n const now = Date.now()\n const e = elapsed()\n if (onProgress && now - lastProgressTime >= 1000) {\n lastProgressTime = now\n const speed = e > 0 ? fileSize / e : 0\n onProgress({ bytes: fileSize, elapsed: e, speed })\n }\n\n if (signal.aborted) break\n if (maxDuration > 0 && e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n if (!getNextUrl) break\n\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n break\n }\n liveUrl = nextUrl\n }\n } catch (err) {\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n } finally {\n await new Promise<void>((resolve) => writer.end(resolve))\n }\n\n const duration = elapsed()\n // Final authoritative size after flush\n try {\n fileSize = statSync(filepath).size\n } catch {\n // keep last known value\n }\n if (onProgress) {\n onProgress({\n bytes: fileSize,\n elapsed: duration,\n speed: duration > 0 ? fileSize / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(fileSize)})`,\n )\n\n return { file: filepath, duration, size: fileSize }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAoBA,eAAsB,eACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACA,OACyB;CACzB,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CACzC,MAAM,SAAS,QAAQ,KAAK,MAAM,KAAK;CACvC,QAAQ,KAAK,YAAY,OAAO,IAAI,UAAU;CAE9C,MAAM,YAAY,KAAK,IAAI;;CAG3B,IAAI,WAAW;CACf,IAAI,mBAAmB;CAEvB,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,2HAEF;CAGF,SAAS,UAAkB;EACzB,QAAQ,KAAK,IAAI,IAAI,aAAa;CACpC;CAEA,MAAM,SAAS,kBAAkB,QAAQ;CAEzC,IAAI;EACF,OAAO,CAAC,OAAO,SAAS;GACtB,MAAM,YAAY,cAAc,IAAI,KAAK,IAAI,GAAG,cAAc,QAAQ,CAAC,IAAI,KAAA;GAC3E,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,SAAS;GAGtE,IAAI;IACF,WAAW,SAAS,QAAQ,CAAC,CAAC;GAChC,QAAQ,CAER;GAGA,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,WAAW,IAAI;IACrC,WAAW;KAAE,OAAO;KAAU,SAAS;KAAG;IAAM,CAAC;GACnD;GAEA,IAAI,OAAO,SAAS;GACpB,IAAI,cAAc,KAAK,KAAK,aAAa;IACvC,QAAQ,KAAK,2BAA2B,YAAY,GAAG;IACvD;GACF;GACA,IAAI,CAAC,YAAY;GAEjB,MAAM,UAAU,MAAM,WAAW;GACjC,IAAI,CAAC,SAAS;IACZ,QAAQ,KAAK,wCAAwC;IACrD;GACF;GACA,UAAU;EACZ;CACF,SAAS,KAAK;EACZ,QAAQ,MAAM,oBAAoB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;CACtF,UAAU;EACR,MAAM,IAAI,SAAe,YAAY,OAAO,IAAI,OAAO,CAAC;CAC1D;CAEA,MAAM,WAAW,QAAQ;CAEzB,IAAI;EACF,WAAW,SAAS,QAAQ,CAAC,CAAC;CAChC,QAAQ,CAER;CACA,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,WAAW,WAAW;CAC9C,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,QAAQ,EAAE,EAC1F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAS;AACpD"}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { TikTokError } from "
|
|
2
|
-
import {
|
|
3
|
-
//#region src/recorder/ffmpeg-utils.ts
|
|
4
|
-
/**
|
|
5
|
-
* Shared FFmpeg utilities — stream-download pipe primitive and constants.
|
|
6
|
-
*/
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
//#region src/core/recorder/ffmpeg-utils.ts
|
|
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)] : [],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ffmpeg-utils.mjs","names":[],"sources":["../../../src/core/recorder/ffmpeg-utils.ts"],"sourcesContent":["/**\n * Shared FFmpeg utilities — stream-download pipe primitive and constants.\n */\n\nimport type { 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"}
|