// --------------------------------------------------------------------------- // State & DOM // --------------------------------------------------------------------------- const $ = (id) => document.getElementById(id); let workerUrl = $('worker-url-display').textContent.trim(); let evtSource = null; let isPolling = false; let excludedFolders = new Set(); let recommendedThreads = 3; const elStatus = $('status-indicator'); const elStatusText = $('status-text'); const elDone = $('stat-done'); const elSkipped = $('stat-skipped'); const elSaved = $('stat-saved'); const elQueued = $('stat-queued'); const elActiveList = $('active-encodes-container'); const elRecentList = $('recent-finished-container'); const elLogCount = $('log-count'); const elWorkerUrl = $('worker-url-display'); const elUrlEditor = $('url-editor'); const elUrlInput = $('worker-url-input'); const elModalBdrop = $('modal-backdrop'); const elModalBody = $('modal-body'); const elModalCrumb = $('modal-breadcrumb'); const elModalPath = $('modal-current-path'); // --------------------------------------------------------------------------- // Worker URL // --------------------------------------------------------------------------- function updateWorkerDisplay() { elWorkerUrl.textContent = workerUrl; elUrlInput.value = workerUrl; } // --------------------------------------------------------------------------- // API Helpers // --------------------------------------------------------------------------- async function POST(path, body = {}) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return res.json(); } // --------------------------------------------------------------------------- // GET Helpers // --------------------------------------------------------------------------- async function GET(path) { const res = await fetch(path); return res.json(); } // --------------------------------------------------------------------------- // Status Polling // --------------------------------------------------------------------------- function applyStatus(data) { const state = data.state || 'unreachable'; elStatus.dataset.state = state; elStatusText.textContent = state.toUpperCase(); // Color dynamic header status indicator elStatus.className = "status-badge px-4 py-2 border rounded-xl text-xs font-bold tracking-wider flex items-center gap-2 transition-all "; let dotColorClass = "bg-zinc-500"; if (state === 'idle') { elStatus.classList.add("border-zinc-800", "text-zinc-400", "bg-zinc-950/80"); dotColorClass = "bg-zinc-500"; } else if (state === 'scanning' || state === 'paused') { elStatus.classList.add("border-amber-500/50", "text-amber-500", "bg-amber-950/10"); dotColorClass = "bg-amber-500"; } else if (state === 'running' || state === 'done') { elStatus.classList.add("border-emerald-500/50", "text-emerald-500", "bg-emerald-950/10"); dotColorClass = "bg-emerald-500"; } else { elStatus.classList.add("border-rose-500/50", "text-rose-500", "bg-rose-950/10"); dotColorClass = "bg-rose-500"; } const statusDot = $('status-dot'); if (statusDot) { statusDot.className = `w-2 h-2 rounded-full ${dotColorClass}`; } elDone.textContent = data.done_count ?? 0; elSkipped.textContent = data.skipped_count ?? 0; elSaved.textContent = data.total_saved_human ?? '0 B'; elQueued.textContent = data.queued_count ?? 0; // Render extra stats const elTotal = $('stat-total'); const elRatio = $('stat-ratio'); if (elTotal) elTotal.textContent = data.total_files ?? 0; if (elRatio) elRatio.textContent = (data.compression_ratio ?? 0.0) + '%'; // Auto-set threads to recommended value if returned and not overridden by user const selectThreads = $('threads'); if (selectThreads && data.recommended_threads && !selectThreads.dataset.userModified) { selectThreads.value = String(data.recommended_threads); } renderActiveEncodes(data.active_encodes || []); renderRecentDone(data.recent_done || []); updateButtons(state); } async function pollStatus() { try { const data = await GET('/proxy/status'); if (data && data.state) applyStatus(data); else throw new Error('Invalid status'); } catch (_) { elStatus.dataset.state = 'unreachable'; elStatusText.textContent = 'UNREACHABLE'; elStatus.className = "status-badge px-4 py-2 border border-rose-500/50 text-rose-500 bg-rose-950/10 rounded-xl text-xs font-bold tracking-wider flex items-center gap-2"; const statusDot = $('status-dot'); if (statusDot) statusDot.className = "w-2 h-2 rounded-full bg-rose-500"; updateButtons('unreachable'); } } function startPolling() { if (isPolling) return; isPolling = true; pollStatus(); setInterval(pollStatus, 2000); } // --------------------------------------------------------------------------- // Render UI // --------------------------------------------------------------------------- function renderActiveEncodes(list) { if (!list || list.length === 0) { elActiveList.innerHTML = '
No active processes
'; return; } let html = ''; list.forEach(enc => { html += `
${escAttr(enc.file)}
${enc.progress}%
${enc.eta} ${enc.fps} fps · ${enc.speed} · ${enc.bitrate}
`; }); elActiveList.innerHTML = html; } function renderRecentDone(list) { if (!list || list.length === 0) { elRecentList.innerHTML = '
Nothing finished yet
'; return; } let html = ''; list.forEach(item => { html += `
${escAttr(item.file)}
Saved ${item.saved_human}
`; }); elRecentList.innerHTML = html; } // --------------------------------------------------------------------------- // Button state machine // --------------------------------------------------------------------------- function updateButtons(state) { const scan = $('btn-scan'); const proc = $('btn-process'); const p = $('btn-pause'); const r = $('btn-resume'); const x = $('btn-stop'); const cl = $('btn-clear-db'); // Reset [scan, proc, p, r, x, cl].forEach(b => { if(b) b.disabled = true; }); if (state === 'idle' || state === 'done' || state === 'unreachable') { if (scan) scan.disabled = false; if (proc) proc.disabled = false; if (cl) cl.disabled = false; setHidden(p, false); setHidden(r, true); } else if (state === 'scanning' || state === 'running') { if (p) p.disabled = false; if (x) x.disabled = false; setHidden(p, false); setHidden(r, true); } else if (state === 'paused') { if (r) r.disabled = false; if (x) x.disabled = false; setHidden(p, true); setHidden(r, false); } } // --------------------------------------------------------------------------- // Server-Sent Events (Logs) // --------------------------------------------------------------------------- function connectSSE() { if (evtSource) { evtSource.close(); } evtSource = new EventSource('/proxy/logs/stream'); const logContainer = $('logs-container'); let lineCount = 0; evtSource.onmessage = (e) => { if (e.data.includes('keepalive')) return; try { const data = JSON.parse(e.data); const div = document.createElement('div'); div.className = 'py-0.5 border-b border-zinc-950 font-mono text-[11px] flex gap-3 text-zinc-300'; let levelColor = 'text-zinc-500'; if (data.level === 'SUCCESS') levelColor = 'text-emerald-400 font-semibold'; else if (data.level === 'WARN') levelColor = 'text-amber-400 font-semibold'; else if (data.level === 'ERROR') levelColor = 'text-rose-400 font-bold'; else if (data.level === 'INFO') levelColor = 'text-zinc-400'; div.innerHTML = ` ${data.time.split(' ')[1]} ${data.level} ${escAttr(data.message)} `; logContainer.appendChild(div); lineCount++; elLogCount.textContent = lineCount + ' lines'; // Auto-scroll logic if (logContainer.scrollHeight - logContainer.scrollTop < logContainer.clientHeight + 100) { logContainer.scrollTop = logContainer.scrollHeight; } } catch (_) {} }; evtSource.onerror = () => { evtSource.close(); setTimeout(connectSSE, 5000); // Reconnect }; } $('btn-clear-logs')?.addEventListener('click', () => { $('logs-container').innerHTML = ''; elLogCount.textContent = '0 lines'; }); // --------------------------------------------------------------------------- // Config Builders // --------------------------------------------------------------------------- function escAttr(str) { return String(str).replace(/"/g,'"'); } function setHidden(element, hidden) { if (!element) return; element.classList.toggle('is-hidden', hidden); } function calcRecommendedThreads(logicalCores) { const cores = Math.max(1, logicalCores || 4); if (cores >= 12) return Math.floor(cores / 2); if (cores >= 6) return cores - 2; return Math.max(1, cores - 1); } async function setupThreadOptions() { const select = $('threads'); if (!select) return; let logicalCores = 8; try { const sysinfo = await GET('/proxy/sysinfo'); logicalCores = sysinfo.cpu_count || 8; recommendedThreads = sysinfo.recommended_threads || 3; if ($('spec-cpu')) $('spec-cpu').textContent = sysinfo.cpu_name || 'Unknown CPU'; if ($('spec-gpu')) $('spec-gpu').textContent = sysinfo.gpu_name || 'Unknown GPU'; } catch (err) { console.error('Error fetching sysinfo from server, falling back to local hardware:', err); logicalCores = Number(navigator.hardwareConcurrency) || 8; recommendedThreads = Math.max(1, Math.min(logicalCores, calcRecommendedThreads(logicalCores))); if ($('spec-cpu')) $('spec-cpu').textContent = 'Unknown CPU'; if ($('spec-gpu')) $('spec-gpu').textContent = 'Unknown GPU'; } const maxThreads = Math.max(4, Math.min(12, logicalCores)); recommendedThreads = Math.max(1, Math.min(maxThreads, recommendedThreads)); select.innerHTML = ''; for (let t = 1; t <= maxThreads; t++) { const opt = document.createElement('option'); opt.value = String(t); opt.textContent = `${t} thread${t === 1 ? '' : 's'}${t === recommendedThreads ? ' (Recommended)' : ''}`; if (t === recommendedThreads) { opt.selected = true; } select.appendChild(opt); } } function buildStartConfig() { const minSizeMB = parseInt($('min-size').value, 10) || 0; return { media_dir: $('media-dir').value.trim(), temp_dir: $('temp-dir').value.trim(), min_size_bytes: minSizeMB * 1024 * 1024, excluded_folders: [...excludedFolders], threads: parseInt($('threads').value, 10) || recommendedThreads, preset: $('preset').value || 'medium', crf: parseInt($('crf').value, 10) || 22, discord_webhook: $('discord-webhook') ? $('discord-webhook').value.trim() : '', custom_ffmpeg: $('custom-ffmpeg') ? $('custom-ffmpeg').value.trim() : '', custom_ffprobe: $('custom-ffprobe') ? $('custom-ffprobe').value.trim() : '', encoder: $('encoder') ? $('encoder').value : 'libx265', audio_codec: $('audio-codec') ? $('audio-codec').value : 'auto', rename_output: $('rename-output') ? $('rename-output').checked : false, force_reencode: $('force-reencode') ? $('force-reencode').checked : false, }; } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- window.addEventListener('DOMContentLoaded', () => { updateWorkerDisplay(); setupThreadOptions(); startPolling(); connectSSE(); // Mark threads as user-modified if manually changed $('threads')?.addEventListener('change', () => { $('threads').dataset.userModified = 'true'; }); // Auto-save typed inputs on blur $('media-dir')?.addEventListener('blur', () => { POST('/config', { media_root: $('media-dir').value.trim() }); }); $('temp-dir')?.addEventListener('blur', () => { POST('/config', { temp_dir: $('temp-dir').value.trim() }); }); $('discord-webhook')?.addEventListener('blur', () => { POST('/config', { discord_webhook: $('discord-webhook').value.trim() }); }); $('custom-ffmpeg')?.addEventListener('blur', () => { POST('/config', { custom_ffmpeg: $('custom-ffmpeg').value.trim() }); }); $('custom-ffprobe')?.addEventListener('blur', () => { POST('/config', { custom_ffprobe: $('custom-ffprobe').value.trim() }); }); $('encoder')?.addEventListener('change', () => { POST('/config', { encoder: $('encoder').value }); }); $('audio-codec')?.addEventListener('change', () => { POST('/config', { audio_codec: $('audio-codec').value }); }); $('rename-output')?.addEventListener('change', () => { POST('/config', { rename_output: $('rename-output').checked }); }); $('force-reencode')?.addEventListener('change', () => { POST('/config', { force_reencode: $('force-reencode').checked }); }); // Test Discord Webhook $('btn-test-webhook')?.addEventListener('click', async () => { const url = $('discord-webhook').value.trim(); if (!url) { alert('Please enter a Discord Webhook URL first.'); return; } const btn = $('btn-test-webhook'); const originalText = btn.textContent; btn.textContent = 'Testing...'; btn.disabled = true; try { const res = await POST('/proxy/test-webhook', { discord_webhook: url }); if (res.ok) { alert('Test notification sent successfully!'); } else { alert('Error: ' + (res.error || 'Failed to send notification.')); } } catch (err) { alert('Network error testing webhook.'); } finally { btn.textContent = originalText; btn.disabled = false; } }); // --- Control buttons --- $('btn-scan')?.addEventListener('click', async () => { await POST('/proxy/scan', buildStartConfig()); pollStatus(); }); $('btn-process')?.addEventListener('click', async () => { await POST('/proxy/process', buildStartConfig()); pollStatus(); }); $('btn-clear-db')?.addEventListener('click', async () => { if (!confirm('Clear the entire media tracking database?')) return; await POST('/proxy/database/clear'); pollStatus(); }); $('btn-pause')?.addEventListener('click', async () => { await POST('/proxy/pause'); pollStatus(); }); $('btn-resume')?.addEventListener('click', async () => { await POST('/proxy/resume'); pollStatus(); }); $('btn-stop')?.addEventListener('click', async () => { await POST('/proxy/stop'); pollStatus(); }); // --- Worker URL Editor --- $('btn-edit-url')?.addEventListener('click', (e) => { e.preventDefault(); elUrlEditor.classList.remove('is-hidden'); }); $('btn-save-url')?.addEventListener('click', async () => { const newVal = elUrlInput.value.trim(); if (newVal) { await POST('/config', { worker_url: newVal }); workerUrl = newVal; updateWorkerDisplay(); elUrlEditor.classList.add('is-hidden'); connectSSE(); pollStatus(); } }); $('btn-cancel-url')?.addEventListener('click', () => { elUrlEditor.classList.add('is-hidden'); }); // --- Tag input --- const tagInput = $('excl-input'); tagInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const val = tagInput.value.trim(); if (val && !excludedFolders.has(val)) { excludedFolders.add(val); renderTags(); } tagInput.value = ''; } }); function renderTags() { const container = $('excl-tags'); // keep only the input Array.from(container.querySelectorAll('.tag')).forEach(el => el.remove()); excludedFolders.forEach(tag => { const el = document.createElement('div'); el.className = 'tag bg-zinc-800 text-zinc-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-lg flex items-center gap-1.5 border border-zinc-700/60 select-none'; el.innerHTML = `${escAttr(tag)} ×`; el.querySelector('.tag-remove').onclick = () => { excludedFolders.delete(tag); renderTags(); }; container.insertBefore(el, tagInput); }); } // --- Local File Browser --- let modalTarget = 'media-dir'; async function loadDrives() { const container = $('modal-drive-switcher'); if (!container) return []; try { const drives = await GET('/proxy/drives'); const label = container.querySelector('span'); container.innerHTML = ''; if (label) container.appendChild(label); if (drives && Array.isArray(drives)) { drives.forEach(drive => { const btn = document.createElement('button'); btn.className = 'px-3 py-1 bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-300 rounded-lg text-xs font-semibold transition-colors'; btn.textContent = drive; btn.onclick = () => loadDir(drive); container.appendChild(btn); }); return drives; } } catch (err) { console.error('Error loading drives:', err); } return []; } $('btn-browse')?.addEventListener('click', async () => { modalTarget = 'media-dir'; elModalBdrop.classList.remove('is-hidden'); const drives = await loadDrives(); const val = $('media-dir').value.trim(); if (val) { loadDir(val); } else if (drives && drives.length > 0) { loadDir(drives[0]); } else { elModalBody.innerHTML = '
Please select a drive.
'; } }); $('btn-browse-temp')?.addEventListener('click', async () => { modalTarget = 'temp-dir'; elModalBdrop.classList.remove('is-hidden'); const drives = await loadDrives(); const val = $('temp-dir').value.trim(); if (val) { loadDir(val); } else if (drives && drives.length > 0) { loadDir(drives[0]); } else { elModalBody.innerHTML = '
Please select a drive.
'; } }); $('btn-browse-excl')?.addEventListener('click', async () => { modalTarget = 'exclude'; elModalBdrop.classList.remove('is-hidden'); const drives = await loadDrives(); const val = $('media-dir').value.trim(); if (val) { loadDir(val); } else if (drives && drives.length > 0) { loadDir(drives[0]); } else { elModalBody.innerHTML = '
Please select a drive.
'; } }); $('btn-modal-cancel')?.addEventListener('click', () => { elModalBdrop.classList.add('is-hidden'); }); $('btn-modal-select')?.addEventListener('click', () => { if (modalTarget === 'media-dir') { $('media-dir').value = elModalPath.value; POST('/config', { media_root: elModalPath.value }); } else if (modalTarget === 'temp-dir') { $('temp-dir').value = elModalPath.value; POST('/config', { temp_dir: elModalPath.value }); } else if (modalTarget === 'exclude') { // Use the last part of the path as the folder name const folderName = elModalPath.value.split('/').pop() || elModalPath.value; if (folderName && !excludedFolders.has(folderName)) { excludedFolders.add(folderName); renderTags(); } } elModalBdrop.classList.add('is-hidden'); }); async function loadDir(path) { elModalBody.innerHTML = '
Loading...
'; try { const res = await GET(`/proxy/browse?path=${encodeURIComponent(path)}`); if (res.error) { elModalBody.innerHTML = `
${escAttr(res.error)}
`; return; } elModalPath.value = res.path; renderBreadcrumb(res.path, res.parent); if (!res.dirs || res.dirs.length === 0) { elModalBody.innerHTML = '
No subdirectories found.
'; return; } let html = ''; res.dirs.forEach(d => { html += `
${escAttr(d.name)}
`; }); elModalBody.innerHTML = html; elModalBody.querySelectorAll('.dir-item').forEach(el => { el.addEventListener('click', () => loadDir(el.dataset.path)); }); } catch (exc) { elModalBody.innerHTML = `
Error loading directory
`; } } function renderBreadcrumb(path, parent) { let html = ''; if (parent) { html += `
↑ Up to ${escAttr(parent)}
`; } html += `
${escAttr(path)}
`; elModalCrumb.innerHTML = html; $('crumb-up')?.addEventListener('click', () => loadDir(parent)); } });