@zfadhli/tokrec 0.5.0 → 0.6.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/dist/index.mjs +10 -2
- package/dist/index.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/ui.mjs +15 -0
- package/dist/ui.mjs.map +1 -1
- package/dist/utils.mjs +13 -1
- package/dist/utils.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { TikTokError, validateConfig } from "./config.mjs";
|
|
3
|
-
import { bytesToHuman } from "./utils.mjs";
|
|
3
|
+
import { bytesToHuman, relativeTime } from "./utils.mjs";
|
|
4
4
|
import { createRecorder } from "./recorder/index.mjs";
|
|
5
5
|
import "./lib.mjs";
|
|
6
6
|
import { extractTikTokCookiesFromFirefox } from "./browser-cookies.mjs";
|
|
@@ -43,12 +43,20 @@ async function main() {
|
|
|
43
43
|
const display = createDisplay();
|
|
44
44
|
if (!config.cookies) display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
|
|
45
45
|
const recorder = createRecorder(config);
|
|
46
|
+
let lastWasLive = null;
|
|
47
|
+
let lastCheckTime = Date.now();
|
|
46
48
|
recorder.on("checking", (info) => {
|
|
47
49
|
display.checkingUser(info.user);
|
|
48
50
|
});
|
|
49
51
|
recorder.on("tick", (info) => {
|
|
50
52
|
if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
|
|
51
|
-
else display.userOffline(info.user);
|
|
53
|
+
else if (lastWasLive === null || lastWasLive === true) display.userOffline(info.user);
|
|
54
|
+
else {
|
|
55
|
+
const elapsed = Date.now() - lastCheckTime;
|
|
56
|
+
display.userOfflineRepeat(info.user, relativeTime(elapsed));
|
|
57
|
+
}
|
|
58
|
+
lastWasLive = info.isLive;
|
|
59
|
+
lastCheckTime = Date.now();
|
|
52
60
|
});
|
|
53
61
|
recorder.on("recording:start", () => {
|
|
54
62
|
display.startRecording();
|
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 { TikTokError, validateConfig } from \"./config\"\nimport type { RecorderConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman } from \"./utils\"\n\nasync function loadCookies(\n cookiesPath?: string,\n): Promise<{ sessionid_ss: string; \"tt-target-idc\"?: 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 {\n sessionid_ss?: string\n \"tt-target-idc\"?: string\n }\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as { sessionid_ss: string; \"tt-target-idc\"?: 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 config.cookies = extractTikTokCookiesFromFirefox() ?? (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 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 // 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 {\n display.userOffline(info.user)\n }\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\", () => {\n // Individual conversion results acknowledged via segmenting:end or recording:end\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 // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) {\n process.stdout.write(\" Force stopping...\\n\")\n process.exit(1)\n }\n stopping = true\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 await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain()\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,eAAe,YACb,aACyE;CACzE,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;EAI/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,OAAO,UAAU,gCAAgC,KAAM,MAAM,YAAY,OAAO,WAAW;CAG3F,OAAO,aAAa;CAGpB,MAAM,UAAU,cAAc;CAE9B,IAAI,CAAC,OAAO,SACV,QAAQ,YACN,kFACF;CAGF,MAAM,WAAW,eAAe,MAAM;
|
|
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 { TikTokError, validateConfig } from \"./config\"\nimport type { RecorderConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(\n cookiesPath?: string,\n): Promise<{ sessionid_ss: string; \"tt-target-idc\"?: 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 {\n sessionid_ss?: string\n \"tt-target-idc\"?: string\n }\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as { sessionid_ss: string; \"tt-target-idc\"?: 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 config.cookies = extractTikTokCookiesFromFirefox() ?? (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 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 lastCheckTime = Date.now()\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 } else {\n const elapsed = Date.now() - lastCheckTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n lastCheckTime = Date.now()\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\", () => {\n // Individual conversion results acknowledged via segmenting:end or recording:end\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 // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) {\n process.stdout.write(\" Force stopping...\\n\")\n process.exit(1)\n }\n stopping = true\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 await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain()\n"],"mappings":"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"}
|
package/dist/package.mjs
CHANGED
package/dist/ui.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { ICON_ERROR, ICON_INFO, ICON_SUCCESS, ICON_WARN, color, createSpinner }
|
|
|
9
9
|
*/
|
|
10
10
|
function createDisplay() {
|
|
11
11
|
let activeSpinner = null;
|
|
12
|
+
let offlineLineShown = false;
|
|
12
13
|
/** Stop the currently active spinner (if any) without printing anything. */
|
|
13
14
|
function clearSpinner() {
|
|
14
15
|
if (activeSpinner?.isSpinning) activeSpinner.stop();
|
|
@@ -55,9 +56,19 @@ function createDisplay() {
|
|
|
55
56
|
},
|
|
56
57
|
userOffline(user) {
|
|
57
58
|
finalize(ICON_INFO, color.dim(`@${user} is offline`));
|
|
59
|
+
offlineLineShown = true;
|
|
60
|
+
},
|
|
61
|
+
userOfflineRepeat(user, lastCheck) {
|
|
62
|
+
clearSpinner();
|
|
63
|
+
if (offlineLineShown) process.stdout.write(`\x1b[1A\r ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\x1b[K\n`);
|
|
64
|
+
else {
|
|
65
|
+
process.stdout.write(` ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\n`);
|
|
66
|
+
offlineLineShown = true;
|
|
67
|
+
}
|
|
58
68
|
},
|
|
59
69
|
userLive(user, roomId) {
|
|
60
70
|
clearSpinner();
|
|
71
|
+
offlineLineShown = false;
|
|
61
72
|
process.stdout.write(` ${ICON_SUCCESS} ${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\n`);
|
|
62
73
|
},
|
|
63
74
|
startRecording() {
|
|
@@ -93,18 +104,22 @@ function createDisplay() {
|
|
|
93
104
|
},
|
|
94
105
|
showError(message) {
|
|
95
106
|
clearSpinner();
|
|
107
|
+
offlineLineShown = false;
|
|
96
108
|
process.stdout.write(` ${ICON_ERROR} ${color.red(message)}\n`);
|
|
97
109
|
},
|
|
98
110
|
showInfo(message) {
|
|
99
111
|
clearSpinner();
|
|
112
|
+
offlineLineShown = false;
|
|
100
113
|
process.stdout.write(` ${ICON_INFO} ${color.dim(message)}\n`);
|
|
101
114
|
},
|
|
102
115
|
showWarning(message) {
|
|
103
116
|
clearSpinner();
|
|
117
|
+
offlineLineShown = false;
|
|
104
118
|
process.stdout.write(` ${ICON_WARN} ${color.yellow(message)}\n`);
|
|
105
119
|
},
|
|
106
120
|
stop() {
|
|
107
121
|
clearSpinner();
|
|
122
|
+
offlineLineShown = false;
|
|
108
123
|
}
|
|
109
124
|
};
|
|
110
125
|
}
|
package/dist/ui.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui.mjs","names":[],"sources":["../src/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 {\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n color,\n createSpinner,\n} from \"@zfadhli/koko-cli\"\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\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 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 /** Update the recording spinner with live counters. */\n updateProgress(bytes: number, elapsed: number, speed: number): 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 /** 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\n /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\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 icon. */\n function finalize(icon: string, text: string): void {\n clearSpinner()\n process.stdout.write(` ${icon} ${text}\\n`)\n }\n\n /** Format seconds into a compact human-readable duration. */\n function fmtDuration(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 /** Format bytes into human-readable size. */\n function fmtBytes(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 /** Format bytes per second into a readable speed string. */\n function fmtSpeed(bytesPerSec: number): string {\n if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`\n if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`\n return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`))\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(ICON_INFO, color.dim(`@${user} is offline`))\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n process.stdout.write(\n ` ${ICON_SUCCESS} ${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Recording stream...\"))\n activeSpinner.start()\n },\n\n updateProgress(bytes: number, elapsed: number, speed: number): void {\n if (!activeSpinner || !activeSpinner.isSpinning) return\n activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim(\"•\")} ${fmtDuration(elapsed)} ${color.dim(\"•\")} ${fmtSpeed(speed)}`\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n ICON_SUCCESS,\n `${color.green(\"Recording finished\")} ${color.dim(`${filename} — ${size} in ${fmtDuration(duration)}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(ICON_ERROR, 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(ICON_SUCCESS, color.green(`Created ${count} MP4 segment${count !== 1 ? \"s\" : \"\"}`))\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(ICON_SUCCESS, color.green(`Converted: ${output}`))\n },\n\n showError(message: string): void {\n clearSpinner()\n process.stdout.write(` ${ICON_ERROR} ${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n process.stdout.write(` ${ICON_INFO} ${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n process.stdout.write(` ${ICON_WARN} ${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n },\n }\n}\n"],"mappings":";;;;;;;;;
|
|
1
|
+
{"version":3,"file":"ui.mjs","names":[],"sources":["../src/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 {\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n color,\n createSpinner,\n} from \"@zfadhli/koko-cli\"\nimport type { SpinnerInstance } from \"@zfadhli/koko-cli\"\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 /** Update the recording spinner with live counters. */\n updateProgress(bytes: number, elapsed: number, speed: number): 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 /** 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 /** Stop the currently active spinner (if any) without printing anything. */\n function clearSpinner(): void {\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 icon. */\n function finalize(icon: string, text: string): void {\n clearSpinner()\n process.stdout.write(` ${icon} ${text}\\n`)\n }\n\n /** Format seconds into a compact human-readable duration. */\n function fmtDuration(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 /** Format bytes into human-readable size. */\n function fmtBytes(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 /** Format bytes per second into a readable speed string. */\n function fmtSpeed(bytesPerSec: number): string {\n if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`\n if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`\n return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`\n }\n\n return {\n pollingStarted(intervalMinutes: number): void {\n finalize(ICON_SUCCESS, color.dim(`Polling started (every ${intervalMinutes} min)`))\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(ICON_INFO, color.dim(`@${user} is offline`))\n offlineLineShown = true\n },\n\n userOfflineRepeat(user: string, lastCheck: string): void {\n clearSpinner()\n if (offlineLineShown) {\n // Move cursor up one line and overwrite the previous offline message\n process.stdout.write(\n `\\x1b[1A\\r ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\\x1b[K\\n`,\n )\n } else {\n // Fallback if state is out of sync\n process.stdout.write(\n ` ${ICON_INFO} ${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\\n`,\n )\n offlineLineShown = true\n }\n },\n\n userLive(user: string, roomId: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(\n ` ${ICON_SUCCESS} ${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\\n`,\n )\n },\n\n startRecording(): void {\n clearSpinner()\n activeSpinner = createSpinner(color.cyan(\"Recording stream...\"))\n activeSpinner.start()\n },\n\n updateProgress(bytes: number, elapsed: number, speed: number): void {\n if (!activeSpinner || !activeSpinner.isSpinning) return\n activeSpinner.text = ` ${fmtBytes(bytes)} ${color.dim(\"•\")} ${fmtDuration(elapsed)} ${color.dim(\"•\")} ${fmtSpeed(speed)}`\n },\n\n finishRecording(filename: string, duration: number, size: string): void {\n finalize(\n ICON_SUCCESS,\n `${color.green(\"Recording finished\")} ${color.dim(`${filename} — ${size} in ${fmtDuration(duration)}`)}`,\n )\n },\n\n recordingError(message: string): void {\n finalize(ICON_ERROR, 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(ICON_SUCCESS, color.green(`Created ${count} MP4 segment${count !== 1 ? \"s\" : \"\"}`))\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(ICON_SUCCESS, color.green(`Converted: ${output}`))\n },\n\n showError(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(` ${ICON_ERROR} ${color.red(message)}\\n`)\n },\n\n showInfo(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(` ${ICON_INFO} ${color.dim(message)}\\n`)\n },\n\n showWarning(message: string): void {\n clearSpinner()\n offlineLineShown = false\n process.stdout.write(` ${ICON_WARN} ${color.yellow(message)}\\n`)\n },\n\n stop(): void {\n clearSpinner()\n offlineLineShown = false\n },\n }\n}\n"],"mappings":"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"}
|
package/dist/utils.mjs
CHANGED
|
@@ -33,7 +33,19 @@ function bytesToHuman(bytes) {
|
|
|
33
33
|
function sanitizeUser(user) {
|
|
34
34
|
return user.replace(/^@+/, "").trim();
|
|
35
35
|
}
|
|
36
|
+
/** Format a millisecond elapsed time as a relative human-readable string. */
|
|
37
|
+
function relativeTime(ms) {
|
|
38
|
+
const sec = Math.floor(ms / 1e3);
|
|
39
|
+
if (sec < 5) return "just now";
|
|
40
|
+
if (sec < 60) return `${sec}s ago`;
|
|
41
|
+
const min = Math.floor(sec / 60);
|
|
42
|
+
if (min < 60) return `${min}m ago`;
|
|
43
|
+
const hours = Math.floor(min / 60);
|
|
44
|
+
const mins = min % 60;
|
|
45
|
+
if (hours < 24) return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`;
|
|
46
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
47
|
+
}
|
|
36
48
|
//#endregion
|
|
37
|
-
export { bytesToHuman, ensureDir, formatFilename, sanitizeUser, sleep };
|
|
49
|
+
export { bytesToHuman, ensureDir, formatFilename, relativeTime, sanitizeUser, sleep };
|
|
38
50
|
|
|
39
51
|
//# sourceMappingURL=utils.mjs.map
|
package/dist/utils.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/**\n * Utility helpers — pure functions, no side effects.\n */\n\nimport { mkdirSync } 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/** Sleep for ms milliseconds */\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\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"],"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;;AAGA,SAAgB,MAAM,IAA2B;CAC/C,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;;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"}
|
|
1
|
+
{"version":3,"file":"utils.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/**\n * Utility helpers — pure functions, no side effects.\n */\n\nimport { mkdirSync } 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/** Sleep for ms milliseconds */\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\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"],"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;;AAGA,SAAgB,MAAM,IAA2B;CAC/C,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;;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"}
|