TikTok-Live-Studio / src /server.ts
fnorby777's picture
Color settings, System log, new seperate and in-built converter
a008314
Raw
History Blame Contribute Delete
34.8 kB
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<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) {
// 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<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}`);
// 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<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, '');
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 `<!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>`;
}
}