@zfadhli/tokrec 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -76
- package/dist/api/client.mjs +45 -19
- package/dist/api/client.mjs.map +1 -1
- package/dist/api/rate-limiter.mjs +29 -0
- package/dist/api/rate-limiter.mjs.map +1 -0
- package/dist/api/tiktok.mjs +180 -51
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +8 -11
- package/dist/browser-cookies.mjs.map +1 -1
- package/dist/cli.mjs +10 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/config.d.mts +18 -6
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +8 -4
- package/dist/config.mjs.map +1 -1
- package/dist/index.mjs +22 -4
- package/dist/index.mjs.map +1 -1
- package/dist/monitor.mjs +12 -13
- package/dist/monitor.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs +25 -25
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/download-hls.mjs +77 -0
- package/dist/recorder/download-hls.mjs.map +1 -0
- package/dist/recorder/download-stream.mjs +78 -0
- package/dist/recorder/download-stream.mjs.map +1 -0
- package/dist/recorder/ffmpeg-utils.mjs +122 -0
- package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/recorder/index.d.mts.map +1 -1
- package/dist/recorder/index.mjs +88 -176
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/post-processing.mjs +150 -0
- package/dist/recorder/post-processing.mjs.map +1 -0
- package/dist/recorder/recorder-events.mjs +34 -0
- package/dist/recorder/recorder-events.mjs.map +1 -0
- package/dist/recorder/recorder-state.mjs +38 -0
- package/dist/recorder/recorder-state.mjs.map +1 -0
- package/dist/recorder/stream.mjs +5 -293
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/ui.mjs +16 -25
- package/dist/ui.mjs.map +1 -1
- package/dist/utils.mjs +19 -3
- package/dist/utils.mjs.map +1 -1
- package/package.json +6 -6
package/dist/config.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"config.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AAUA;;;;AAA+B;AAE/B;;;KAFY,UAAA,GAAa,MAAM;AAAA,KAEnB,QAAA;AAAA,UAEK,cAAA;EAAc;EAE7B,IAAA;EAcmB;EAZnB,SAAA;EAAA;EAEA,QAAA;EAEA;EAAA,QAAA;EAIA;EAFA,KAAA;EAIU;EAFV,WAAA;EAIW;EAFX,OAAA,GAAU,UAAA;EAMV;EAJA,QAAA,GAAW,QAAQ;EAQnB;EANA,UAAA;EAUA;EARA,cAAA;EAgBA;EAdA,cAAA;EAcK;EAZL,iBAAA;EAeiC;EAbjC,cAAA;EAeS;EAbT,gBAAA;EAiBa;;;;;EAXb,aAAA;EAeiD;EAbjD,KAAA;AAAA;AAAA,UAGe,kBAAA;EAUoE;EARnF,KAAA,IAAS,OAAA;EAAA;EAET,IAAA,IAAQ,OAAA;EAAA;EAER,SAAA,IAAa,cAAA;EAAA;EAEb,EAAA,iBAAmB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;EAA9E;EAEH,GAAA,iBAAoB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;AAAA;AAAA,UAGnE,cAAA;EACf,KAAA;EACA,IAAA;EACA,WAAA;EACA,eAAA;EACA,YAAA;EACA,SAAA;AAAA;AAAA,KAGU,aAAA,SAAsB,oBAAoB;AAAA,UAErC,oBAAA;EACf,QAAA,GAAW,IAAA;IAAQ,IAAA;EAAA;EACnB,IAAA,GAAO,IAAA;IAAQ,IAAA;IAAc,MAAA;IAAiB,MAAA;EAAA;EAC9C,iBAAA,GAAoB,IAAA;IAAQ,IAAA;IAAc,IAAA;EAAA;EAC1C,mBAAA,GAAsB,IAAA;IAAQ,KAAA;IAAe,OAAA;IAAiB,KAAA;EAAA;EAC9D,cAAA,GAAiB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EACzD,eAAA,GAAkB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EAC1D,kBAAA,GAAqB,IAAA;IAAQ,KAAA;IAAe,aAAA;EAAA;EAC5C,gBAAA,GAAmB,IAAA;IAAQ,QAAA;EAAA;EAC3B,kBAAA,GAAqB,IAAA;IAAQ,KAAA;EAAA;EAC7B,SAAA,GAAY,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACnC,iBAAA,GAAoB,IAAA;IAAQ,IAAA;EAAA;EAC5B,oBAAA,GAAuB,IAAA;IACrB,IAAA;IACA,OAAA;IACA,KAAA;EAAA;EAEF,eAAA,GAAkB,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACzC,iBAAA,GAAoB,IAAA;IAAQ,KAAA;IAAe,KAAA;EAAA;EAC3C,KAAA,GAAQ,GAAA,EAAK,WAAW;AAAA;AAAA,KAGd,YAAA;AAAA,cAaC,WAAA,SAAoB,KAAA;EAAA,SAEb,IAAA,EAAM,YAAA;EAAA,SAEN,KAAA;cAFA,IAAA,EAAM,YAAA,EACtB,OAAA,UACgB,KAAA;AAAA"}
|
package/dist/config.mjs
CHANGED
|
@@ -15,11 +15,13 @@ const DEFAULTS = {
|
|
|
15
15
|
duration: 0,
|
|
16
16
|
logLevel: "info",
|
|
17
17
|
logConsole: true,
|
|
18
|
-
segmentMinutes:
|
|
19
|
-
normalizeAudio:
|
|
18
|
+
segmentMinutes: 0,
|
|
19
|
+
normalizeAudio: true,
|
|
20
20
|
normalizeLoudness: -14,
|
|
21
21
|
normalizeCodec: "aac",
|
|
22
|
-
normalizeBitrate: "128k"
|
|
22
|
+
normalizeBitrate: "128k",
|
|
23
|
+
ratePerSecond: 5,
|
|
24
|
+
debug: false
|
|
23
25
|
};
|
|
24
26
|
function normalizeConfig(config) {
|
|
25
27
|
return {
|
|
@@ -36,7 +38,9 @@ function normalizeConfig(config) {
|
|
|
36
38
|
normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,
|
|
37
39
|
normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,
|
|
38
40
|
normalizeCodec: config.normalizeCodec ?? DEFAULTS.normalizeCodec,
|
|
39
|
-
normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate
|
|
41
|
+
normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate,
|
|
42
|
+
ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,
|
|
43
|
+
debug: config.debug ?? DEFAULTS.debug
|
|
40
44
|
};
|
|
41
45
|
}
|
|
42
46
|
function validateConfig(config) {
|
package/dist/config.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\n/**\n * TikTok cookie key-value pairs extracted from Firefox or cookies.json.\n * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF\n * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.\n */\nexport type CookieAuth = Record<string, string>\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nexport interface RecorderConfig {\n /** TikTok username (required) */\n user: string\n /** Output directory for recordings (default: ./recordings) */\n outputDir?: string\n /** Polling interval in minutes (default: 3) */\n interval?: number\n /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */\n duration?: number\n /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */\n proxy?: string\n /** Path to cookies.json (default: ./cookies.json) */\n cookiesPath?: string\n /** Cookie auth loaded from cookies.json */\n cookies?: CookieAuth\n /** Log level (default: info) */\n logLevel?: LogLevel\n /** Whether to print logs to console (default: true). Set false when using Display-based UI. */\n logConsole?: boolean\n /** Segment duration in minutes (default: 0 — disabled). When >= 1, the recording is split into segments of this length. */\n segmentMinutes?: number\n /** Normalize audio loudness via EBU R128 (default: false). */\n normalizeAudio?: boolean\n /** Target loudness in LUFS (default: -14). */\n normalizeLoudness?: number\n /** Audio codec for normalized output (default: \"aac\"). */\n normalizeCodec?: string\n /** Audio bitrate for normalized output (default: \"128k\"). */\n normalizeBitrate?: string\n /**\n * Max API requests per second (default: 5).\n * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.\n * Set to 0 for unlimited (not recommended).\n */\n ratePerSecond?: number\n /** Show API debug logging on stderr (default: false). */\n debug?: boolean\n}\n\nexport interface RecorderController {\n /** Start the polling loop */\n start(): Promise<void>\n /** Gracefully stop (joins active recording, then exits) */\n stop(): Promise<void>\n /** Snapshot of current state */\n getStatus(): RecorderStatus\n /** Subscribe to events */\n on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n /** Unsubscribe a previously registered event handler */\n off<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n}\n\nexport interface RecorderStatus {\n state: \"idle\" | \"polling\" | \"recording\" | \"converting\" | \"stopped\"\n user: string\n currentFile?: string\n sessionDuration?: number\n lastPollTime?: string\n lastError?: string\n}\n\nexport type RecorderEvent = keyof RecorderEventHandler\n\nexport interface RecorderEventHandler {\n checking: (info: { user: string }) => void\n tick: (info: { user: string; isLive: boolean; roomId?: string }) => void\n \"recording:start\": (info: { user: string; file: string }) => void\n \"download:progress\": (info: { bytes: number; elapsed: number; speed: number }) => void\n \"download:end\": (info: { file: string; duration: number; size: number }) => void\n \"recording:end\": (info: { file: string; duration: number; size: number }) => void\n \"segmenting:start\": (info: { input: string; outputPattern: string }) => void\n \"segmenting:end\": (info: { segments: number }) => void\n \"converting:start\": (info: { input: string }) => void\n converted: (info: { input: string; output: string }) => void\n \"normalize:start\": (info: { file: string }) => void\n \"normalize:progress\": (info: {\n file: string\n percent: number\n phase: \"analyzing\" | \"normalizing\"\n }) => void\n \"normalize:end\": (info: { input: string; output: string }) => void\n \"normalize:error\": (info: { input: string; error: string }) => void\n error: (err: TikTokError) => void\n}\n\nexport type AppErrorKind =\n | \"user-not-live\"\n | \"room-not-found\"\n | \"stream-url-not-found\"\n | \"waf-blocked\"\n | \"country-blocked\"\n | \"network-error\"\n | \"ffmpeg-not-found\"\n | \"ffmpeg-error\"\n | \"aborted\"\n | \"config-error\"\n | \"unknown\"\n\nexport class TikTokError extends Error {\n constructor(\n public readonly kind: AppErrorKind,\n message: string,\n public readonly cause?: unknown,\n ) {\n super(message)\n this.name = \"TikTokError\"\n }\n}\n\nconst DEFAULTS = {\n outputDir: \"./recordings\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 0,\n normalizeAudio: true,\n normalizeLoudness: -14,\n normalizeCodec: \"aac\",\n normalizeBitrate: \"128k\",\n ratePerSecond: 5,\n debug: false,\n}\n\nexport function normalizeConfig(\n config: RecorderConfig,\n): Required<Omit<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\">> &\n Pick<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\"> {\n return {\n user: config.user,\n outputDir: config.outputDir ?? DEFAULTS.outputDir,\n interval: config.interval ?? DEFAULTS.interval,\n duration: config.duration ?? DEFAULTS.duration,\n logLevel: config.logLevel ?? DEFAULTS.logLevel,\n logConsole: config.logConsole ?? DEFAULTS.logConsole,\n proxy: config.proxy,\n cookies: config.cookies,\n cookiesPath: config.cookiesPath,\n segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes,\n normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,\n normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,\n normalizeCodec: config.normalizeCodec ?? DEFAULTS.normalizeCodec,\n normalizeBitrate: config.normalizeBitrate ?? DEFAULTS.normalizeBitrate,\n ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,\n debug: config.debug ?? DEFAULTS.debug,\n }\n}\n\nexport function validateConfig(config: RecorderConfig): void {\n if (!config.user || config.user.trim().length === 0) {\n throw new TikTokError(\"config-error\", \"--user is required\")\n }\n if (config.interval !== undefined && config.interval < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be >= 1 minute\")\n }\n if (config.duration !== undefined && config.duration < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be >= 0 seconds\")\n }\n}\n"],"mappings":";AAgHA,IAAa,cAAb,cAAiC,MAAM;CAEnB;CAEA;CAHlB,YACE,MACA,SACA,OACA;EACA,MAAM,OAAO;EAJG,KAAA,OAAA;EAEA,KAAA,QAAA;EAGhB,KAAK,OAAO;CACd;AACF;AAEA,MAAM,WAAW;CACf,WAAW;CACX,UAAU;CACV,UAAU;CACV,UAAU;CACV,YAAY;CACZ,gBAAgB;CAChB,gBAAgB;CAChB,mBAAmB;CACnB,gBAAgB;CAChB,kBAAkB;CAClB,eAAe;CACf,OAAO;AACT;AAEA,SAAgB,gBACd,QAE0D;CAC1D,OAAO;EACL,MAAM,OAAO;EACb,WAAW,OAAO,aAAa,SAAS;EACxC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,YAAY,OAAO,cAAc,SAAS;EAC1C,OAAO,OAAO;EACd,SAAS,OAAO;EAChB,aAAa,OAAO;EACpB,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,mBAAmB,OAAO,qBAAqB,SAAS;EACxD,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,kBAAkB,OAAO,oBAAoB,SAAS;EACtD,eAAe,OAAO,iBAAiB,SAAS;EAChD,OAAO,OAAO,SAAS,SAAS;CAClC;AACF;AAEA,SAAgB,eAAe,QAA8B;CAC3D,IAAI,CAAC,OAAO,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC,WAAW,GAChD,MAAM,IAAI,YAAY,gBAAgB,oBAAoB;CAE5D,IAAI,OAAO,aAAa,KAAA,KAAa,OAAO,WAAW,GACrD,MAAM,IAAI,YAAY,gBAAgB,gCAAgC;CAExE,IAAI,OAAO,aAAa,KAAA,KAAa,OAAO,WAAW,GACrD,MAAM,IAAI,YAAY,gBAAgB,iCAAiC;AAE3E"}
|
package/dist/index.mjs
CHANGED
|
@@ -38,10 +38,16 @@ async function main() {
|
|
|
38
38
|
}
|
|
39
39
|
throw err;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
42
|
+
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
42
43
|
config.logConsole = false;
|
|
43
44
|
const display = createDisplay();
|
|
44
|
-
if (
|
|
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");
|
|
45
51
|
const recorder = createRecorder(config);
|
|
46
52
|
let lastWasLive = null;
|
|
47
53
|
let firstOfflineTime = 0;
|
|
@@ -79,7 +85,10 @@ async function main() {
|
|
|
79
85
|
recorder.on("converting:start", () => {
|
|
80
86
|
display.startConverting();
|
|
81
87
|
});
|
|
82
|
-
recorder.on("converted", () => {
|
|
88
|
+
recorder.on("converted", (info) => {
|
|
89
|
+
const parsed = info.output.split("/").pop() ?? info.output;
|
|
90
|
+
display.conversionDone(parsed);
|
|
91
|
+
});
|
|
83
92
|
recorder.on("recording:end", () => {});
|
|
84
93
|
recorder.on("error", (err) => {
|
|
85
94
|
display.showError(`[${err.kind}] ${err.message}`);
|
|
@@ -100,6 +109,9 @@ async function main() {
|
|
|
100
109
|
const handleSignal = async () => {
|
|
101
110
|
if (stopping) return;
|
|
102
111
|
stopping = true;
|
|
112
|
+
process.off("SIGINT", handleSignal);
|
|
113
|
+
process.off("SIGTERM", handleSignal);
|
|
114
|
+
process.off("SIGHUP", handleSignal);
|
|
103
115
|
display.showInfo("Shutting down gracefully...");
|
|
104
116
|
await recorder.stop();
|
|
105
117
|
display.stop();
|
|
@@ -114,12 +126,18 @@ async function main() {
|
|
|
114
126
|
} catch (err) {
|
|
115
127
|
if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
|
|
116
128
|
else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
process.off("SIGINT", handleSignal);
|
|
130
|
+
process.off("SIGTERM", handleSignal);
|
|
131
|
+
process.off("SIGHUP", handleSignal);
|
|
117
132
|
await recorder.stop();
|
|
118
133
|
display.stop();
|
|
119
134
|
process.exit(1);
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
|
-
main()
|
|
137
|
+
main().catch((err) => {
|
|
138
|
+
console.error("Fatal:", err instanceof Error ? err.message : String(err));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
123
141
|
//#endregion
|
|
124
142
|
export {};
|
|
125
143
|
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.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 { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport type { RecorderConfig } from \"./config\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.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 { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport type { RecorderConfig } from \"./config\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./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:progress\", (info) => {\n display.updateProgress(info.bytes, info.elapsed, info.speed)\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":";;;;;;;;;;;;;;;;AAiBA,eAAe,YAAY,aAAmE;CAC5F,MAAM,OAAO,eAAe,KAAK,QAAQ,IAAI,GAAG,cAAc;CAC9D,IAAI,CAAC,WAAW,IAAI,GAClB;CAEF,IAAI;EACF,MAAM,UAAU,aAAa,MAAM,OAAO;EAC1C,MAAM,OAAO,KAAK,MAAM,OAAO;EAC/B,IAAI,KAAK,gBAAgB,KAAK,aAAa,SAAS,GAClD,OAAO;EAET;CACF,QAAQ;EACN;CACF;AACF;AAEA,eAAe,OAAsB;CACnC,IAAI;CAEJ,IAAI;EACF,SAAS,UAAU;EACnB,eAAe,MAAM;CACvB,SAAS,KAAK;EACZ,IAAI,eAAe,eAAe,IAAI,SAAS,gBAAgB;GAC7D,QAAQ,MAAM,GAAG,MAAM,IAAI,QAAQ,EAAE,GAAG,IAAI,SAAS;GACrD,QAAQ,KAAK,CAAC;EAChB;EACA,MAAM;CACR;CAGA,MAAM,iBAAiB,gCAAgC;CACvD,OAAO,UAAU,kBAAmB,MAAM,YAAY,OAAO,WAAW;CAGxE,OAAO,aAAa;CAGpB,MAAM,UAAU,cAAc;CAE9B,IAAI,OAAO,SAAS;EAClB,MAAM,SAAS,iBAAiB,YAAY;EAC5C,MAAM,QAAQ,OAAO,KAAK,OAAO,OAAO;EACxC,MAAM,SAAS,MAAM,SAAS,IAAI,KAAK,MAAM,OAAO,aAAa;EACjE,QAAQ,SAAS,GAAG,OAAO,iBAAiB,QAAQ;CACtD,OACE,QAAQ,YACN,kFACF;CAGF,MAAM,WAAW,eAAe,MAAM;CAEtC,IAAI,cAA8B;CAClC,IAAI,mBAAmB;CAGvB,SAAS,GAAG,aAAa,SAAS;EAChC,QAAQ,aAAa,KAAK,IAAI;CAChC,CAAC;CAED,SAAS,GAAG,SAAS,SAAS;EAC5B,IAAI,KAAK,QACP,QAAQ,SAAS,KAAK,MAAM,KAAK,UAAU,GAAG;OACzC,IAAI,gBAAgB,QAAQ,gBAAgB,MAAM;GACvD,QAAQ,YAAY,KAAK,IAAI;GAC7B,mBAAmB,KAAK,IAAI;EAC9B,OAAO;GACL,MAAM,UAAU,KAAK,IAAI,IAAI;GAC7B,QAAQ,kBAAkB,KAAK,MAAM,aAAa,OAAO,CAAC;EAC5D;EACA,cAAc,KAAK;CACrB,CAAC;CAED,SAAS,GAAG,yBAAyB;EACnC,QAAQ,eAAe;CACzB,CAAC;CAED,SAAS,GAAG,sBAAsB,SAAS;EACzC,QAAQ,eAAe,KAAK,OAAO,KAAK,SAAS,KAAK,KAAK;CAC7D,CAAC;CAED,SAAS,GAAG,iBAAiB,SAAS;EACpC,MAAM,SAAS,KAAK,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,KAAK;EAClD,MAAM,UAAU,aAAa,KAAK,IAAI;EACtC,QAAQ,gBAAgB,QAAQ,KAAK,UAAU,OAAO;CACxD,CAAC;CAED,SAAS,GAAG,0BAA0B;EACpC,QAAQ,gBAAgB;CAC1B,CAAC;CAED,SAAS,GAAG,mBAAmB,SAAS;EACtC,QAAQ,gBAAgB,KAAK,QAAQ;CACvC,CAAC;CAED,SAAS,GAAG,0BAA0B;EACpC,QAAQ,gBAAgB;CAC1B,CAAC;CAED,SAAS,GAAG,cAAc,SAAS;EACjC,MAAM,SAAS,KAAK,OAAO,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,KAAK;EACpD,QAAQ,eAAe,MAAM;CAC/B,CAAC;CAED,SAAS,GAAG,uBAAuB,CAKnC,CAAC;CAED,SAAS,GAAG,UAAU,QAAQ;EAC5B,QAAQ,UAAU,IAAI,IAAI,KAAK,IAAI,IAAI,SAAS;CAClD,CAAC;CAED,SAAS,GAAG,yBAAyB;EACnC,QAAQ,eAAe;CACzB,CAAC;CAED,SAAS,GAAG,uBAAuB,SAAS;EAC1C,QAAQ,kBAAkB,KAAK,SAAS,KAAK,KAAK;CACpD,CAAC;CAED,SAAS,GAAG,uBAAuB;EACjC,QAAQ,kBAAkB;CAC5B,CAAC;CAED,SAAS,GAAG,oBAAoB,SAAS;EACvC,QAAQ,eAAe,KAAK,KAAK;CACnC,CAAC;CAGD,IAAI,WAAW;CACf,MAAM,eAAe,YAAY;EAC/B,IAAI,UAAU;EACd,WAAW;EAEX,QAAQ,IAAI,UAAU,YAAY;EAClC,QAAQ,IAAI,WAAW,YAAY;EACnC,QAAQ,IAAI,UAAU,YAAY;EAClC,QAAQ,SAAS,6BAA6B;EAC9C,MAAM,SAAS,KAAK;EACpB,QAAQ,KAAK;EACb,QAAQ,KAAK,CAAC;CAChB;CAEA,QAAQ,GAAG,UAAU,YAAY;CACjC,QAAQ,GAAG,WAAW,YAAY;CAClC,QAAQ,GAAG,UAAU,YAAY;CAGjC,QAAQ,eAAe,OAAO,YAAY,CAAC;CAC3C,IAAI;EACF,MAAM,SAAS,MAAM;CACvB,SAAS,KAAK;EACZ,IAAI,eAAe,aACjB,QAAQ,UAAU,IAAI,IAAI,KAAK,IAAI,IAAI,SAAS;OAEhD,QAAQ,UAAU,qBAAqB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;EAE3F,QAAQ,IAAI,UAAU,YAAY;EAClC,QAAQ,IAAI,WAAW,YAAY;EACnC,QAAQ,IAAI,UAAU,YAAY;EAClC,MAAM,SAAS,KAAK;EACpB,QAAQ,KAAK;EACb,QAAQ,KAAK,CAAC;CAChB;AACF;AAEA,KAAK,CAAC,CAAC,OAAO,QAAQ;CACpB,QAAQ,MAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;CACxE,QAAQ,KAAK,CAAC;AAChB,CAAC"}
|
package/dist/monitor.mjs
CHANGED
|
@@ -2,12 +2,12 @@ import { sleep } from "./utils.mjs";
|
|
|
2
2
|
//#region src/monitor.ts
|
|
3
3
|
function createPollingMonitor(opts) {
|
|
4
4
|
const intervalMs = opts.intervalMinutes * 60 * 1e3;
|
|
5
|
-
|
|
5
|
+
const stopSignal = new AbortController();
|
|
6
6
|
let currentTick = null;
|
|
7
|
+
let stopAfterTick = false;
|
|
7
8
|
async function start() {
|
|
8
|
-
active = true;
|
|
9
9
|
opts.logger?.info(`Polling started (interval: ${opts.intervalMinutes} min)`);
|
|
10
|
-
while (
|
|
10
|
+
while (!stopSignal.signal.aborted) {
|
|
11
11
|
currentTick = opts.onTick();
|
|
12
12
|
try {
|
|
13
13
|
await currentTick;
|
|
@@ -17,24 +17,23 @@ function createPollingMonitor(opts) {
|
|
|
17
17
|
} finally {
|
|
18
18
|
currentTick = null;
|
|
19
19
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
while (waited < intervalMs) {
|
|
24
|
-
if (!active) break;
|
|
25
|
-
await sleep(Math.min(checkInterval, intervalMs - waited));
|
|
26
|
-
waited += checkInterval;
|
|
27
|
-
}
|
|
20
|
+
if (stopAfterTick) stopSignal.abort();
|
|
21
|
+
if (stopSignal.signal.aborted) break;
|
|
22
|
+
await sleep(intervalMs, stopSignal.signal);
|
|
28
23
|
}
|
|
29
24
|
opts.logger?.info("Polling stopped");
|
|
30
25
|
}
|
|
31
26
|
async function stop() {
|
|
32
|
-
|
|
27
|
+
stopSignal.abort();
|
|
33
28
|
if (currentTick) await currentTick;
|
|
34
29
|
}
|
|
30
|
+
function stopAfterCurrentTick() {
|
|
31
|
+
stopAfterTick = true;
|
|
32
|
+
}
|
|
35
33
|
return {
|
|
36
34
|
start,
|
|
37
|
-
stop
|
|
35
|
+
stop,
|
|
36
|
+
stopAfterCurrentTick
|
|
38
37
|
};
|
|
39
38
|
}
|
|
40
39
|
//#endregion
|
package/dist/monitor.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monitor.mjs","names":[],"sources":["../src/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\nimport type { Logger } from \"./logger\"\nimport { sleep } from \"./utils\"\n\nexport interface PollingMonitor {\n start: () => Promise<void>\n stop: () => Promise<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
|
|
1
|
+
{"version":3,"file":"monitor.mjs","names":[],"sources":["../src/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 \"./logger\"\nimport { sleep } from \"./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"}
|
package/dist/package.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
import { TikTokError } from "../config.mjs";
|
|
2
|
+
import { findFfmpegPath } from "./ffmpeg-utils.mjs";
|
|
1
3
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
4
|
+
import { unlinkSync } from "node:fs";
|
|
3
5
|
//#region src/recorder/convert.ts
|
|
4
6
|
/**
|
|
5
|
-
* Converter — spawns FFmpeg to convert
|
|
7
|
+
* Converter — spawns FFmpeg to convert TS → MP4 (stream copy by default).
|
|
6
8
|
* Deletes the original file on success.
|
|
7
9
|
*/
|
|
8
|
-
function createConverter(logger) {
|
|
10
|
+
function createConverter(logger, signal) {
|
|
9
11
|
async function convert(input) {
|
|
10
12
|
const output = input.replace(/\.(flv|ts)$/i, ".mp4");
|
|
11
13
|
logger?.info(`Converting: ${input} → ${output}`);
|
|
12
|
-
const ffmpegPath =
|
|
13
|
-
if (!ffmpegPath) throw new
|
|
14
|
-
await runFfmpeg(ffmpegPath, input, output);
|
|
14
|
+
const ffmpegPath = findFfmpegPath();
|
|
15
|
+
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found. Install it:\n Linux: apt install ffmpeg / brew install ffmpeg / pacman -S ffmpeg\n macOS: brew install ffmpeg\n Windows: choco install ffmpeg");
|
|
16
|
+
await runFfmpeg(ffmpegPath, input, output, signal);
|
|
15
17
|
try {
|
|
16
18
|
unlinkSync(input);
|
|
17
19
|
logger?.info(`Deleted original: ${input}`);
|
|
@@ -23,18 +25,7 @@ function createConverter(logger) {
|
|
|
23
25
|
}
|
|
24
26
|
return { convert };
|
|
25
27
|
}
|
|
26
|
-
function
|
|
27
|
-
const bunWhich = Bun?.which;
|
|
28
|
-
if (typeof bunWhich === "function") return bunWhich("ffmpeg");
|
|
29
|
-
const paths = process.env.PATH?.split(":") ?? [];
|
|
30
|
-
for (const dir of paths) try {
|
|
31
|
-
const full = `${dir}/ffmpeg`;
|
|
32
|
-
statSync(full);
|
|
33
|
-
return full;
|
|
34
|
-
} catch {}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
function runFfmpeg(ffmpegPath, input, output) {
|
|
28
|
+
function runFfmpeg(ffmpegPath, input, output, signal) {
|
|
38
29
|
return new Promise((resolve, reject) => {
|
|
39
30
|
const proc = spawn(ffmpegPath, [
|
|
40
31
|
"-i",
|
|
@@ -43,21 +34,30 @@ function runFfmpeg(ffmpegPath, input, output) {
|
|
|
43
34
|
"copy",
|
|
44
35
|
"-y",
|
|
45
36
|
output
|
|
46
|
-
], {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
37
|
+
], {
|
|
38
|
+
stdio: [
|
|
39
|
+
"ignore",
|
|
40
|
+
"pipe",
|
|
41
|
+
"pipe"
|
|
42
|
+
],
|
|
43
|
+
signal
|
|
44
|
+
});
|
|
51
45
|
let stderr = "";
|
|
52
46
|
proc.stderr?.on("data", (chunk) => {
|
|
53
47
|
stderr += chunk.toString();
|
|
48
|
+
if (stderr.length > 1e4) stderr = stderr.slice(-5e3);
|
|
54
49
|
});
|
|
55
50
|
proc.on("close", (code) => {
|
|
51
|
+
if (signal?.aborted) {
|
|
52
|
+
reject(new TikTokError("aborted", "Aborted by user"));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
56
55
|
if (code === 0) resolve();
|
|
57
|
-
else reject(
|
|
56
|
+
else reject(new TikTokError("ffmpeg-error", `FFmpeg exited with code ${code}\n${stderr.slice(-500)}`));
|
|
58
57
|
});
|
|
59
58
|
proc.on("error", (err) => {
|
|
60
|
-
|
|
59
|
+
if (err instanceof Error && err.name === "AbortError") return;
|
|
60
|
+
reject(new TikTokError("ffmpeg-error", `Failed to spawn FFmpeg: ${err.message}`));
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convert.mjs","names":[],"sources":["../../src/recorder/convert.ts"],"sourcesContent":["/**\n * Converter — spawns FFmpeg to convert
|
|
1
|
+
{"version":3,"file":"convert.mjs","names":[],"sources":["../../src/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 \"../config\"\nimport type { Logger } from \"../logger\"\nimport { findFfmpegPath } from \"./ffmpeg-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"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { TikTokError } from "../config.mjs";
|
|
2
|
+
import { bytesToHuman, formatFilename } from "../utils.mjs";
|
|
3
|
+
import { findFfmpegPath, formatDuration, pipeFfmpegSegment } from "./ffmpeg-utils.mjs";
|
|
4
|
+
import { createWriteStream } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/recorder/download-hls.ts
|
|
7
|
+
/**
|
|
8
|
+
* HLS stream downloader — downloads a live HLS stream via FFmpeg subprocess.
|
|
9
|
+
*
|
|
10
|
+
* FFmpeg handles M3U8 playlist fetching and .ts segment concatenation.
|
|
11
|
+
* The -reconnect flags handle transient CDN drops. When the playlist URL
|
|
12
|
+
* expires, getNextUrl fetches a fresh URL and FFmpeg is respawned.
|
|
13
|
+
*/
|
|
14
|
+
async function downloadHls(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger) {
|
|
15
|
+
const filename = formatFilename(user, "ts");
|
|
16
|
+
const filepath = join(outputDir, filename);
|
|
17
|
+
logger?.info(`Recording (HLS): ${filename}`);
|
|
18
|
+
const startTime = Date.now();
|
|
19
|
+
let totalBytes = 0;
|
|
20
|
+
let lastProgressTime = 0;
|
|
21
|
+
const ffmpegPath = findFfmpegPath();
|
|
22
|
+
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found — required for HLS streams. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
|
|
23
|
+
function elapsed() {
|
|
24
|
+
return (Date.now() - startTime) / 1e3;
|
|
25
|
+
}
|
|
26
|
+
const writer = createWriteStream(filepath);
|
|
27
|
+
try {
|
|
28
|
+
while (!signal.aborted) {
|
|
29
|
+
const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : void 0;
|
|
30
|
+
const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining);
|
|
31
|
+
totalBytes += bytes;
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const e = elapsed();
|
|
34
|
+
if (onProgress && now - lastProgressTime >= 1e3) {
|
|
35
|
+
lastProgressTime = now;
|
|
36
|
+
const speed = e > 0 ? totalBytes / e : 0;
|
|
37
|
+
onProgress({
|
|
38
|
+
bytes: totalBytes,
|
|
39
|
+
elapsed: e,
|
|
40
|
+
speed
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (signal.aborted) break;
|
|
44
|
+
if (maxDuration > 0 && e >= maxDuration) {
|
|
45
|
+
logger?.info(`Duration limit reached (${maxDuration}s)`);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (!getNextUrl) break;
|
|
49
|
+
const nextUrl = await getNextUrl();
|
|
50
|
+
if (!nextUrl) {
|
|
51
|
+
logger?.info("No new stream URL — recording finished");
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
liveUrl = nextUrl;
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
} finally {
|
|
59
|
+
await new Promise((resolve) => writer.end(resolve));
|
|
60
|
+
}
|
|
61
|
+
const duration = elapsed();
|
|
62
|
+
if (onProgress) onProgress({
|
|
63
|
+
bytes: totalBytes,
|
|
64
|
+
elapsed: duration,
|
|
65
|
+
speed: duration > 0 ? totalBytes / duration : 0
|
|
66
|
+
});
|
|
67
|
+
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`);
|
|
68
|
+
return {
|
|
69
|
+
file: filepath,
|
|
70
|
+
duration,
|
|
71
|
+
size: totalBytes
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { downloadHls };
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=download-hls.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download-hls.mjs","names":[],"sources":["../../src/recorder/download-hls.ts"],"sourcesContent":["/**\n * HLS stream downloader — downloads a live HLS stream via FFmpeg subprocess.\n *\n * FFmpeg handles M3U8 playlist fetching and .ts segment concatenation.\n * The -reconnect flags handle transient CDN drops. When the playlist URL\n * expires, getNextUrl fetches a fresh URL and FFmpeg is respawned.\n */\n\nimport { createWriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, formatFilename } from \"../utils\"\nimport { findFfmpegPath, formatDuration, pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadHls(\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): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 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 HLS streams. 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 const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining)\n totalBytes += bytes\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 ? totalBytes / e : 0\n onProgress({ bytes: totalBytes, 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 if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n}\n"],"mappings":";;;;;;;;;;;;;AAgBA,eAAsB,YACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACyB;CACzB,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CACzC,QAAQ,KAAK,oBAAoB,UAAU;CAE3C,MAAM,YAAY,KAAK,IAAI;CAC3B,IAAI,aAAa;CACjB,IAAI,mBAAmB;CAEvB,MAAM,aAAa,eAAe;CAClC,IAAI,CAAC,YACH,MAAM,IAAI,YACR,oBACA,uHAEF;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,EAAE,UAAU,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,SAAS;GACxF,cAAc;GAGd,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,aAAa,IAAI;IACvC,WAAW;KAAE,OAAO;KAAY,SAAS;KAAG;IAAM,CAAC;GACrD;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;CACzB,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,aAAa,WAAW;CAChD,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,UAAU,EAAE,EAC5F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAW;AACtD"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { TikTokError } from "../config.mjs";
|
|
2
|
+
import { bytesToHuman, formatFilename } from "../utils.mjs";
|
|
3
|
+
import { findFfmpegPath, formatDuration, pipeFfmpegSegment } from "./ffmpeg-utils.mjs";
|
|
4
|
+
import { createWriteStream } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/recorder/download-stream.ts
|
|
7
|
+
/**
|
|
8
|
+
* Live stream downloader — downloads a live stream via FFmpeg stdout pipe.
|
|
9
|
+
*
|
|
10
|
+
* Outputs MPEG-TS (.ts) format, which is crash-safe and append-friendly.
|
|
11
|
+
* FFmpeg handles HTTP reconnection via -reconnect flags. When TikTok's
|
|
12
|
+
* short-lived stream URL expires, FFmpeg exits and the outer loop fetches
|
|
13
|
+
* a fresh URL via getNextUrl.
|
|
14
|
+
*/
|
|
15
|
+
async function downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl, signal, logger) {
|
|
16
|
+
const filename = formatFilename(user, "ts");
|
|
17
|
+
const filepath = join(outputDir, filename);
|
|
18
|
+
logger?.info(`Recording: ${filename}`);
|
|
19
|
+
const startTime = Date.now();
|
|
20
|
+
let totalBytes = 0;
|
|
21
|
+
let lastProgressTime = 0;
|
|
22
|
+
const ffmpegPath = findFfmpegPath();
|
|
23
|
+
if (!ffmpegPath) throw new TikTokError("ffmpeg-not-found", "FFmpeg not found — required for stream download. Install it:\n Linux: apt install ffmpeg\n macOS: brew install ffmpeg");
|
|
24
|
+
function elapsed() {
|
|
25
|
+
return (Date.now() - startTime) / 1e3;
|
|
26
|
+
}
|
|
27
|
+
const writer = createWriteStream(filepath);
|
|
28
|
+
try {
|
|
29
|
+
while (!signal.aborted) {
|
|
30
|
+
const remaining = maxDuration > 0 ? Math.max(1, maxDuration - elapsed()) : void 0;
|
|
31
|
+
const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining);
|
|
32
|
+
totalBytes += bytes;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const e = elapsed();
|
|
35
|
+
if (onProgress && now - lastProgressTime >= 1e3) {
|
|
36
|
+
lastProgressTime = now;
|
|
37
|
+
const speed = e > 0 ? totalBytes / e : 0;
|
|
38
|
+
onProgress({
|
|
39
|
+
bytes: totalBytes,
|
|
40
|
+
elapsed: e,
|
|
41
|
+
speed
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (signal.aborted) break;
|
|
45
|
+
if (maxDuration > 0 && e >= maxDuration) {
|
|
46
|
+
logger?.info(`Duration limit reached (${maxDuration}s)`);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (!getNextUrl) break;
|
|
50
|
+
const nextUrl = await getNextUrl();
|
|
51
|
+
if (!nextUrl) {
|
|
52
|
+
logger?.info("No new stream URL — recording finished");
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
liveUrl = nextUrl;
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
} finally {
|
|
60
|
+
await new Promise((resolve) => writer.end(resolve));
|
|
61
|
+
}
|
|
62
|
+
const duration = elapsed();
|
|
63
|
+
if (onProgress) onProgress({
|
|
64
|
+
bytes: totalBytes,
|
|
65
|
+
elapsed: duration,
|
|
66
|
+
speed: duration > 0 ? totalBytes / duration : 0
|
|
67
|
+
});
|
|
68
|
+
logger?.info(`Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`);
|
|
69
|
+
return {
|
|
70
|
+
file: filepath,
|
|
71
|
+
duration,
|
|
72
|
+
size: totalBytes
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { downloadFlv };
|
|
77
|
+
|
|
78
|
+
//# sourceMappingURL=download-stream.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download-stream.mjs","names":[],"sources":["../../src/recorder/download-stream.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\nimport { createWriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { TikTokError } from \"../config\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, formatFilename } from \"../utils\"\nimport { findFfmpegPath, formatDuration, pipeFfmpegSegment } from \"./ffmpeg-utils\"\nimport type { DownloadResult, ProgressInfo } from \"./stream\"\n\nexport async function downloadFlv(\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): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 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 const { bytes } = await pipeFfmpegSegment(ffmpegPath, liveUrl, writer, signal, remaining)\n totalBytes += bytes\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 ? totalBytes / e : 0\n onProgress({ bytes: totalBytes, 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 if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n}\n"],"mappings":";;;;;;;;;;;;;;AAiBA,eAAsB,YACpB,SACA,MACA,WACA,aACA,YACA,YACA,QACA,QACyB;CACzB,MAAM,WAAW,eAAe,MAAM,IAAI;CAC1C,MAAM,WAAW,KAAK,WAAW,QAAQ;CACzC,QAAQ,KAAK,cAAc,UAAU;CAErC,MAAM,YAAY,KAAK,IAAI;CAC3B,IAAI,aAAa;CACjB,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,EAAE,UAAU,MAAM,kBAAkB,YAAY,SAAS,QAAQ,QAAQ,SAAS;GACxF,cAAc;GAGd,MAAM,MAAM,KAAK,IAAI;GACrB,MAAM,IAAI,QAAQ;GAClB,IAAI,cAAc,MAAM,oBAAoB,KAAM;IAChD,mBAAmB;IACnB,MAAM,QAAQ,IAAI,IAAI,aAAa,IAAI;IACvC,WAAW;KAAE,OAAO;KAAY,SAAS;KAAG;IAAM,CAAC;GACrD;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;CACzB,IAAI,YACF,WAAW;EACT,OAAO;EACP,SAAS;EACT,OAAO,WAAW,IAAI,aAAa,WAAW;CAChD,CAAC;CAEH,QAAQ,KACN,uBAAuB,SAAS,IAAI,eAAe,QAAQ,EAAE,IAAI,aAAa,UAAU,EAAE,EAC5F;CAEA,OAAO;EAAE,MAAM;EAAU;EAAU,MAAM;CAAW;AACtD"}
|