| 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"; |
|
|
| |
| |
| |
| 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 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(); }); |
| |
| |
| this.manager.on("log", (msg: string) => { |
| const coloredMsg = this.formatLog(msg); |
| console.log(`[RECORDER] ${coloredMsg}`); |
| }); |
| } |
|
|
| |
| 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 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<void> { |
| 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) { |
| |
| 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 }); |
| } |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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); |
| |
| |
| 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}`); |
| } |
|
|
| |
| |
| |
|
|
| private async smartFixVideos(config: Config, format: string, autoMode: boolean): Promise<void> { |
| 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}`); |
|
|
| |
| 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${COLORS.RESET}`); |
|
|
| if (finalChatPath && finalChatPath.includes("_emoji_fixed.ass")) { |
| try { unlinkSync(finalChatPath); } catch {} |
| } |
| } |
| } |
|
|
| |
| 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 getHTML(): string { |
| return `<!DOCTYPE html> |
| <html lang="en"> |
| <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>TikTok Live Studio</title><script src="https://cdn.tailwindcss.com"></script><style>body{background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);min-height:100vh}.glass{background:rgba(30,41,59,0.7);backdrop-filter:blur(10px);border:1px solid rgba(148,163,184,0.1)}.status-badge{animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite}@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.7}}.checkbox-item{cursor:pointer;transition:all 0.2s}.checkbox-item:hover{background:rgba(148,163,184,0.1)}.checkbox-item.selected{background:rgba(59,130,246,0.2);border-color:rgb(59,130,246)}</style></head> |
| <body class="text-gray-100"> |
| <div class="container mx-auto px-4 py-8 max-w-7xl"> |
| <div class="glass rounded-2xl p-8 mb-6 shadow-2xl"><div class="flex items-center justify-between flex-wrap gap-4"><div><h1 class="text-4xl font-bold bg-gradient-to-r from-pink-500 to-violet-500 bg-clip-text text-transparent">π¬ TikTok Live Studio</h1></div><div class="flex gap-3"><button onclick="showSettings()" class="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-xl font-semibold">βοΈ Settings</button><button onclick="refresh(true)" id="refreshBtn" class="px-6 py-3 bg-slate-700 hover:bg-slate-600 rounded-xl font-semibold">π Refresh</button><button onclick="exitApp()" class="px-6 py-3 bg-red-600 hover:bg-red-700 rounded-xl font-semibold">β Exit</button></div></div></div> |
| <div id="settingsModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"><div class="glass rounded-2xl p-8 max-w-2xl w-full mx-4"><h3 class="text-2xl font-bold mb-6">βοΈ Settings</h3><div class="space-y-4"><div><label class="block text-sm font-semibold mb-2">Output Path:</label><input type="text" id="outputPath" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-pink-500"/></div><div class="p-4 bg-slate-800 rounded-xl space-y-3"><label class="flex items-center space-x-3 cursor-pointer"><input type="checkbox" id="enableChat" class="w-5 h-5 rounded border-gray-600 text-pink-600 focus:ring-pink-500"><span class="font-semibold">Enable Chat Capture</span></label><div id="chatFormatContainer" class="hidden pl-8 space-y-3"><div><label class="block text-sm text-gray-400 mb-1">Chat Format:</label><select id="chatFormat" class="w-full px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg"><option value="json">JSON</option><option value="txt">TXT</option><option value="srt">SRT (Subtitle)</option><option value="ass">ASS (Advanced Overlay)</option></select></div><label class="flex items-center space-x-3 cursor-pointer" id="overlayOption"><input type="checkbox" id="autoOverlay" class="w-5 h-5 rounded border-gray-600 text-purple-600 focus:ring-purple-500"><span class="font-semibold text-purple-300">Auto-add chat overlay (Requires SRT/ASS)</span></label></div></div></div><div class="flex gap-3 mt-6"><button onclick="saveSettings()" class="flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 rounded-xl font-semibold">πΎ Save & Apply</button><button onclick="hideSettings()" class="flex-1 px-6 py-3 bg-gray-600 hover:bg-gray-500 rounded-xl font-semibold">Cancel</button></div></div></div> |
| <div class="glass rounded-2xl p-6 mb-6 shadow-2xl"><h2 class="text-2xl font-bold mb-4 flex items-center gap-2"><span>π</span> Start Downloads</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-6"><div><label class="block text-sm font-semibold mb-2">Single User:</label><div class="flex gap-2"><input type="text" id="usernameInput" placeholder="Enter username..." class="flex-1 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl focus:ring-2 focus:ring-pink-500" onkeypress="if(event.key==='Enter') startDownload()"/><button onclick="startDownload()" class="px-6 py-3 bg-gradient-to-r from-pink-600 to-violet-600 hover:from-pink-700 hover:to-violet-700 rounded-xl font-semibold">β Start</button></div></div><div><label class="block text-sm font-semibold mb-2">From List:</label><div class="flex gap-2"><button onclick="toggleUsersList()" class="flex-1 px-6 py-3 bg-slate-700 hover:bg-slate-600 rounded-xl font-semibold">π Select Users</button><button id="startSelectedBtn" onclick="startSelected()" class="hidden flex-1 px-6 py-3 bg-green-600 hover:bg-green-700 rounded-xl font-semibold animate-pulse">π Start Selected</button><button id="startAllBtn" onclick="startAll()" class="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl font-semibold">π Start All</button></div></div></div><div id="usersList" class="hidden mt-4 p-4 bg-slate-800 rounded-xl border border-slate-700"><div class="flex justify-between items-center mb-3"><span class="text-sm text-gray-400">Click to select users:</span><button onclick="toggleUsersList()" class="text-xs text-gray-500 hover:text-white">Close</button></div><div id="usersCheckboxList" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 max-h-60 overflow-y-auto pr-2"></div></div></div> |
| |
| <div class="glass rounded-2xl p-6 mb-6 shadow-2xl"><h2 class="text-2xl font-bold mb-4 flex items-center gap-2"><span>π οΈ</span> Utilities</h2><div class="grid grid-cols-1 md:grid-cols-3 gap-4"><button onclick="killAll()" class="px-6 py-4 bg-orange-600 hover:bg-orange-700 rounded-xl font-semibold text-left"><div class="flex items-center gap-3"><span class="text-2xl">π</span><div><div class="font-bold">Kill All Processes</div><div class="text-sm text-orange-200">Close Python, FFmpeg...</div></div></div></button><button onclick="showFixVideos()" class="px-6 py-4 bg-blue-600 hover:bg-blue-700 rounded-xl font-semibold text-left"><div class="flex items-center gap-3"><span class="text-2xl">π§</span><div><div class="font-bold">Fix & Overlay Videos</div><div class="text-sm text-blue-200">Convert raw files & burn chat</div></div></div></button><button onclick="stopAll()" class="px-6 py-4 bg-red-600 hover:bg-red-700 rounded-xl font-semibold text-left"><div class="flex items-center gap-3"><span class="text-2xl">βΉοΈ</span><div><div class="font-bold">Stop All</div><div class="text-sm text-red-200">Halt all downloads</div></div></div></button></div></div> |
| |
| <div id="formatModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"><div class="glass rounded-2xl p-8 max-w-xl w-full mx-4"><h3 class="text-2xl font-bold mb-4">Select Output Format</h3><div class="grid grid-cols-1 gap-2 mb-6"><button onclick="fixVideos('x264_mp4')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">π₯ <b>MP4</b> (H.264)</button><button onclick="fixVideos('x265_mp4')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">π¬ <b>MP4</b> (H.265)</button><button onclick="fixVideos('mkv')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">π¦ <b>MKV</b> (Matroska)</button><button onclick="fixVideos('mov')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">ποΈ <b>MOV</b> (QuickTime)</button><button onclick="fixVideos('avi')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">πΌ <b>AVI</b> (Legacy)</button><button onclick="fixVideos('wmv')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">πͺ <b>WMV</b> (Windows)</button><button onclick="fixVideos('webm')" class="p-3 bg-slate-700 hover:bg-slate-600 rounded-lg text-left">π <b>WEBM</b> (Web)</button></div><button onclick="hideFormatModal()" class="w-full py-3 bg-gray-600 rounded-xl font-semibold">Cancel</button></div></div> |
| <div class="glass rounded-2xl p-6 shadow-2xl"><h2 class="text-2xl font-bold mb-6 flex items-center gap-2"><span>π</span> Active Downloads</h2><div id="downloadsList" class="space-y-3"></div></div> |
| </div> |
| <script>let users=[],selectedUsers=new Set(),currentConfig={};async function loadUsers(){const r=await fetch('/api/users');const d=await r.json();users=d.users||[]} |
| async function refresh(isManual = false){ |
| const btn=document.getElementById('refreshBtn'); |
| let originalText = ""; |
| if (isManual && btn) { |
| originalText = btn.innerHTML; |
| btn.innerHTML='π Updating...'; |
| btn.disabled=true; |
| } |
| try { |
| const r=await fetch('/api/status'); |
| const d=await r.json(); |
| currentConfig=d.config||{}; |
| if(document.getElementById('settingsModal').classList.contains('hidden')){ |
| if(currentConfig.outputPath)document.getElementById('outputPath').value=currentConfig.outputPath; |
| if(currentConfig.enableChat!==undefined)document.getElementById('enableChat').checked=currentConfig.enableChat; |
| if(currentConfig.chatFormat)document.getElementById('chatFormat').value=currentConfig.chatFormat; |
| if(currentConfig.autoOverlay!==undefined)document.getElementById('autoOverlay').checked=currentConfig.autoOverlay; |
| toggleChatFormat() |
| } |
| renderDownloads(d.downloads) |
| } finally { |
| if (isManual && btn) { |
| setTimeout(() => { |
| btn.innerHTML=originalText; |
| btn.disabled=false; |
| }, 500); |
| } |
| } |
| } |
| function showSettings(){document.getElementById('settingsModal').classList.remove('hidden')}function hideSettings(){document.getElementById('settingsModal').classList.add('hidden')}async function saveSettings(){const body={outputPath:document.getElementById('outputPath').value,enableChat:document.getElementById('enableChat').checked,chatFormat:document.getElementById('chatFormat').value,autoOverlay:document.getElementById('autoOverlay').checked};await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});hideSettings();refresh(true)} |
| document.getElementById('enableChat').addEventListener('change',toggleChatFormat);document.getElementById('chatFormat').addEventListener('change',toggleChatFormat); |
| function toggleChatFormat(){const enabled=document.getElementById('enableChat').checked;const format=document.getElementById('chatFormat').value;document.getElementById('chatFormatContainer').classList.toggle('hidden',!enabled);const overlayOption=document.getElementById('overlayOption');if(enabled&&(format==='srt'||format==='ass')){overlayOption.classList.remove('opacity-50','pointer-events-none')}else{overlayOption.classList.add('opacity-50','pointer-events-none');document.getElementById('autoOverlay').checked=false}} |
| async function startDownload(){const u=document.getElementById('usernameInput').value.trim();if(!u)return;await fetch('/api/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:u})});document.getElementById('usernameInput').value='';refresh(true)}async function startAll(){if(!confirm('Start ALL?'))return;await fetch('/api/start-all',{method:'POST'});refresh(true)} |
| function toggleUsersList(){const l=document.getElementById('usersList');l.classList.toggle('hidden');if(!l.classList.contains('hidden'))renderUserCheckboxes()}function renderUserCheckboxes(){const c=document.getElementById('usersCheckboxList');c.innerHTML=users.map(u=>\`<div class="checkbox-item px-3 py-2 rounded-lg border border-slate-700 \${selectedUsers.has(u)?'selected':''}" onclick="toggleUser('\${u}')"><label class="flex items-center cursor-pointer pointer-events-none"><input type="checkbox" \${selectedUsers.has(u)?'checked':''} class="mr-2"><span class="truncate text-sm font-medium">\${u}</span></label></div>\`).join('');updateStartSelectedBtn()} |
| function toggleUser(u){selectedUsers.has(u)?selectedUsers.delete(u):selectedUsers.add(u);renderUserCheckboxes()}function updateStartSelectedBtn(){const btn=document.getElementById('startSelectedBtn');const allBtn=document.getElementById('startAllBtn');if(selectedUsers.size>0){btn.classList.remove('hidden');btn.innerHTML=\`π Start (\${selectedUsers.size})\`;allBtn.classList.add('hidden')}else{btn.classList.add('hidden');allBtn.classList.remove('hidden')}} |
| async function startSelected(){const u=Array.from(selectedUsers);if(u.length===0)return;await fetch('/api/start-bulk',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({users:u})});selectedUsers.clear();document.getElementById('usersList').classList.add('hidden');updateStartSelectedBtn();refresh(true)} |
| async function stopDownload(id){await fetch('/api/stop',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});refresh(true)}async function deleteDownload(id){if(!confirm('Delete?'))return;await fetch('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});refresh(true)}async function restartDownload(id){await fetch('/api/restart',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});refresh(true)} |
| async function stopAll(){if(!confirm('Stop all?'))return;await fetch('/api/stop-all',{method:'POST'});refresh(true)}async function killAll(){if(confirm('Kill background processes?'))await fetch('/api/kill-all',{method:'POST'});} |
| function showFixVideos(){document.getElementById('formatModal').classList.remove('hidden')}function hideFormatModal(){document.getElementById('formatModal').classList.add('hidden')}async function fixVideos(f){hideFormatModal();const r=await fetch('/api/fix-videos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({format:f})});const d=await r.json();alert(d.message)} |
| async function exitApp(){if(!confirm('Exit?'))return;document.body.innerHTML='<div class="flex h-screen items-center justify-center text-2xl font-bold text-pink-500">Stopping & Saving... Please wait!</div>';await fetch('/api/exit',{method:'POST'});} |
| function renderDownloads(dl){const c=document.getElementById('downloadsList');if(!dl||dl.length===0){c.innerHTML='<div class="text-center text-gray-500 py-8">No active downloads</div>';return}c.innerHTML=dl.map(d=>{const st={waiting:{i:'β³',c:'bg-yellow-500'},downloading:{i:'π₯',c:'bg-green-500'},completed:{i:'β
',c:'bg-blue-500'},stopped:{i:'π',c:'bg-gray-500'},error:{i:'β',c:'bg-red-500'}}[d.status]||{i:'?',c:'bg-gray-500'};return \`<div class="bg-slate-800 rounded-xl p-4 flex items-center justify-between gap-4"><div class="flex items-center gap-4 min-w-0"><div class="text-2xl">\${st.i}</div><div><div class="font-bold truncate">@\${d.user}</div><div class="text-xs text-gray-400">\${d.status} β’ \${getElapsed(d.startTime)}</div></div></div><div class="flex gap-2">\${d.status!=='downloading'?\`<button onclick="restartDownload('\${d.id}')" class="px-3 py-1 bg-blue-600 rounded text-sm hover:bg-blue-700">Restart</button>\`:''}<button onclick="stopDownload('\${d.id}')" class="px-3 py-1 bg-red-600 rounded text-sm hover:bg-red-700">Stop</button><button onclick="deleteDownload('\${d.id}')" class="px-3 py-1 bg-gray-600 rounded text-sm hover:bg-gray-700">ποΈ</button></div></div>\`}).join('')} |
| function getElapsed(t){const s=Math.floor((Date.now()-new Date(t))/1000);if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h';} |
| // Call refresh(false) for auto-updates (no animation) |
| setInterval(() => refresh(false), 2000); |
| loadUsers(); |
| refresh(false); // Initial load without animation |
| </script></body></html>`; |
| } |
| } |