import { cancel, group, intro, log, multiselect, outro, select, text, confirm } from "@clack/prompts"; import { spawn } from "bun"; import { DEFAULT_CONFIG } from "./config"; import { DownloadManager } from "./manager"; import { Terminal } from "./terminal"; import type { Config } from "./types"; import { readFile, sleep, loadConfig, saveConfigToFile } from "./utils"; import { existsSync, unlinkSync } from "node:fs"; import { join, basename, parse } from "node:path"; // ========================================== // CONSOLE COLOR CONFIGURATION // ========================================== const COLORS = { RED: "\x1b[31m", GREEN: "\x1b[32m", YELLOW: "\x1b[33m", RESET: "\x1b[0m" }; // ========================================== // FFMPEG ENCODING SETTINGS // ========================================== const ENCODING_SETTINGS = { crf: "23", preset: "veryfast", width: "1920", height: "1080" }; // ========================================== export class CLI { private manager: DownloadManager; private terminal: Terminal; private users: string[] = []; private config: Config; private isBusy = false; private logBuffer: string[] = []; private readonly MAX_LOGS = 100; private showChatLogs = true; constructor() { this.config = loadConfig(DEFAULT_CONFIG); this.manager = new DownloadManager(); this.terminal = new Terminal(); this.manager.on("log", (msg: string) => { // FIX: If chat logs are HIDDEN, we COMPLETELY IGNORE them if (!this.showChatLogs) { // If it looks like a chat message (contains emoji OR starts with typical Chat Capture output) if (msg.includes("šŸ’¬") || msg.includes("šŸŽ") || msg.includes("āš”ļø")) { return; // Do not push to buffer, do not print } } // Apply Coloring Logic const coloredMsg = this.formatLog(msg); this.logBuffer.push(`[${new Date().toLocaleTimeString()}] ${coloredMsg}`); if (this.logBuffer.length > this.MAX_LOGS) { this.logBuffer.shift(); } // FIX for "Streaming" logs: Print immediately if chat is enabled // This ensures messages flow in real-time without needing a keypress if (this.showChatLogs) { console.log(coloredMsg); } }); this.manager.on("statusChange", (e) => { if (!this.isBusy) { if (e.status === "completed" || e.status === "error") this.terminal.resetThrottle(); this.terminal.renderStatus(this.manager.getAll()); } }); } // --- COLORIZER LOGIC --- private formatLog(msg: string): string { const { RED, GREEN, YELLOW, RESET } = COLORS; const redTriggers = [ "[*]", "ROOM_ID:", "USERNAME:", "Started recording...", "PRESS CTRL + C", "DEVICE_BLOCKED", "Connection error", "disconnected", "Disconnected", "connection lost", "retry", "Waiting", "Chat Capture started for", "Connected to", "Opening stream", // New triggers requested "Event handlers registered", "Monitoring chat", "Connecting to" ]; const greenTriggers = [ "Converting", "Fixing", "Moving", "Success", "Done", "[DONE]" ]; // 1. Check for RED base let isRed = redTriggers.some(trigger => msg.includes(trigger)); // 2. Check for GREEN base let isGreen = greenTriggers.some(trigger => msg.includes(trigger)); // 3. Highlight Usernames (Yellow) let finalMsg = msg.replace(/(@[\w\.]+)/g, (match) => { const baseColor = isRed ? RED : (isGreen ? GREEN : RESET); return `${YELLOW}${match}${baseColor}`; }); // 4. Apply Base Color if (isRed) { return `${RED}${finalMsg}${RESET}`; } if (isGreen) { return `${GREEN}${finalMsg}${RESET}`; } return finalMsg; } private printHeader() { console.clear(); const running = this.manager.getRunning(); const activeCount = running.length.toString(); console.log(`\n╔═══════════════════════════════════════════════════════════╗`); console.log(`ā•‘ šŸŽ¬ TIKTOK LIVE STUDIO CLI | Active: ${activeCount.padEnd(20)}ā•‘`); console.log(`╠═══════════════════════════════════════════════════════════╣`); if (running.length === 0) { console.log(`ā•‘ (Idle - No active downloads) ā•‘`); } else { running.forEach(d => { const diff = Math.floor((Date.now() - d.startTime.getTime()) / 1000); const h = Math.floor(diff / 3600).toString().padStart(2, '0'); const m = Math.floor((diff % 3600) / 60).toString().padStart(2, '0'); const s = (diff % 60).toString().padStart(2, '0'); let userStr = `@${d.user}`; const statusIcon = d.status === 'waiting' ? 'ā³' : 'šŸ”“'; if (userStr.length > 18) userStr = userStr.substring(0, 15) + ".."; userStr = userStr.padEnd(18); const timeStr = `[${h}:${m}:${s}]`; const padding = ' '.repeat(59 - 2 - statusIcon.length - 1 - userStr.length - 1 - timeStr.length); console.log(`ā•‘ ${statusIcon} ${userStr} ${timeStr}${padding}ā•‘`); }); } console.log(`ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•`); const logStatus = this.showChatLogs ? "[CHAT: ON]" : "[CHAT: OFF]"; console.log(`\n--- RECENT LOGS ${logStatus} ---`); const recentLogs = this.logBuffer.slice(-10); if (recentLogs.length > 0) { recentLogs.forEach(line => { console.log(line); }); console.log("-------------------\n"); } else { console.log("\n"); } } private getPythonCommand(): string[] { const cwd = process.cwd(); if (process.platform === "win32") { const venvPath = join(cwd, ".venv", "Scripts", "python.exe"); if (existsSync(venvPath)) return [venvPath]; } return ["python"]; } async start(): Promise { this.config = await this.promptConfig(); saveConfigToFile(this.config); this.users = readFile(this.config.userListFile); if (await this.promptInitialUsers(this.config)) { await this.mainMenu(this.config); } process.exit(0); } private async promptConfig(): Promise { console.clear(); intro("šŸŽ¬ TikTok Live Studio"); const input = await group({ commandPrefix: () => text({ message: "Command prefix:", placeholder: this.config.commandPrefix, defaultValue: this.config.commandPrefix }), outputPath: () => text({ message: "Output path:", placeholder: this.config.outputPath, defaultValue: this.config.outputPath }), userListFile: () => text({ message: "Users list:", placeholder: this.config.userListFile, defaultValue: this.config.userListFile }), enableChat: () => confirm({ message: "Enable chat?", initialValue: this.config.enableChat }), chatFormat: ({ results }) => results.enableChat ? select({ message: "Format:", options: [{ value: "json", label: "JSON" }, { value: "txt", label: "TXT" }, { value: "srt", label: "SRT" }, { value: "ass", label: "ASS" }], initialValue: this.config.chatFormat }) : Promise.resolve("json"), autoOverlay: ({ results }) => results.enableChat ? confirm({ message: "Auto overlay?", initialValue: (this.config as any).autoOverlay || false }) : Promise.resolve(false), }, { onCancel: () => process.exit(0) }); return { commandPrefix: input.commandPrefix as string, outputPath: input.outputPath as string, userListFile: input.userListFile as string, enableChat: input.enableChat as boolean, chatFormat: input.chatFormat as any, autoOverlay: input.autoOverlay as boolean }; } private async promptInitialUsers(config: Config): Promise { if (!this.users.length) return true; const action = await select({ message: `Found ${this.users.length} users. Startup:`, options: [{ value: "all", label: "šŸš€ Start/Monitor All" }, { value: "select", label: "šŸ”Ž Select" }, { value: "skip", label: "ā­ļø Menu" }] }); if (action === "all") { for (const u of this.users) { const randomDelay = Math.floor(Math.random() * (45000 - 30000 + 1) + 30000); await this.manager.start(u, config, randomDelay); } } if (action === "select") { const s = await this.selectUsers(); await this.startUsers(s, config); } return true; } private async mainMenu(config: Config): Promise { while (true) { this.printHeader(); const chatLogLabel = this.showChatLogs ? "šŸ”‡ Hide Chat Logs" : "šŸ”Š Show Chat Logs"; const action = await select({ message: "Main Menu", options: [ { value: "start", label: "āž• Start/Monitor New" }, { value: "start_all", label: "šŸš€ Start/Monitor List" }, { value: "stop", label: "ā¹ļø Stop Single" }, { value: "stop_all", label: "šŸ›‘ Stop All" }, { value: "delete", label: "šŸ—‘ļø Delete History" }, { value: "restart", label: "šŸ”„ Restart Process" }, { value: "logs", label: "šŸ“œ View Full Logs" }, { value: "toggle_chat", label: chatLogLabel }, { value: "refresh", label: "šŸ”ƒ Redraw Screen" }, { value: "kill_all", label: "šŸ›‘ Kill All Processes" }, { value: "fix_videos", label: "šŸ”§ Fix & Overlay Videos" }, { value: "exit", label: "āŒ Exit" } ] }); switch (action) { case "start": await this.handleStart(config); break; case "start_all": await this.handleStartAll(config); break; case "stop": await this.handleStop(); break; case "stop_all": await this.handleStopAll(); break; case "delete": await this.handleDelete(); break; case "restart": await this.handleRestart(config); break; case "logs": await this.viewFullLogs(); break; case "toggle_chat": this.showChatLogs = !this.showChatLogs; break; case "refresh": break; case "kill_all": await this.manager.killGlobalProcesses(); break; case "fix_videos": await this.smartFixVideos(config); break; case "exit": if(await confirm({ message: "Exit?" })) { this.isBusy = true; log.info("šŸ›‘ Stopping recordings..."); await this.manager.stopAll(); await sleep(3000); await this.smartFixVideos(config, true); await this.manager.killGlobalProcesses(); return; } break; } } } private async viewFullLogs(): Promise { console.clear(); console.log("\n╔═══════════════════════════════════════════════════════════╗"); console.log("ā•‘ šŸ“œ FULL LOGS HISTORY ā•‘"); console.log("╠═══════════════════════════════════════════════════════════╣\n"); if (this.logBuffer.length === 0) { console.log(" (No logs available yet)\n"); } else { this.logBuffer.forEach(line => { console.log(line); }); } console.log("\nā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n"); await text({ message: "Press Enter to return to menu..." }); } // ======================================================== // SMART VIDEO FIXER & OVERLAY (Python Port) // ======================================================== private async smartFixVideos(config: Config, autoMode = false): Promise { this.isBusy = true; const sourceFolder = config.outputPath; const outputFolder = join(sourceFolder, "Fixed"); const chatDir = join(sourceFolder, "chats"); if (!autoMode) { log.info("ā„¹ļø Running Fix & Overlay Videos (Background recordings continue)..."); } let format = "mkv"; if (!autoMode) { format = await select({ message: "Format:", options: [ { value: "x264_mp4", label: "šŸŽ„ MP4 (H264)" }, { value: "x265_mp4", label: "šŸŽ¬ MP4 (H265)" }, { value: "mkv", label: "šŸ“¦ MKV" }, { value: "mov", label: "šŸŽžļø MOV" }, { value: "avi", label: "šŸ“¼ AVI" }, { value: "wmv", label: "🪟 WMV" }, { value: "webm", label: "🌐 WEBM" } ] }) as string; } try { await Bun.write(join(outputFolder, ".keep"), ""); } catch {} const glob = new Bun.Glob("**/*.{mp4,flv,mkv,avi,mov,webm}"); const files: string[] = []; try { for await (const f of glob.scan(sourceFolder)) { if (!f.includes("Fixed") && !f.includes("chats") && !f.includes(".recording")) { files.push(join(sourceFolder, f)); } } } catch {} console.log(`\n${COLORS.GREEN}šŸ”§ Found ${files.length} videos to process...${COLORS.RESET}`); for (const videoPath of files) { const videoName = basename(videoPath); const nameWithoutExt = parse(videoName).name; // Output filename determination const extension = format === "x264_mp4" || format === "x265_mp4" ? ".mp4" : `.${format}`; // Check for matching chat first to determine output name const chatFile = await this.findMatchingChat(videoName, chatDir); const outputName = chatFile ? `${nameWithoutExt}_fixed_with_chat${extension}` : `${nameWithoutExt}_fixed${extension}`; const outputPath = join(outputFolder, outputName); if (existsSync(outputPath)) { if (!autoMode) console.log(`ā­ļø Skipping existing: ${videoName}`); continue; } console.log(`${COLORS.GREEN}\n[VIDEO] Processing: ${videoName}${COLORS.RESET}`); // 1. Get Video Info const videoInfo = await this.getVideoInfo(videoPath); if (videoInfo) console.log(`${COLORS.GREEN} [INFO] Source: ${videoInfo.width}x${videoInfo.height}${COLORS.RESET}`); // 2. Detect Black Bars const cropInfo = await this.detectBlackBars(videoPath); // 3. Fix ASS Font if chat exists let finalChatPath = null; if (chatFile) { finalChatPath = await this.fixAssFontForEmoji(chatFile); console.log(`${COLORS.GREEN} [CHAT] Match found: ${basename(chatFile)}${COLORS.RESET}`); } // 4. Build Filter const vf = this.buildFilter(videoInfo, cropInfo, finalChatPath); // 5. Build FFmpeg Command const codec = format.includes("x265") ? "libx265" : (format === "webm" ? "libvpx-vp9" : (format === "wmv" ? "wmv2" : "libx264")); const extraParams = format.includes("x265") ? ["-preset", "medium", "-crf", "23"] : ["-preset", "veryfast", "-crf", "23"]; const args = [ "-err_detect", "ignore_err", "-i", videoPath, "-vf", vf, "-c:v", codec, ...extraParams, "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-y", "-loglevel", "error", "-stats", outputPath ]; console.log(`${COLORS.GREEN} [ENCODE] Processing...${COLORS.RESET}`); const proc = spawn({ cmd: ["ffmpeg", ...args], stdout: "inherit", stderr: "inherit" }); await proc.exited; console.log(` ${COLORS.GREEN}[DONE] āœ“ Saved to Fixed/${outputName}${COLORS.RESET}`); // Cleanup temp fixed ass file if (finalChatPath && finalChatPath.includes("_emoji_fixed.ass")) { try { unlinkSync(finalChatPath); } catch {} } } if (!autoMode) { log.success("All tasks finished."); await text({ message: "Press Enter to return..." }); } this.isBusy = false; } // Helper: Get Video Info private async getVideoInfo(videoPath: string): Promise<{width: number, height: number} | null> { try { const proc = spawn({ cmd: ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", videoPath], stdout: "pipe" }); const output = await new Response(proc.stdout).json(); if (output.streams && output.streams[0]) { return { width: parseInt(output.streams[0].width), height: parseInt(output.streams[0].height) }; } } catch {} return null; } // Helper: Detect Black Bars (Cropdetect) private async detectBlackBars(videoPath: string): Promise<{w: number, h: number, x: number, y: number} | null> { console.log(`${COLORS.GREEN} [DETECT] Analyzing black bars...${COLORS.RESET}`); try { const proc = spawn({ cmd: ["ffmpeg", "-i", videoPath, "-vf", "cropdetect=24:2:0", "-f", "null", "-t", "5", "-"], stderr: "pipe" }); const text = await new Response(proc.stderr).text(); const lines = text.split("\n"); const cropRegex = /crop=(\d+):(\d+):(\d+):(\d+)/; let lastCrop = null; for (const line of lines) { const match = line.match(cropRegex); if (match) { lastCrop = { w: parseInt(match[1]), h: parseInt(match[2]), x: parseInt(match[3]), y: parseInt(match[4]) }; } } if (lastCrop) console.log(`${COLORS.GREEN} [DETECT] Black bars found! Content: ${lastCrop.w}x${lastCrop.h}${COLORS.RESET}`); else console.log(`${COLORS.GREEN} [DETECT] No black bars found.${COLORS.RESET}`); return lastCrop; } catch { return null; } } // Helper: Fix ASS Font private async fixAssFontForEmoji(assPath: string): Promise { try { const content = await Bun.file(assPath).text(); // Replace Arial with Segoe UI Emoji const fixedContent = content.replace(/(Style:[^,]*,)(Arial|Roboto|sans-serif)/gi, "$1Segoe UI Emoji"); const newPath = assPath.replace(".ass", "_emoji_fixed.ass"); await Bun.write(newPath, fixedContent); return newPath; } catch { return assPath; } } // Helper: Find Matching Chat private async findMatchingChat(videoName: string, chatDir: string): Promise { const parts = videoName.split('_'); if (parts.length < 4) return null; const username = parts[1]; const datePart = parts[2].replace(/\./g, ''); // 2026.01.25 -> 20260125 const timePart = parts[3].replace(/-/g, ''); // 17-30-40 -> 173040 const videoTime = parseInt(datePart + timePart); // 20260125173040 // Find matches const glob = new Bun.Glob(`${username}_*.ass`); let bestMatch = null; let minDiff = Infinity; try { for await (const file of glob.scan(chatDir)) { const cParts = file.split('_'); if (cParts.length < 3) continue; const chatDate = cParts[1]; // 20260125 const chatTime = cParts[2].split('.')[0]; // 173040 // Only same day if (chatDate !== datePart) continue; const chatFullTime = parseInt(chatDate + chatTime); const diff = Math.abs(chatFullTime - videoTime); if (diff < minDiff) { minDiff = diff; bestMatch = join(chatDir, file); } } } catch {} return bestMatch; } // Helper: Build FFmpeg Filter private buildFilter(info: any, crop: any, chatPath: string | null): string { const TARGET_W = 1920; const TARGET_H = 1080; const vfParts = []; // 1. Crop if (crop) { vfParts.push(`crop=${crop.w}:${crop.h}:${crop.x}:${crop.y}`); } // 2. Scale & Pad (Fit to 1920x1080) vfParts.push(`scale=${TARGET_W}:${TARGET_H}:force_original_aspect_ratio=decrease`); vfParts.push(`pad=${TARGET_W}:${TARGET_H}:(ow-iw)/2:(oh-ih)/2:black`); // 3. SAR vfParts.push("setsar=1"); // 4. Chat if (chatPath) { // Escape path for ffmpeg const safePath = chatPath.replace(/\\/g, '/').replace(/:/g, '\\:'); vfParts.push(`ass='${safePath}'`); } return vfParts.join(","); } // ======================================================== private async handleStart(c: Config): Promise { this.terminal.setMenuActive(true); const mode = await select({ message: "Start:", options: [{ value: "custom", label: "āœļø Manual User" }, { value: "list", label: "šŸ“‹ From List" }] }); this.terminal.setMenuActive(false); if (mode === "custom") { const u = await text({ message: "Username:" }); if (u && typeof u === 'string') await this.manager.start(u, c, 10000); } else { const s = await this.selectUsers(); await this.startUsers(s, c); } } private async handleStop(): Promise { const r = this.manager.getRunning(); if(!r.length) { log.warn("No active downloads."); await sleep(1000); return; } const s = await multiselect({ message: "Stop:", options: r.map(d=>({value:d.id, label:d.user})) }); for(const id of s as string[]) await this.manager.stop(id); } private async handleStopAll(): Promise { if(await confirm({ message: "Stop ALL active downloads?" })) { await this.manager.stopAll(); log.success("All downloads stopped."); } } private async handleDelete(): Promise { const all = this.manager.getAll(); if(!all.length) { log.warn("History empty."); await sleep(1000); return; } const s = await multiselect({ message: "Delete:", options: all.map(d=>({value:d.id, label:`${d.status} @${d.user}`})) }); for(const id of s as string[]) this.manager.delete(id); } private async handleStartAll(config: Config): Promise { if (await confirm({ message: "Start/Monitor all?" })) { for (const u of this.users) { const randomDelay = Math.floor(Math.random() * (45000 - 30000 + 1) + 30000); await this.manager.start(u, config, randomDelay); } } } private async handleRestart(config: Config): Promise { const all = this.manager.getAll(); if(!all.length) return; const s = await multiselect({ message: "Restart:", options: all.map(d=>({value:d.id, label:d.user})) }); for(const id of s as string[]) await this.manager.restart(id, config); } private async selectUsers(): Promise { this.terminal.setMenuActive(true); const s = await multiselect({ message: "Select:", options: this.users.map(u => ({ value: u, label: u })) }); this.terminal.setMenuActive(false); return s as string[]; } private async startUsers(users: string[], config: Config): Promise { for (const u of users) { const randomDelay = Math.floor(Math.random() * (45000 - 30000 + 1) + 30000); await this.manager.start(u, config, randomDelay); } } getManager(): DownloadManager { return this.manager; } }