@zfadhli/tokrec 0.7.0 → 0.7.1
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 +1 -4
- package/dist/index.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/stream.mjs +27 -6
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/ui.mjs +7 -7
- package/dist/ui.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -98,10 +98,7 @@ async function main() {
|
|
|
98
98
|
});
|
|
99
99
|
let stopping = false;
|
|
100
100
|
const handleSignal = async () => {
|
|
101
|
-
if (stopping)
|
|
102
|
-
process.stdout.write(" Force stopping...\n");
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
101
|
+
if (stopping) return;
|
|
105
102
|
stopping = true;
|
|
106
103
|
display.showInfo("Shutting down gracefully...");
|
|
107
104
|
await recorder.stop();
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport type { RecorderConfig } from \"./config\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(\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 firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:progress\", (info) => {\n display.updateProgress(info.bytes, info.elapsed, info.speed)\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", () => {\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 recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping)
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { extractTikTokCookiesFromFirefox } from \"./browser-cookies\"\nimport { parseArgs } from \"./cli\"\nimport type { RecorderConfig } from \"./config\"\nimport { TikTokError, validateConfig } from \"./config\"\nimport { createRecorder } from \"./lib\"\nimport { createDisplay } from \"./ui\"\nimport { bytesToHuman, relativeTime } from \"./utils\"\n\nasync function loadCookies(\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 firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:progress\", (info) => {\n display.updateProgress(info.bytes, info.elapsed, info.speed)\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", () => {\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 recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n 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/recorder/stream.mjs
CHANGED
|
@@ -12,10 +12,12 @@ import { join } from "node:path";
|
|
|
12
12
|
*/
|
|
13
13
|
function createStreamDownloader(logger) {
|
|
14
14
|
let abortFlag = false;
|
|
15
|
+
let abortController = new AbortController();
|
|
15
16
|
let streamReader = null;
|
|
16
17
|
let ffmpegProcess = null;
|
|
17
18
|
function abort() {
|
|
18
19
|
abortFlag = true;
|
|
20
|
+
abortController.abort();
|
|
19
21
|
streamReader?.cancel().catch(() => {});
|
|
20
22
|
if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill("SIGTERM");
|
|
21
23
|
}
|
|
@@ -46,9 +48,9 @@ function createStreamDownloader(logger) {
|
|
|
46
48
|
function elapsed() {
|
|
47
49
|
return (Date.now() - startTime) / 1e3;
|
|
48
50
|
}
|
|
51
|
+
const bufferSize = 512 * 1024;
|
|
52
|
+
let buffer = Buffer.alloc(0);
|
|
49
53
|
try {
|
|
50
|
-
const bufferSize = 512 * 1024;
|
|
51
|
-
let buffer = Buffer.alloc(0);
|
|
52
54
|
function reportProgress(bytes) {
|
|
53
55
|
if (!onProgress) return;
|
|
54
56
|
const now = Date.now();
|
|
@@ -86,14 +88,25 @@ function createStreamDownloader(logger) {
|
|
|
86
88
|
return false;
|
|
87
89
|
}
|
|
88
90
|
}
|
|
91
|
+
/** Resolves if the abort signal fires — used to break out of read waits instantly. */
|
|
92
|
+
function waitForAbort() {
|
|
93
|
+
return new Promise((_, reject) => {
|
|
94
|
+
if (abortController.signal.aborted) {
|
|
95
|
+
reject(/* @__PURE__ */ new Error("Aborted"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
abortController.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("Aborted")), { once: true });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
89
101
|
while (!abortFlag) {
|
|
90
102
|
let chunk;
|
|
91
103
|
try {
|
|
92
|
-
chunk = await timeout(reader.read(), 6e4, "Stream read timed out");
|
|
93
|
-
} catch (
|
|
94
|
-
|
|
104
|
+
chunk = await Promise.race([timeout(reader.read(), 6e4, "Stream read timed out"), waitForAbort()]);
|
|
105
|
+
} catch (_err) {
|
|
106
|
+
if (abortFlag) break;
|
|
107
|
+
logger?.info("Stream ended or timed out, attempting reconnection...");
|
|
95
108
|
if (await tryReconnect()) continue;
|
|
96
|
-
|
|
109
|
+
break;
|
|
97
110
|
}
|
|
98
111
|
if (chunk.done) {
|
|
99
112
|
if (await tryReconnect()) continue;
|
|
@@ -132,6 +145,13 @@ function createStreamDownloader(logger) {
|
|
|
132
145
|
};
|
|
133
146
|
} catch (err) {
|
|
134
147
|
abort();
|
|
148
|
+
try {
|
|
149
|
+
if (buffer.length > 0) {
|
|
150
|
+
await writeBuffer(writer, buffer);
|
|
151
|
+
totalBytes += buffer.length;
|
|
152
|
+
}
|
|
153
|
+
await new Promise((resolve) => writer.end(resolve));
|
|
154
|
+
} catch {}
|
|
135
155
|
const duration = elapsed();
|
|
136
156
|
logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`);
|
|
137
157
|
return {
|
|
@@ -243,6 +263,7 @@ function createStreamDownloader(logger) {
|
|
|
243
263
|
}
|
|
244
264
|
async function download(liveUrl, user, outputDir, maxDuration = 0, onProgress, getNextUrl) {
|
|
245
265
|
abortFlag = false;
|
|
266
|
+
abortController = new AbortController();
|
|
246
267
|
ensureDir(outputDir);
|
|
247
268
|
if (isHlsUrl(liveUrl)) return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress);
|
|
248
269
|
return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a live stream and writes it to disk with buffering.\n *\n * FLV streams: downloaded via raw fetch() with byte buffering and transparent\n * reconnection when TikTok stream segments end naturally.\n * HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport { createWriteStream, statSync, type WriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\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 abortFlag = false\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n let ffmpegProcess: ChildProcess | null = null\n\n function abort(): void {\n abortFlag = true\n streamReader?.cancel().catch(() => {})\n if (ffmpegProcess && !ffmpegProcess.killed) {\n ffmpegProcess.kill(\"SIGTERM\")\n }\n }\n\n /** Detect whether a URL is an HLS (.m3u8) playlist. */\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n // ─── FLV downloader (existing fetch-based path) ────────────────\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n try {\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await timeout(reader.read(), 60_000, \"Stream read timed out\")\n chunk = result\n } catch (err) {\n logger?.info(\"Read timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n throw err\n }\n\n if (chunk.done) {\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n reportProgress(totalBytes)\n }\n\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n // ─── HLS downloader (FFmpeg-based path) ───────────────────────\n\n async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let lastProgressTime = 0\n\n // Find FFmpeg\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found — required for HLS streams. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n // Spawn FFmpeg with the M3U8 URL as input.\n // FFmpeg handles downloading all .ts segments and concatenating them.\n const args = [\"-i\", liveUrl, \"-c\", \"copy\", \"-y\", filepath]\n\n return new Promise<DownloadResult>((resolve, reject) => {\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n ffmpegProcess = proc\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n // Poll output file size for progress reporting\n const progressTimer = setInterval(() => {\n if (onProgress) {\n try {\n const stats = statSync(filepath)\n const elapsed = (Date.now() - startTime) / 1000\n const speed = elapsed > 0 ? stats.size / elapsed : 0\n const now = Date.now()\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes: stats.size, elapsed, speed })\n }\n } catch {\n // File not created yet (FFmpeg still initializing) — skip\n }\n }\n }, 1000)\n\n // Max duration timer\n let maxDurationTimer: ReturnType<typeof setTimeout> | null = null\n if (maxDuration > 0) {\n maxDurationTimer = setTimeout(() => {\n logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`)\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }, maxDuration * 1000)\n }\n\n proc.on(\"close\", (code) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n\n if (abortFlag) {\n // Aborted by user — return whatever we have\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n resolve({ file: filepath, duration: 0, size: 0 })\n }\n return\n }\n\n // Normal completion or error\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n if (onProgress) {\n onProgress({\n bytes: stats.size,\n elapsed: duration,\n speed: duration > 0 ? stats.size / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n const msg = `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`\n reject(new Error(msg))\n }\n })\n\n proc.on(\"error\", (err) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n // ─── Main download entry point ──────────────────────────────\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 abortFlag = false\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n // HLS: FFmpeg handles playlist + segments natively.\n // Reconnection is not needed (FFmpeg refreshes the playlist internally).\n return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress)\n }\n\n // FLV: existing fetch-based path with reconnection support\n return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl)\n }\n\n return { download, abort }\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nfunction findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.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\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream.mjs","names":[],"sources":["../../src/recorder/stream.ts"],"sourcesContent":["/**\n * Stream downloader — fetches a live stream and writes it to disk with buffering.\n *\n * FLV streams: downloaded via raw fetch() with byte buffering and transparent\n * reconnection when TikTok stream segments end naturally.\n * HLS streams: downloaded via FFmpeg (which handles M3U8 playlist + .ts segments).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\"\nimport { createWriteStream, statSync, type WriteStream } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Logger } from \"../logger\"\nimport { bytesToHuman, ensureDir, formatFilename } from \"../utils\"\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 abortFlag = false\n let abortController = new AbortController()\n let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null\n let ffmpegProcess: ChildProcess | null = null\n\n function abort(): void {\n abortFlag = true\n abortController.abort()\n streamReader?.cancel().catch(() => {})\n if (ffmpegProcess && !ffmpegProcess.killed) {\n ffmpegProcess.kill(\"SIGTERM\")\n }\n }\n\n /** Detect whether a URL is an HLS (.m3u8) playlist. */\n function isHlsUrl(url: string): boolean {\n return url.includes(\".m3u8\")\n }\n\n // ─── FLV downloader (existing fetch-based path) ────────────────\n\n async function openReader(url: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {\n const response = await fetch(url)\n if (!response.ok || !response.body) {\n throw new Error(`Failed to fetch stream: ${response.status}`)\n }\n return response.body.getReader()\n }\n\n async function downloadFlv(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n getNextUrl?: () => Promise<string | null>,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"flv\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording: ${filename}`)\n\n const startTime = Date.now()\n let totalBytes = 0\n let lastProgressTime = 0\n\n let reader: ReadableStreamDefaultReader<Uint8Array>\n try {\n reader = await openReader(liveUrl)\n } catch (err) {\n throw new Error(`Failed to open stream: ${err instanceof Error ? err.message : String(err)}`)\n }\n streamReader = reader\n\n const writer = createWriteStream(filepath)\n\n function elapsed(): number {\n return (Date.now() - startTime) / 1000\n }\n\n const bufferSize = 512 * 1024 // 512 KB buffer\n let buffer = Buffer.alloc(0)\n\n try {\n function reportProgress(bytes: number): void {\n if (!onProgress) return\n const now = Date.now()\n const e = elapsed()\n const speed = e > 0 ? bytes / e : 0\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes, elapsed: e, speed })\n }\n }\n\n /** Try to reconnect to a fresh stream URL. Returns true if reconnected. */\n async function tryReconnect(): Promise<boolean> {\n if (abortFlag) return false\n if (!getNextUrl) return false\n if (maxDuration > 0 && elapsed() >= maxDuration) return false\n\n logger?.info(\"Stream ended, attempting reconnection...\")\n const nextUrl = await getNextUrl()\n if (!nextUrl) {\n logger?.info(\"No new stream URL — recording finished\")\n return false\n }\n\n logger?.info(`Reconnecting to new stream URL: ${nextUrl}`)\n try {\n reader.cancel().catch(() => {})\n reader = await openReader(nextUrl)\n streamReader = reader\n logger?.info(\"Reconnected successfully\")\n return true\n } catch (err) {\n logger?.error(`Reconnection failed: ${err instanceof Error ? err.message : String(err)}`)\n return false\n }\n }\n\n /** Resolves if the abort signal fires — used to break out of read waits instantly. */\n function waitForAbort(): Promise<never> {\n return new Promise((_, reject) => {\n if (abortController.signal.aborted) {\n reject(new Error(\"Aborted\"))\n return\n }\n abortController.signal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")), {\n once: true,\n })\n })\n }\n\n while (!abortFlag) {\n let chunk: { done: boolean; value?: Uint8Array }\n\n try {\n const result = await Promise.race([\n timeout(reader.read(), 60_000, \"Stream read timed out\"),\n waitForAbort(),\n ])\n chunk = result\n } catch (_err) {\n if (abortFlag) break\n logger?.info(\"Stream ended or timed out, attempting reconnection...\")\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n if (chunk.done) {\n if (await tryReconnect()) {\n continue\n }\n break\n }\n\n buffer = Buffer.concat([buffer, Buffer.from(chunk.value!)])\n\n if (buffer.length >= bufferSize) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n buffer = Buffer.alloc(0)\n }\n\n if (maxDuration > 0) {\n const e = elapsed()\n if (e >= maxDuration) {\n logger?.info(`Duration limit reached (${maxDuration}s)`)\n break\n }\n }\n\n reportProgress(totalBytes)\n }\n\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n\n const duration = elapsed()\n if (onProgress) {\n onProgress({\n bytes: totalBytes,\n elapsed: duration,\n speed: duration > 0 ? totalBytes / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(totalBytes)})`,\n )\n\n return { file: filepath, duration, size: totalBytes }\n } catch (err) {\n abort()\n // Flush remaining buffer and close the write stream so the partial file is valid\n try {\n if (buffer.length > 0) {\n await writeBuffer(writer, buffer)\n totalBytes += buffer.length\n }\n await new Promise<void>((resolve) => writer.end(resolve))\n } catch {\n // best effort — file may be truncated but readable\n }\n const duration = elapsed()\n logger?.error(`Recording error: ${err instanceof Error ? err.message : String(err)}`)\n return { file: filepath, duration, size: totalBytes }\n } finally {\n streamReader = null\n }\n }\n\n // ─── HLS downloader (FFmpeg-based path) ───────────────────────\n\n async function downloadHls(\n liveUrl: string,\n user: string,\n outputDir: string,\n maxDuration: number,\n onProgress?: (info: ProgressInfo) => void,\n ): Promise<DownloadResult> {\n const filename = formatFilename(user, \"ts\")\n const filepath = join(outputDir, filename)\n logger?.info(`Recording (HLS): ${filename}`)\n\n const startTime = Date.now()\n let lastProgressTime = 0\n\n // Find FFmpeg\n const ffmpegPath = findFfmpegPath()\n if (!ffmpegPath) {\n throw new Error(\n \"FFmpeg not found — required for HLS streams. Install it:\\n\" +\n \" Linux: apt install ffmpeg\\n macOS: brew install ffmpeg\",\n )\n }\n\n // Spawn FFmpeg with the M3U8 URL as input.\n // FFmpeg handles downloading all .ts segments and concatenating them.\n const args = [\"-i\", liveUrl, \"-c\", \"copy\", \"-y\", filepath]\n\n return new Promise<DownloadResult>((resolve, reject) => {\n const proc = spawn(ffmpegPath, args, {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n })\n ffmpegProcess = proc\n\n let stderr = \"\"\n\n proc.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString()\n })\n\n // Poll output file size for progress reporting\n const progressTimer = setInterval(() => {\n if (onProgress) {\n try {\n const stats = statSync(filepath)\n const elapsed = (Date.now() - startTime) / 1000\n const speed = elapsed > 0 ? stats.size / elapsed : 0\n const now = Date.now()\n if (now - lastProgressTime >= 1000) {\n lastProgressTime = now\n onProgress({ bytes: stats.size, elapsed, speed })\n }\n } catch {\n // File not created yet (FFmpeg still initializing) — skip\n }\n }\n }, 1000)\n\n // Max duration timer\n let maxDurationTimer: ReturnType<typeof setTimeout> | null = null\n if (maxDuration > 0) {\n maxDurationTimer = setTimeout(() => {\n logger?.info(`Duration limit reached (${maxDuration}s) — stopping FFmpeg`)\n if (!proc.killed) proc.kill(\"SIGTERM\")\n }, maxDuration * 1000)\n }\n\n proc.on(\"close\", (code) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n\n if (abortFlag) {\n // Aborted by user — return whatever we have\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n resolve({ file: filepath, duration: 0, size: 0 })\n }\n return\n }\n\n // Normal completion or error\n try {\n const stats = statSync(filepath)\n const duration = (Date.now() - startTime) / 1000\n if (onProgress) {\n onProgress({\n bytes: stats.size,\n elapsed: duration,\n speed: duration > 0 ? stats.size / duration : 0,\n })\n }\n logger?.info(\n `Recording finished: ${filename} (${formatDuration(duration)}, ${bytesToHuman(stats.size)})`,\n )\n resolve({ file: filepath, duration, size: stats.size })\n } catch {\n const msg = `FFmpeg exited with code ${code}\\n${stderr.slice(-500)}`\n reject(new Error(msg))\n }\n })\n\n proc.on(\"error\", (err) => {\n clearInterval(progressTimer)\n if (maxDurationTimer) clearTimeout(maxDurationTimer)\n ffmpegProcess = null\n reject(new Error(`Failed to spawn FFmpeg: ${err.message}`))\n })\n })\n }\n\n // ─── Main download entry point ──────────────────────────────\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 abortFlag = false\n abortController = new AbortController()\n ensureDir(outputDir)\n\n if (isHlsUrl(liveUrl)) {\n // HLS: FFmpeg handles playlist + segments natively.\n // Reconnection is not needed (FFmpeg refreshes the playlist internally).\n return downloadHls(liveUrl, user, outputDir, maxDuration, onProgress)\n }\n\n // FLV: existing fetch-based path with reconnection support\n return downloadFlv(liveUrl, user, outputDir, maxDuration, onProgress, getNextUrl)\n }\n\n return { download, abort }\n}\n\n/** Find FFmpeg binary via Bun.which() or PATH search. */\nfunction findFfmpegPath(): string | null {\n const bunWhich = (Bun as any)?.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\nfunction writeBuffer(writer: WriteStream, buffer: Buffer): Promise<void> {\n return new Promise((resolve, reject) => {\n writer.write(buffer, (err) => {\n if (err) reject(err)\n else resolve()\n })\n })\n}\n\nfunction formatDuration(seconds: number): string {\n const m = Math.floor(seconds / 60)\n const s = Math.floor(seconds % 60)\n return `${m}m ${s}s`\n}\n\n/** Race a promise against a timeout. The losing promise is ignored (no unhandled rejection). */\nfunction timeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(label)), ms)\n promise.then(\n (result) => {\n clearTimeout(timer)\n resolve(result)\n },\n (err) => {\n clearTimeout(timer)\n reject(err)\n },\n )\n })\n}\n"],"mappings":"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"}
|
package/dist/ui.mjs
CHANGED
|
@@ -11,7 +11,7 @@ function createDisplay() {
|
|
|
11
11
|
/** Stop the current spinner and print a one-liner with the given icon. */
|
|
12
12
|
function finalize(icon, text) {
|
|
13
13
|
clearSpinner();
|
|
14
|
-
process.stdout.write(
|
|
14
|
+
process.stdout.write(`${icon}${text}\n`);
|
|
15
15
|
}
|
|
16
16
|
/** Format seconds into a compact human-readable duration. */
|
|
17
17
|
function fmtDuration(seconds) {
|
|
@@ -53,16 +53,16 @@ function createDisplay() {
|
|
|
53
53
|
},
|
|
54
54
|
userOfflineRepeat(user, lastCheck) {
|
|
55
55
|
clearSpinner();
|
|
56
|
-
if (offlineLineShown) process.stdout.write(`\x1b[1A\r
|
|
56
|
+
if (offlineLineShown) process.stdout.write(`\x1b[1A\r${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\x1b[K\n`);
|
|
57
57
|
else {
|
|
58
|
-
process.stdout.write(
|
|
58
|
+
process.stdout.write(`${ICON_INFO}${color.dim(`@${user} is offline`)} ${color.dim(`[last check: ${lastCheck}]`)}\n`);
|
|
59
59
|
offlineLineShown = true;
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
userLive(user, roomId) {
|
|
63
63
|
clearSpinner();
|
|
64
64
|
offlineLineShown = false;
|
|
65
|
-
process.stdout.write(
|
|
65
|
+
process.stdout.write(`${ICON_SUCCESS}${color.green(`@${user} is LIVE!`)} ${color.dim(`(room: ${roomId})`)}\n`);
|
|
66
66
|
},
|
|
67
67
|
startRecording() {
|
|
68
68
|
clearSpinner();
|
|
@@ -113,17 +113,17 @@ function createDisplay() {
|
|
|
113
113
|
showError(message) {
|
|
114
114
|
clearSpinner();
|
|
115
115
|
offlineLineShown = false;
|
|
116
|
-
process.stdout.write(
|
|
116
|
+
process.stdout.write(`${ICON_ERROR}${color.red(message)}\n`);
|
|
117
117
|
},
|
|
118
118
|
showInfo(message) {
|
|
119
119
|
clearSpinner();
|
|
120
120
|
offlineLineShown = false;
|
|
121
|
-
process.stdout.write(
|
|
121
|
+
process.stdout.write(`${ICON_INFO}${color.dim(message)}\n`);
|
|
122
122
|
},
|
|
123
123
|
showWarning(message) {
|
|
124
124
|
clearSpinner();
|
|
125
125
|
offlineLineShown = false;
|
|
126
|
-
process.stdout.write(
|
|
126
|
+
process.stdout.write(`${ICON_WARN}${color.yellow(message)}\n`);
|
|
127
127
|
},
|
|
128
128
|
stop() {
|
|
129
129
|
clearSpinner();
|
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 type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport {\n color,\n createSpinner,\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n} 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 /** 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 /** 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?.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 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}... ${percent}%`\n },\n\n normalizeComplete(): void {\n finalize(ICON_SUCCESS, color.green(\"Audio normalized\"))\n },\n\n normalizeError(message: string): void {\n finalize(ICON_ERROR, color.red(`Normalization failed: ${message}`))\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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"}
|
|
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 type { SpinnerInstance } from \"@zfadhli/koko-cli\"\nimport {\n color,\n createSpinner,\n ICON_ERROR,\n ICON_INFO,\n ICON_SUCCESS,\n ICON_WARN,\n} 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 /** 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 /** 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?.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 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}... ${percent}%`\n },\n\n normalizeComplete(): void {\n finalize(ICON_SUCCESS, color.green(\"Audio normalized\"))\n },\n\n normalizeError(message: string): void {\n finalize(ICON_ERROR, color.red(`Normalization failed: ${message}`))\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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"}
|