@zfadhli/tokrec 0.11.3 → 0.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +141 -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/{recorder → core/recorder}/convert.mjs +3 -3
- 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 +2 -2
- 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 +17 -14
- 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 -143
- package/dist/{browser-cookies.mjs → lib/browser-cookies.mjs} +1 -1
- package/dist/lib/browser-cookies.mjs.map +1 -0
- package/dist/{cli.mjs → lib/cli.mjs} +2 -2
- 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} +1 -1
- 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/{ui.mjs → lib/ui.mjs} +32 -22
- package/dist/lib/ui.mjs.map +1 -0
- package/dist/{utils.mjs → lib/utils.mjs} +2 -2
- package/dist/lib/utils.mjs.map +1 -0
- package/dist/package.mjs +1 -1
- package/package.json +9 -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.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.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/dist/bin.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { TikTokError, validateConfig } 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 { createDisplay } from "./lib/ui.mjs";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { color } from "@zfadhli/koko-cli";
|
|
12
|
+
//#region src/bin.ts
|
|
13
|
+
/**
|
|
14
|
+
* Entry point — parses CLI args, creates the recorder, and handles signals.
|
|
15
|
+
* Uses the terminal Display manager for beautiful console output.
|
|
16
|
+
*/
|
|
17
|
+
async function loadCookies(cookiesPath) {
|
|
18
|
+
const path = cookiesPath ?? join(process.cwd(), "cookies.json");
|
|
19
|
+
if (!existsSync(path)) return;
|
|
20
|
+
try {
|
|
21
|
+
const content = readFileSync(path, "utf-8");
|
|
22
|
+
const data = JSON.parse(content);
|
|
23
|
+
if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
|
|
24
|
+
return;
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function main() {
|
|
30
|
+
let config;
|
|
31
|
+
try {
|
|
32
|
+
config = parseArgs();
|
|
33
|
+
validateConfig(config);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof TikTokError && err.kind === "config-error") {
|
|
36
|
+
console.error(`${color.red("Error:")} ${err.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
42
|
+
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
43
|
+
config.logConsole = false;
|
|
44
|
+
const display = createDisplay();
|
|
45
|
+
if (config.cookies) {
|
|
46
|
+
const source = firefoxCookies ? "Firefox" : "cookies.json";
|
|
47
|
+
const names = Object.keys(config.cookies);
|
|
48
|
+
const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
|
|
49
|
+
display.showInfo(`${source} cookies loaded${detail}`);
|
|
50
|
+
} else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
|
|
51
|
+
const recorder = createRecorder(config);
|
|
52
|
+
let lastWasLive = null;
|
|
53
|
+
let firstOfflineTime = 0;
|
|
54
|
+
recorder.on("checking", (info) => {
|
|
55
|
+
display.checkingUser(info.user);
|
|
56
|
+
});
|
|
57
|
+
recorder.on("tick", (info) => {
|
|
58
|
+
if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
|
|
59
|
+
else if (lastWasLive === null || lastWasLive === true) {
|
|
60
|
+
display.userOffline(info.user);
|
|
61
|
+
firstOfflineTime = Date.now();
|
|
62
|
+
} else {
|
|
63
|
+
const elapsed = Date.now() - firstOfflineTime;
|
|
64
|
+
display.userOfflineRepeat(info.user, relativeTime(elapsed));
|
|
65
|
+
}
|
|
66
|
+
lastWasLive = info.isLive;
|
|
67
|
+
});
|
|
68
|
+
recorder.on("recording:start", () => {
|
|
69
|
+
display.startRecording();
|
|
70
|
+
});
|
|
71
|
+
recorder.on("download:end", (info) => {
|
|
72
|
+
const parsed = info.file.split("/").pop() ?? info.file;
|
|
73
|
+
const sizeStr = bytesToHuman(info.size);
|
|
74
|
+
display.finishRecording(parsed, info.duration, sizeStr);
|
|
75
|
+
});
|
|
76
|
+
recorder.on("segmenting:start", () => {
|
|
77
|
+
display.startSegmenting();
|
|
78
|
+
});
|
|
79
|
+
recorder.on("segmenting:end", (info) => {
|
|
80
|
+
display.segmentsCreated(info.segments);
|
|
81
|
+
});
|
|
82
|
+
recorder.on("converting:start", () => {
|
|
83
|
+
display.startConverting();
|
|
84
|
+
});
|
|
85
|
+
recorder.on("converted", (info) => {
|
|
86
|
+
const parsed = info.output.split("/").pop() ?? info.output;
|
|
87
|
+
display.conversionDone(parsed);
|
|
88
|
+
});
|
|
89
|
+
recorder.on("recording:end", () => {});
|
|
90
|
+
recorder.on("error", (err) => {
|
|
91
|
+
display.showError(`[${err.kind}] ${err.message}`);
|
|
92
|
+
});
|
|
93
|
+
recorder.on("normalize:start", () => {
|
|
94
|
+
display.normalizeStart();
|
|
95
|
+
});
|
|
96
|
+
recorder.on("normalize:progress", (info) => {
|
|
97
|
+
display.normalizeProgress(info.percent, info.phase);
|
|
98
|
+
});
|
|
99
|
+
recorder.on("normalize:end", () => {
|
|
100
|
+
display.normalizeComplete();
|
|
101
|
+
});
|
|
102
|
+
recorder.on("normalize:error", (info) => {
|
|
103
|
+
display.normalizeError(info.error);
|
|
104
|
+
});
|
|
105
|
+
let stopping = false;
|
|
106
|
+
const handleSignal = async () => {
|
|
107
|
+
if (stopping) return;
|
|
108
|
+
stopping = true;
|
|
109
|
+
process.off("SIGINT", handleSignal);
|
|
110
|
+
process.off("SIGTERM", handleSignal);
|
|
111
|
+
process.off("SIGHUP", handleSignal);
|
|
112
|
+
display.showInfo("Shutting down gracefully...");
|
|
113
|
+
await recorder.stop();
|
|
114
|
+
display.stop();
|
|
115
|
+
process.exit(0);
|
|
116
|
+
};
|
|
117
|
+
process.on("SIGINT", handleSignal);
|
|
118
|
+
process.on("SIGTERM", handleSignal);
|
|
119
|
+
process.on("SIGHUP", handleSignal);
|
|
120
|
+
display.pollingStarted(config.interval ?? 3);
|
|
121
|
+
try {
|
|
122
|
+
await recorder.start();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
|
|
125
|
+
else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
process.off("SIGINT", handleSignal);
|
|
127
|
+
process.off("SIGTERM", handleSignal);
|
|
128
|
+
process.off("SIGHUP", handleSignal);
|
|
129
|
+
await recorder.stop();
|
|
130
|
+
display.stop();
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
main().catch((err) => {
|
|
135
|
+
console.error("Fatal:", err instanceof Error ? err.message : String(err));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
});
|
|
138
|
+
//#endregion
|
|
139
|
+
export {};
|
|
140
|
+
|
|
141
|
+
//# sourceMappingURL=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 } 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,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":";AAiCA,MAAM,eAAe,CAAC,0BAA0B,sBAAsB;AACtE,MAAM,eAAe;AAErB,SAAgB,gBAAgB,MAAkB,QAAiB,WAAgC;CAEjG,MAAM,SAAS,QAAgB;EAC7B,QAAQ,MAAM,GAAG;EACjB,IAAI,WAAW,QAAQ,OAAO,MAAM,eAAe,IAAI,GAAG;CAC5D;CACA,IAAI,SAA0B;CAC9B,IAAI,aAAa;CAEjB,eAAe,YAAY,MAAwC;EACjE,IAAI,UAAU,eAAe,MAAM,OAAO;EAC1C,SAAS,MAAM,cAAc,IAAI;EACjC,aAAa;EACb,OAAO;CACT;CAEA,SAAS,kBAAwB;EAC/B,SAAS;EACT,aAAa;CACf;CAEA,OAAO;EACL;EAEA,MAAM,UAAU,MAAsC;GAEpD,QAAO,MADY,YAAY,IAAI,EAAA,EACtB,UAAU;EACzB;EAEA,MAAM,YAAY,QAAkC;GAClD,IAAI,UAAU,OAAO,OAAO,MAAM,MAAM,OAAO,MAAM,GACnD,OAAO,OAAO;GAIhB,OAAO,gBAAgB,MAAM;EAC/B;EAEA,MAAM,WAAW,QAAwC;GAEvD,QAAO,MADY,YAAY,cAAc,MAAM,EAAA,EACtC,aAAa;EAC5B;CACF;;;;;;;CAUA,eAAe,aACb,MACA,MACmD;EACnD,KAAK,MAAM,QAAQ,cAAc;GAC/B,MAAM,MAAM,GAAG,KAAK,IAAI,OAAO;GAC/B,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,iBAAiB,IAAI,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC/D,IAAI,CAAC,IAAI,IAAI;GAEb,MAAM,OAAO,MAAM,IAAI,KAAK;GAE5B,IAAI,KAAK,SAAS,OAAQ,KAAK,SAAS,YAAY,GAAG;IACrD,MACE,iBAAiB,IAAI,gBAAgB,KAAK,OAAO,kBAAkB,KAAK,SAAS,YAAY,EAAE,EACjG;IACA;GACF;GAEA,MAAM,iBAAiB,IAAI,cAAc,KAAK,OAAO,QAAQ;GAC7D,OAAO;IAAE;IAAM,SAAS;GAAK;EAC/B;EACA,OAAO;CACT;CAEA,eAAe,cAAc,MAAwC;EACnE,IAAI,OAAyB;EAC7B,IAAI,OAAO;EAEX,IAAI;GACF,MAAM,OAAO,MAAM,aAAa,MAAM,OAAO;GAC7C,IAAI,CAAC,MAAM;IACT,MAAM,mDAAmD;IAEzD,OAAO;GACT,OAAO;IACL,OAAO,KAAK;IACZ,MACE,iCAAiC,KAAK,OAAO,UAAU,KAAK,QAAQ,gBAAgB,KAAK,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,OAAO,KAAK,GAC7H;IAEA,OAAO,iBAAiB,IAAI;IAC5B,MAAM,6BAA6B,OAAO,UAAU,aAAa;IAEjE,IAAI,MAAM;KACR,MAAM,WAAW,KAAK,UAAU,kBAAkB;KAClD,MAAM,WAAW,KAAK,UAAU,kBAAkB;KAElD,MACE,2BAA2B,CAAC,CAAC,SAAS,aAAa,CAAC,CAAC,SAAS,oBAAoB,UAAU,UAAU,QACxG;KAGA,IAAI,YAAY,UAAU,QAAQ;MAChC,MAAM,SAAS,OAAO,SAAS,MAAM;MACrC,MAAM,SAAS,SAAS,WAAW;MACnC,MACE,wCAAwC,OAAO,WAAW,SAAS,OAAO,WAAW,QACvF;MAGA,IAAI,YAA2B;MAC/B,IAAI,QAAQ;OACV,YAAY,qBAAqB,QAAQ;OACzC,IAAI,CAAC,WAAW,YAAY,yBAAyB,IAAI;OACzD,IAAI,CAAC,WAAW,YAAY,MAAM,sBAAsB,MAAM;MAChE;MAEA,OAAO;OAAE;OAAQ;OAAQ;OAAW,OAAO,SAAS,SAAS;MAAK;KACpE;KAKA,IAAI,UAAU,QAAQ;MACpB,MAAM,SAAS,OAAO,SAAS,MAAM;MACrC,MAAM,6CAA6C,OAAO,uBAAuB;MACjF,MAAM,WAAW,MAAM,yBAAyB,MAAM;MACtD,MAAM,8BAA8B,WAAW,uBAAuB,iBAAiB;MACvF,IAAI,UAAU,OAAO;KACvB;KAGA,MAAM,oDAAoD;KAC1D,MAAM,SAAS,MAAM,sBAAsB,MAAM,IAAI;KACrD,MAAM,kCAAkC,SAAS,cAAc,iBAAiB;KAChF,IAAI,QAAQ,OAAO;IACrB;GACF;EACF,SAAS,MAAM;GAEb,MAAM,iCAAiC,gBAAgB,QAAQ,KAAK,UAAU,OAAO,IAAI,GAAG;GAC5F,OAAO;EACT;EAKA,IAAI,CAAC,QAAQ,KAAK,SAAS,KAAM;GAC/B,MAAM,yDAAyD;GAC/D,MAAM,YAAY,MAAM,qBAAqB,IAAI;GACjD,IAAI,WAAW,OAAO;EACxB;EAKA,MAAM,8DAA8D,KAAK,OAAO,EAAE;EAClF,MAAM,EAAE,WAAW,6BAA6B,IAAI;EACpD,MAAM,wCAAwC,UAAU,QAAQ;EAChE,IAAI,QAAQ;GAEV,MAAM,SAAS,MAAM,yBAAyB,MAAM;GACpD,MAAM,6CAA6C,SAAS,cAAc,iBAAiB;GAC3F,IAAI,QAAQ,OAAO;EACrB;EAKA,MAAM,6CAA6C;EACnD,MAAM,gBAAgB,MAAM,yBAAyB,IAAI;EACzD,MAAM,+BAA+B,gBAAgB,cAAc,iBAAiB;EACpF,OAAO;CACT;;;;;;;;CASA,eAAe,yBAAyB,MAAwC;EAC9E,IAAI;GACF,MAAM,OAAO,MAAM,aAAa,MAAM,EAAE;GACxC,IAAI,CAAC,MAAM;IACT,MAAM,gEAAgE;IACtE,OAAO;GACT;GACA,MACE,sCAAsC,KAAK,KAAK,OAAO,UAAU,KAAK,QAAQ,gBAAgB,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC,CAAC,QAAQ,OAAO,KAAK,GAC5I;GACA,MAAM,EAAE,QAAQ,WAAW,6BAA6B,KAAK,IAAI;GACjE,IAAI,CAAC,QAAQ,OAAO;GAIpB,IAAI,QACF,OAAO,yBAAyB,MAAM;GAIxC,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;;;;;;;;;CAUA,eAAe,qBAAqB,MAAwC;EAC1E,IAAI;GACF,MAAM,MAAM,gEAAgE,mBAAmB,IAAI,EAAE;GACrG,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,yBAAyB,IAAI,UAAU,IAAI,QAAQ;GACzD,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,kCAAkC,KAAK,OAAO,OAAO;GAC3D,IAAI,KAAK,WAAW,GAAG,OAAO;GAG9B,MAAM,YADO,KAAK,MAAM,IACH,CAAC,EAAE,KAAA,EAA8C;GAItE,IAAI,CAAC,UAAU,QAAQ;IACrB,MAAM,iDAAiD;IACvD,OAAO;GACT;GAEA,MAAM,SAAS,OAAO,SAAS,MAAM;GAErC,MAAM,SAAS,SAAS,WAAW;GACnC,MAAM,gCAAgC,OAAO,WAAW,SAAS,OAAO,WAAW,QAAQ;GAE3F,IAAI,CAAC,QACH,OAAO;IAAE;IAAQ,QAAQ;IAAO,WAAW;IAAM,OAAO;GAAK;GAK/D,OAAO;IAAE;IAAQ,QAAQ;IAAM,WAAA,MADP,sBAAsB,MAAM;IACV,OAAO;GAAK;EACxD,QAAQ;GACN,OAAO;EACT;CACF;;;;;;CAOA,eAAe,iBACb,QACA,SACmB;EACnB,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,uCAAuC,OAAO;GAC1E,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,4BAA4B,OAAO,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC7E,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,4BAA4B,OAAO,YAAY,KAAK,OAAO,OAAO;GACxE,IAAI,KAAK,WAAW,GAAG,OAAO;GAE9B,MAAM,OAAO,KAAK,MAAM,IAAI;GAG5B,IAAK,KAAmC,gBAAgB,SAAS,OAAO;GAExE,OAAO,QAAQ,IAAI;EACrB,QAAQ;GACN,OAAO;EACT;CACF;;;;;;;;;;;CAYA,eAAe,gBAAgB,QAAkC;EAC/D,IAAI;GACF,MAAM,MAAM,GAAG,aAAa,yDAAyD,OAAO;GAC5F,MAAM,MAAM,MAAM,KAAK,IAAI,GAAG;GAC9B,MAAM,2BAA2B,OAAO,UAAU,IAAI,OAAO,OAAO,IAAI,IAAI;GAC5E,IAAI,CAAC,IAAI,IAAI,OAAO;GAEpB,MAAM,OAAO,MAAM,IAAI,KAAK;GAC5B,MAAM,2BAA2B,OAAO,YAAY,KAAK,OAAO,OAAO;GACvE,IAAI,KAAK,WAAW,GAAG,OAAO;GAI9B,MAAM,SAFO,KAAK,MAAM,IACP,CAAC,EAAE,KAAA,GACE,EAAE,EAAE,UAAU;GACpC,MAAM,2BAA2B,OAAO,SAAS,OAAO;GACxD,OAAO;EACT,SAAS,KAAK;GACZ,MACE,2BAA2B,OAAO,WAAW,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAC9F;GACA,OAAO;EACT;CACF;;;;CAKA,eAAe,yBAAyB,QAA0C;EAChF,OAAO,iBAAiB,SAAS,SAAS;GACxC,MAAM,WAAY,KAA4C;GAC9D,IAAI,CAAC,UAAU,OAAO;GAGtB,MAAM,UADS,SAAS,UAAU,OACR;GAG1B,OAAO;IAAE;IAAQ;IAAQ,WAFP,SAAS,yBAAyB,QAAQ,IAAI;IAE5B,OAAQ,SAAS,SAAgC;GAAK;EAC5F,CAAC;CACH;;;;;;;CAQA,eAAe,sBAAsB,MAAiB,MAAwC;EAE5F,MAAM,SADiB,KAAK,UAAU,mBACJ;EAGlC,MAAM,aAAa,KAAK,YAAY,QAAQ;EAC5C,IAAI,SAAwB;EAE5B,IAAI,YAAY,QACd,SACE,OAAO,WAAW,WAAW,WACzB,OAAQ,WAAW,OAA0B,MAAM,EAAE,IACrD,OAAO,WAAW,MAAM;EAIhC,IAAI,QAAQ;GACV,MAAM,YAAY,SAAS,MAAM,sBAAsB,MAAM,IAAI;GACjE,OAAO;IAAE;IAAQ;IAAQ;IAAW,OAAO;GAAK;EAClD;EAGA,OAAO;CACT;;;;;CAMA,eAAe,sBAAsB,QAAwC;EAC3E,OAAO,iBAAiB,SAAS,SAAS,yBAAyB,IAAI,CAAC;CAC1E;AACF;;;;AA+CA,SAAgB,iBAAiB,MAAgC;CAC/D,MAAM,QAAQ,KAAK,MAAM,sEAAsE;CAC/F,IAAI,CAAC,QAAQ,IAAI,OAAO;CACxB,IAAI;EACF,OAAO,KAAK,MAAM,MAAM,EAAE;CAC5B,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;AAeA,SAAgB,6BAA6B,MAG3C;CACA,MAAM,QAAQ,KAAK,MACjB,8FACF;CACA,IAAI,CAAC,QAAQ,IAAI,OAAO;EAAE,QAAQ;EAAM,QAAQ;CAAK;CACrD,IAAI;EACF,MAAM,OAAO,KAAK,MAAM,MAAM,EAAE;EAGhC,MAAM,SAFS,KAAiC,qBAAqB,KAAA,GAC1C,sBAAA,EACF,UAAU;EAGnC,OAAO;GAAE,QAFM,MAAM,KAAK,OAAO,KAAK,EAAE,IAAI;GAE3B,QADF,MAAM,SAAS,OAAO,KAAK,MAAM,IAAI;EAC5B;CAC1B,QAAQ;EACN,OAAO;GAAE,QAAQ;GAAM,QAAQ;EAAK;CACtC;AACF;;;;;;;AAUA,SAAS,qBACP,UACe;CACf,IAAI;EACF,MAAM,MAAM,UAAU,YAAY,WAAW;EAC7C,IAAI,CAAC,KAAK,OAAO;EAEjB,MAAM,OADS,KAAK,MAAM,GACP,CAAC,EAA8B;EAClD,IAAI,CAAC,MAAM,OAAO;EAKlB,KAAK,MAAM,KAAK;GADG;GAAU;GAAO;GAAO;GAAM;GAAM;EAC/B,GAAG;GACzB,MAAM,QAAQ,KAAK;GAEnB,MAAM,MAAM,OAAO,MAAM,OAAO,OAAO,MAAM;GAC7C,IAAI,KAAK,OAAO;EAClB;EACA,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAgB,yBAAyB,KAA6B;CAEpE,MAAM,UAAoB,CAAC;CAC3B,MAAM,WAAqB,CAAC;CAE5B,SAAS,KAAK,OAAsB;EAClC,IAAI,OAAO,UAAU,UAAU;GAE7B,KACG,MAAM,WAAW,SAAS,KAAK,MAAM,WAAW,UAAU,OAC1D,MAAM,SAAS,MAAM,KAAK,MAAM,SAAS,OAAO,IACjD;IACA,IAAI,MAAM,SAAS,MAAM,GACvB,QAAQ,KAAK,KAAK;SAElB,SAAS,KAAK,KAAK;IAErB;GACF;GAEA,IAAI,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,GAAG,GAC/C,IAAI;IACF,KAAK,KAAK,MAAM,KAAK,CAAC;GACxB,QAAQ,CAER;GAEF;EACF;EAEA,IAAI,SAAS,OAAO,UAAU,UAC5B,IAAI,MAAM,QAAQ,KAAK,GACrB,KAAK,MAAM,QAAQ,OACjB,KAAK,IAAI;OAGX,KAAK,MAAM,OAAO,OAAO,OAAO,KAAgC,GAC9D,KAAK,GAAG;CAIhB;CAEA,KAAK,GAAG;CAGR,IAAI,QAAQ,SAAS,GAAG,OAAO,QAAQ;CACvC,IAAI,SAAS,SAAS,GAAG,OAAO,SAAS;CACzC,OAAO;AACT"}
|
|
@@ -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"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { TikTokError } from "
|
|
2
|
-
import { findFfmpegPath } from "
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
|
+
import { findFfmpegPath } from "../../lib/utils.mjs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { unlinkSync } from "node:fs";
|
|
5
|
-
//#region src/recorder/convert.ts
|
|
5
|
+
//#region src/core/recorder/convert.ts
|
|
6
6
|
/**
|
|
7
7
|
* Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).
|
|
8
8
|
* Deletes the original file on success.
|
|
@@ -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 { spawn } from \"node:child_process\"\nimport { unlinkSync } from \"node:fs\"\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\nfunction runFfmpeg(\n ffmpegPath: string,\n input: string,\n output: string,\n signal?: AbortSignal,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const args = [\n \"-i\",\n input,\n \"-c\",\n \"copy\", // stream copy — no re-encode, fast\n \"-y\", // overwrite output\n output,\n ]\n\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n signal,\n })\n\n let stderr = \"\"\n\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(\"aborted\", \"Aborted by user\"))\n return\n }\n if (code === 0) {\n resolve()\n } else {\n reject(\n new TikTokError(\"ffmpeg-error\", `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(\"ffmpeg-error\", `Failed to spawn FFmpeg: ${err.message}`))\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,SAAS,UACP,YACA,OACA,QACA,QACe;CACf,OAAO,IAAI,SAAS,SAAS,WAAW;EAUtC,MAAM,OAAO,MAAM,YAAY;GAR7B;GACA;GACA;GACA;GACA;GACA;EAGgC,GAAG;GACnC,OAAO;IAAC;IAAU;IAAQ;GAAM;GAChC;EACF,CAAC;EAED,IAAI,SAAS;EAEb,KAAK,QAAQ,GAAG,SAAS,UAAkB;GACzC,UAAU,MAAM,SAAS;GACzB,IAAI,OAAO,SAAS,KAAO,SAAS,OAAO,MAAM,IAAK;EACxD,CAAC;EAED,KAAK,GAAG,UAAU,SAAS;GACzB,IAAI,QAAQ,SAAS;IACnB,OAAO,IAAI,YAAY,WAAW,iBAAiB,CAAC;IACpD;GACF;GACA,IAAI,SAAS,GACX,QAAQ;QAER,OACE,IAAI,YAAY,gBAAgB,2BAA2B,KAAK,IAAI,OAAO,MAAM,IAAI,GAAG,CAC1F;EAEJ,CAAC;EAED,KAAK,GAAG,UAAU,QAAQ;GACxB,IAAI,eAAe,SAAS,IAAI,SAAS,cAAc;GACvD,OAAO,IAAI,YAAY,gBAAgB,2BAA2B,IAAI,SAAS,CAAC;EAClF,CAAC;CACH,CAAC;AACH"}
|
|
@@ -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,6 +1,6 @@
|
|
|
1
|
-
import { TikTokError } from "
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
//#region src/recorder/ffmpeg-utils.ts
|
|
3
|
+
//#region src/core/recorder/ffmpeg-utils.ts
|
|
4
4
|
/**
|
|
5
5
|
* Shared FFmpeg utilities — stream-download pipe primitive and constants.
|
|
6
6
|
*/
|
|
@@ -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 { spawn } from \"node:child_process\"\nimport type { WriteStream } from \"node:fs\"\nimport { TikTokError } from \"../../lib/config\"\n\n/** Kill FFmpeg if no output is received within this window after spawn. */\nexport const FFMPEG_STARTUP_TIMEOUT = 30_000\n\n/** 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 = spawn(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"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { RecorderConfig, RecorderController } from "../../lib/config.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/core/recorder/index.d.ts
|
|
4
|
+
declare function createRecorder(config: RecorderConfig): RecorderController;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { createRecorder };
|
|
7
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/core/recorder/index.ts"],"mappings":";;;iBAqBgB,cAAA,CAAe,MAAA,EAAQ,cAAA,GAAiB,kBAAA"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { TikTokError, normalizeConfig } from "../../lib/config.mjs";
|
|
2
|
+
import { createLogger } from "../../lib/logger.mjs";
|
|
3
|
+
import { findFfmpegPath, sleep } from "../../lib/utils.mjs";
|
|
1
4
|
import { createHttpClient } from "../api/client.mjs";
|
|
2
5
|
import { createTikTokApi } from "../api/tiktok.mjs";
|
|
3
|
-
import { TikTokError, normalizeConfig } from "../config.mjs";
|
|
4
|
-
import { createLogger } from "../logger.mjs";
|
|
5
|
-
import { findFfmpegPath, sleep } from "../utils.mjs";
|
|
6
6
|
import { createPollingMonitor } from "../monitor.mjs";
|
|
7
7
|
import { createConverter } from "./convert.mjs";
|
|
8
8
|
import { createAudioNormalizer } from "./normalize.mjs";
|
|
@@ -11,7 +11,7 @@ import { createEventEmitter } from "./recorder-events.mjs";
|
|
|
11
11
|
import { createRecorderState } from "./recorder-state.mjs";
|
|
12
12
|
import { createStreamDownloader } from "./stream.mjs";
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
|
-
//#region src/recorder/index.ts
|
|
14
|
+
//#region src/core/recorder/index.ts
|
|
15
15
|
/**
|
|
16
16
|
* Recorder orchestrator — wires together the API, monitor, stream downloader,
|
|
17
17
|
* and converter into a single RecorderController.
|
|
@@ -108,11 +108,14 @@ function createRecorder(config) {
|
|
|
108
108
|
logger,
|
|
109
109
|
onTick: async () => {
|
|
110
110
|
if (stopRequested) return;
|
|
111
|
+
const tikTokApi = api;
|
|
112
|
+
const streamDownloader = downloader;
|
|
113
|
+
const pollingMonitor = monitor;
|
|
111
114
|
const user = cfg.user;
|
|
112
|
-
|
|
115
|
+
tikTokApi.invalidateCache();
|
|
113
116
|
emitter.emit("checking", { user });
|
|
114
117
|
logger.info(`Checking @${user}...`);
|
|
115
|
-
const roomId = await
|
|
118
|
+
const roomId = await tikTokApi.getRoomId(user);
|
|
116
119
|
if (!roomId) {
|
|
117
120
|
logger.info(`@${user} is offline`);
|
|
118
121
|
emitter.emit("tick", {
|
|
@@ -121,7 +124,7 @@ function createRecorder(config) {
|
|
|
121
124
|
});
|
|
122
125
|
return;
|
|
123
126
|
}
|
|
124
|
-
const liveUrl = await
|
|
127
|
+
const liveUrl = await tikTokApi.getLiveUrl(roomId);
|
|
125
128
|
if (!liveUrl) {
|
|
126
129
|
logger.warn(`@${user} is live but no stream URL found`);
|
|
127
130
|
emitter.emit("tick", {
|
|
@@ -143,14 +146,14 @@ function createRecorder(config) {
|
|
|
143
146
|
file: ""
|
|
144
147
|
});
|
|
145
148
|
const getNextUrl = async () => {
|
|
146
|
-
if (!await
|
|
149
|
+
if (!await tikTokApi.isRoomAlive(roomId)) {
|
|
147
150
|
logger.info(`@${user} is no longer live — stopping`);
|
|
148
151
|
return null;
|
|
149
152
|
}
|
|
150
|
-
|
|
151
|
-
return
|
|
153
|
+
tikTokApi.invalidateCache();
|
|
154
|
+
return tikTokApi.getLiveUrl(roomId);
|
|
152
155
|
};
|
|
153
|
-
const result = await
|
|
156
|
+
const result = await streamDownloader.download(liveUrl, user, cfg.outputDir, cfg.duration, (info) => emitter.emit("download:progress", info), getNextUrl);
|
|
154
157
|
emitter.emit("download:end", {
|
|
155
158
|
file: result.file,
|
|
156
159
|
duration: result.duration,
|
|
@@ -162,16 +165,16 @@ function createRecorder(config) {
|
|
|
162
165
|
audioNormalizer,
|
|
163
166
|
stopAbortController,
|
|
164
167
|
pendingRemuxes,
|
|
165
|
-
emit:
|
|
168
|
+
emit: emitter.emit.bind(emitter),
|
|
166
169
|
setState: stateManager.setState,
|
|
167
170
|
stopRequested: () => stopRequested,
|
|
168
171
|
logger
|
|
169
172
|
});
|
|
170
173
|
if (cfg.duration > 0) {
|
|
171
174
|
logger.info("Duration limit reached — exiting");
|
|
172
|
-
|
|
175
|
+
pollingMonitor.stopAfterCurrentTick();
|
|
173
176
|
} else if (!stopRequested && roomId) {
|
|
174
|
-
if (!await
|
|
177
|
+
if (!await tikTokApi.isRoomAlive(roomId)) {
|
|
175
178
|
logger.info(`@${user} is offline`);
|
|
176
179
|
emitter.emit("tick", {
|
|
177
180
|
user,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../../src/core/recorder/index.ts"],"sourcesContent":["/**\n * Recorder orchestrator — wires together the API, monitor, stream downloader,\n * and converter into a single RecorderController.\n */\n\nimport { 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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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.mjs","names":[],"sources":["../../../src/core/recorder/normalize.ts"],"sourcesContent":["/**\n * Audio normalizer — wraps peaknorm's EBU R128 loudness normalization.\n * Runs after conversion/segmenting to normalize the final MP4 audio.\n */\n\nimport type { NormalizeOptions, NormalizeResult } from \"peaknorm\"\nimport { normalizeFile } from \"peaknorm\"\nimport type { Logger } from \"../../lib/logger\"\n\nexport interface AudioNormalizer {\n /** Normalize a single audio/video file. Returns the normalize result. */\n normalize(file: string): Promise<NormalizeResult>\n}\n\nexport interface AudioNormalizerOptions {\n /** Target loudness in LUFS (default: -14). */\n loudness: number\n /** Audio codec for output (default: peaknorm's default, \"libopus\"). */\n audioCodec?: string\n /** Audio bitrate for output (default: peaknorm's default, \"96k\"). */\n audioBitrate?: string\n /** Called when a file starts normalization. */\n onStart?: (file: string) => void\n /** Called with progress updates (percent 0-100, phase \"analyzing\"|\"normalizing\"). */\n onProgress?: (file: string, percent: number, phase: string) => void\n /** Called when a file completes normalization successfully. */\n onComplete?: (result: NormalizeResult) => void\n /** Called when a file errors during normalization. */\n onError?: (file: string, error: Error) => void\n}\n\nexport function createAudioNormalizer(\n opts: AudioNormalizerOptions,\n logger?: Logger,\n): AudioNormalizer {\n return {\n async normalize(file: string): Promise<NormalizeResult> {\n logger?.info(`Normalizing audio: ${file}`)\n opts.onStart?.(file)\n\n try {\n const normalizeOpts: NormalizeOptions = {\n loudness: opts.loudness,\n backup: false,\n onFileProgress: (f, percent, phase) => {\n opts.onProgress?.(f, percent, phase)\n },\n }\n if (opts.audioCodec !== undefined) normalizeOpts.audioCodec = opts.audioCodec\n if (opts.audioBitrate !== undefined) normalizeOpts.audioBitrate = opts.audioBitrate\n const result = await normalizeFile(file, normalizeOpts)\n\n logger?.info(`Audio normalized: ${result.output}`)\n opts.onComplete?.(result)\n return result\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err))\n logger?.error(`Audio normalization failed: ${error.message}`)\n opts.onError?.(file, error)\n throw error\n }\n },\n }\n}\n"],"mappings":";;AA+BA,SAAgB,sBACd,MACA,QACiB;CACjB,OAAO,EACL,MAAM,UAAU,MAAwC;EACtD,QAAQ,KAAK,sBAAsB,MAAM;EACzC,KAAK,UAAU,IAAI;EAEnB,IAAI;GACF,MAAM,gBAAkC;IACtC,UAAU,KAAK;IACf,QAAQ;IACR,iBAAiB,GAAG,SAAS,UAAU;KACrC,KAAK,aAAa,GAAG,SAAS,KAAK;IACrC;GACF;GACA,IAAI,KAAK,eAAe,KAAA,GAAW,cAAc,aAAa,KAAK;GACnE,IAAI,KAAK,iBAAiB,KAAA,GAAW,cAAc,eAAe,KAAK;GACvE,MAAM,SAAS,MAAM,cAAc,MAAM,aAAa;GAEtD,QAAQ,KAAK,qBAAqB,OAAO,QAAQ;GACjD,KAAK,aAAa,MAAM;GACxB,OAAO;EACT,SAAS,KAAK;GACZ,MAAM,QAAQ,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;GAChE,QAAQ,MAAM,+BAA+B,MAAM,SAAS;GAC5D,KAAK,UAAU,MAAM,KAAK;GAC1B,MAAM;EACR;CACF,EACF;AACF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { TikTokError } from "
|
|
1
|
+
import { TikTokError } from "../../lib/config.mjs";
|
|
2
2
|
import { readdirSync, statSync, unlinkSync, utimesSync } from "node:fs";
|
|
3
3
|
import { join, parse } from "node:path";
|
|
4
|
-
//#region src/recorder/post-processing.ts
|
|
4
|
+
//#region src/core/recorder/post-processing.ts
|
|
5
5
|
/**
|
|
6
6
|
* Post-recording processing — segmenting, conversion, and audio normalization.
|
|
7
7
|
*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"post-processing.mjs","names":[],"sources":["../../../src/core/recorder/post-processing.ts"],"sourcesContent":["/**\n * Post-recording processing — segmenting, conversion, and audio normalization.\n *\n * After a stream download completes, this module handles:\n * - Splitting the downloaded file into timed MP4 segments\n * - Converting TS to MP4 (fallback or direct)\n * - Audio normalization via EBU R128\n * - File management (deleting originals, setting timestamps)\n * - Event emissions for each step\n */\n\nimport { readdirSync, statSync, unlinkSync, utimesSync } from \"node:fs\"\nimport { join, parse } from \"node:path\"\nimport type { RecorderConfig } from \"../../lib/config\"\nimport { TikTokError } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\nimport type { Converter } from \"./convert\"\nimport type { AudioNormalizer } from \"./normalize\"\nimport type { DownloadResult } from \"./stream\"\n\nexport interface ProcessingDeps {\n runFfmpeg: (args: string[], signal?: AbortSignal) => Promise<void>\n converter: Converter\n audioNormalizer: AudioNormalizer | null\n stopAbortController: AbortController\n pendingRemuxes: Promise<unknown>[]\n emit: (event: string, ...args: unknown[]) => void\n setState: (partial: Record<string, unknown>) => void\n stopRequested: () => boolean\n logger: Logger\n}\n\nexport async function processRecording(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n try {\n if (statSync(result.file).size === 0) return\n } catch {\n return // file doesn't exist or can't be accessed\n }\n\n deps.setState({ state: \"converting\" })\n\n if (cfg.segmentMinutes && cfg.segmentMinutes >= 1) {\n await processSegmenting(result, cfg, deps)\n } else {\n await processConversion(result, cfg, deps)\n }\n\n deps.setState({ state: \"polling\" })\n}\n\n// ─── Segmenting path ──────────────────────────────────────────────\n\nasync function processSegmenting(\n result: DownloadResult,\n cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n const parsed = parse(result.file)\n const segmentTime = Math.max(1, (cfg.segmentMinutes ?? 20) * 60)\n const outputPattern = join(parsed.dir, `${parsed.name}_part%d.mp4`)\n\n deps.logger.info(\n `Segmenting: ${parsed.base} → ${parsed.name}_partN.mp4 (${cfg.segmentMinutes ?? 20} min each)`,\n )\n deps.emit(\"segmenting:start\", { input: result.file, outputPattern })\n\n try {\n const segmentPromise = deps.runFfmpeg(\n [\n \"-i\",\n result.file,\n \"-c\",\n \"copy\",\n \"-f\",\n \"segment\",\n \"-segment_time\",\n String(segmentTime),\n \"-reset_timestamps\",\n \"1\",\n \"-segment_start_number\",\n \"1\",\n outputPattern,\n ],\n deps.stopAbortController.signal,\n )\n deps.pendingRemuxes.push(segmentPromise)\n await segmentPromise\n\n // Capture file timestamp before deletion\n const fileMtimeMs = statSync(result.file).mtimeMs\n const recStartMs = fileMtimeMs - (result.duration ?? 0) * 1000\n\n // Delete the original TS\n try {\n unlinkSync(result.file)\n deps.logger.info(`Deleted original: ${parsed.base}`)\n } catch {\n deps.logger.warn(`Could not delete original: ${parsed.base}`)\n }\n\n // Find generated segment files\n const allFiles = readdirSync(parsed.dir)\n const prefix = `${parsed.name}_part`\n const segmentFiles = allFiles\n .filter((f) => f.startsWith(prefix) && f.endsWith(\".mp4\"))\n .sort((a, b) => {\n const na = Number.parseInt(a.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n const nb = Number.parseInt(b.match(/_part(\\d+)\\.mp4$/)?.[1] ?? \"0\", 10)\n return na - nb\n })\n\n const segmentDurationSec = segmentTime\n const totalDuration = result.duration\n\n // Emit events for each segment\n for (let i = 0; i < segmentFiles.length; i++) {\n const filePath = join(parsed.dir, segmentFiles[i] as string)\n const stats = statSync(filePath)\n const isLast = i === segmentFiles.length - 1\n const estDuration = isLast\n ? Math.max(0, totalDuration - segmentDurationSec * (segmentFiles.length - 1))\n : segmentDurationSec\n\n deps.emit(\"recording:end\", { file: filePath, duration: estDuration, size: stats.size })\n deps.emit(\"converted\", { input: result.file, output: filePath })\n }\n\n // Set file modification times so segments sort chronologically\n for (let i = 0; i < segmentFiles.length; i++) {\n const fp = join(parsed.dir, segmentFiles[i] as string)\n const segStart = recStartMs + i * segmentDurationSec * 1000\n utimesSync(fp, new Date(segStart), new Date(segStart))\n }\n\n deps.emit(\"segmenting:end\", { segments: segmentFiles.length })\n\n // Normalize audio for each segment if enabled\n if (deps.audioNormalizer) {\n const normalizer = deps.audioNormalizer\n const normalizePromises = segmentFiles.map(async (segFile) => {\n const segPath = join(parsed.dir, segFile)\n try {\n await normalizer.normalize(segPath)\n } catch {\n // Error already emitted via onError callback\n }\n })\n deps.pendingRemuxes.push(...normalizePromises)\n await Promise.allSettled(normalizePromises)\n }\n\n deps.setState({\n currentFile:\n segmentFiles.length > 0 ? join(parsed.dir, segmentFiles.at(-1) as string) : result.file,\n sessionDuration: result.duration,\n })\n } catch (err) {\n if (deps.stopRequested()) return\n const msg = err instanceof Error ? err.message : String(err)\n deps.logger.error(`Segmenting failed: ${msg}`)\n deps.setState({ lastError: `Segmenting failed: ${msg}` })\n // Fallback: try simple conversion of the whole TS\n await runFallbackConversion(result, deps)\n }\n}\n\n// ─── Shared converter helper ────────────────────────────────────\n\n/**\n * Convert a downloaded file to MP4 and optionally normalize audio.\n * Shared by the direct conversion and fallback paths.\n *\n * @param emitRecordingEnd When true, also emit a `recording:end` event\n * (used by the direct conversion path; the segmenting path emits its\n * own `recording:end` events per segment).\n */\nasync function convertFile(\n result: DownloadResult,\n deps: ProcessingDeps,\n emitRecordingEnd?: boolean,\n): Promise<void> {\n deps.emit(\"converting:start\", { input: result.file })\n try {\n const convertPromise = deps.converter.convert(result.file)\n deps.pendingRemuxes.push(convertPromise)\n const mp4File = await convertPromise\n\n if (deps.audioNormalizer) {\n const normalizePromise = deps.audioNormalizer.normalize(mp4File).catch(() => {})\n deps.pendingRemuxes.push(normalizePromise)\n await normalizePromise\n }\n\n if (emitRecordingEnd) {\n deps.emit(\"recording:end\", { file: mp4File, duration: result.duration, size: result.size })\n }\n deps.emit(\"converted\", { input: result.file, output: mp4File })\n } catch (convErr) {\n const tkErr =\n convErr instanceof TikTokError ? convErr : new TikTokError(\"unknown\", String(convErr))\n deps.logger.error(`Conversion failed: ${tkErr.message}`)\n deps.setState({ lastError: `Conversion failed: ${tkErr.message}` })\n deps.emit(\"error\", tkErr)\n }\n}\n\n// ─── Conversion path (no segmenting) ─────────────────────────────\n\nasync function processConversion(\n result: DownloadResult,\n _cfg: RecorderConfig,\n deps: ProcessingDeps,\n): Promise<void> {\n return convertFile(result, deps, true)\n}\n\n// ─── Fallback conversion (when segmenting fails) ─────────────────\n\nasync function runFallbackConversion(result: DownloadResult, deps: ProcessingDeps): Promise<void> {\n return convertFile(result, deps)\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-events.mjs","names":[],"sources":["../../../src/core/recorder/recorder-events.ts"],"sourcesContent":["/**\n * Typed event emitter for recorder lifecycle events.\n */\n\nimport type { RecorderEvent, RecorderEventHandler } from \"../../lib/config\"\n\nexport interface RecorderEventEmitter {\n on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void\n emit<E extends RecorderEvent>(event: E, ...args: Parameters<RecorderEventHandler[E]>): void\n clear(): void\n}\n\nexport function createEventEmitter(): RecorderEventEmitter {\n const handlers = new Map<string, Array<(...args: unknown[]) => void>>()\n\n function on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event) ?? []\n list.push(handler as (...args: unknown[]) => void)\n handlers.set(event, list)\n }\n\n function off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void {\n const list = handlers.get(event)\n if (!list) return\n const idx = list.indexOf(handler as (...args: unknown[]) => void)\n if (idx !== -1) list.splice(idx, 1)\n }\n\n function emit<E extends RecorderEvent>(\n event: E,\n ...args: Parameters<RecorderEventHandler[E]>\n ): void {\n const list = handlers.get(event)\n if (list) {\n for (const h of list) {\n try {\n h(...args)\n } catch {\n // Never let an event handler crash the recorder\n }\n }\n }\n }\n\n function clear(): void {\n handlers.clear()\n }\n\n return { on, off, emit, clear }\n}\n"],"mappings":";AAaA,SAAgB,qBAA2C;CACzD,MAAM,2BAAW,IAAI,IAAiD;CAEtE,SAAS,GAA4B,OAAU,SAAwC;EACrF,MAAM,OAAO,SAAS,IAAI,KAAK,KAAK,CAAC;EACrC,KAAK,KAAK,OAAuC;EACjD,SAAS,IAAI,OAAO,IAAI;CAC1B;CAEA,SAAS,IAA6B,OAAU,SAAwC;EACtF,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,CAAC,MAAM;EACX,MAAM,MAAM,KAAK,QAAQ,OAAuC;EAChE,IAAI,QAAQ,IAAI,KAAK,OAAO,KAAK,CAAC;CACpC;CAEA,SAAS,KACP,OACA,GAAG,MACG;EACN,MAAM,OAAO,SAAS,IAAI,KAAK;EAC/B,IAAI,MACF,KAAK,MAAM,KAAK,MACd,IAAI;GACF,EAAE,GAAG,IAAI;EACX,QAAQ,CAER;CAGN;CAEA,SAAS,QAAc;EACrB,SAAS,MAAM;CACjB;CAEA,OAAO;EAAE;EAAI;EAAK;EAAM;CAAM;AAChC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-state.mjs","names":[],"sources":["../../../src/core/recorder/recorder-state.ts"],"sourcesContent":["/**\n * State machine for the recorder lifecycle.\n */\n\nimport type { RecorderStatus } from \"../../lib/config\"\nimport type { Logger } from \"../../lib/logger\"\n\n// Valid state transitions: [from] → [to, to, ...]\nconst TRANSITIONS: Record<string, string[]> = {\n idle: [\"polling\"],\n polling: [\"recording\", \"stopped\"],\n recording: [\"converting\", \"stopped\"],\n converting: [\"polling\", \"stopped\"],\n stopped: [],\n}\n\nexport interface RecorderStateManager {\n setState(partial: Partial<RecorderStatus>): void\n getStatus(): RecorderStatus\n}\n\nexport function createRecorderState(initial: RecorderStatus, logger: Logger): RecorderStateManager {\n let state: RecorderStatus = { ...initial }\n\n function setState(partial: Partial<RecorderStatus>): void {\n const prevState = state.state\n const newState = partial.state\n\n // Validate state transitions\n if (newState && newState !== prevState) {\n const allowed = TRANSITIONS[prevState]\n if (!allowed?.includes(newState)) {\n logger.error(`Invalid state transition: ${prevState} → ${newState} (ignored)`)\n return\n }\n logger.info(`State: ${prevState} → ${newState}`)\n }\n\n state = { ...state, ...partial }\n\n // Auto-track timestamps on state entry\n if (newState === \"polling\" && newState !== prevState) {\n state.lastPollTime = new Date().toISOString()\n }\n }\n\n function getStatus(): RecorderStatus {\n return { ...state }\n }\n\n return { setState, getStatus }\n}\n"],"mappings":";AAQA,MAAM,cAAwC;CAC5C,MAAM,CAAC,SAAS;CAChB,SAAS,CAAC,aAAa,SAAS;CAChC,WAAW,CAAC,cAAc,SAAS;CACnC,YAAY,CAAC,WAAW,SAAS;CACjC,SAAS,CAAC;AACZ;AAOA,SAAgB,oBAAoB,SAAyB,QAAsC;CACjG,IAAI,QAAwB,EAAE,GAAG,QAAQ;CAEzC,SAAS,SAAS,SAAwC;EACxD,MAAM,YAAY,MAAM;EACxB,MAAM,WAAW,QAAQ;EAGzB,IAAI,YAAY,aAAa,WAAW;GAEtC,IAAI,CADY,YAAY,UAChB,EAAE,SAAS,QAAQ,GAAG;IAChC,OAAO,MAAM,6BAA6B,UAAU,KAAK,SAAS,WAAW;IAC7E;GACF;GACA,OAAO,KAAK,UAAU,UAAU,KAAK,UAAU;EACjD;EAEA,QAAQ;GAAE,GAAG;GAAO,GAAG;EAAQ;EAG/B,IAAI,aAAa,aAAa,aAAa,WACzC,MAAM,gCAAe,IAAI,KAAK,EAAA,CAAE,YAAY;CAEhD;CAEA,SAAS,YAA4B;EACnC,OAAO,EAAE,GAAG,MAAM;CACpB;CAEA,OAAO;EAAE;EAAU;CAAU;AAC/B"}
|