import { spawn } from "bun"; import { DownloadManager } from "./manager"; import { DEFAULT_CONFIG } from "./config"; 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 = { // Quality (Lower = Better, 18 is High Quality, 23 is Default) crf: "23", // Speed (ultrafast, superfast, veryfast, medium, slow) preset: "veryfast", // Resolution (1920x1080) width: "1920", height: "1080" }; // ========================================== export class WebServer { private manager: DownloadManager; private config: Config; private users: string[] = []; private lastStatus: any = { downloads: [], config: {} }; private isProcessing: boolean = false; constructor(initialConfig: Config) { this.config = loadConfig(initialConfig); this.manager = new DownloadManager(); try { this.users = readFile(this.config.userListFile); } catch { this.users = []; } this.manager.on("statusChange", () => { this.broadcastStatus(); }); // --- COLORED LOGGING FOR SERVER CONSOLE --- this.manager.on("log", (msg: string) => { const coloredMsg = this.formatLog(msg); console.log(`[RECORDER] ${coloredMsg}`); }); } // --- 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 broadcastStatus(): void { const status = { downloads: this.manager.getAll().map((d) => ({ id: d.id, user: d.user, status: d.status, startTime: d.startTime.toISOString(), outputPath: d.outputPath, })), config: { outputPath: this.config.outputPath, enableChat: this.config.enableChat || false, chatFormat: this.config.chatFormat || "json", autoOverlay: (this.config as any).autoOverlay || false, }, isProcessing: this.isProcessing }; this.lastStatus = status; } private getPythonCommand(): string[] { const cwd = process.cwd(); if (process.platform === "win32") { const venvPath = join(cwd, ".venv", "Scripts", "python.exe"); if (existsSync(venvPath)) return [venvPath]; } else { const venvPath = join(cwd, ".venv", "bin", "python"); if (existsSync(venvPath)) return [venvPath]; } return ["python"]; } async start(port: number = 3000): Promise { this.manager.startAutoRestart(this.config); const server = Bun.serve({ port, fetch: async (req) => { const url = new URL(req.url); if (url.pathname === "/" || url.pathname === "/index.html") { return new Response(this.getHTML(), { headers: { "Content-Type": "text/html" } }); } if (url.pathname === "/api/status") { this.broadcastStatus(); return Response.json(this.lastStatus); } if (url.pathname === "/api/users") { return Response.json({ users: this.users }); } if (url.pathname === "/api/config" && req.method === "POST") { const body = await req.json(); if (body.outputPath) this.config.outputPath = body.outputPath; if (body.enableChat !== undefined) this.config.enableChat = body.enableChat; if (body.chatFormat) this.config.chatFormat = body.chatFormat; if (body.autoOverlay !== undefined) (this.config as any).autoOverlay = body.autoOverlay; saveConfigToFile(this.config); this.broadcastStatus(); return Response.json({ success: true, config: this.config }); } if (url.pathname === "/api/start" && req.method === "POST") { const body = await req.json(); if (body.user) { // Start with 10s default delay await this.manager.start(body.user, this.config, 10000); return Response.json({ success: true }); } return Response.json({ error: "User required" }, { status: 400 }); } if (url.pathname === "/api/start-bulk" && req.method === "POST") { const body = await req.json(); if (Array.isArray(body.users)) { for (const u of body.users) { const randomDelay = Math.floor(Math.random() * (45000 - 30000 + 1) + 30000); await this.manager.start(u, this.config, randomDelay); } return Response.json({ success: true }); } return Response.json({ error: "List required" }, { status: 400 }); } if (url.pathname === "/api/start-all" && req.method === "POST") { for (const u of this.users) { const randomDelay = Math.floor(Math.random() * (45000 - 30000 + 1) + 30000); await this.manager.start(u, this.config, randomDelay); } return Response.json({ success: true }); } if (url.pathname === "/api/stop" && req.method === "POST") { const body = await req.json(); if (body.id) { await this.manager.stop(body.id); return Response.json({ success: true }); } return Response.json({ error: "ID required" }, { status: 400 }); } if (url.pathname === "/api/delete" && req.method === "POST") { const body = await req.json(); if (body.id) { this.manager.delete(body.id); this.broadcastStatus(); return Response.json({ success: true }); } return Response.json({ error: "ID required" }, { status: 400 }); } if (url.pathname === "/api/restart" && req.method === "POST") { const body = await req.json(); if (body.id) { await this.manager.restart(body.id, this.config); return Response.json({ success: true }); } return Response.json({ error: "ID required" }, { status: 400 }); } if (url.pathname === "/api/stop-all" && req.method === "POST") { await this.manager.stopAll(); return Response.json({ success: true }); } if (url.pathname === "/api/kill-all" && req.method === "POST") { await this.manager.killGlobalProcesses(); return Response.json({ success: true }); } // ======================================================== // SMART VIDEO FIXER API (Python Port) // ======================================================== if (url.pathname === "/api/fix-videos" && req.method === "POST") { if (this.isProcessing) return Response.json({ error: "Busy" }, { status: 429 }); const body = await req.json(); console.log("šŸ› ļø Safe Fix Requested via Web UI..."); this.isProcessing = true; // 1. Run the new smart fixer logic directly await this.smartFixVideos(this.config, body.format || "mkv", false); this.isProcessing = false; return Response.json({ success: true, message: "Video conversion completed successfully." }); } if (url.pathname === "/api/exit" && req.method === "POST") { if (this.isProcessing) return Response.json({ error: "Busy" }, { status: 429 }); console.log("šŸ›‘ Exit requested..."); this.isProcessing = true; this.manager.stopAutoRestart(); await this.manager.stopAll(); await sleep(3000); // Exit triggers smart fix automatically await this.smartFixVideos(this.config, "mkv", true); try { await this.manager.killGlobalProcesses(); } catch {} setTimeout(() => process.exit(0), 1000); return Response.json({ success: true }); } return new Response("Not found", { status: 404 }); }, }); console.log(`\n🌐 Web UI running at http://localhost:${port}`); } // ======================================================== // SMART VIDEO FIXER LOGIC (Same as CLI) // ======================================================== private async smartFixVideos(config: Config, format: string, autoMode: boolean): Promise { const sourceFolder = config.outputPath; const outputFolder = join(sourceFolder, "Fixed"); const chatDir = join(sourceFolder, "chats"); if (!autoMode) { console.log(`\n${COLORS.GREEN}šŸ”§ Fixing videos to ${format}...${COLORS.RESET}`); } 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...${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}`); // 1. Get Info & Crop 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); // 2. Fix Chat let finalChatPath = null; if (chatFile) { finalChatPath = await this.fixAssFontForEmoji(chatFile); console.log(`${COLORS.GREEN} [CHAT] Match found: ${basename(chatFile)}${COLORS.RESET}`); } // 3. Build Filter const vf = this.buildFilter(videoInfo, cropInfo, finalChatPath); // 4. Encode 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${COLORS.RESET}`); if (finalChatPath && finalChatPath.includes("_emoji_fixed.ass")) { try { unlinkSync(finalChatPath); } catch {} } } } // 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 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, ''); 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; } // Helper: Build FFmpeg Filter 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 getHTML(): string { return ` TikTok Live Studio

šŸŽ¬ TikTok Live Studio

šŸš€ Start Downloads

šŸ› ļø Utilities

šŸ“Š Active Downloads

`; } }