"use strict"; // ── Types ──────────────────────────────────────────── // ── State ──────────────────────────────────────────── let authToken = sessionStorage.getItem('authToken'); let selectedFilePath = null; const sseMap = new Map(); let pollTimer = null; // ── DOM helpers ─────────────────────────────────────── const $$ = (sel) => document.querySelectorAll(sel); const el = (id) => { const e = document.getElementById(id); if (!e) throw new Error(`Missing element #${id}`); return e; }; // ── 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').hidden = false; el('main-screen').hidden = true; } function showMainScreen() { el('login-screen').hidden = true; el('main-screen').hidden = false; } // ── Tablist (WAI-ARIA) ──────────────────────────────── function activateTab(tablistId, tabId) { const tablist = el(tablistId); const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')); tabs.forEach(t => { const selected = t.id === tabId; t.setAttribute('aria-selected', selected ? 'true' : 'false'); t.setAttribute('tabindex', selected ? '0' : '-1'); t.classList.toggle('active', selected); const panelId = t.getAttribute('aria-controls'); if (!panelId) return; const panel = document.getElementById(panelId); if (panel) panel.hidden = !selected; }); const tab = tabs.find(t => t.id === tabId); const tabName = tab?.getAttribute('aria-controls') || ''; onTabActivated(tablistId, tabName); } function onTabActivated(tablistId, panelId) { if (tablistId !== 'main-tablist') return; if (panelId === 'dashboard') loadJobs(); if (panelId === 'files') loadFilesList(); } function wireTablist(tablistId) { const tablist = el(tablistId); const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')); tabs.forEach(tab => { tab.addEventListener('click', () => activateTab(tablistId, tab.id)); }); tablist.addEventListener('keydown', (e) => { const ke = e; const current = document.activeElement; if (!current || !tabs.includes(current)) return; let next; const idx = tabs.indexOf(current); if (ke.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length]; else if (ke.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length]; else if (ke.key === 'Home') next = tabs[0]; else if (ke.key === 'End') next = tabs[tabs.length - 1]; if (next) { ke.preventDefault(); activateTab(tablistId, next.id); next.focus(); } }); } // ── Login ───────────────────────────────────────────── el('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const username = el('login-username').value; const password = el('login-password').value; const errorEl = el('login-error'); 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); errorEl.hidden = true; showMainScreen(); initApp(); } else { errorEl.textContent = data.error || 'Login failed'; errorEl.hidden = false; } } catch { errorEl.textContent = 'Connection failed'; errorEl.hidden = false; } }); 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 ─────────────────────────────────────── const videoUpload = el('video-upload'); const uploadName = el('upload-name'); videoUpload.addEventListener('change', function () { if (this.files?.length) { selectedFilePath = null; uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`; } else { uploadName.textContent = ''; } }); // ── YouTube download (SSE) ──────────────────────────── let youtubeStream = null; el('download-url').addEventListener('click', () => { const url = el('youtube-url').value.trim(); if (!url) return; if (!authToken) return; const status = el('download-status'); const progressWrap = document.querySelector('.download-progress'); const progressbar = el('download-progressbar'); const fill = el('download-fill'); status.textContent = 'Starting download...'; status.className = 'status'; if (progressWrap) progressWrap.hidden = false; progressbar.setAttribute('aria-valuenow', '0'); fill.style.width = '0%'; if (youtubeStream) youtubeStream.close(); const streamUrl = `/api/files/youtube/stream?url=${encodeURIComponent(url)}&token=${encodeURIComponent(authToken)}`; const es = new EventSource(streamUrl); youtubeStream = es; es.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch { return; } if (data.type === 'progress' && typeof data.percent === 'number') { const pct = Math.max(0, Math.min(100, data.percent)); progressbar.setAttribute('aria-valuenow', String(Math.round(pct))); fill.style.width = `${pct}%`; status.textContent = `Downloading ${pct.toFixed(1)}%`; return; } if (data.type === 'done' && data.filePath && data.filename) { progressbar.setAttribute('aria-valuenow', '100'); fill.style.width = '100%'; 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); es.close(); youtubeStream = null; return; } if (data.type === 'error') { status.textContent = `Error: ${data.message || 'Download failed'}`; status.className = 'status error'; if (progressWrap) progressWrap.hidden = true; es.close(); youtubeStream = null; } }; es.onerror = () => { if (es.readyState === EventSource.CLOSED) return; status.textContent = 'Connection lost'; status.className = 'status error'; es.close(); youtubeStream = null; }; }); // ── New Job form ────────────────────────────────────── el('new-job-form').addEventListener('submit', async (e) => { e.preventDefault(); if (!selectedFilePath) { if (videoUpload.files?.length) { const formData = new FormData(); formData.append('video', videoUpload.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; } // Empty strings would clobber server-side defaults during the spread-merge in // JobManager.createJob — drop them. (The server also filters defensively.) for (const k of Object.keys(config)) { const v = config[k]; if (v === '' || v === undefined || v === null) delete config[k]; } const outputOptions = { audio: fd.get('output-audio') === 'on', subtitles: fd.get('output-subtitles') === 'on', muxed: fd.get('output-muxed') === 'on', muxMode: fd.get('mux-mode') === 'mixed' ? 'mixed' : 'separate', }; 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']; delete config['mux-mode']; try { const data = await apiJson('POST', '/api/jobs', { videoPath: selectedFilePath, config, outputOptions, }); await apiJson('POST', `/api/jobs/${data.job.id}/start`); selectedFilePath = null; videoUpload.value = ''; uploadName.textContent = ''; el('new-job-form').reset(); activateTab('main-tablist', 'tab-dashboard'); } catch (err) { alert('Error creating job: ' + err.message); } }); // ── Job list & rendering ────────────────────────────── async function loadJobs() { const container = el('jobs-list'); container.setAttribute('aria-busy', 'true'); 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); } finally { container.setAttribute('aria-busy', 'false'); } } 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') { // Plain navigations don't send our Authorization header. // Pass the token via query string — middleware/auth.ts accepts ?token=. const tok = authToken ? `token=${encodeURIComponent(authToken)}` : ''; const sep = (qs) => qs.includes('?') ? '&' : '?'; const url = (path) => tok ? `${path}${sep(path)}${tok}` : path; 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 += ``; } const pct = Math.round(j.progress); return `

${escapeHtml(j.video_filename)}

${actions}
${j.status}
${pct}% Idx: ${j.current_index}/${j.total_units} ${new Date(j.created_at).toLocaleString()}
${j.error ? `` : ''} ${downloads.length ? `` : ''}
`; }).join(''); container.querySelectorAll('.act-start').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start'))); container.querySelectorAll('.act-pause').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'pause'))); container.querySelectorAll('.act-restart').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'restart'))); container.querySelectorAll('.act-delete').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'delete'))); container.querySelectorAll('.toggle-detail').forEach(b => { b.addEventListener('click', () => { const jobId = b.dataset.id || ''; const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`); if (!detail) return; const willOpen = detail.hidden; detail.hidden = !willOpen; b.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); const job = jobs.find(j => j.id === jobId); const segs = job ? JSON.parse(job.segments || '[]') : []; b.textContent = willOpen ? 'Hide segments' : `${segs.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}`); } } el('refresh-jobs').addEventListener('click', loadJobs); // ── Polling ─────────────────────────────────────────── function startPolling() { if (pollTimer) return; pollTimer = window.setInterval(loadJobs, 5000); } // ── SSE live progress ───────────────────────────────── function connectSSE(jobId) { if (sseMap.has(jobId)) return; if (!authToken) return; const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`); es.onmessage = (event) => { const data = JSON.parse(event.data); updateJobCard(jobId, data); if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') { es.close(); sseMap.delete(jobId); } }; es.onerror = () => { es.close(); sseMap.delete(jobId); }; sseMap.set(jobId, es); } function updateJobCard(jobId, data) { const card = document.querySelector(`.job-card[data-id="${jobId}"]`); if (!card) return; const badge = card.querySelector('.status-badge'); if (badge) { badge.className = `status-badge status-${data.status}`; badge.textContent = data.status; } const pct = Math.round(data.progress); const bar = card.querySelector('[role="progressbar"]'); if (bar) bar.setAttribute('aria-valuenow', String(pct)); const fill = card.querySelector('.progress-fill'); if (fill) { fill.style.width = pct + '%'; fill.className = 'progress-fill'; if (data.status === 'completed') fill.classList.add('completed'); else if (data.status === 'failed') fill.classList.add('failed'); } const metaSpans = card.querySelectorAll('.job-meta span'); if (metaSpans[0]) metaSpans[0].textContent = pct + '%'; if (metaSpans[1]) metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`; const log = card.querySelector('.segment-log'); if (log && data.segments) { log.innerHTML = data.segments.map(s => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join(''); } const toggleBtn = card.querySelector('.toggle-detail'); if (toggleBtn && data.segments) { const expanded = toggleBtn.getAttribute('aria-expanded') === 'true'; if (!expanded) toggleBtn.textContent = `${data.segments.length} segments`; } } // ── Settings ────────────────────────────────────────── async function loadSettings() { try { const data = await apiJson('GET', '/api/config'); const container = el('settings-fields'); const entries = Object.entries(data.config || {}); if (!entries.length) { container.innerHTML = '

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

'; return; } container.innerHTML = entries.map(([key, value]) => { const safeKey = escapeHtml(key); return `
`; }).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 => ` ${escapeHtml(f.filename)} ${formatSize(f.size)} `).join(''); tbody.querySelectorAll('.file-checkbox').forEach(cb => { cb.addEventListener('change', updateFileSelection); }); el('select-all-files').checked = false; selectedFiles.clear(); updateFileSelection(); } catch (err) { console.error(err); } } function updateFileSelection() { selectedFiles.clear(); document.querySelectorAll('.file-checkbox:checked').forEach(cb => { if (cb.dataset.filename) selectedFiles.add(cb.dataset.filename); }); el('delete-selected-files').disabled = selectedFiles.size === 0; } el('select-all-files').addEventListener('change', function () { document.querySelectorAll('.file-checkbox').forEach(cb => { cb.checked = this.checked; }); updateFileSelection(); }); el('delete-selected-files').addEventListener('click', async () => { if (!selectedFiles.size) return; if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return; const failures = []; for (const filename of selectedFiles) { try { await api('DELETE', `/api/files/${encodeURIComponent(filename)}`); } catch (err) { failures.push(`${filename}: ${err.message}`); } } if (failures.length) { alert(`Some deletions failed:\n${failures.join('\n')}`); } await loadFilesList(); await loadBrowseFiles(); }); el('refresh-files-list').addEventListener('click', loadFilesList); // ── Config defaults for New Job form ───────────────── async function loadConfigDefaults() { try { const data = await apiJson('GET', '/api/config'); const c = data.config || {}; if (c.visionProvider) { const sel = document.querySelector('[name="visionProvider"]'); if (sel) { sel.innerHTML = ''; sel.value = c.visionProvider; } } if (c.ttsProvider) { const sel = document.querySelector('[name="ttsProvider"]'); if (sel) { sel.innerHTML = ''; sel.value = c.ttsProvider; } } const fields = [ 'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions', 'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize', 'defaultPrompt', 'changePrompt', 'batchPrompt', ]; for (const name of fields) { const field = document.querySelector(`[name="${name}"]`); if (field && c[name] !== undefined) field.value = c[name]; } } catch (err) { console.error(err); } } // ── Init ────────────────────────────────────────────── function initApp() { wireTablist('main-tablist'); wireTablist('source-tablist'); loadJobs(); loadBrowseFiles(); loadConfigDefaults(); loadSettings(); startPolling(); } // ── Startup ─────────────────────────────────────────── (async () => { if (authToken) { try { const res = await fetch('/api/auth/check', { headers: { Authorization: `Basic ${authToken}` }, }); const data = await res.json(); if (data.authenticated) { showMainScreen(); initApp(); return; } } catch { /* fall through to login */ } } showLoginScreen(); })(); //# sourceMappingURL=app.js.map