// ---------------------------------------------------------------------------
// 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 += `
`;
});
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));
}
});