let authToken = sessionStorage.getItem('authToken'); let selectedFilePath = ''; let currentConfig = {}; 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; showLogin(); 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; } // Login function showLogin() { document.getElementById('login-screen').classList.remove('hidden'); document.getElementById('main-screen').classList.add('hidden'); document.getElementById('login-error').classList.add('hidden'); } function showMain() { document.getElementById('login-screen').classList.add('hidden'); document.getElementById('main-screen').classList.remove('hidden'); } document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('login-username').value; const password = document.getElementById('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; sessionStorage.setItem('authToken', authToken); showMain(); initApp(); } else { document.getElementById('login-error').textContent = data.error; document.getElementById('login-error').classList.remove('hidden'); } } catch (err) { document.getElementById('login-error').textContent = 'Connection failed'; document.getElementById('login-error').classList.remove('hidden'); } }); document.getElementById('logout-btn').addEventListener('click', () => { sessionStorage.removeItem('authToken'); authToken = null; showLogin(); }); // Tab navigation document.querySelectorAll('button.tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('button.tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.getElementById(btn.dataset.tab).classList.add('active'); if (btn.dataset.tab === 'dashboard') loadJobs(); if (btn.dataset.tab === 'files') loadFilesList(); }); }); // Mini tabs (video source) document.querySelectorAll('button.tab-mini').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('button.tab-mini').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.src-panel').forEach(p => p.classList.remove('active')); document.getElementById('src-' + btn.dataset.src).classList.add('active'); }); }); // File upload document.getElementById('video-upload').addEventListener('change', () => { const file = document.getElementById('video-upload').files[0]; if (file) selectedFilePath = null; }); // Refresh browse files document.getElementById('refresh-files').addEventListener('click', loadBrowseFiles); async function loadBrowseFiles() { try { const data = await apiJson('GET', '/api/files'); const sel = document.getElementById('video-select'); sel.innerHTML = ''; data.files.forEach(f => { sel.innerHTML += ``; }); } catch (err) { console.error(err); } } document.getElementById('video-select').addEventListener('change', (e) => { if (e.target.value) selectedFilePath = e.target.value; }); // YouTube download document.getElementById('download-url').addEventListener('click', async () => { const url = document.getElementById('youtube-url').value; if (!url) return; const status = document.getElementById('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; document.getElementById('video-select').innerHTML += ``; } catch (err) { status.textContent = `Error: ${err.message}`; status.className = 'status error'; } }); // New job form document.getElementById('new-job-form').addEventListener('submit', async (e) => { e.preventDefault(); if (!selectedFilePath) { const fileEl = document.getElementById('video-upload'); if (fileEl.files.length > 0) { const formData = new FormData(); formData.append('video', fileEl.files[0]); try { const res = await fetch('/api/files/upload', { method: 'POST', headers: { Authorization: `Basic ${authToken}` }, 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' }; // Build config with vision/tts providers if (config.visionProvider) { config.visionProviders = {}; config.visionProviders[config.visionProvider] = { model: config.visionModel || 'gpt-4o', maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300 }; } if (config.ttsProvider) { config.ttsProviders = {}; config.ttsProviders[config.ttsProvider] = { model: config.ttsModel || 'tts-1', voice: config.ttsVoice || 'alloy' }; } 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 = ''; document.getElementById('video-upload').value = ''; document.getElementById('new-job-form').reset(); document.querySelector('.tab[data-tab="dashboard"]').click(); loadJobs(); } catch (err) { alert('Error creating job: ' + err.message); } }); // Load jobs 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 = document.getElementById('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 `

${escapeHtml(j.video_filename)}

${actions}
${j.status}
${Math.round(j.progress)}% Index: ${j.current_index}/${j.total_units} ${new Date(j.created_at).toLocaleString()}
${j.error ? `
${escapeHtml(j.error)}
` : ''} ${downloads.length ? `` : ''}
${segs.map((s, i) => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join('')}
`; }).join(''); // Wire up buttons container.querySelectorAll('.start-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'start'))); container.querySelectorAll('.pause-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'pause'))); container.querySelectorAll('.restart-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'restart'))); container.querySelectorAll('.delete-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'delete'))); container.querySelectorAll('.toggle-detail').forEach(b => b.addEventListener('click', () => { const detail = container.querySelector(`.job-detail[data-id="${b.dataset.id}"]`); detail.classList.toggle('open'); b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${JSON.parse((jobs.find(j => j.id === b.dataset.id) || {}).segments || '[]').length} segments`; })); } async function handleJobAction(id, action) { const method = action === 'delete' ? 'DELETE' : 'POST'; const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`; try { await api(method, url); loadJobs(); } catch (err) { alert(`Error: ${err.message}`); } } // Jobs refresh document.getElementById('refresh-jobs').addEventListener('click', loadJobs); // Auto-refresh jobs let jobsInterval; function startJobsPolling() { jobsInterval = setInterval(loadJobs, 5000); } function stopJobsPolling() { clearInterval(jobsInterval); } // Settings async function loadSettings() { try { const data = await apiJson('GET', '/api/config'); const container = document.getElementById('settings-fields'); const config = data.config || {}; currentConfig = config; let html = ''; for (const [key, value] of Object.entries(config)) { html += ``; } if (!Object.keys(config).length) { html = '

No custom settings yet. Settings from .env are used as defaults.

'; } container.innerHTML = html; } catch (err) { console.error(err); } } document.getElementById('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 => ` ${escapeHtml(f.filename)} ${formatSize(f.size)} `).join(''); document.querySelectorAll('.file-checkbox').forEach(cb => { cb.addEventListener('change', () => updateFileSelection()); }); } catch (err) { console.error(err); } } function updateFileSelection() { selectedFiles.clear(); document.querySelectorAll('.file-checkbox:checked').forEach(cb => { selectedFiles.add(cb.dataset.path); }); document.getElementById('delete-selected-files').disabled = selectedFiles.size === 0; } document.getElementById('select-all-files').addEventListener('change', (e) => { document.querySelectorAll('.file-checkbox').forEach(cb => { cb.checked = e.target.checked; }); updateFileSelection(); }); document.getElementById('delete-selected-files').addEventListener('click', async () => { if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return; for (const path of selectedFiles) { // Files are served from uploads dir, delete via fs on server... // Not implementing server-side file deletion for now } alert('File deletion not yet implemented'); }); document.getElementById('refresh-files-list').addEventListener('click', loadFilesList); // Pre-fill new job form with config defaults async function loadConfigDefaults() { try { const data = await apiJson('GET', '/api/config'); const config = data.config || {}; if (config.visionProvider) { const sel = document.querySelector('[name="visionProvider"]'); sel.innerHTML = ''; sel.value = config.visionProvider; } if (config.visionModel) document.querySelector('[name="visionModel"]').value = config.visionModel; if (config.ttsProvider) { const sel = document.querySelector('[name="ttsProvider"]'); sel.innerHTML = ''; sel.value = config.ttsProvider; } if (config.ttsModel) document.querySelector('[name="ttsModel"]').value = config.ttsModel; if (config.ttsVoice) document.querySelector('[name="ttsVoice"]').value = config.ttsVoice; if (config.ttsSpeedFactor) document.querySelector('[name="ttsSpeedFactor"]').value = config.ttsSpeedFactor; if (config.ttsInstructions) document.querySelector('[name="ttsInstructions"]').value = config.ttsInstructions; if (config.batchWindowDuration) document.querySelector('[name="batchWindowDuration"]').value = config.batchWindowDuration; if (config.framesInBatch) document.querySelector('[name="framesInBatch"]').value = config.framesInBatch; if (config.captureIntervalSeconds) document.querySelector('[name="captureIntervalSeconds"]').value = config.captureIntervalSeconds; if (config.contextWindowSize) document.querySelector('[name="contextWindowSize"]').value = config.contextWindowSize; if (config.defaultPrompt) document.querySelector('[name="defaultPrompt"]').value = config.defaultPrompt; if (config.changePrompt) document.querySelector('[name="changePrompt"]').value = config.changePrompt; if (config.batchPrompt) document.querySelector('[name="batchPrompt"]').value = config.batchPrompt; } catch (err) { console.error(err); } } // Setup SSE for live progress const sseConnections = {}; function connectSSE(jobId) { if (sseConnections[jobId]) return; const source = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`); source.onmessage = (event) => { const data = JSON.parse(event.data); updateJobCard(jobId, data); if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') { source.close(); delete sseConnections[jobId]; } }; source.onerror = () => { source.close(); delete sseConnections[jobId]; }; sseConnections[jobId] = source; } function updateJobCard(jobId, data) { const card = document.querySelector(`.job-card[data-id="${jobId}"]`); if (!card) return; const badge = card.querySelector('.status-badge'); badge.className = `status-badge status-${data.status}`; badge.textContent = data.status; const fill = card.querySelector('.progress-fill'); fill.style.width = data.progress + '%'; fill.className = 'progress-fill'; if (data.status === 'completed') fill.classList.add('completed'); if (data.status === 'failed') fill.classList.add('failed'); const metaSpans = card.querySelectorAll('.job-meta span'); if (metaSpans[0]) metaSpans[0].textContent = Math.round(data.progress) + '%'; if (metaSpans[1]) metaSpans[1].textContent = `Index: ${data.currentIndex}/${data.totalUnits}`; // Update segments const log = card.querySelector('.segment-log'); if (data.segments && log) { log.innerHTML = data.segments.map((s, i) => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join(''); } // Update segment count button const toggleBtn = card.querySelector('.toggle-detail'); if (toggleBtn && data.segments) { toggleBtn.textContent = `${data.segments.length} segments`; } } // Initialize function initApp() { loadJobs(); loadBrowseFiles(); loadConfigDefaults(); startJobsPolling(); } // Escape HTML for safe rendering 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]}`; } // Check if already authenticated (async () => { if (authToken) { try { const res = await fetch('/api/auth/check', { headers: { Authorization: `Basic ${authToken}` } }); const data = await res.json(); if (data.authenticated) { showMain(); initApp(); return; } } catch (e) {} } showLogin(); })();