| 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"; |
|
|
| |
| |
| |
| const COLORS = { |
| RED: "\x1b[31m", |
| GREEN: "\x1b[32m", |
| YELLOW: "\x1b[33m", |
| RESET: "\x1b[0m" |
| }; |
|
|
| |
| |
| |
| 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) => { |
| |
| if (!this.showChatLogs) { |
| |
| if (msg.includes("💬") || msg.includes("🎁") || msg.includes("⚔️")) { |
| return; |
| } |
| } |
|
|
| |
| const coloredMsg = this.formatLog(msg); |
| this.logBuffer.push(`[${new Date().toLocaleTimeString()}] ${coloredMsg}`); |
| |
| if (this.logBuffer.length > this.MAX_LOGS) { this.logBuffer.shift(); } |
|
|
| |
| |
| 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()); |
| } |
| }); |
| } |
|
|
| |
| 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", |
| |
| "Event handlers registered", |
| "Monitoring chat", |
| "Connecting to" |
| ]; |
|
|
| const greenTriggers = [ |
| "Converting", |
| "Fixing", |
| "Moving", |
| "Success", |
| "Done", |
| "[DONE]" |
| ]; |
|
|
| |
| let isRed = redTriggers.some(trigger => msg.includes(trigger)); |
| |
| |
| let isGreen = greenTriggers.some(trigger => msg.includes(trigger)); |
|
|
| |
| let finalMsg = msg.replace(/(@[\w\.]+)/g, (match) => { |
| const baseColor = isRed ? RED : (isGreen ? GREEN : RESET); |
| return `${YELLOW}${match}${baseColor}`; |
| }); |
|
|
| |
| 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<void> { |
| 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<Config> { |
| 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<boolean> { |
| 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<void> { |
| 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<void> { |
| 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..." }); |
| } |
|
|
| |
| |
| |
|
|
| private async smartFixVideos(config: Config, autoMode = false): Promise<void> { |
| 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; |
| |
| |
| const extension = format === "x264_mp4" || format === "x265_mp4" ? ".mp4" : `.${format}`; |
| |
| |
| 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}`); |
|
|
| |
| const videoInfo = await this.getVideoInfo(videoPath); |
| if (videoInfo) console.log(`${COLORS.GREEN} [INFO] Source: ${videoInfo.width}x${videoInfo.height}${COLORS.RESET}`); |
|
|
| |
| const cropInfo = await this.detectBlackBars(videoPath); |
|
|
| |
| let finalChatPath = null; |
| if (chatFile) { |
| finalChatPath = await this.fixAssFontForEmoji(chatFile); |
| console.log(`${COLORS.GREEN} [CHAT] Match found: ${basename(chatFile)}${COLORS.RESET}`); |
| } |
|
|
| |
| const vf = this.buildFilter(videoInfo, cropInfo, finalChatPath); |
|
|
| |
| 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}`); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; } |
| } |
|
|
| |
| private async fixAssFontForEmoji(assPath: string): Promise<string> { |
| try { |
| const content = await Bun.file(assPath).text(); |
| |
| 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; } |
| } |
|
|
| |
| private async findMatchingChat(videoName: string, chatDir: string): Promise<string | null> { |
| const parts = videoName.split('_'); |
| if (parts.length < 4) return null; |
| |
| const username = parts[1]; |
| const datePart = parts[2].replace(/\./g, ''); |
| const timePart = parts[3].replace(/-/g, ''); |
| const videoTime = parseInt(datePart + timePart); |
|
|
| |
| 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]; |
| const chatTime = cParts[2].split('.')[0]; |
| |
| |
| 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; |
| } |
|
|
| |
| private buildFilter(info: any, crop: any, chatPath: string | null): string { |
| const TARGET_W = 1920; |
| const TARGET_H = 1080; |
| const vfParts = []; |
|
|
| |
| if (crop) { |
| vfParts.push(`crop=${crop.w}:${crop.h}:${crop.x}:${crop.y}`); |
| } |
|
|
| |
| 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`); |
| |
| |
| vfParts.push("setsar=1"); |
|
|
| |
| if (chatPath) { |
| |
| const safePath = chatPath.replace(/\\/g, '/').replace(/:/g, '\\:'); |
| vfParts.push(`ass='${safePath}'`); |
| } |
|
|
| return vfParts.join(","); |
| } |
|
|
| |
|
|
| private async handleStart(c: Config): Promise<void> { |
| 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<void> { |
| 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<void> { |
| if(await confirm({ message: "Stop ALL active downloads?" })) { |
| await this.manager.stopAll(); |
| log.success("All downloads stopped."); |
| } |
| } |
|
|
| private async handleDelete(): Promise<void> { |
| 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<void> { |
| 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<void> { |
| 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<string[]> { 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<void> { |
| 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; } |
| } |