diff --git a/.gitignore b/.gitignore index 7d1a791..085bee9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ node_modules desc/ data/ -uploads/ \ No newline at end of file +uploads/ + +# Compiled frontend (built from src/server/public/app.ts) +src/server/public/app.js \ No newline at end of file diff --git a/package.json b/package.json index 2525960..97de300 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "build:frontend": "tsc -p src/server/public/tsconfig.json", "start": "node dist/cli/index.js", "dev": "ts-node src/cli/index.ts", + "prestart:server": "npm run build:frontend", + "preserver": "npm run build:frontend", "server": "ts-node src/server/index.ts", "server:build": "node dist/server/index.js", "test": "jest", diff --git a/src/server/public/app.js b/src/server/public/app.js deleted file mode 100644 index f6c04ee..0000000 --- a/src/server/public/app.js +++ /dev/null @@ -1,567 +0,0 @@ -"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 ─────────────────────────────────────── -const videoUpload = el('video-upload'); -const uploadName = el('upload-name'); -videoUpload.addEventListener('change', function () { - if (this.files?.length) { - selectedFilePath = null; // will upload on submit - uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`; - } - else { - uploadName.textContent = ''; - } -}); -// ── 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) { - 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; - } - 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; - videoUpload.value = ''; - uploadName.textContent = ''; - 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 ` -
-
-

${escapeHtml(j.video_filename)}

-
${actions}
-
- ${j.status} -
-
- ${Math.round(j.progress)}% - Idx: ${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 action buttons - 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; - detail.classList.toggle('open'); - const job = jobs.find(j => j.id === jobId); - const segs = job ? JSON.parse(job.segments || '[]') : []; - b.textContent = detail.classList.contains('open') ? '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; - 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 fill = card.querySelector('.progress-fill'); - if (fill) { - fill.style.width = data.progress + '%'; - 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 = Math.round(data.progress) + '%'; - 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) { - 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]) => ``).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); - }); - } - catch (err) { - console.error(err); - } -} -function updateFileSelection() { - selectedFiles.clear(); - document.querySelectorAll('.file-checkbox:checked').forEach(cb => { - if (cb.dataset.path) - selectedFiles.add(cb.dataset.path); - }); - 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', () => { - if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) - return; - alert('File deletion not yet implemented'); -}); -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', 'textarea'], ['batchWindowDuration'], ['framesInBatch'], - ['captureIntervalSeconds'], ['contextWindowSize'], - ['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'], - ]; - for (const [name, tag] of fields) { - const el = document.querySelector(`[name="${name}"]`); - if (el && c[name] !== undefined) - el.value = c[name]; - } - } - catch (err) { - console.error(err); - } -} -// ── Init ────────────────────────────────────────────── -function initApp() { - loadJobs(); - loadBrowseFiles(); - loadConfigDefaults(); - 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(); -})(); diff --git a/src/server/public/app.ts b/src/server/public/app.ts index 791caa0..bdf3706 100644 --- a/src/server/public/app.ts +++ b/src/server/public/app.ts @@ -59,9 +59,12 @@ let pollTimer: number | null = null; // ── DOM helpers ─────────────────────────────────────── -const $ = (sel: string): HTMLElement => document.querySelector(sel) as HTMLElement; const $$ = (sel: string): NodeListOf => document.querySelectorAll(sel); -const el = (id: string): HTMLElement => document.getElementById(id)!; +const el = (id: string): HTMLElement => { + const e = document.getElementById(id); + if (!e) throw new Error(`Missing element #${id}`); + return e; +}; // ── API ─────────────────────────────────────────────── @@ -96,51 +99,76 @@ async function apiJson(method: string, url: string, body?: unknown): Promise< // ── Screen switching ────────────────────────────────── function showLoginScreen(): void { - el('login-screen').classList.remove('hidden'); - el('main-screen').classList.add('hidden'); + el('login-screen').hidden = false; + el('main-screen').hidden = true; } function showMainScreen(): void { - el('login-screen').classList.add('hidden'); - el('main-screen').classList.remove('hidden'); + el('login-screen').hidden = true; + el('main-screen').hidden = false; } -// ── Tab navigation ──────────────────────────────────── +// ── Tablist (WAI-ARIA) ──────────────────────────────── -function switchTab(name: string): void { - $$('button.tab').forEach(b => b.classList.remove('active')); - document.querySelector(`button.tab[data-tab="${name}"]`)?.classList.add('active'); +function activateTab(tablistId: string, tabId: string): void { + 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); - $$('.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'); + 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: string, panelId: string): void { + if (tablistId !== 'main-tablist') return; + if (panelId === 'dashboard') loadJobs(); + if (panelId === 'files') loadFilesList(); +} + +function wireTablist(tablistId: string): void { + 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 as KeyboardEvent; + const current = document.activeElement as HTMLElement | null; + if (!current || !tabs.includes(current)) return; + let next: HTMLElement | undefined; + 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) => { +(el('login-form') as HTMLFormElement).addEventListener('submit', async (e) => { e.preventDefault(); const username = (el('login-username') as HTMLInputElement).value; const password = (el('login-password') as HTMLInputElement).value; + const errorEl = el('login-error'); try { const res = await fetch('/api/auth/login', { method: 'POST', @@ -151,15 +179,16 @@ el('login-form').addEventListener('submit', async (e) => { if (data.authenticated) { authToken = data.token; if (authToken) sessionStorage.setItem('authToken', authToken); + errorEl.hidden = true; showMainScreen(); initApp(); } else { - el('login-error').textContent = data.error; - el('login-error').classList.remove('hidden'); + errorEl.textContent = data.error || 'Login failed'; + errorEl.hidden = false; } } catch { - el('login-error').textContent = 'Connection failed'; - el('login-error').classList.remove('hidden'); + errorEl.textContent = 'Connection failed'; + errorEl.hidden = false; } }); @@ -223,41 +252,91 @@ const uploadName = el('upload-name'); videoUpload.addEventListener('change', function () { if (this.files?.length) { - selectedFilePath = null; // will upload on submit + selectedFilePath = null; uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`; } else { uploadName.textContent = ''; } }); -// ── YouTube download ────────────────────────────────── +// ── YouTube download (SSE) ──────────────────────────── -el('download-url').addEventListener('click', async () => { - const url = (el('youtube-url') as HTMLInputElement).value; +let youtubeStream: EventSource | null = null; + +el('download-url').addEventListener('click', () => { + const url = (el('youtube-url') as HTMLInputElement).value.trim(); if (!url) return; + if (!authToken) return; + const status = el('download-status'); - status.textContent = 'Downloading...'; + const progressWrap = document.querySelector('.download-progress'); + const progressbar = el('download-progressbar'); + const fill = el('download-fill'); + + status.textContent = 'Starting download...'; status.className = 'status'; - try { - const data = await apiJson<{ filePath: string; filename: string }>('POST', '/api/files/youtube', { url }); - status.textContent = `Downloaded: ${data.filename}`; - status.className = 'status success'; - selectedFilePath = data.filePath; - const sel = el('video-select') as HTMLSelectElement; - const opt = document.createElement('option'); - opt.value = data.filePath; - opt.textContent = data.filename; - opt.selected = true; - sel.appendChild(opt); - } catch (err: any) { - status.textContent = `Error: ${err.message}`; + 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: { type: string; percent?: number; filePath?: string; filename?: string; title?: string; message?: string }; + 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') as HTMLSelectElement; + 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) => { +(el('new-job-form') as HTMLFormElement).addEventListener('submit', async (e) => { e.preventDefault(); if (!selectedFilePath) { if (videoUpload.files?.length) { @@ -289,6 +368,12 @@ el('new-job-form').addEventListener('submit', async (e) => { else if (!isNaN(val as any) && val !== '') config[key] = parseFloat(val as string); 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', @@ -331,7 +416,7 @@ el('new-job-form').addEventListener('submit', async (e) => { videoUpload.value = ''; uploadName.textContent = ''; (el('new-job-form') as HTMLFormElement).reset(); - switchTab('dashboard'); + activateTab('main-tablist', 'tab-dashboard'); } catch (err: any) { alert('Error creating job: ' + err.message); } @@ -340,6 +425,8 @@ el('new-job-form').addEventListener('submit', async (e) => { // ── Job list & rendering ────────────────────────────── async function loadJobs(): Promise { + const container = el('jobs-list'); + container.setAttribute('aria-busy', 'true'); try { const data = await apiJson<{ jobs: Job[] }>('GET', '/api/jobs'); renderJobs(data.jobs); @@ -350,13 +437,15 @@ async function loadJobs(): Promise { }); } catch (err) { console.error(err); + } finally { + container.setAttribute('aria-busy', 'false'); } } function renderJobs(jobs: Job[]): void { const container = el('jobs-list'); if (!jobs.length) { - container.innerHTML = '

No jobs yet. Create one from the "New Job" tab.

'; + container.innerHTML = '

No jobs yet. Create one from the “New Job” tab.

'; return; } @@ -366,49 +455,56 @@ function renderJobs(jobs: Job[]): void { const downloads: string[] = []; 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`); + // 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: string) => qs.includes('?') ? '&' : '?'; + const url = (path: string) => 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 += ``; + actions += ``; } if (j.status === 'processing') { - actions += ``; + actions += ``; } if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { - actions += ``; + actions += ``; } if (j.status !== 'processing') { - actions += ``; + actions += ``; } + const pct = Math.round(j.progress); return ` -
+
-

${escapeHtml(j.video_filename)}

+

${escapeHtml(j.video_filename)}

${actions}
${j.status} -
+
+
+
- ${Math.round(j.progress)}% + ${pct}% Idx: ${j.current_index}/${j.total_units} ${new Date(j.created_at).toLocaleString()}
- ${j.error ? `
${escapeHtml(j.error)}
` : ''} + ${j.error ? `` : ''} ${downloads.length ? `` : ''} -
-
${segs.map((s, i) => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join('')}
+ + - -
`; +
`; }).join(''); - // Wire up action buttons container.querySelectorAll('.act-start').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start'))); container.querySelectorAll('.act-pause').forEach(b => @@ -422,10 +518,12 @@ function renderJobs(jobs: Job[]): void { const jobId = b.dataset.id || ''; const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`); if (!detail) return; - detail.classList.toggle('open'); + const willOpen = detail.hidden; + detail.hidden = !willOpen; + b.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); const job = jobs.find(j => j.id === jobId); const segs: AudioSegment[] = job ? JSON.parse(job.segments || '[]') : []; - b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${segs.length} segments`; + b.textContent = willOpen ? 'Hide segments' : `${segs.length} segments`; }); }); } @@ -454,7 +552,8 @@ function startPolling(): void { function connectSSE(jobId: string): void { if (sseMap.has(jobId)) return; - const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken!)}`); + if (!authToken) return; + const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`); es.onmessage = (event: MessageEvent) => { const data: ProgressData = JSON.parse(event.data); updateJobCard(jobId, data); @@ -480,16 +579,20 @@ function updateJobCard(jobId: string, data: ProgressData): void { 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 = data.progress + '%'; + 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 = Math.round(data.progress) + '%'; + if (metaSpans[0]) metaSpans[0].textContent = pct + '%'; if (metaSpans[1]) metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`; const log = card.querySelector('.segment-log'); @@ -501,7 +604,8 @@ function updateJobCard(jobId: string, data: ProgressData): void { const toggleBtn = card.querySelector('.toggle-detail'); if (toggleBtn && data.segments) { - toggleBtn.textContent = `${data.segments.length} segments`; + const expanded = toggleBtn.getAttribute('aria-expanded') === 'true'; + if (!expanded) toggleBtn.textContent = `${data.segments.length} segments`; } } @@ -516,15 +620,16 @@ async function loadSettings(): Promise { container.innerHTML = '

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

'; return; } - container.innerHTML = entries.map(([key, value]) => - `` - ).join(''); + container.innerHTML = entries.map(([key, value]) => { + const safeKey = escapeHtml(key); + return `
`; + }).join(''); } catch (err) { console.error(err); } } -el('settings-form').addEventListener('submit', async (e) => { +(el('settings-form') as HTMLFormElement).addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target as HTMLFormElement); const config: Record = {}; @@ -549,7 +654,7 @@ async function loadFilesList(): Promise { const tbody = document.querySelector('#files-table tbody')!; tbody.innerHTML = data.files.map(f => ` - + ${escapeHtml(f.filename)} ${formatSize(f.size)} @@ -558,6 +663,9 @@ async function loadFilesList(): Promise { tbody.querySelectorAll('.file-checkbox').forEach(cb => { cb.addEventListener('change', updateFileSelection); }); + (el('select-all-files') as HTMLInputElement).checked = false; + selectedFiles.clear(); + updateFileSelection(); } catch (err) { console.error(err); } @@ -566,7 +674,7 @@ async function loadFilesList(): Promise { function updateFileSelection(): void { selectedFiles.clear(); document.querySelectorAll('.file-checkbox:checked').forEach(cb => { - if (cb.dataset.path) selectedFiles.add(cb.dataset.path); + if (cb.dataset.filename) selectedFiles.add(cb.dataset.filename); }); (el('delete-selected-files') as HTMLButtonElement).disabled = selectedFiles.size === 0; } @@ -578,9 +686,23 @@ function updateFileSelection(): void { updateFileSelection(); }); -el('delete-selected-files').addEventListener('click', () => { +el('delete-selected-files').addEventListener('click', async () => { + if (!selectedFiles.size) return; if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return; - alert('File deletion not yet implemented'); + + const failures: string[] = []; + for (const filename of selectedFiles) { + try { + await api('DELETE', `/api/files/${encodeURIComponent(filename)}`); + } catch (err: any) { + 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); @@ -606,15 +728,14 @@ async function loadConfigDefaults(): Promise { sel.value = c.ttsProvider; } } - const fields: [string, string?][] = [ - ['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'], - ['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'], - ['captureIntervalSeconds'], ['contextWindowSize'], - ['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'], + const fields: string[] = [ + 'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions', + 'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize', + 'defaultPrompt', 'changePrompt', 'batchPrompt', ]; - for (const [name, tag] of fields) { - const el = document.querySelector(`[name="${name}"]`); - if (el && c[name] !== undefined) el.value = c[name]; + 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); @@ -624,9 +745,12 @@ async function loadConfigDefaults(): Promise { // ── Init ────────────────────────────────────────────── function initApp(): void { + wireTablist('main-tablist'); + wireTablist('source-tablist'); loadJobs(); loadBrowseFiles(); loadConfigDefaults(); + loadSettings(); startPolling(); } diff --git a/src/server/public/index.html b/src/server/public/index.html index 937bc03..d573957 100644 --- a/src/server/public/index.html +++ b/src/server/public/index.html @@ -7,153 +7,224 @@ + +
-
+
-
+ - - diff --git a/src/server/public/style.css b/src/server/public/style.css index a5a5f36..5c57c6a 100644 --- a/src/server/public/style.css +++ b/src/server/public/style.css @@ -1,31 +1,41 @@ *, *::before, *::after { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; } +[hidden] { display: none !important; } .hidden { display: none !important; } .error { color: #f85149; } .success { color: #3fb950; } .status { font-size: 0.85rem; margin: 4px 0; } +:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 4px; } + +.skip-link { + position: absolute; left: -9999px; top: 0; + background: #1f6feb; color: #fff; padding: 8px 12px; border-radius: 0 0 6px 0; z-index: 10000; +} +.skip-link:focus { left: 0; } + .screen { min-height: 100vh; } #login-screen { display: flex; align-items: center; justify-content: center; } .login-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; text-align: center; } .login-card h1 { margin: 0 0 8px; font-size: 1.4rem; } .login-card p { margin: 0 0 20px; color: #8b949e; } -.login-card label { display: block; text-align: left; font-size: 0.85rem; margin-bottom: 12px; color: #8b949e; } -.login-card input { width: 100%; margin-top: 4px; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 1rem; } +.login-card .field { text-align: left; margin-bottom: 12px; } +.login-card .field label { display: block; font-size: 0.85rem; color: #8b949e; margin-bottom: 4px; } +.login-card input { width: 100%; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 1rem; } .login-card button { width: 100%; padding: 10px; background: #238636; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; margin-top: 8px; } .login-card button:hover { background: #2ea043; } header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: #161b22; border-bottom: 1px solid #30363d; } header h1 { font-size: 1.1rem; margin: 0; } -nav { display: flex; gap: 4px; } +nav { display: flex; gap: 4px; align-items: center; } +[role="tablist"] { display: flex; gap: 4px; } button.tab { background: transparent; color: #8b949e; border: none; padding: 8px 16px; cursor: pointer; border-radius: 6px; font-size: 0.9rem; } button.tab:hover { background: #21262d; color: #c9d1d9; } button.tab.active { background: #1f6feb; color: #fff; } button.tab.danger:hover { background: #da3633; color: #fff; } -.tab-content { padding: 24px; display: none; } -.tab-content.active { display: block; } +.tab-content { padding: 24px; } .toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .toolbar h2 { margin: 0; font-size: 1.2rem; } @@ -45,17 +55,21 @@ legend { font-weight: 600; padding: 0 8px; } .tabs-mini { display: flex; gap: 4px; margin-bottom: 12px; } button.tab-mini { background: transparent; color: #8b949e; border: 1px solid #30363d; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; } -button.tab-mini.active { background: #1f6feb; color: #fff; border-color: #1f6feb; } -.src-panel { display: none; } -.src-panel.active { display: block; } +button.tab-mini.active, button.tab-mini[aria-selected="true"] { background: #1f6feb; color: #fff; border-color: #1f6feb; } +.src-panel { padding: 8px 0; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.form-grid label.full { grid-column: 1 / -1; } -.form-grid label { display: flex; flex-direction: column; font-size: 0.85rem; color: #8b949e; gap: 4px; } +.form-grid .full, .form-grid label.full { grid-column: 1 / -1; } +.form-grid .field { display: flex; flex-direction: column; gap: 4px; } +.form-grid .field label, .form-grid label { font-size: 0.85rem; color: #8b949e; } .form-grid input, .form-grid select, .form-grid textarea { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; } .form-grid textarea { resize: vertical; min-height: 60px; } .form-grid input[type="checkbox"] { width: auto; } +fieldset .field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; } +fieldset .field label { font-size: 0.85rem; color: #8b949e; } +fieldset .field input, fieldset .field select, fieldset .field textarea { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; } + details { margin-bottom: 12px; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; } details summary { cursor: pointer; font-weight: 600; padding: 4px 0; } details .form-grid { margin-top: 12px; } @@ -65,8 +79,10 @@ details .form-grid { margin-top: 12px; } select, input[type="file"], input[type="url"] { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; } .file-label { display: inline-block; padding: 10px 20px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95rem; } .file-label:hover { background: #2ea043; } -.file-name { margin: 8px 0 0; font-size: 0.85rem; color: #8b949e; } -.visually-hidden { position: absolute; opacity: 0; width: 0; height: 0; border: 0; padding: 0; } +.file-label:focus-within { outline: 2px solid #58a6ff; outline-offset: 2px; } +.file-name { margin: 8px 0 0; font-size: 0.85rem; color: #8b949e; min-height: 1.2em; } +.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.download-progress { margin: 10px 0 6px; } /* Job cards */ .jobs-list { display: flex; flex-direction: column; gap: 8px; } @@ -77,7 +93,7 @@ select, input[type="file"], input[type="url"] { padding: 8px 12px; background: # .job-actions button { font-size: 0.8rem; padding: 4px 10px; } .status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } -.status-pending { background: #21262d; color: #8b949e; } +.status-pending { background: #21262d; color: #c9d1d9; } .status-queued { background: #1a2332; color: #58a6ff; } .status-processing { background: #1a2332; color: #58a6ff; } .status-completed { background: #172f1e; color: #3fb950; } @@ -92,8 +108,7 @@ select, input[type="file"], input[type="url"] { padding: 8px 12px; background: # .job-meta { display: flex; gap: 16px; font-size: 0.8rem; color: #8b949e; margin-bottom: 8px; } -.job-detail { margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d; display: none; } -.job-detail.open { display: block; } +.job-detail { margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d; } .segment-log { max-height: 200px; overflow-y: auto; font-size: 0.8rem; color: #8b949e; background: #0d1117; padding: 8px; border-radius: 4px; margin-bottom: 8px; } .segment-entry { padding: 4px 0; border-bottom: 1px solid #1c2128; } .segment-entry:last-child { border-bottom: none; } diff --git a/src/server/routes/config.ts b/src/server/routes/config.ts index e8b5181..fd8a8ed 100644 --- a/src/server/routes/config.ts +++ b/src/server/routes/config.ts @@ -1,11 +1,44 @@ import { Router, Request, Response } from 'express'; import { getAllConfig, setConfigValue } from '../db/jobStore'; +import { getDefaultConfig } from '../../config/config'; const router = Router(); +// Optional .env overrides for the (long) prompt strings — keep getDefaultConfig()'s +// hardcoded prompts as the final fallback. Users who want to tweak prompts without +// editing source can set these in .env, or set them per-job in the Settings UI. +const ENV_OVERRIDES: Record = { + defaultPrompt: process.env.AIDIO_DEFAULT_PROMPT, + changePrompt: process.env.AIDIO_CHANGE_PROMPT, + batchPrompt: process.env.AIDIO_BATCH_PROMPT, +}; + +// Fields in Config that are nested objects (provider configs, etc.) and shouldn't +// be flattened into the form-facing config map. API keys live inside these — keep +// them off the wire. +const NESTED_FIELDS = new Set(['visionProviders', 'ttsProviders']); + +function buildLayeredConfig(): Record { + const defaults = getDefaultConfig() as unknown as Record; + const db = getAllConfig(); + + const merged: Record = {}; + for (const [key, value] of Object.entries(defaults)) { + if (NESTED_FIELDS.has(key)) continue; + if (value === undefined || value === null) continue; + merged[key] = String(value); + } + for (const [key, value] of Object.entries(ENV_OVERRIDES)) { + if (value !== undefined && value !== '') merged[key] = value; + } + for (const [key, value] of Object.entries(db)) { + if (value !== undefined && value !== '') merged[key] = value; + } + return merged; +} + router.get('/', (_req: Request, res: Response) => { - const config = getAllConfig(); - res.json({ config }); + res.json({ config: buildLayeredConfig() }); }); router.put('/', (req: Request, res: Response) => { @@ -17,8 +50,7 @@ router.put('/', (req: Request, res: Response) => { for (const [key, value] of Object.entries(updates)) { setConfigValue(key, String(value)); } - const config = getAllConfig(); - res.json({ config }); + res.json({ config: buildLayeredConfig() }); }); export default router; diff --git a/src/server/routes/files.ts b/src/server/routes/files.ts index 820168e..30a9518 100644 --- a/src/server/routes/files.ts +++ b/src/server/routes/files.ts @@ -66,24 +66,73 @@ router.get('/', (_req: Request, res: Response) => { res.json({ files }); }); -router.post('/youtube', (req: Request, res: Response) => { +router.delete('/:filename', (req: Request, res: Response) => { + const raw = req.params.filename; + const requested = Array.isArray(raw) ? raw[0] : raw; + if (!requested) { + res.status(400).json({ error: 'filename is required' }); + return; + } + + const resolved = path.resolve(UPLOADS_DIR, requested); + const uploadsWithSep = UPLOADS_DIR.endsWith(path.sep) ? UPLOADS_DIR : UPLOADS_DIR + path.sep; + if (!resolved.startsWith(uploadsWithSep)) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + if (!fs.existsSync(resolved)) { + res.status(404).json({ error: 'File not found' }); + return; + } + + try { + fs.unlinkSync(resolved); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: `Failed to delete: ${err.message}` }); + } +}); + +// Stream yt-dlp download progress over SSE. +// Returns events: {type:'progress', percent} ... {type:'done', filePath, filename, title} +// or {type:'error', message} +router.get('/youtube/stream', (req: Request, res: Response) => { + const url = (req.query.url as string) || ''; + if (!url) { + res.status(400).json({ error: 'url query param is required' }); + return; + } if (!isYtDlpAvailable()) { res.status(400).json({ error: 'yt-dlp is not installed or not in PATH' }); return; } - const { url } = req.body; - if (!url) { - res.status(400).json({ error: 'URL is required' }); - return; - } + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders?.(); - try { - const result = downloadVideo(url, UPLOADS_DIR); - res.json(result); - } catch (err: any) { - res.status(500).json({ error: `Failed to download: ${err.message}` }); - } + const send = (data: Record) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + let clientGone = false; + req.on('close', () => { clientGone = true; }); + + downloadVideo(url, UPLOADS_DIR, (percent) => { + if (clientGone) return; + send({ type: 'progress', percent }); + }).then((result) => { + if (clientGone) return; + send({ type: 'done', ...result }); + res.end(); + }).catch((err: Error) => { + if (clientGone) return; + send({ type: 'error', message: err.message }); + res.end(); + }); }); export default router; diff --git a/src/server/services/jobManager.ts b/src/server/services/jobManager.ts index c5c15b0..f018dea 100644 --- a/src/server/services/jobManager.ts +++ b/src/server/services/jobManager.ts @@ -1,17 +1,31 @@ import path from 'path'; import fs from 'fs'; -import { - getAllJobs, getJob, createJob, updateJobStatus, saveCheckpoint, - saveJobOutputs, deleteJob as deleteJobFromDb, Job, OutputOptions +import { + getAllJobs, getJob, createJob, updateJobStatus, saveCheckpoint, + saveJobOutputs, deleteJob as deleteJobFromDb, Job, OutputOptions } from '../db/jobStore'; import { generateAudioDescriptionFromOptions } from '../../utils/processor'; import { generateSRT, generateVTT } from './subtitleGenerator'; import { muxAudioDescription } from './muxer'; import { getDefaultConfig, Config } from '../../config/config'; import { AudioSegment, BatchContext } from '../../interfaces'; -import { getVideoDuration } from '../../utils/mediaUtils'; +import { getVideoDuration, cleanupTempFiles } from '../../utils/mediaUtils'; import { EventEmitter } from 'events'; +function jobTempDir(baseTempDir: string, jobId: string): string { + return path.join(baseTempDir, jobId); +} + +function safeCleanupJobTmp(dir: string): void { + try { + if (!fs.existsSync(dir)) return; + cleanupTempFiles(dir); + fs.rmSync(dir, { recursive: true, force: true }); + } catch (err: any) { + console.warn(`Failed to clean up tmp dir ${dir}:`, err.message); + } +} + interface ProgressData { id: string; status: string; @@ -49,7 +63,17 @@ export class JobManager { createJob(videoPath: string, configOverride: Partial = {}, outputOptions: Partial = {}): Job { const baseConfig = getDefaultConfig(); - const mergedConfig: Config = { ...baseConfig, ...configOverride }; + + // Drop empty/undefined/null values so blank form fields don't clobber the + // baked-in defaults (a blank prompt textarea must NOT overwrite the real + // prompt with ""). + const cleanedOverride: Record = {}; + for (const [k, v] of Object.entries(configOverride)) { + if (v === '' || v === null || v === undefined) continue; + cleanedOverride[k] = v; + } + + const mergedConfig: Config = { ...baseConfig, ...(cleanedOverride as Partial) }; const filename = path.basename(videoPath); const opts: OutputOptions = { @@ -111,6 +135,18 @@ export class JobManager { if (!job) throw new Error('Job not found'); if (job.status === 'processing') throw new Error('Cannot delete a running job'); + try { + const config: Config = JSON.parse(job.config); + // job.config may contain either the base tempDir (older jobs) or the + // per-job tempDir (newer jobs). Trim a trailing job-id segment if present; + // otherwise compute the per-job dir from the stored base. + const stored = config.tempDir || './desc/tmp/'; + const candidate = path.basename(stored) === jobId ? stored : jobTempDir(stored, jobId); + safeCleanupJobTmp(candidate); + } catch { + // ignore: cleanup is best-effort and must not block deletion + } + deleteJobFromDb(jobId); } @@ -183,7 +219,16 @@ export class JobManager { const config: Config = JSON.parse(job.config); const outputOptions: OutputOptions = JSON.parse(job.output_options); - + + // Isolate this job's intermediates so concurrent jobs (or future resumes) + // don't collide on filenames like frame_00001.jpg / segment_3_std.wav. + // The pipeline already reads config.tempDir, so just override it here. + const baseTempDir = config.tempDir || './desc/tmp/'; + if (path.basename(baseTempDir) !== job.id) { + config.tempDir = jobTempDir(baseTempDir, job.id); + } + fs.mkdirSync(config.tempDir, { recursive: true }); + const existingSegments: AudioSegment[] = JSON.parse(job.segments || '[]'); const lastContext: BatchContext = JSON.parse(job.last_context || '{}'); @@ -278,16 +323,21 @@ export class JobManager { updateJobStatus(job.id, 'completed'); this.emitProgress(job.id); + safeCleanupJobTmp(config.tempDir); + } catch (err: any) { if (err.message === 'JOB_PAUSED') { + // Keep config.tempDir intact — restart will resume into the same dir. updateJobStatus(job.id, 'paused'); this.emitProgress(job.id); return; } - + const errorMsg = err.message || 'Unknown error'; updateJobStatus(job.id, 'failed', errorMsg); this.emitProgress(job.id); + + safeCleanupJobTmp(config.tempDir); } } } diff --git a/src/server/services/muxer.ts b/src/server/services/muxer.ts index 0ad0b8c..22a2a30 100644 --- a/src/server/services/muxer.ts +++ b/src/server/services/muxer.ts @@ -1,29 +1,46 @@ -import { execSync } from 'child_process'; +import { spawnSync } from 'child_process'; import path from 'path'; +import fs from 'fs'; export function muxAudioDescription( videoPath: string, audioPath: string, outputPath: string ): void { - const ext = path.extname(outputPath).toLowerCase(); - const isMkv = ext === '.mkv'; + if (!fs.existsSync(videoPath)) { + throw new Error(`mux: video not found: ${videoPath}`); + } + if (!fs.existsSync(audioPath)) { + throw new Error(`mux: audio not found: ${audioPath}`); + } - const cmd = [ - 'ffmpeg -v error', - `-i "${videoPath}"`, - `-i "${audioPath}"`, - '-map 0:v', - '-map 0:a?', - '-map 1:a', - '-c:v copy', - '-c:a copy', - isMkv - ? '-metadata:s:a:1 title="Audio Description"' - : '-metadata:s:a:1 title="Audio Description"', - `"${outputPath}"`, - '-y' - ].join(' '); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - execSync(cmd); + // Argv form — no shell, no quoting issues, and -y is a global option (placed + // up front, not after the output). Stderr is captured so failures aren't + // silent. + const args = [ + '-y', + '-v', 'error', + '-i', videoPath, + '-i', audioPath, + '-map', '0:v', + '-map', '0:a?', + '-map', '1:a', + '-c:v', 'copy', + '-c:a', 'copy', + '-metadata:s:a:1', 'title=Audio Description', + '-disposition:a:1', 'visual_impaired', + outputPath, + ]; + + const result = spawnSync('ffmpeg', args, { shell: false, encoding: 'utf-8' }); + + if (result.error) { + throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | '); + throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`); + } } diff --git a/src/server/services/ytDlp.ts b/src/server/services/ytDlp.ts index c06c9fd..b137184 100644 --- a/src/server/services/ytDlp.ts +++ b/src/server/services/ytDlp.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; @@ -8,6 +8,8 @@ export interface YtDlpResult { title: string; } +export type YtDlpProgress = (percent: number) => void; + export function isYtDlpAvailable(): boolean { try { execSync('yt-dlp --version', { stdio: 'pipe' }); @@ -17,31 +19,113 @@ export function isYtDlpAvailable(): boolean { } } -export function downloadVideo(url: string, outputDir: string): YtDlpResult { - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } +const PROGRESS_PREFIX = 'PROG '; - const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s'); +export function downloadVideo( + url: string, + outputDir: string, + onProgress?: YtDlpProgress +): Promise { + return new Promise((resolve, reject) => { + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } - const result = execSync( - `yt-dlp -f "best[ext=mp4]/best" -o "${outputTemplate}" --print filename --print title "${url}"`, - { encoding: 'utf-8', timeout: 600000 } - ); + const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s'); - const lines = result.trim().split('\n'); - const filename = lines[0]?.trim(); - const title = lines[1]?.trim() || filename; + // Pass arguments as an array — no shell, no quoting issues, no truncation + // on URLs containing & | % ^ etc. (the original execSync bug on Windows). + const args = [ + '-f', 'best[ext=mp4]/best', + '-o', outputTemplate, + '--newline', + '--progress-template', `${PROGRESS_PREFIX}%(progress._percent_str)s`, + '--print', 'after_move:filepath', + '--print', 'title', + '--no-simulate', + url, + ]; - if (!filename) { - throw new Error('yt-dlp: Failed to parse downloaded filename'); - } + const child = spawn('yt-dlp', args, { shell: false }); - const filePath = path.resolve(outputDir, filename); + const stderrLines: string[] = []; + const outputLines: string[] = []; + let stdoutBuf = ''; + let stderrBuf = ''; - if (!fs.existsSync(filePath)) { - throw new Error(`yt-dlp: Downloaded file not found at ${filePath}`); - } + const handleStdoutLine = (line: string) => { + if (!line) return; + if (line.startsWith(PROGRESS_PREFIX)) { + const m = line.slice(PROGRESS_PREFIX.length).match(/([\d.]+)\s*%/); + if (m && onProgress) { + const pct = parseFloat(m[1]); + if (!isNaN(pct)) onProgress(pct); + } + return; + } + outputLines.push(line); + }; - return { filePath, filename, title }; + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuf += chunk.toString('utf-8'); + let nl; + while ((nl = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, nl).replace(/\r$/, ''); + stdoutBuf = stdoutBuf.slice(nl + 1); + handleStdoutLine(line.trim()); + } + }); + + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf-8'); + let nl; + while ((nl = stderrBuf.indexOf('\n')) !== -1) { + const line = stderrBuf.slice(0, nl).replace(/\r$/, '').trim(); + stderrBuf = stderrBuf.slice(nl + 1); + if (line) stderrLines.push(line); + } + }); + + child.on('error', (err) => { + reject(new Error(`yt-dlp failed to start: ${err.message}`)); + }); + + const timeoutMs = 600000; + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`yt-dlp timed out after ${timeoutMs / 1000}s`)); + }, timeoutMs); + + child.on('close', (code) => { + clearTimeout(timer); + + if (stdoutBuf.trim()) handleStdoutLine(stdoutBuf.trim()); + if (stderrBuf.trim()) stderrLines.push(stderrBuf.trim()); + + if (code !== 0) { + const tail = stderrLines.slice(-3).join(' | ') || `exit code ${code}`; + reject(new Error(tail)); + return; + } + + const filePath = outputLines[0]; + const title = outputLines[1] || (filePath ? path.basename(filePath) : ''); + + if (!filePath) { + reject(new Error('yt-dlp completed but did not report a filename')); + return; + } + + if (!fs.existsSync(filePath)) { + reject(new Error(`yt-dlp reported success but file not found: ${filePath}`)); + return; + } + + resolve({ + filePath: path.resolve(filePath), + filename: path.basename(filePath), + title, + }); + }); + }); }