@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.
Files changed (74) hide show
  1. package/dist/bin.d.mts +1 -0
  2. package/dist/bin.mjs +141 -0
  3. package/dist/bin.mjs.map +1 -0
  4. package/dist/{api → core/api}/client.mjs +1 -1
  5. package/dist/core/api/client.mjs.map +1 -0
  6. package/dist/{api → core/api}/rate-limiter.mjs +1 -1
  7. package/dist/core/api/rate-limiter.mjs.map +1 -0
  8. package/dist/{api → core/api}/tiktok.mjs +1 -1
  9. package/dist/core/api/tiktok.mjs.map +1 -0
  10. package/dist/{monitor.mjs → core/monitor.mjs} +2 -2
  11. package/dist/core/monitor.mjs.map +1 -0
  12. package/dist/{recorder → core/recorder}/convert.mjs +3 -3
  13. package/dist/core/recorder/convert.mjs.map +1 -0
  14. package/dist/{recorder → core/recorder}/download.mjs +3 -3
  15. package/dist/core/recorder/download.mjs.map +1 -0
  16. package/dist/{recorder → core/recorder}/ffmpeg-utils.mjs +2 -2
  17. package/dist/core/recorder/ffmpeg-utils.mjs.map +1 -0
  18. package/dist/core/recorder/index.d.mts +7 -0
  19. package/dist/core/recorder/index.d.mts.map +1 -0
  20. package/dist/{recorder → core/recorder}/index.mjs +17 -14
  21. package/dist/core/recorder/index.mjs.map +1 -0
  22. package/dist/{recorder → core/recorder}/normalize.mjs +1 -1
  23. package/dist/core/recorder/normalize.mjs.map +1 -0
  24. package/dist/{recorder → core/recorder}/post-processing.mjs +2 -2
  25. package/dist/core/recorder/post-processing.mjs.map +1 -0
  26. package/dist/{recorder → core/recorder}/recorder-events.mjs +1 -1
  27. package/dist/core/recorder/recorder-events.mjs.map +1 -0
  28. package/dist/{recorder → core/recorder}/recorder-state.mjs +1 -1
  29. package/dist/core/recorder/recorder-state.mjs.map +1 -0
  30. package/dist/{recorder → core/recorder}/stream.mjs +2 -2
  31. package/dist/core/recorder/stream.mjs.map +1 -0
  32. package/dist/index.d.mts +3 -1
  33. package/dist/index.mjs +2 -143
  34. package/dist/{browser-cookies.mjs → lib/browser-cookies.mjs} +1 -1
  35. package/dist/lib/browser-cookies.mjs.map +1 -0
  36. package/dist/{cli.mjs → lib/cli.mjs} +2 -2
  37. package/dist/lib/cli.mjs.map +1 -0
  38. package/dist/{config.d.mts → lib/config.d.mts} +1 -1
  39. package/dist/lib/config.d.mts.map +1 -0
  40. package/dist/{config.mjs → lib/config.mjs} +1 -1
  41. package/dist/lib/config.mjs.map +1 -0
  42. package/dist/{logger.mjs → lib/logger.mjs} +1 -1
  43. package/dist/lib/logger.mjs.map +1 -0
  44. package/dist/{ui.mjs → lib/ui.mjs} +32 -22
  45. package/dist/lib/ui.mjs.map +1 -0
  46. package/dist/{utils.mjs → lib/utils.mjs} +2 -2
  47. package/dist/lib/utils.mjs.map +1 -0
  48. package/dist/package.mjs +1 -1
  49. package/package.json +9 -9
  50. package/dist/api/client.mjs.map +0 -1
  51. package/dist/api/rate-limiter.mjs.map +0 -1
  52. package/dist/api/tiktok.mjs.map +0 -1
  53. package/dist/browser-cookies.mjs.map +0 -1
  54. package/dist/cli.mjs.map +0 -1
  55. package/dist/config.d.mts.map +0 -1
  56. package/dist/config.mjs.map +0 -1
  57. package/dist/index.mjs.map +0 -1
  58. package/dist/lib.d.mts +0 -3
  59. package/dist/lib.mjs +0 -2
  60. package/dist/logger.mjs.map +0 -1
  61. package/dist/monitor.mjs.map +0 -1
  62. package/dist/recorder/convert.mjs.map +0 -1
  63. package/dist/recorder/download.mjs.map +0 -1
  64. package/dist/recorder/ffmpeg-utils.mjs.map +0 -1
  65. package/dist/recorder/index.d.mts +0 -7
  66. package/dist/recorder/index.d.mts.map +0 -1
  67. package/dist/recorder/index.mjs.map +0 -1
  68. package/dist/recorder/normalize.mjs.map +0 -1
  69. package/dist/recorder/post-processing.mjs.map +0 -1
  70. package/dist/recorder/recorder-events.mjs.map +0 -1
  71. package/dist/recorder/recorder-state.mjs.map +0 -1
  72. package/dist/recorder/stream.mjs.map +0 -1
  73. package/dist/ui.mjs.map +0 -1
  74. package/dist/utils.mjs.map +0 -1
@@ -1,6 +1,6 @@
1
- import { ensureDir } from "../utils.mjs";
1
+ import { ensureDir } from "../../lib/utils.mjs";
2
2
  import { downloadStream } from "./download.mjs";
3
- //#region src/recorder/stream.ts
3
+ //#region src/core/recorder/stream.ts
4
4
  function createStreamDownloader(logger) {
5
5
  let abortController = new AbortController();
6
6
  function abort() {
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.mjs","names":[],"sources":["../../../src/core/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — downloads a live stream to disk using FFmpeg.\n *\n * Both FLV and HLS streams are downloaded via FFmpeg subprocess with\n * transparent reconnection for transient network drops. Routes to the\n * appropriate downloader based on URL format.\n */\n\nimport type { Logger } from \"../../lib/logger\"\nimport { ensureDir } from \"../../lib/utils\"\nimport { downloadStream } from \"./download\"\n\nexport interface ProgressInfo {\n bytes: number\n elapsed: number\n speed: number\n}\n\nexport interface DownloadResult {\n file: string\n duration: number\n size: number\n}\n\nexport interface StreamDownloader {\n /** Download a live stream. Resolves when the stream ends or is aborted. */\n download: (\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration?: number,\n onProgress?: (info: ProgressInfo) => void,\n /** Called when a stream segment ends and we attempt to fetch a fresh URL. */\n getNextUrl?: () => Promise<string | null>,\n ) => Promise<DownloadResult>\n /** Abort an active download */\n abort: () => void\n}\n\nexport function createStreamDownloader(logger?: Logger): StreamDownloader {\n let abortController = new AbortController()\n\n function abort(): void {\n abortController.abort()\n }\n\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n async function download(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration = 0,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n abortController = new AbortController()\n ensureDir(outputDir)\n\n return downloadStream(\n liveUrl,\n user,\n outputDir,\n maxDuration,\n onProgress,\n getNextUrl,\n abortController.signal,\n logger,\n isHlsUrl(liveUrl) ? \"HLS\" : undefined,\n )\n }\n\n return { download, abort }\n}\n"],"mappings":";;;AAuCA,SAAgB,uBAAuB,QAAmC;CACxE,IAAI,kBAAkB,IAAI,gBAAgB;CAE1C,SAAS,QAAc;EACrB,gBAAgB,MAAM;CACxB;CAEA,SAAS,SAAS,KAAsB;EACtC,OAAO,IAAI,SAAS,OAAO;CAC7B;CAEA,eAAe,SACb,SACA,MACA,WACA,cAAc,GACd,YACA,YACyB;EACzB,kBAAkB,IAAI,gBAAgB;EACtC,UAAU,SAAS;EAEnB,OAAO,eACL,SACA,MACA,WACA,aACA,YACA,YACA,gBAAgB,QAChB,QACA,SAAS,OAAO,IAAI,QAAQ,KAAA,CAC9B;CACF;CAEA,OAAO;EAAE;EAAU;CAAM;AAC3B"}
package/dist/index.d.mts CHANGED
@@ -1 +1,3 @@
1
- export { };
1
+ import { AppErrorKind, RecorderConfig, RecorderController, RecorderEvent, RecorderEventHandler, RecorderStatus, TikTokError } from "./lib/config.mjs";
2
+ import { createRecorder } from "./core/recorder/index.mjs";
3
+ export { type AppErrorKind, type RecorderConfig, type RecorderController, type RecorderEvent, type RecorderEventHandler, type RecorderStatus, type TikTokError, createRecorder };
package/dist/index.mjs CHANGED
@@ -1,143 +1,2 @@
1
- #!/usr/bin/env bun
2
- import { TikTokError, validateConfig } from "./config.mjs";
3
- import { bytesToHuman, relativeTime } from "./utils.mjs";
4
- import { createRecorder } from "./recorder/index.mjs";
5
- import "./lib.mjs";
6
- import { version } from "./package.mjs";
7
- import { extractTikTokCookiesFromFirefox } from "./browser-cookies.mjs";
8
- import { parseArgs } from "./cli.mjs";
9
- import { createDisplay } from "./ui.mjs";
10
- import { existsSync, readFileSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { color } from "@zfadhli/koko-cli";
13
- //#region src/index.ts
14
- /**
15
- * Entry point — parses CLI args, creates the recorder, and handles signals.
16
- * Uses the terminal Display manager for beautiful console output.
17
- */
18
- async function loadCookies(cookiesPath) {
19
- const path = cookiesPath ?? join(process.cwd(), "cookies.json");
20
- if (!existsSync(path)) return;
21
- try {
22
- const content = readFileSync(path, "utf-8");
23
- const data = JSON.parse(content);
24
- if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
25
- return;
26
- } catch {
27
- return;
28
- }
29
- }
30
- async function main() {
31
- let config;
32
- try {
33
- config = parseArgs();
34
- validateConfig(config);
35
- } catch (err) {
36
- if (err instanceof TikTokError && err.kind === "config-error") {
37
- console.error(`${color.red("Error:")} ${err.message}`);
38
- process.exit(1);
39
- }
40
- throw err;
41
- }
42
- const firefoxCookies = extractTikTokCookiesFromFirefox();
43
- config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
44
- config.logConsole = false;
45
- const display = createDisplay();
46
- display.showVersion("tokrec", version);
47
- if (config.cookies) {
48
- const source = firefoxCookies ? "Firefox" : "cookies.json";
49
- const names = Object.keys(config.cookies);
50
- const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
51
- display.showInfo(`${source} cookies loaded${detail}`);
52
- } else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
53
- const recorder = createRecorder(config);
54
- let lastWasLive = null;
55
- let firstOfflineTime = 0;
56
- recorder.on("checking", (info) => {
57
- display.checkingUser(info.user);
58
- });
59
- recorder.on("tick", (info) => {
60
- if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
61
- else if (lastWasLive === null || lastWasLive === true) {
62
- display.userOffline(info.user);
63
- firstOfflineTime = Date.now();
64
- } else {
65
- const elapsed = Date.now() - firstOfflineTime;
66
- display.userOfflineRepeat(info.user, relativeTime(elapsed));
67
- }
68
- lastWasLive = info.isLive;
69
- });
70
- recorder.on("recording:start", () => {
71
- display.startRecording();
72
- });
73
- recorder.on("download:end", (info) => {
74
- const parsed = info.file.split("/").pop() ?? info.file;
75
- const sizeStr = bytesToHuman(info.size);
76
- display.finishRecording(parsed, info.duration, sizeStr);
77
- });
78
- recorder.on("segmenting:start", () => {
79
- display.startSegmenting();
80
- });
81
- recorder.on("segmenting:end", (info) => {
82
- display.segmentsCreated(info.segments);
83
- });
84
- recorder.on("converting:start", () => {
85
- display.startConverting();
86
- });
87
- recorder.on("converted", (info) => {
88
- const parsed = info.output.split("/").pop() ?? info.output;
89
- display.conversionDone(parsed);
90
- });
91
- recorder.on("recording:end", () => {});
92
- recorder.on("error", (err) => {
93
- display.showError(`[${err.kind}] ${err.message}`);
94
- });
95
- recorder.on("normalize:start", () => {
96
- display.normalizeStart();
97
- });
98
- recorder.on("normalize:progress", (info) => {
99
- display.normalizeProgress(info.percent, info.phase);
100
- });
101
- recorder.on("normalize:end", () => {
102
- display.normalizeComplete();
103
- });
104
- recorder.on("normalize:error", (info) => {
105
- display.normalizeError(info.error);
106
- });
107
- let stopping = false;
108
- const handleSignal = async () => {
109
- if (stopping) return;
110
- stopping = true;
111
- process.off("SIGINT", handleSignal);
112
- process.off("SIGTERM", handleSignal);
113
- process.off("SIGHUP", handleSignal);
114
- display.showInfo("Shutting down gracefully...");
115
- await recorder.stop();
116
- display.stop();
117
- process.exit(0);
118
- };
119
- process.on("SIGINT", handleSignal);
120
- process.on("SIGTERM", handleSignal);
121
- process.on("SIGHUP", handleSignal);
122
- display.pollingStarted(config.interval ?? 3);
123
- try {
124
- await recorder.start();
125
- } catch (err) {
126
- if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
127
- else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
128
- process.off("SIGINT", handleSignal);
129
- process.off("SIGTERM", handleSignal);
130
- process.off("SIGHUP", handleSignal);
131
- await recorder.stop();
132
- display.stop();
133
- process.exit(1);
134
- }
135
- }
136
- main().catch((err) => {
137
- console.error("Fatal:", err instanceof Error ? err.message : String(err));
138
- process.exit(1);
139
- });
140
- //#endregion
141
- export {};
142
-
143
- //# sourceMappingURL=index.mjs.map
1
+ import { createRecorder } from "./core/recorder/index.mjs";
2
+ export { createRecorder };
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import { homedir } from "node:os";
5
- //#region src/browser-cookies.ts
5
+ //#region src/lib/browser-cookies.ts
6
6
  /**
7
7
  * Browser cookie extraction — reads TikTok session cookies from Firefox's
8
8
  * cookie store using bun:sqlite (no external dependencies).
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-cookies.mjs","names":[],"sources":["../../src/lib/browser-cookies.ts"],"sourcesContent":["/**\n * Browser cookie extraction — reads TikTok session cookies from Firefox's\n * cookie store using bun:sqlite (no external dependencies).\n *\n * Falls back to cookies.json if Firefox isn't available or if no TikTok\n * login session is found.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { join } from \"node:path\"\n\n/**\n * Extract TikTok session cookies from Firefox's cookie store.\n *\n * Queries ALL cookies for TikTok domains from Firefox, including the\n * Slardar WAF challenge cookie which is needed to bypass bot detection.\n *\n * Returns null if Firefox isn't found, cookies can't be read, or no\n * sessionid_ss cookie exists (without which the session is useless).\n */\nexport function extractTikTokCookiesFromFirefox(): Record<string, string> | null {\n const dbPath = findFirefoxCookieDb()\n if (!dbPath) return null\n\n try {\n const db = new Database(dbPath, { readonly: true })\n try {\n const rows = db\n .query(\n `SELECT name, value FROM moz_cookies\n WHERE host LIKE '%.tiktok.com' OR host LIKE '%.tiktokv.com'`,\n )\n .all() as Array<{ name: string; value: string }>\n\n // Require sessionid_ss — without it the session is useless\n const hasSession = rows.some((r) => r.name === \"sessionid_ss\")\n if (!hasSession) return null\n\n const cookies: Record<string, string> = {}\n for (const row of rows) {\n cookies[row.name] = row.value\n }\n return cookies\n } finally {\n db.close()\n }\n } catch {\n return null\n }\n}\n\n/**\n * Locate the Firefox cookies.sqlite by scanning profile directories.\n *\n * Strategy:\n * 1. Check ~/.mozilla/firefox/ for profiles.ini and parse the default profile.\n * 2. Fall back to scanning directory entries that look like Firefox profiles\n * (contain a cookies.sqlite file).\n */\n/** @public Exported for testing */\nexport function findFirefoxCookieDb(): string | null {\n const firefoxDir = join(homedir(), \".mozilla\", \"firefox\")\n if (!existsSync(firefoxDir)) return null\n\n // Try profiles.ini first for a reliable default-profile path\n const iniPath = join(firefoxDir, \"profiles.ini\")\n if (existsSync(iniPath)) {\n try {\n const profilePath = parseFirefoxProfilesIni(iniPath, firefoxDir)\n if (profilePath) {\n const dbPath = join(profilePath, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n } catch {\n // fall through to directory scan\n }\n }\n\n // Fallback: scan for any directory containing cookies.sqlite\n const entries = readdirSync(firefoxDir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && !entry.isSymbolicLink()) continue\n const dbPath = join(firefoxDir, entry.name, \"cookies.sqlite\")\n if (existsSync(dbPath)) return dbPath\n }\n\n return null\n}\n\n/**\n * Parse Firefox's profiles.ini to find the default profile path.\n *\n * The INI format looks like:\n * [Profile0]\n * Name=default\n * IsRelative=1\n * Path=1pese0rl.default-release\n * Default=1\n */\n/** @public Exported for testing */\nexport function parseFirefoxProfilesIni(iniPath: string, firefoxDir: string): string | null {\n const text = readFileSync(iniPath, \"utf-8\")\n\n let defaultPath: string | null = null\n let isRelative = true\n let inProfile = false\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim()\n\n // Section header\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n if (inProfile && defaultPath) break // found it\n inProfile = trimmed.toLowerCase().startsWith(\"[profile\")\n if (inProfile) {\n defaultPath = null\n isRelative = true\n }\n continue\n }\n\n if (!inProfile) continue\n\n const lower = trimmed.toLowerCase()\n if (lower.startsWith(\"path=\")) {\n defaultPath = trimmed.slice(5)\n } else if (lower.startsWith(\"isrelative=\")) {\n isRelative = trimmed.slice(11).trim() !== \"0\"\n } else if (lower.startsWith(\"default=\")) {\n if (trimmed.slice(8).trim() !== \"1\") {\n // Not the default profile — skip\n inProfile = false\n defaultPath = null\n }\n }\n }\n\n if (!defaultPath) return null\n return isRelative ? join(firefoxDir, defaultPath) : defaultPath\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,kCAAiE;CAC/E,MAAM,SAAS,oBAAoB;CACnC,IAAI,CAAC,QAAQ,OAAO;CAEpB,IAAI;EACF,MAAM,KAAK,IAAI,SAAS,QAAQ,EAAE,UAAU,KAAK,CAAC;EAClD,IAAI;GACF,MAAM,OAAO,GACV,MACC;uEAEF,CAAC,CACA,IAAI;GAIP,IAAI,CADe,KAAK,MAAM,MAAM,EAAE,SAAS,cACjC,GAAG,OAAO;GAExB,MAAM,UAAkC,CAAC;GACzC,KAAK,MAAM,OAAO,MAChB,QAAQ,IAAI,QAAQ,IAAI;GAE1B,OAAO;EACT,UAAU;GACR,GAAG,MAAM;EACX;CACF,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAgB,sBAAqC;CACnD,MAAM,aAAa,KAAK,QAAQ,GAAG,YAAY,SAAS;CACxD,IAAI,CAAC,WAAW,UAAU,GAAG,OAAO;CAGpC,MAAM,UAAU,KAAK,YAAY,cAAc;CAC/C,IAAI,WAAW,OAAO,GACpB,IAAI;EACF,MAAM,cAAc,wBAAwB,SAAS,UAAU;EAC/D,IAAI,aAAa;GACf,MAAM,SAAS,KAAK,aAAa,gBAAgB;GACjD,IAAI,WAAW,MAAM,GAAG,OAAO;EACjC;CACF,QAAQ,CAER;CAIF,MAAM,UAAU,YAAY,YAAY,EAAE,eAAe,KAAK,CAAC;CAC/D,KAAK,MAAM,SAAS,SAAS;EAC3B,IAAI,CAAC,MAAM,YAAY,KAAK,CAAC,MAAM,eAAe,GAAG;EACrD,MAAM,SAAS,KAAK,YAAY,MAAM,MAAM,gBAAgB;EAC5D,IAAI,WAAW,MAAM,GAAG,OAAO;CACjC;CAEA,OAAO;AACT;;;;;;;;;;;;AAaA,SAAgB,wBAAwB,SAAiB,YAAmC;CAC1F,MAAM,OAAO,aAAa,SAAS,OAAO;CAE1C,IAAI,cAA6B;CACjC,IAAI,aAAa;CACjB,IAAI,YAAY;CAEhB,KAAK,MAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;EACnC,MAAM,UAAU,KAAK,KAAK;EAG1B,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GAAG;GACpD,IAAI,aAAa,aAAa;GAC9B,YAAY,QAAQ,YAAY,CAAC,CAAC,WAAW,UAAU;GACvD,IAAI,WAAW;IACb,cAAc;IACd,aAAa;GACf;GACA;EACF;EAEA,IAAI,CAAC,WAAW;EAEhB,MAAM,QAAQ,QAAQ,YAAY;EAClC,IAAI,MAAM,WAAW,OAAO,GAC1B,cAAc,QAAQ,MAAM,CAAC;OACxB,IAAI,MAAM,WAAW,aAAa,GACvC,aAAa,QAAQ,MAAM,EAAE,CAAC,CAAC,KAAK,MAAM;OACrC,IAAI,MAAM,WAAW,UAAU;OAChC,QAAQ,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,KAAK;IAEnC,YAAY;IACZ,cAAc;GAChB;;CAEJ;CAEA,IAAI,CAAC,aAAa,OAAO;CACzB,OAAO,aAAa,KAAK,YAAY,WAAW,IAAI;AACtD"}
@@ -1,8 +1,8 @@
1
1
  import { TikTokError } from "./config.mjs";
2
2
  import { sanitizeUser } from "./utils.mjs";
3
- import { version } from "./package.mjs";
3
+ import { version } from "../package.mjs";
4
4
  import { createCLI } from "@zfadhli/koko-cli";
5
- //#region src/cli.ts
5
+ //#region src/lib/cli.ts
6
6
  /**
7
7
  * CLI argument parser — uses koko-cli's createCLI (wraps cac).
8
8
  * Exports a single parseArgs() function.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../../src/lib/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from \"@zfadhli/koko-cli\"\nimport pkg from \"../../package.json\"\nimport type { LogLevel, RecorderConfig } from \"./config\"\nimport { TikTokError } from \"./config\"\nimport { sanitizeUser } from \"./utils\"\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI(\"tokrec\", pkg.version).description(\n \"Minimal TikTok live stream recorder — Bun + TypeScript\",\n )\n\n cli.command(\"\", \"Record a TikTok live stream\", (cmd) => {\n cmd.option(\"-u, --user <name>\", \"TikTok username (or pass as first argument)\")\n cmd.option(\"-o, --output <dir>\", \"Output directory\", {\n default: \"./recordings\",\n })\n cmd.option(\"-i, --interval <minutes>\", \"Polling interval in minutes\", {\n default: \"3\",\n })\n cmd.option(\"-d, --duration <minutes>\", \"Max recording duration in minutes\")\n cmd.option(\"-p, --proxy <url>\", \"HTTP proxy (e.g. http://127.0.0.1:8080)\")\n cmd.option(\"-l, --log-level <level>\", \"Log level: debug | info | warn | error\")\n cmd.option(\"-c, --cookies <path>\", \"Path to cookies.json\")\n cmd.option(\n \"-s, --segment-minutes <minutes>\",\n \"Segment duration in minutes (default: disabled). Split recording into segments of this length.\",\n )\n cmd.option(\"--normalize\", \"Normalize audio loudness (EBU R128)\")\n cmd.option(\"--normalize-loudness <num>\", \"Target loudness in LUFS (default: -14)\")\n cmd.option(\"--normalize-codec <name>\", \"Audio codec for normalized output (default: libopus)\")\n cmd.option(\"--normalize-bitrate <str>\", \"Audio bitrate for normalized output (default: 96k)\")\n cmd.option(\"--rate <n>\", \"Max API requests per second (default: 5). Set 0 for unlimited.\")\n cmd.option(\"--debug\", \"Show API debug logging on stderr for troubleshooting\")\n\n cmd.action((opts: Record<string, unknown>) => {\n try {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError(\"config-error\", \"<user> is required (positional or --user)\")\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be a number >= 1\")\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be a number >= 0 (minutes)\")\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = [\"debug\", \"info\", \"warn\", \"error\"]\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n \"config-error\",\n `--log-level must be one of: ${levels.join(\", \")}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--segment-minutes must be a number >= 0\")\n }\n parsed.segmentMinutes = n\n }\n\n if (opts.normalize !== undefined) parsed.normalizeAudio = opts.normalize as boolean\n if (opts.normalizeLoudness !== undefined) {\n const n = Number(opts.normalizeLoudness)\n if (!Number.isFinite(n)) {\n throw new TikTokError(\"config-error\", \"--normalize-loudness must be a number\")\n }\n parsed.normalizeLoudness = n\n }\n if (opts.normalizeCodec) parsed.normalizeCodec = opts.normalizeCodec as string\n if (opts.normalizeBitrate) parsed.normalizeBitrate = opts.normalizeBitrate as string\n if (opts.rate !== undefined) {\n const n = Number(opts.rate)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError(\"config-error\", \"--rate must be a number >= 0\")\n }\n parsed.ratePerSecond = n\n }\n if (opts.debug) parsed.debug = true\n\n config = parsed\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push(\"--user\", positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError(\"config-error\", error)\n }\n\n if (!config) {\n throw new TikTokError(\n \"config-error\",\n \"<user> is required (positional or --user). Use --help for usage.\",\n )\n }\n\n return config\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i] as string\n // -- signals end of options; anything after is a positional\n if (arg === \"--\") {\n const pos = argv.splice(i, 1)[0] as string\n return pos\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith(\"-\")) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1]\n if (next && !next.startsWith(\"-\") && arg !== \"--help\" && arg !== \"--version\") {\n i++ // skip the value\n }\n continue\n }\n // Found a bare positional — remove it and return\n argv.splice(i, 1)\n return arg\n }\n return null\n}\n"],"mappings":";;;;;;;;;AAWA,SAAgB,UAAU,OAAiB,QAAQ,MAAsB;CACvE,IAAI;CACJ,IAAI;CAEJ,MAAM,MAAM,UAAU,UAAUA,OAAW,CAAC,CAAC,YAC3C,wDACF;CAEA,IAAI,QAAQ,IAAI,gCAAgC,QAAQ;EACtD,IAAI,OAAO,qBAAqB,6CAA6C;EAC7E,IAAI,OAAO,sBAAsB,oBAAoB,EACnD,SAAS,eACX,CAAC;EACD,IAAI,OAAO,4BAA4B,+BAA+B,EACpE,SAAS,IACX,CAAC;EACD,IAAI,OAAO,4BAA4B,mCAAmC;EAC1E,IAAI,OAAO,qBAAqB,yCAAyC;EACzE,IAAI,OAAO,2BAA2B,wCAAwC;EAC9E,IAAI,OAAO,wBAAwB,sBAAsB;EACzD,IAAI,OACF,mCACA,gGACF;EACA,IAAI,OAAO,eAAe,qCAAqC;EAC/D,IAAI,OAAO,8BAA8B,wCAAwC;EACjF,IAAI,OAAO,4BAA4B,sDAAsD;EAC7F,IAAI,OAAO,6BAA6B,oDAAoD;EAC5F,IAAI,OAAO,cAAc,gEAAgE;EACzF,IAAI,OAAO,WAAW,sDAAsD;EAE5E,IAAI,QAAQ,SAAkC;GAC5C,IAAI;IACF,MAAM,OAAO,KAAK;IAClB,IAAI,CAAC,MACH,MAAM,IAAI,YAAY,gBAAgB,2CAA2C;IAGnF,MAAM,SAAyB,EAC7B,MAAM,aAAa,IAAI,EACzB;IAEA,IAAI,KAAK,QAAQ,OAAO,YAAY,KAAK;IACzC,IAAI,KAAK,aAAa,KAAA,GAAW;KAC/B,MAAM,IAAI,OAAO,KAAK,QAAQ;KAC9B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,kCAAkC;KAE1E,OAAO,WAAW;IACpB;IACA,IAAI,KAAK,aAAa,KAAA,GAAW;KAC/B,MAAM,IAAI,OAAO,KAAK,QAAQ;KAC9B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,4CAA4C;KAEpF,OAAO,WAAW,IAAI;IACxB;IACA,IAAI,KAAK,OAAO,OAAO,QAAQ,KAAK;IACpC,IAAI,KAAK,UAAU;KACjB,MAAM,SAAS;MAAC;MAAS;MAAQ;MAAQ;KAAO;KAChD,IAAI,CAAC,OAAO,SAAS,KAAK,QAAkB,GAC1C,MAAM,IAAI,YACR,gBACA,+BAA+B,OAAO,KAAK,IAAI,GACjD;KAEF,OAAO,WAAW,KAAK;IACzB;IACA,IAAI,KAAK,SAAS,OAAO,cAAc,KAAK;IAC5C,IAAI,KAAK,mBAAmB,KAAA,GAAW;KACrC,MAAM,IAAI,OAAO,KAAK,cAAc;KACpC,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,yCAAyC;KAEjF,OAAO,iBAAiB;IAC1B;IAEA,IAAI,KAAK,cAAc,KAAA,GAAW,OAAO,iBAAiB,KAAK;IAC/D,IAAI,KAAK,sBAAsB,KAAA,GAAW;KACxC,MAAM,IAAI,OAAO,KAAK,iBAAiB;KACvC,IAAI,CAAC,OAAO,SAAS,CAAC,GACpB,MAAM,IAAI,YAAY,gBAAgB,uCAAuC;KAE/E,OAAO,oBAAoB;IAC7B;IACA,IAAI,KAAK,gBAAgB,OAAO,iBAAiB,KAAK;IACtD,IAAI,KAAK,kBAAkB,OAAO,mBAAmB,KAAK;IAC1D,IAAI,KAAK,SAAS,KAAA,GAAW;KAC3B,MAAM,IAAI,OAAO,KAAK,IAAI;KAC1B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,8BAA8B;KAEtE,OAAO,gBAAgB;IACzB;IACA,IAAI,KAAK,OAAO,OAAO,QAAQ;IAE/B,SAAS;GACX,SAAS,KAAK;IACZ,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GACzD;EACF,CAAC;CACH,CAAC;CAID,MAAM,iBAAiB,kBAAkB,IAAI;CAC7C,IAAI,gBACF,KAAK,KAAK,UAAU,cAAc;CAGpC,IAAI,MAAM,IAAI;CAEd,IAAI,OACF,MAAM,IAAI,YAAY,gBAAgB,KAAK;CAG7C,IAAI,CAAC,QACH,MAAM,IAAI,YACR,gBACA,kEACF;CAGF,OAAO;AACT;;;;;AAMA,SAAS,kBAAkB,MAA+B;CACxD,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EACpC,MAAM,MAAM,KAAK;EAEjB,IAAI,QAAQ,MAEV,OADY,KAAK,OAAO,GAAG,CAAC,CAAC,CAAC;EAIhC,IAAI,IAAI,WAAW,GAAG,GAAG;GAEvB,MAAM,OAAO,KAAK,IAAI;GACtB,IAAI,QAAQ,CAAC,KAAK,WAAW,GAAG,KAAK,QAAQ,YAAY,QAAQ,aAC/D;GAEF;EACF;EAEA,KAAK,OAAO,GAAG,CAAC;EAChB,OAAO;CACT;CACA,OAAO;AACT"}
@@ -1,4 +1,4 @@
1
- //#region src/config.d.ts
1
+ //#region src/lib/config.d.ts
2
2
  /**
3
3
  * Config — types, defaults, and normalization for the recorder.
4
4
  * The single source of truth for every user-facing option.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.mts","names":[],"sources":["../../src/lib/config.ts"],"mappings":";;AAUA;;;;AAAyB;AAEzB;;;KAFY,UAAA,GAAa,MAAA;AAAA,KAEb,QAAA;AAAA,UAEK,cAAA;EAAA;EAEf,IAAA;EAcW;EAZX,SAAA;;EAEA,QAAA;;EAEA,QAAA;;EAEA,KAAA;;EAEA,WAAA;;EAEA,OAAA,GAAU,UAAA;;EAEV,QAAA,GAAW,QAAA;;EAEX,UAAA;;EAEA,cAAA;;EAEA,cAAA;EAcA;EAZA,iBAAA;EAee;EAbf,cAAA;;EAEA,gBAAA;;;;;;EAMA,aAAA;;EAEA,KAAA;AAAA;AAAA,UAGe,kBAAA;EAUmE;EARlF,KAAA,IAAS,OAAA;;EAET,IAAA,IAAQ,OAAA;;EAER,SAAA,IAAa,cAAA;;EAEb,EAAA,iBAAmB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;;EAEjF,GAAA,iBAAoB,oBAAA,EAAsB,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,oBAAA,CAAqB,CAAA;AAAA;AAAA,UAGnE,cAAA;EACf,KAAA;EACA,IAAA;EACA,WAAA;EACA,eAAA;EACA,YAAA;EACA,SAAA;AAAA;AAAA,KAGU,aAAA,SAAsB,oBAAA;AAAA,UAEjB,oBAAA;EACf,QAAA,GAAW,IAAA;IAAQ,IAAA;EAAA;EACnB,IAAA,GAAO,IAAA;IAAQ,IAAA;IAAc,MAAA;IAAiB,MAAA;EAAA;EAC9C,iBAAA,GAAoB,IAAA;IAAQ,IAAA;IAAc,IAAA;EAAA;EAC1C,mBAAA,GAAsB,IAAA;IAAQ,KAAA;IAAe,OAAA;IAAiB,KAAA;EAAA;EAC9D,cAAA,GAAiB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EACzD,eAAA,GAAkB,IAAA;IAAQ,IAAA;IAAc,QAAA;IAAkB,IAAA;EAAA;EAC1D,kBAAA,GAAqB,IAAA;IAAQ,KAAA;IAAe,aAAA;EAAA;EAC5C,gBAAA,GAAmB,IAAA;IAAQ,QAAA;EAAA;EAC3B,kBAAA,GAAqB,IAAA;IAAQ,KAAA;EAAA;EAC7B,SAAA,GAAY,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACnC,iBAAA,GAAoB,IAAA;IAAQ,IAAA;EAAA;EAC5B,oBAAA,GAAuB,IAAA;IACrB,IAAA;IACA,OAAA;IACA,KAAA;EAAA;EAEF,eAAA,GAAkB,IAAA;IAAQ,KAAA;IAAe,MAAA;EAAA;EACzC,iBAAA,GAAoB,IAAA;IAAQ,KAAA;IAAe,KAAA;EAAA;EAC3C,KAAA,GAAQ,GAAA,EAAK,WAAA;AAAA;AAAA,KAGH,YAAA;AAAA,cAaC,WAAA,SAAoB,KAAA;EAAA,SAE7B,IAAA,EAAsB,YAAA;EAAA,kBAEtB,KAAA;EAHF,WAAA,CACE,IAAA,EAAsB,YAAA,EACtB,OAAA,UACA,KAAA;AAAA"}
@@ -1,4 +1,4 @@
1
- //#region src/config.ts
1
+ //#region src/lib/config.ts
2
2
  var TikTokError = class extends Error {
3
3
  kind;
4
4
  cause;
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../../src/lib/config.ts"],"sourcesContent":["/**\n * Config — types, defaults, and normalization for the recorder.\n * The single source of truth for every user-facing option.\n */\n\n/**\n * TikTok cookie key-value pairs extracted from Firefox or cookies.json.\n * Includes session cookies (sessionid_ss, tt-target-idc) and any WAF\n * bypass cookies needed to avoid Slardar challenges on www.tiktok.com.\n */\nexport type CookieAuth = Record<string, string>\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nexport interface RecorderConfig {\n /** TikTok username (required) */\n user: string\n /** Output directory for recordings (default: ./recordings) */\n outputDir?: string\n /** Polling interval in minutes (default: 3) */\n interval?: number\n /** Max recording duration in seconds (default: 0 — unlimited). CLI accepts minutes, converted internally. */\n duration?: number\n /** HTTP proxy URL (e.g. http://127.0.0.1:8080) */\n proxy?: string\n /** Path to cookies.json (default: ./cookies.json) */\n cookiesPath?: string\n /** Cookie auth loaded from cookies.json */\n cookies?: CookieAuth\n /** Log level (default: info) */\n logLevel?: LogLevel\n /** Whether to print logs to console (default: true). Set false when using Display-based UI. */\n logConsole?: boolean\n /** Segment duration in minutes (default: 0 — disabled). When >= 1, the recording is split into segments of this length. */\n segmentMinutes?: number\n /** Normalize audio loudness via EBU R128 (default: false). */\n normalizeAudio?: boolean\n /** Target loudness in LUFS (default: -14). */\n normalizeLoudness?: number\n /** Audio codec for normalized output (default: peaknorm's default, \"libopus\"). */\n normalizeCodec?: string\n /** Audio bitrate for normalized output (default: peaknorm's default, \"96k\"). */\n normalizeBitrate?: string\n /**\n * Max API requests per second (default: 5).\n * Prevents TikTok WAF triggering on rapid polling or URL refresh loops.\n * Set to 0 for unlimited (not recommended).\n */\n ratePerSecond?: number\n /** Show API debug logging on stderr (default: false). */\n debug?: boolean\n}\n\nexport interface RecorderController {\n /** Start the polling loop */\n start(): Promise<void>\n /** Gracefully stop (joins active recording, then exits) */\n stop(): Promise<void>\n /** Snapshot of current state */\n getStatus(): RecorderStatus\n /** Subscribe to events */\n on<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n /** Unsubscribe a previously registered event handler */\n off<E extends keyof RecorderEventHandler>(event: E, handler: RecorderEventHandler[E]): void\n}\n\nexport interface RecorderStatus {\n state: \"idle\" | \"polling\" | \"recording\" | \"converting\" | \"stopped\"\n user: string\n currentFile?: string\n sessionDuration?: number\n lastPollTime?: string\n lastError?: string\n}\n\nexport type RecorderEvent = keyof RecorderEventHandler\n\nexport interface RecorderEventHandler {\n checking: (info: { user: string }) => void\n tick: (info: { user: string; isLive: boolean; roomId?: string }) => void\n \"recording:start\": (info: { user: string; file: string }) => void\n \"download:progress\": (info: { bytes: number; elapsed: number; speed: number }) => void\n \"download:end\": (info: { file: string; duration: number; size: number }) => void\n \"recording:end\": (info: { file: string; duration: number; size: number }) => void\n \"segmenting:start\": (info: { input: string; outputPattern: string }) => void\n \"segmenting:end\": (info: { segments: number }) => void\n \"converting:start\": (info: { input: string }) => void\n converted: (info: { input: string; output: string }) => void\n \"normalize:start\": (info: { file: string }) => void\n \"normalize:progress\": (info: {\n file: string\n percent: number\n phase: \"analyzing\" | \"normalizing\"\n }) => void\n \"normalize:end\": (info: { input: string; output: string }) => void\n \"normalize:error\": (info: { input: string; error: string }) => void\n error: (err: TikTokError) => void\n}\n\nexport type AppErrorKind =\n | \"user-not-live\"\n | \"room-not-found\"\n | \"stream-url-not-found\"\n | \"waf-blocked\"\n | \"country-blocked\"\n | \"network-error\"\n | \"ffmpeg-not-found\"\n | \"ffmpeg-error\"\n | \"aborted\"\n | \"config-error\"\n | \"unknown\"\n\nexport class TikTokError extends Error {\n constructor(\n public readonly kind: AppErrorKind,\n message: string,\n public override readonly cause?: unknown,\n ) {\n super(message)\n this.name = \"TikTokError\"\n }\n}\n\nconst DEFAULTS = {\n outputDir: \"./recordings\",\n interval: 3,\n duration: 0,\n logLevel: \"info\" as LogLevel,\n logConsole: true,\n segmentMinutes: 0,\n normalizeAudio: true,\n normalizeLoudness: -14,\n ratePerSecond: 5,\n debug: false,\n}\n\nexport function normalizeConfig(\n config: RecorderConfig,\n): Required<\n Omit<RecorderConfig, \"cookies\" | \"cookiesPath\" | \"proxy\" | \"normalizeCodec\" | \"normalizeBitrate\">\n> &\n Pick<\n RecorderConfig,\n \"cookies\" | \"cookiesPath\" | \"proxy\" | \"normalizeCodec\" | \"normalizeBitrate\"\n > {\n return {\n user: config.user,\n outputDir: config.outputDir ?? DEFAULTS.outputDir,\n interval: config.interval ?? DEFAULTS.interval,\n duration: config.duration ?? DEFAULTS.duration,\n logLevel: config.logLevel ?? DEFAULTS.logLevel,\n logConsole: config.logConsole ?? DEFAULTS.logConsole,\n proxy: config.proxy,\n cookies: config.cookies,\n cookiesPath: config.cookiesPath,\n segmentMinutes: config.segmentMinutes ?? DEFAULTS.segmentMinutes,\n normalizeAudio: config.normalizeAudio ?? DEFAULTS.normalizeAudio,\n normalizeLoudness: config.normalizeLoudness ?? DEFAULTS.normalizeLoudness,\n normalizeCodec: config.normalizeCodec,\n normalizeBitrate: config.normalizeBitrate,\n ratePerSecond: config.ratePerSecond ?? DEFAULTS.ratePerSecond,\n debug: config.debug ?? DEFAULTS.debug,\n }\n}\n\nexport function validateConfig(config: RecorderConfig): void {\n if (!config.user || config.user.trim().length === 0) {\n throw new TikTokError(\"config-error\", \"--user is required\")\n }\n if (config.interval !== undefined && config.interval < 1) {\n throw new TikTokError(\"config-error\", \"--interval must be >= 1 minute\")\n }\n if (config.duration !== undefined && config.duration < 0) {\n throw new TikTokError(\"config-error\", \"--duration must be >= 0 seconds\")\n }\n}\n"],"mappings":";AAgHA,IAAa,cAAb,cAAiC,MAAM;CAEnB;CAES;CAH3B,YACE,MACA,SACA,OACA;EACA,MAAM,OAAO;EAJG,KAAA,OAAA;EAES,KAAA,QAAA;EAGzB,KAAK,OAAO;CACd;AACF;AAEA,MAAM,WAAW;CACf,WAAW;CACX,UAAU;CACV,UAAU;CACV,UAAU;CACV,YAAY;CACZ,gBAAgB;CAChB,gBAAgB;CAChB,mBAAmB;CACnB,eAAe;CACf,OAAO;AACT;AAEA,SAAgB,gBACd,QAOE;CACF,OAAO;EACL,MAAM,OAAO;EACb,WAAW,OAAO,aAAa,SAAS;EACxC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,UAAU,OAAO,YAAY,SAAS;EACtC,YAAY,OAAO,cAAc,SAAS;EAC1C,OAAO,OAAO;EACd,SAAS,OAAO;EAChB,aAAa,OAAO;EACpB,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,gBAAgB,OAAO,kBAAkB,SAAS;EAClD,mBAAmB,OAAO,qBAAqB,SAAS;EACxD,gBAAgB,OAAO;EACvB,kBAAkB,OAAO;EACzB,eAAe,OAAO,iBAAiB,SAAS;EAChD,OAAO,OAAO,SAAS,SAAS;CAClC;AACF;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"}
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- //#region src/logger.ts
3
+ //#region src/lib/logger.ts
4
4
  /**
5
5
  * Logger — rotating file + console with levels.
6
6
  * Zero-dependency minimal implementation (~60 lines).
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.mjs","names":[],"sources":["../../src/lib/logger.ts"],"sourcesContent":["/**\n * Logger — rotating file + console with levels.\n * Zero-dependency minimal implementation (~60 lines).\n */\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from \"node:fs\"\nimport { dirname } from \"node:path\"\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nconst LEVEL_ORDER: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n}\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n debug: \"\\x1b[90m\", // gray\n info: \"\\x1b[36m\", // cyan\n warn: \"\\x1b[33m\", // yellow\n error: \"\\x1b[31m\", // red\n}\n\nconst RESET = \"\\x1b[0m\"\n\nexport interface Logger {\n debug: (msg: string, ...args: unknown[]) => void\n info: (msg: string, ...args: unknown[]) => void\n warn: (msg: string, ...args: unknown[]) => void\n error: (msg: string, ...args: unknown[]) => void\n}\n\nexport function createLogger(opts: {\n level?: LogLevel\n logFile?: string\n maxSize?: number\n backups?: number\n /** When true (default), writes to stdout/stderr. Set false for file-only logging. */\n console?: boolean\n}): Logger {\n const level = opts.level ?? \"info\"\n const logFile = opts.logFile ?? \"tiktok-recorder.log\"\n const maxSize = opts.maxSize ?? 5 * 1024 * 1024 // 5 MB\n const backups = opts.backups ?? 3\n const showConsole = opts.console !== false // default true\n\n // Ensure parent directory exists\n const logDir = dirname(logFile)\n if (logDir !== \".\") {\n mkdirSync(logDir, { recursive: true })\n }\n\n function rotateIfNeeded(): void {\n try {\n if (existsSync(logFile)) {\n const size = statSync(logFile).size\n if (size >= maxSize) {\n // Remove the oldest backup\n const lastBackup = `${logFile}.${backups}`\n if (existsSync(lastBackup)) {\n try {\n renameSync(lastBackup, `${lastBackup}.old`)\n } catch {\n // Best-effort cleanup\n }\n }\n // Shift backups\n for (let i = backups - 1; i >= 1; i--) {\n const src = `${logFile}.${i}`\n const dst = `${logFile}.${i + 1}`\n if (existsSync(src)) {\n try {\n renameSync(src, dst)\n } catch {\n // Best-effort\n }\n }\n }\n // Rename current → .1\n try {\n renameSync(logFile, `${logFile}.1`)\n } catch {\n // Best-effort\n }\n }\n }\n } catch {\n // Silent — never throw in logger\n }\n }\n\n // Touch the file\n try {\n if (!existsSync(logFile)) {\n writeFileSync(logFile, \"\", \"utf-8\")\n }\n } catch {\n // Best-effort\n }\n\n function write(level: LogLevel, msg: string, args: unknown[]): void {\n const ts = new Date().toISOString()\n const suffix = args.length > 0 ? ` ${args.map((a) => String(a)).join(\" \")}` : \"\"\n const line = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`\n const formatted = `[${ts}] [${level.toUpperCase()}] ${msg}${suffix}`\n\n // Console (optional — can be suppressed for file-only logging)\n if (showConsole) {\n if (level === \"error\") {\n process.stderr.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\\n`)\n } else {\n process.stdout.write(`${LEVEL_COLORS[level]}${formatted}${RESET}\\n`)\n }\n }\n\n // File\n try {\n rotateIfNeeded()\n appendFileSync(logFile, `${line}\\n`, \"utf-8\")\n } catch {\n // Best-effort\n }\n }\n\n return {\n debug: (msg, ...args) => {\n if (LEVEL_ORDER.debug >= LEVEL_ORDER[level]) write(\"debug\", msg, args)\n },\n info: (msg, ...args) => {\n if (LEVEL_ORDER.info >= LEVEL_ORDER[level]) write(\"info\", msg, args)\n },\n warn: (msg, ...args) => {\n if (LEVEL_ORDER.warn >= LEVEL_ORDER[level]) write(\"warn\", msg, args)\n },\n error: (msg, ...args) => {\n if (LEVEL_ORDER.error >= LEVEL_ORDER[level]) write(\"error\", msg, args)\n },\n }\n}\n"],"mappings":";;;;;;;AAUA,MAAM,cAAwC;CAC5C,OAAO;CACP,MAAM;CACN,MAAM;CACN,OAAO;AACT;AAEA,MAAM,eAAyC;CAC7C,OAAO;CACP,MAAM;CACN,MAAM;CACN,OAAO;AACT;AAEA,MAAM,QAAQ;AASd,SAAgB,aAAa,MAOlB;CACT,MAAM,QAAQ,KAAK,SAAS;CAC5B,MAAM,UAAU,KAAK,WAAW;CAChC,MAAM,UAAU,KAAK,WAAW,IAAI,OAAO;CAC3C,MAAM,UAAU,KAAK,WAAW;CAChC,MAAM,cAAc,KAAK,YAAY;CAGrC,MAAM,SAAS,QAAQ,OAAO;CAC9B,IAAI,WAAW,KACb,UAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;CAGvC,SAAS,iBAAuB;EAC9B,IAAI;GACF,IAAI,WAAW,OAAO;QACP,SAAS,OAAO,CAAC,CAAC,QACnB,SAAS;KAEnB,MAAM,aAAa,GAAG,QAAQ,GAAG;KACjC,IAAI,WAAW,UAAU,GACvB,IAAI;MACF,WAAW,YAAY,GAAG,WAAW,KAAK;KAC5C,QAAQ,CAER;KAGF,KAAK,IAAI,IAAI,UAAU,GAAG,KAAK,GAAG,KAAK;MACrC,MAAM,MAAM,GAAG,QAAQ,GAAG;MAC1B,MAAM,MAAM,GAAG,QAAQ,GAAG,IAAI;MAC9B,IAAI,WAAW,GAAG,GAChB,IAAI;OACF,WAAW,KAAK,GAAG;MACrB,QAAQ,CAER;KAEJ;KAEA,IAAI;MACF,WAAW,SAAS,GAAG,QAAQ,GAAG;KACpC,QAAQ,CAER;IACF;;EAEJ,QAAQ,CAER;CACF;CAGA,IAAI;EACF,IAAI,CAAC,WAAW,OAAO,GACrB,cAAc,SAAS,IAAI,OAAO;CAEtC,QAAQ,CAER;CAEA,SAAS,MAAM,OAAiB,KAAa,MAAuB;EAClE,MAAM,sBAAK,IAAI,KAAK,EAAA,CAAE,YAAY;EAClC,MAAM,SAAS,KAAK,SAAS,IAAI,IAAI,KAAK,KAAK,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM;EAC9E,MAAM,OAAO,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,IAAI,MAAM;EACvD,MAAM,YAAY,IAAI,GAAG,KAAK,MAAM,YAAY,EAAE,IAAI,MAAM;EAG5D,IAAI,aACF,IAAI,UAAU,SACZ,QAAQ,OAAO,MAAM,GAAG,aAAa,SAAS,YAAY,MAAM,GAAG;OAEnE,QAAQ,OAAO,MAAM,GAAG,aAAa,SAAS,YAAY,MAAM,GAAG;EAKvE,IAAI;GACF,eAAe;GACf,eAAe,SAAS,GAAG,KAAK,KAAK,OAAO;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EACL,QAAQ,KAAK,GAAG,SAAS;GACvB,IAAI,YAAY,SAAS,YAAY,QAAQ,MAAM,SAAS,KAAK,IAAI;EACvE;EACA,OAAO,KAAK,GAAG,SAAS;GACtB,IAAI,YAAY,QAAQ,YAAY,QAAQ,MAAM,QAAQ,KAAK,IAAI;EACrE;EACA,OAAO,KAAK,GAAG,SAAS;GACtB,IAAI,YAAY,QAAQ,YAAY,QAAQ,MAAM,QAAQ,KAAK,IAAI;EACrE;EACA,QAAQ,KAAK,GAAG,SAAS;GACvB,IAAI,YAAY,SAAS,YAAY,QAAQ,MAAM,SAAS,KAAK,IAAI;EACvE;CACF;AACF"}
@@ -1,6 +1,15 @@
1
1
  import { formatDuration } from "./utils.mjs";
2
- import { ICON_ERROR, ICON_INFO, ICON_SUCCESS, ICON_WARN, color, createSpinner } from "@zfadhli/koko-cli";
3
- //#region src/ui.ts
2
+ import { color, createSpinner } from "@zfadhli/koko-cli";
3
+ //#region src/lib/ui.ts
4
+ const tag = {
5
+ ok: color.bold(color.green("[OK] ")),
6
+ done: color.bold(color.green("[DONE] ")),
7
+ live: color.bold(color.green("[LIVE] ")),
8
+ info: color.bold(color.blue("[INFO] ")),
9
+ off: color.bold(color.blue("[OFF] ")),
10
+ warn: color.bold(color.yellow("[WARN] ")),
11
+ err: color.bold(color.red("[ERR] "))
12
+ };
4
13
  function createDisplay() {
5
14
  let activeSpinner = null;
6
15
  let offlineLineShown = false;
@@ -15,14 +24,14 @@ function createDisplay() {
15
24
  if (activeSpinner?.isSpinning) activeSpinner.stop();
16
25
  activeSpinner = null;
17
26
  }
18
- /** Stop the current spinner and print a one-liner with the given icon. */
19
- function finalize(icon, text) {
27
+ /** Stop the current spinner and print a one-liner with the given tag prefix. */
28
+ function finalize(prefix, text) {
20
29
  clearSpinner();
21
- process.stdout.write(`${icon}${text}\n`);
30
+ process.stdout.write(`${prefix}${text}\n`);
22
31
  }
23
32
  return {
24
33
  pollingStarted(intervalMinutes) {
25
- finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`));
34
+ finalize(tag.info, `${color.dim("Polling started (every ")}${color.bold(color.yellow(String(intervalMinutes)))}${color.dim(" min)")}`);
26
35
  },
27
36
  checkingUser(user) {
28
37
  clearSpinner();
@@ -30,38 +39,39 @@ function createDisplay() {
30
39
  activeSpinner.start();
31
40
  },
32
41
  userOffline(user) {
33
- finalize(ICON_INFO, color.dim(`@${user} is offline`));
42
+ finalize(tag.off, `${color.bold(color.yellow(`@${user}`))}${color.dim(" is offline")}`);
34
43
  offlineLineShown = true;
35
44
  },
36
45
  userOfflineRepeat(user, lastCheck) {
37
46
  clearSpinner();
38
- if (offlineLineShown) process.stdout.write(`\x1b[1A\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\x1b[K\n`);
47
+ const text = `${tag.off}${color.bold(color.yellow(`@${user}`))}${color.dim(" is offline")} ${color.dim(`[last online: ${color.bold(color.yellow(lastCheck))}]`)}`;
48
+ if (offlineLineShown) process.stdout.write(`\x1b[1A\r${text}\x1b[K\n`);
39
49
  else {
40
- process.stdout.write(`${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last online: ${lastCheck}]`)}\n`);
50
+ process.stdout.write(`${text}\n`);
41
51
  offlineLineShown = true;
42
52
  }
43
53
  },
44
54
  userLive(user, roomId) {
45
55
  clearSpinner();
46
56
  offlineLineShown = false;
47
- process.stdout.write(`${ICON_SUCCESS}${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\n`);
57
+ process.stdout.write(`${tag.live}${color.bold(color.green(`@${user}`))} ${color.dim(`(room: ${color.bold(color.yellow(roomId))})`)}\n`);
48
58
  },
49
59
  startRecording() {
50
60
  clearSpinner();
51
61
  recordingStartTime = Date.now();
52
- activeSpinner = createSpinner(`${color.cyan("Recording...")} ${color.dim("[0s]")}`);
62
+ activeSpinner = createSpinner(`${color.cyan("Recording...")} ${color.dim(`[${color.bold(color.yellow("0s"))}]`)}`);
53
63
  activeSpinner.start();
54
64
  recordingTimer = setInterval(() => {
55
65
  if (!activeSpinner?.isSpinning || !recordingStartTime) return;
56
66
  const elapsed = (Date.now() - recordingStartTime) / 1e3;
57
- activeSpinner.text = `${color.cyan("Recording...")} ${color.dim(`[${formatDuration(elapsed)}]`)}`;
67
+ activeSpinner.text = `${color.cyan("Recording...")} ${color.dim(`[${color.bold(color.yellow(formatDuration(elapsed)))}]`)}`;
58
68
  }, 1e3);
59
69
  },
60
70
  finishRecording(filename, duration, size) {
61
- finalize(ICON_SUCCESS, `${color.green("Recording finished")} ${color.dim(`${filename} — ${size} in ${formatDuration(duration)}`)}`);
71
+ finalize(tag.done, `${color.cyan(filename)}${color.dim(" — ")}${color.bold(color.yellow(size))}${color.dim(` in ${color.bold(color.yellow(formatDuration(duration)))}`)}`);
62
72
  },
63
73
  recordingError(message) {
64
- finalize(ICON_ERROR, color.red(`Recording failed: ${message}`));
74
+ finalize(tag.err, color.red(`Recording failed: ${message}`));
65
75
  },
66
76
  startSegmenting() {
67
77
  clearSpinner();
@@ -69,7 +79,7 @@ function createDisplay() {
69
79
  activeSpinner.start();
70
80
  },
71
81
  segmentsCreated(count) {
72
- finalize(ICON_SUCCESS, color.green(`Created ${count} MP4 segment${count !== 1 ? "s" : ""}`));
82
+ finalize(tag.ok, `${color.dim("Created ")}${color.bold(color.yellow(String(count)))}${color.dim(` MP4 segment${count !== 1 ? "s" : ""}`)}`);
73
83
  },
74
84
  startConverting() {
75
85
  clearSpinner();
@@ -77,7 +87,7 @@ function createDisplay() {
77
87
  activeSpinner.start();
78
88
  },
79
89
  conversionDone(output) {
80
- finalize(ICON_SUCCESS, color.green(`Converted: ${output}`));
90
+ finalize(tag.done, `${color.cyan(output)}`);
81
91
  },
82
92
  normalizeStart() {
83
93
  clearSpinner();
@@ -86,13 +96,13 @@ function createDisplay() {
86
96
  },
87
97
  normalizeProgress(percent, phase) {
88
98
  if (!activeSpinner?.isSpinning) return;
89
- activeSpinner.text = ` ${phase === "analyzing" ? "Analyzing" : "Normalizing"}... ${percent}%`;
99
+ activeSpinner.text = ` ${phase === "analyzing" ? "Analyzing" : "Normalizing"}... ${color.bold(color.yellow(`${percent}%`))}`;
90
100
  },
91
101
  normalizeComplete() {
92
- finalize(ICON_SUCCESS, color.green("Audio normalized"));
102
+ finalize(tag.ok, color.green("Audio normalized"));
93
103
  },
94
104
  normalizeError(message) {
95
- finalize(ICON_ERROR, color.red(`Normalization failed: ${message}`));
105
+ finalize(tag.err, color.red(`Normalization failed: ${message}`));
96
106
  },
97
107
  showVersion(name, version) {
98
108
  clearSpinner();
@@ -102,17 +112,17 @@ function createDisplay() {
102
112
  showError(message) {
103
113
  clearSpinner();
104
114
  offlineLineShown = false;
105
- process.stdout.write(`${ICON_ERROR}${color.red(message)}\n`);
115
+ process.stdout.write(`${tag.err}${color.red(message)}\n`);
106
116
  },
107
117
  showInfo(message) {
108
118
  clearSpinner();
109
119
  offlineLineShown = false;
110
- process.stdout.write(`${ICON_INFO}${color.dim(message)}\n`);
120
+ process.stdout.write(`${tag.info}${color.dim(message)}\n`);
111
121
  },
112
122
  showWarning(message) {
113
123
  clearSpinner();
114
124
  offlineLineShown = false;
115
- process.stdout.write(`${ICON_WARN}${color.yellow(message)}\n`);
125
+ process.stdout.write(`${tag.warn}${color.yellow(message)}\n`);
116
126
  },
117
127
  stop() {
118
128
  clearSpinner();
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.mjs","names":[],"sources":["../../src/lib/ui.ts"],"sourcesContent":["/**\n * Terminal Display Manager — beautiful console output using koko-cli's\n * spinner, icons, and color utilities.\n *\n * This module owns the terminal and should be the only thing writing to\n * stdout/stderr (the file logger handles persistent logs).\n */\n\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport { color, createSpinner } from \"@zfadhli/koko-cli\"\nimport { formatDuration } from \"./utils\"\n\n// ─── Tag helpers ──────────────────────────────────────────\nconst tag = {\n ok: color.bold(color.green(\"[OK] \")),\n done: color.bold(color.green(\"[DONE] \")),\n live: color.bold(color.green(\"[LIVE] \")),\n info: color.bold(color.blue(\"[INFO] \")),\n off: color.bold(color.blue(\"[OFF] \")),\n warn: color.bold(color.yellow(\"[WARN] \")),\n err: color.bold(color.red(\"[ERR] \")),\n}\n\nexport interface Display {\n /** Called once when polling begins. */\n pollingStarted(intervalMinutes: number): void\n /** Spinner while checking if a user is live. */\n checkingUser(user: string): void\n /** User is offline (no active display). */\n userOffline(user: string): void\n /** User is still offline — updates the previous offline line with a timestamp. */\n userOfflineRepeat(user: string, lastCheck: string): void\n /** User is live! Clears the checking spinner. */\n userLive(user: string, roomId: string): void\n\n /** Recording starts: spinner without filename (known only post-download). */\n startRecording(): void\n /** Recording finished successfully. Shows filename from the result. */\n finishRecording(filename: string, duration: number, size: string): void\n /** Recording encountered a non-fatal error. */\n recordingError(message: string): void\n\n /** FFmpeg segmenting starts. */\n startSegmenting(): void\n /** FFmpeg segmenting done. */\n segmentsCreated(count: number): void\n\n /** FFmpeg simple conversion starts (fallback path). */\n startConverting(): void\n /** Simple conversion done. */\n conversionDone(output: string): void\n\n /** Audio normalization starts (EBU R128 via peaknorm). */\n normalizeStart(): void\n /** Audio normalization progress update. */\n normalizeProgress(percent: number, phase: string): void\n /** Audio normalization completed successfully. */\n normalizeComplete(): void\n /** Audio normalization failed. */\n normalizeError(message: string): void\n\n /** Show version banner at startup. */\n showVersion(name: string, version: string): void\n\n /** General-purpose status helpers. */\n showError(message: string): void\n showInfo(message: string): void\n showWarning(message: string): void\n\n /** Stop all active spinners and clean up the terminal. */\n stop(): void\n}\n\nexport function createDisplay(): Display {\n let activeSpinner: SpinnerInstance | null = null\n let offlineLineShown = false\n\n // Recording timer state\n let recordingStartTime: number | null = null\n let recordingTimer: ReturnType<typeof setInterval> | null = null\n\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\n if (recordingTimer) {\n clearInterval(recordingTimer)\n recordingTimer = null\n }\n if (activeSpinner?.isSpinning) {\n activeSpinner.stop()\n }\n activeSpinner = null\n }\n\n /** Stop the current spinner and print a one-liner with the given tag prefix. */\n function finalize(prefix: string, text: string): void {\n clearSpinner()\n process.stdout.write(`${prefix}${text}\\n`)\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(\n tag.info,\n `${color.dim(\"Polling started (every \")}${color.bold(color.yellow(String(intervalMinutes)))}${color.dim(\" min)\")}`,\n )\n },\n\n checkingUser(user: string): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(`Checking @${user}...`))\n activeSpinner.start()\n },\n\n userOffline(user: string): void {\n finalize(tag.off, `${color.bold(color.yellow(`@${user}`))}${color.dim(\" is offline\")}`)\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n const text = `${tag.off}${color.bold(color.yellow(`@${user}`))}${color.dim(\" is offline\")} ${color.dim(`[last online: ${color.bold(color.yellow(lastCheck))}]`)}`\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(`\\x1b[1A\\r${text}\\x1b[K\\n`)\n } else {\n process.stdout.write(`${text}\\n`)\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n `${tag.live}${color.bold(color.green(`@${user}`))} ${color.dim(`(room: ${color.bold(color.yellow(roomId))})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n recordingStartTime = Date.now()\n activeSpinner = createSpinner(\n `${color.cyan(\"Recording...\")} ${color.dim(`[${color.bold(color.yellow(\"0s\"))}]`)}`,\n )\n activeSpinner.start()\n\n recordingTimer = setInterval(() => {\n if (!activeSpinner?.isSpinning || !recordingStartTime) return\n const elapsed = (Date.now() - recordingStartTime) / 1000\n activeSpinner.text = `${color.cyan(\"Recording...\")} ${color.dim(`[${color.bold(color.yellow(formatDuration(elapsed)))}]`)}`\n }, 1000)\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n tag.done,\n `${color.cyan(filename)}${color.dim(\" — \")}${color.bold(color.yellow(size))}${color.dim(` in ${color.bold(color.yellow(formatDuration(duration)))}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(tag.err, color.red(`Recording failed: ${message}`))\n },\n\n startSegmenting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Segmenting...\"))\n activeSpinner.start()\n },\n\n segmentsCreated(count: number): void {\n finalize(\n tag.ok,\n `${color.dim(\"Created \")}${color.bold(color.yellow(String(count)))}${color.dim(` MP4 segment${count !== 1 ? \"s\" : \"\"}`)}`,\n )\n },\n\n startConverting(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Converting to MP4...\"))\n activeSpinner.start()\n },\n\n conversionDone(output: string): void {\n finalize(tag.done, `${color.cyan(output)}`)\n },\n\n normalizeStart(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Normalizing audio...\"))\n activeSpinner.start()\n },\n\n normalizeProgress(percent: number, phase: string): void {\n if (!activeSpinner?.isSpinning) return\n const label = phase === \"analyzing\" ? \"Analyzing\" : \"Normalizing\"\n activeSpinner.text = ` ${label}... ${color.bold(color.yellow(`${percent}%`))}`\n },\n\n normalizeComplete(): void {\n finalize(tag.ok, color.green(\"Audio normalized\"))\n },\n\n normalizeError(message: string): void {\n finalize(tag.err, color.red(`Normalization failed: ${message}`))\n },\n\n showVersion(name: string, version: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${color.dim(`${name} ${version}`)}\\n`)\n },\n\n showError(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${tag.err}${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${tag.info}${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(`${tag.warn}${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n offlineLineShown = false\n },\n }\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync, statSync } from "node:fs";
2
- //#region src/utils.ts
2
+ //#region src/lib/utils.ts
3
3
  /**
4
4
  * Utility helpers — pure functions, no side effects.
5
5
  */
@@ -70,7 +70,7 @@ function formatDuration(seconds) {
70
70
  }
71
71
  /** Find FFmpeg binary via Bun.which() or PATH search. */
72
72
  function findFfmpegPath() {
73
- const bunWhich = Bun?.which;
73
+ const bunWhich = globalThis.Bun?.which;
74
74
  if (typeof bunWhich === "function") return bunWhich("ffmpeg") ?? null;
75
75
  const paths = process.env.PATH?.split(":") ?? [];
76
76
  for (const dir of paths) try {
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.mjs","names":[],"sources":["../../src/lib/utils.ts"],"sourcesContent":["/**\n * Utility helpers — pure functions, no side effects.\n */\n\nimport { mkdirSync, statSync } from \"node:fs\"\n\n/** Format a filename like username=2025.06.09_14-30-00_1.flv */\nexport function formatFilename(user: string, ext = \"flv\", part?: number): string {\n const now = new Date()\n const pad = (n: number) => String(n).padStart(2, \"0\")\n const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`\n const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`\n const partSuffix = part !== undefined ? `_part${part}` : \"\"\n return `${user}=${date}_${time}${partSuffix}.${ext}`\n}\n\n/** Ensure a directory exists (recursive) */\nexport function ensureDir(dir: string): void {\n mkdirSync(dir, { recursive: true })\n}\n\n/**\n * Sleep for `ms` milliseconds.\n *\n * When an `AbortSignal` is provided and fires before the timer elapses,\n * the promise resolves immediately and the underlying timeout is cleared.\n * This prevents orphaned timers in Promise.race patterns.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n if (signal?.aborted) {\n resolve()\n return\n }\n const timer = setTimeout(resolve, ms)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n}\n\n/** Format bytes to human-readable size */\nexport function bytesToHuman(bytes: number): string {\n if (bytes === 0) return \"0 B\"\n const units = [\"B\", \"KB\", \"MB\", \"GB\"]\n const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)\n return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`\n}\n\n/** Strip leading @ from a username */\nexport function sanitizeUser(user: string): string {\n return user.replace(/^@+/, \"\").trim()\n}\n\n/** Format a millisecond elapsed time as a relative human-readable string. */\nexport function relativeTime(ms: number): string {\n const sec = Math.floor(ms / 1000)\n if (sec < 5) return \"just now\"\n if (sec < 60) return `${sec}s ago`\n const min = Math.floor(sec / 60)\n if (min < 60) return `${min}m ago`\n const hours = Math.floor(min / 60)\n const mins = min % 60\n if (hours < 24) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`\n const days = Math.floor(hours / 24)\n return `${days}d ago`\n}\n\n/** Format seconds into a compact human-readable duration (e.g. \"1m 5s\", \"30s\"). */\nexport function formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n if (m > 0) return `${m}m ${s}s`\n return `${s}s`\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nexport function findFfmpegPath(): string | null {\n const bunWhich = (globalThis as { Bun?: { which?: (cmd: string) => string | null } }).Bun?.which\n if (typeof bunWhich === \"function\") {\n return (bunWhich(\"ffmpeg\") as string) ?? null\n }\n const paths = process.env.PATH?.split(\":\") ?? []\n for (const dir of paths) {\n try {\n const full = `${dir}/ffmpeg`\n statSync(full)\n return full\n } catch {\n // not found in this directory\n }\n }\n return null\n}\n"],"mappings":";;;;;;AAOA,SAAgB,eAAe,MAAc,MAAM,OAAO,MAAuB;CAC/E,MAAM,sBAAM,IAAI,KAAK;CACrB,MAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;CAIpD,OAAO,GAAG,KAAK,GAAG,GAHF,IAAI,YAAY,IAAI,IAAI,IAAI,SAAS,IAAI,CAAC,IAAI,IAAI,IAAI,QAAQ,CAAC,IAGxD,GAAG,GAFV,IAAI,IAAI,SAAS,CAAC,IAAI,IAAI,IAAI,WAAW,CAAC,IAAI,IAAI,IAAI,WAAW,CAAC,MAC/D,SAAS,KAAA,IAAY,QAAQ,SAAS,GACb,GAAG;AACjD;;AAGA,SAAgB,UAAU,KAAmB;CAC3C,UAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AACpC;;;;;;;;AASA,SAAgB,MAAM,IAAY,QAAqC;CACrE,OAAO,IAAI,SAAS,YAAY;EAC9B,IAAI,QAAQ,SAAS;GACnB,QAAQ;GACR;EACF;EACA,MAAM,QAAQ,WAAW,SAAS,EAAE;EACpC,IAAI,QACF,OAAO,iBACL,eACM;GACJ,aAAa,KAAK;GAClB,QAAQ;EACV,GACA,EAAE,MAAM,KAAK,CACf;CAEJ,CAAC;AACH;;AAGA,SAAgB,aAAa,OAAuB;CAClD,IAAI,UAAU,GAAG,OAAO;CACxB,MAAM,QAAQ;EAAC;EAAK;EAAM;EAAM;CAAI;CACpC,MAAM,IAAI,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,IAAI,CAAC,GAAG,MAAM,SAAS,CAAC;CACjF,OAAO,IAAI,QAAQ,QAAQ,EAAA,CAAG,QAAQ,CAAC,EAAE,GAAG,MAAM;AACpD;;AAGA,SAAgB,aAAa,MAAsB;CACjD,OAAO,KAAK,QAAQ,OAAO,EAAE,CAAC,CAAC,KAAK;AACtC;;AAGA,SAAgB,aAAa,IAAoB;CAC/C,MAAM,MAAM,KAAK,MAAM,KAAK,GAAI;CAChC,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,MAAM,IAAI,OAAO,GAAG,IAAI;CAC5B,MAAM,MAAM,KAAK,MAAM,MAAM,EAAE;CAC/B,IAAI,MAAM,IAAI,OAAO,GAAG,IAAI;CAC5B,MAAM,QAAQ,KAAK,MAAM,MAAM,EAAE;CACjC,MAAM,OAAO,MAAM;CACnB,IAAI,QAAQ,IAAI,OAAO,OAAO,IAAI,GAAG,MAAM,IAAI,KAAK,SAAS,GAAG,MAAM;CAEtE,OAAO,GADM,KAAK,MAAM,QAAQ,EACnB,EAAE;AACjB;;AAGA,SAAgB,eAAe,SAAyB;CACtD,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;CACjC,MAAM,IAAI,KAAK,MAAM,UAAU,EAAE;CACjC,IAAI,IAAI,GAAG,OAAO,GAAG,EAAE,IAAI,EAAE;CAC7B,OAAO,GAAG,EAAE;AACd;;AAGA,SAAgB,iBAAgC;CAC9C,MAAM,WAAY,WAAoE,KAAK;CAC3F,IAAI,OAAO,aAAa,YACtB,OAAQ,SAAS,QAAQ,KAAgB;CAE3C,MAAM,QAAQ,QAAQ,IAAI,MAAM,MAAM,GAAG,KAAK,CAAC;CAC/C,KAAK,MAAM,OAAO,OAChB,IAAI;EACF,MAAM,OAAO,GAAG,IAAI;EACpB,SAAS,IAAI;EACb,OAAO;CACT,QAAQ,CAER;CAEF,OAAO;AACT"}
package/dist/package.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.11.3";
2
+ var version = "0.11.5";
3
3
  //#endregion
4
4
  export { version };
5
5
 
package/package.json CHANGED
@@ -1,28 +1,28 @@
1
1
  {
2
2
  "name": "@zfadhli/tokrec",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Minimal TikTok live stream recorder — Bun + TypeScript",
5
5
  "type": "module",
6
- "main": "./dist/lib.mjs",
7
- "types": "./dist/lib.d.mts",
6
+ "main": "./dist/index.mjs",
7
+ "types": "./dist/index.d.mts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./dist/lib.d.mts",
11
- "default": "./dist/lib.mjs"
12
- },
13
- "./cli": {
14
10
  "types": "./dist/index.d.mts",
15
11
  "default": "./dist/index.mjs"
12
+ },
13
+ "./cli": {
14
+ "types": "./dist/bin.d.mts",
15
+ "default": "./dist/bin.mjs"
16
16
  }
17
17
  },
18
18
  "bin": {
19
- "tokrec": "./dist/index.mjs"
19
+ "tokrec": "./dist/bin.mjs"
20
20
  },
21
21
  "files": [
22
22
  "dist"
23
23
  ],
24
24
  "scripts": {
25
- "dev": "bun run src/index.ts",
25
+ "dev": "bun run src/bin.ts",
26
26
  "build": "tsdown",
27
27
  "typecheck": "tsc --noEmit",
28
28
  "test": "bun test",
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../../src/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 \"../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 any,\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 any,\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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"rate-limiter.mjs","names":[],"sources":["../../src/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"}