"use strict"; // ── Types ──────────────────────────────────────────── // ── State ──────────────────────────────────────────── let authToken = sessionStorage.getItem('authToken'); let selectedFilePath = null; const sseMap = new Map(); let pollTimer = null; // ── DOM helpers ─────────────────────────────────────── const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); const el = (id) => document.getElementById(id); // ── API ─────────────────────────────────────────────── function apiHeaders() { const h = { 'Content-Type': 'application/json' }; if (authToken) h['Authorization'] = `Basic ${authToken}`; return h; } async function api(method, url, body) { const res = await fetch(url, { method, headers: apiHeaders(), body: body ? JSON.stringify(body) : undefined, }); if (res.status === 401) { sessionStorage.removeItem('authToken'); authToken = null; showLoginScreen(); throw new Error('Unauthorized'); } return res; } async function apiJson(method, url, body) { const res = await api(method, url, body); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Request failed'); return data; } // ── Screen switching ────────────────────────────────── function showLoginScreen() { el('login-screen').classList.remove('hidden'); el('main-screen').classList.add('hidden'); } function showMainScreen() { el('login-screen').classList.add('hidden'); el('main-screen').classList.remove('hidden'); } // ── Tab navigation ──────────────────────────────────── function switchTab(name) { $$('button.tab').forEach(b => b.classList.remove('active')); document.querySelector(`button.tab[data-tab="${name}"]`)?.classList.add('active'); $$('.tab-content').forEach(c => c.classList.remove('active')); const pane = document.getElementById(name); if (pane) pane.classList.add('active'); if (name === 'dashboard') loadJobs(); if (name === 'files') loadFilesList(); } $$('button.tab').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab || '')); }); // ── Mini tabs (video source) ────────────────────────── $$('button.tab-mini').forEach(btn => { btn.addEventListener('click', () => { $$('button.tab-mini').forEach(b => b.classList.remove('active')); btn.classList.add('active'); $$('.src-panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById('src-' + (btn.dataset.src || '')); if (panel) panel.classList.add('active'); }); }); // ── Login ───────────────────────────────────────────── el('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const username = el('login-username').value; const password = el('login-password').value; try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const data = await res.json(); if (data.authenticated) { authToken = data.token; if (authToken) sessionStorage.setItem('authToken', authToken); showMainScreen(); initApp(); } else { el('login-error').textContent = data.error; el('login-error').classList.remove('hidden'); } } catch { el('login-error').textContent = 'Connection failed'; el('login-error').classList.remove('hidden'); } }); el('logout-btn').addEventListener('click', () => { sessionStorage.removeItem('authToken'); authToken = null; sseMap.forEach(s => s.close()); sseMap.clear(); if (pollTimer) clearInterval(pollTimer); showLoginScreen(); }); // ── Utils ───────────────────────────────────────────── function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function formatSize(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let size = bytes; while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } return `${size.toFixed(1)} ${units[i]}`; } // ── Browse files (for New Job) ──────────────────────── async function loadBrowseFiles() { try { const data = await apiJson('GET', '/api/files'); const sel = el('video-select'); sel.innerHTML = ''; data.files.forEach(f => { const opt = document.createElement('option'); opt.value = f.filePath; opt.textContent = `${f.filename} (${formatSize(f.size)})`; sel.appendChild(opt); }); } catch (err) { console.error(err); } } el('refresh-files').addEventListener('click', loadBrowseFiles); el('video-select').addEventListener('change', function () { if (this.value) selectedFilePath = this.value; }); // ── File upload ─────────────────────────────────────── el('video-upload').addEventListener('change', function () { if (this.files?.length) selectedFilePath = null; // will upload on submit }); // ── YouTube download ────────────────────────────────── el('download-url').addEventListener('click', async () => { const url = el('youtube-url').value; if (!url) return; const status = el('download-status'); status.textContent = 'Downloading...'; status.className = 'status'; try { const data = await apiJson('POST', '/api/files/youtube', { url }); status.textContent = `Downloaded: ${data.filename}`; status.className = 'status success'; selectedFilePath = data.filePath; const sel = el('video-select'); const opt = document.createElement('option'); opt.value = data.filePath; opt.textContent = data.filename; opt.selected = true; sel.appendChild(opt); } catch (err) { status.textContent = `Error: ${err.message}`; status.className = 'status error'; } }); // ── New Job form ────────────────────────────────────── el('new-job-form').addEventListener('submit', async (e) => { e.preventDefault(); if (!selectedFilePath) { const fileEl = el('video-upload'); if (fileEl.files?.length) { const formData = new FormData(); formData.append('video', fileEl.files[0]); try { const headers = {}; if (authToken) headers['Authorization'] = `Basic ${authToken}`; const res = await fetch('/api/files/upload', { method: 'POST', headers, body: formData }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Upload failed'); selectedFilePath = data.filePath; } catch (err) { alert('Upload error: ' + err.message); return; } } else { alert('Please select a video file or source'); return; } } const fd = new FormData(e.target); const config = {}; for (const [key, val] of fd.entries()) { if (key === '') continue; if (val === 'on') config[key] = true; else if (val === 'off') config[key] = false; else if (!isNaN(val) && val !== '') config[key] = parseFloat(val); else config[key] = val; } const outputOptions = { audio: fd.get('output-audio') === 'on', subtitles: fd.get('output-subtitles') === 'on', muxed: fd.get('output-muxed') === 'on', }; if (config.visionProvider) { const vp = {}; vp[config.visionProvider] = { model: config.visionModel || 'gpt-4o', maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300, }; config.visionProviders = vp; } if (config.ttsProvider) { const tp = {}; tp[config.ttsProvider] = { model: config.ttsModel || 'tts-1', voice: config.ttsVoice || 'alloy', }; config.ttsProviders = tp; } delete config.visionModel; delete config.visionMaxTokens; delete config.ttsModel; delete config['output-audio']; delete config['output-subtitles']; delete config['output-muxed']; try { const data = await apiJson('POST', '/api/jobs', { videoPath: selectedFilePath, config, outputOptions, }); await apiJson('POST', `/api/jobs/${data.job.id}/start`); selectedFilePath = null; el('video-upload').value = ''; el('new-job-form').reset(); switchTab('dashboard'); } catch (err) { alert('Error creating job: ' + err.message); } }); // ── Job list & rendering ────────────────────────────── async function loadJobs() { try { const data = await apiJson('GET', '/api/jobs'); renderJobs(data.jobs); data.jobs.forEach(j => { if (j.status === 'processing' || j.status === 'queued') { connectSSE(j.id); } }); } catch (err) { console.error(err); } } function renderJobs(jobs) { const container = el('jobs-list'); if (!jobs.length) { container.innerHTML = '
No jobs yet. Create one from the "New Job" tab.
'; return; } container.innerHTML = jobs.map(j => { const segs = JSON.parse(j.segments || '[]'); const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : ''; const downloads = []; if (j.status === 'completed') { if (j.output_audio) downloads.push(`Audio`); if (j.output_subtitles_srt) downloads.push(`SRT`); if (j.output_subtitles_vtt) downloads.push(`VTT`); if (j.output_muxed) downloads.push(`Muxed`); } let actions = ''; if (j.status === 'pending' || j.status === 'queued') { actions += ``; } if (j.status === 'processing') { actions += ``; } if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { actions += ``; } if (j.status !== 'processing') { actions += ``; } return `No custom settings yet. Settings from .env are used as defaults.
'; return; } container.innerHTML = entries.map(([key, value]) => ``).join(''); } catch (err) { console.error(err); } } el('settings-form').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); const config = {}; for (const [key, val] of fd.entries()) { config[key] = val; } try { await apiJson('PUT', '/api/config', config); alert('Settings saved'); } catch (err) { alert('Error: ' + err.message); } }); // ── Files list ──────────────────────────────────────── let selectedFiles = new Set(); async function loadFilesList() { try { const data = await apiJson('GET', '/api/files'); const tbody = document.querySelector('#files-table tbody'); tbody.innerHTML = data.files.map(f => `