fnorby777's picture
Color settings, System log, new seperate and in-built converter
a008314
Raw
History Blame Contribute Delete
25.8 kB
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<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..." });
}
// ========================================================
// SMART VIDEO FIXER & OVERLAY (Python Port)
// ========================================================
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;
// 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<string> {
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<string | null> {
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<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; }
}