From e42098a25af424b9102a8dbb7818e472e1224af9 Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 13 May 2026 17:36:42 +0200 Subject: [PATCH] Restore TypeScript frontend, fix src-panel class mismatch, add onclick fallbacks for mini-tabs and file label --- src/server/public/app.js | 567 ++++++++++++++++++++++++++++ src/server/public/app.ts | 650 ++++++++++++++++++++++++++++++++ src/server/public/index.html | 581 +++++++--------------------- src/server/public/style.css | 114 ++++++ src/server/public/tsconfig.json | 15 + 5 files changed, 1487 insertions(+), 440 deletions(-) create mode 100644 src/server/public/app.js create mode 100644 src/server/public/app.ts create mode 100644 src/server/public/style.css create mode 100644 src/server/public/tsconfig.json diff --git a/src/server/public/app.js b/src/server/public/app.js new file mode 100644 index 0000000..f6c04ee --- /dev/null +++ b/src/server/public/app.js @@ -0,0 +1,567 @@ +"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 new file mode 100644 index 0000000..791caa0 --- /dev/null +++ b/src/server/public/app.ts @@ -0,0 +1,650 @@ +// ── Types ──────────────────────────────────────────── + +interface Job { + id: string; + video_path: string; + video_filename: string; + status: 'pending' | 'queued' | 'processing' | 'paused' | 'completed' | 'failed' | 'cancelled'; + config: string; + progress: number; + current_index: number; + total_units: number; + segments: string; + last_context: string; + current_time_position: number; + error: string | null; + created_at: string; + updated_at: string; + completed_at: string | null; + output_audio: string | null; + output_subtitles_srt: string | null; + output_subtitles_vtt: string | null; + output_muxed: string | null; + output_options: string; +} + +interface AudioSegment { + audioFile: string; + startTime: number; + duration: number; + description: string; +} + +interface ProgressData { + id: string; + status: string; + progress: number; + currentIndex: number; + totalUnits: number; + segments: AudioSegment[]; + error: string | null; + output_audio: string | null; + output_subtitles_srt: string | null; + output_subtitles_vtt: string | null; + output_muxed: string | null; +} + +interface FileInfo { + filename: string; + filePath: string; + size: number; +} + +// ── State ──────────────────────────────────────────── + +let authToken: string | null = sessionStorage.getItem('authToken'); +let selectedFilePath: string | null = null; +const sseMap = new Map(); +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)!; + +// ── API ─────────────────────────────────────────────── + +function apiHeaders(): Record { + const h: Record = { 'Content-Type': 'application/json' }; + if (authToken) h['Authorization'] = `Basic ${authToken}`; + return h; +} + +async function api(method: string, url: string, body?: unknown): Promise { + 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: string, url: string, body?: unknown): Promise { + const res = await api(method, url, body); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data as T; +} + +// ── Screen switching ────────────────────────────────── + +function showLoginScreen(): void { + el('login-screen').classList.remove('hidden'); + el('main-screen').classList.add('hidden'); +} + +function showMainScreen(): void { + el('login-screen').classList.add('hidden'); + el('main-screen').classList.remove('hidden'); +} + +// ── Tab navigation ──────────────────────────────────── + +function switchTab(name: string): void { + $$('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') as HTMLInputElement).value; + const password = (el('login-password') as HTMLInputElement).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: string | null | undefined): string { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function formatSize(bytes: number): string { + 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(): Promise { + try { + const data = await apiJson<{ files: FileInfo[] }>('GET', '/api/files'); + const sel = el('video-select') as HTMLSelectElement; + 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') as HTMLSelectElement).addEventListener('change', function () { + if (this.value) selectedFilePath = this.value; +}); + +// ── File upload ─────────────────────────────────────── + +const videoUpload = el('video-upload') as HTMLInputElement; +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') as HTMLInputElement).value; + if (!url) return; + const status = el('download-status'); + status.textContent = 'Downloading...'; + 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}`; + 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: Record = {}; + 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: any) { + alert('Upload error: ' + err.message); + return; + } + } else { + alert('Please select a video file or source'); + return; + } + } + + const fd = new FormData(e.target as HTMLFormElement); + const config: Record = {}; + 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 as any) && val !== '') config[key] = parseFloat(val as string); + 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: Record = {}; + vp[config.visionProvider as string] = { + model: config.visionModel || 'gpt-4o', + maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens as string) : 300, + }; + config.visionProviders = vp; + } + if (config.ttsProvider) { + const tp: Record = {}; + tp[config.ttsProvider as string] = { + 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<{ job: Job }>('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') as HTMLFormElement).reset(); + switchTab('dashboard'); + } catch (err: any) { + alert('Error creating job: ' + err.message); + } +}); + +// ── Job list & rendering ────────────────────────────── + +async function loadJobs(): Promise { + try { + const data = await apiJson<{ jobs: Job[] }>('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: Job[]): void { + 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: AudioSegment[] = JSON.parse(j.segments || '[]'); + const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : ''; + 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`); + } + + 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: AudioSegment[] = job ? JSON.parse(job.segments || '[]') : []; + b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${segs.length} segments`; + }); + }); +} + +async function handleJobAction(id: string, action: string): Promise { + const method = action === 'delete' ? 'DELETE' : 'POST'; + const url = `/api/jobs/${id}${action === 'delete' ? '' : '/' + action}`; + try { + await api(method, url); + loadJobs(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } +} + +el('refresh-jobs').addEventListener('click', loadJobs); + +// ── Polling ─────────────────────────────────────────── + +function startPolling(): void { + if (pollTimer) return; + pollTimer = window.setInterval(loadJobs, 5000); +} + +// ── SSE live progress ───────────────────────────────── + +function connectSSE(jobId: string): void { + if (sseMap.has(jobId)) 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); + 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: string, data: ProgressData): void { + 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(): Promise { + try { + const data = await apiJson<{ config: Record }>('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 as HTMLFormElement); + const config: Record = {}; + for (const [key, val] of fd.entries()) { + config[key] = val as string; + } + try { + await apiJson('PUT', '/api/config', config); + alert('Settings saved'); + } catch (err: any) { + alert('Error: ' + err.message); + } +}); + +// ── Files list ──────────────────────────────────────── + +let selectedFiles = new Set(); + +async function loadFilesList(): Promise { + try { + const data = await apiJson<{ files: FileInfo[] }>('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(): void { + selectedFiles.clear(); + document.querySelectorAll('.file-checkbox:checked').forEach(cb => { + if (cb.dataset.path) selectedFiles.add(cb.dataset.path); + }); + (el('delete-selected-files') as HTMLButtonElement).disabled = selectedFiles.size === 0; +} + +(el('select-all-files') as HTMLInputElement).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(): Promise { + try { + const data = await apiJson<{ config: Record }>('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: [string, string?][] = [ + ['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(): void { + 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/index.html b/src/server/public/index.html index 48582ff..64d829d 100644 --- a/src/server/public/index.html +++ b/src/server/public/index.html @@ -4,453 +4,154 @@ Audio Description Server - + - - -
- -
- - -
-
-

Audio Description Server

- -
- - -
-

Jobs

-
-
- - -
-

Create New Job

-
- Video Source -
- - - -
-
- - -
-
-
- - -
-
- - -
-
-
- -
- Output - - - -
- -
- Vision Settings -
- - - -
-
- -
- TTS Settings -
- - - - - -
-
- -
- Processing -
- - - - - -
-
- -
- Prompts -
- - - -
-
- - -
- - -
-

Server Config

-

Stored on server, used as defaults for new jobs.

-
- -
- - -
-

Uploaded Files

-
- +
+
+ +
+ +
-
JS ✓
- + diff --git a/src/server/public/style.css b/src/server/public/style.css new file mode 100644 index 0000000..04b8933 --- /dev/null +++ b/src/server/public/style.css @@ -0,0 +1,114 @@ +*, *::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; } +.error { color: #f85149; } +.success { color: #3fb950; } +.status { font-size: 0.85rem; margin: 4px 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 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; } + +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; } + +.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.toolbar h2 { margin: 0; font-size: 1.2rem; } + +button { padding: 8px 16px; background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; cursor: pointer; font-size: 0.9rem; } +button:hover { background: #30363d; } +button.btn-primary { background: #238636; border-color: #238636; color: #fff; } +button.btn-primary:hover { background: #2ea043; } +button.danger { background: transparent; color: #f85149; } +button.danger:hover { background: #da3633; color: #fff; border-color: #da3633; } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.empty { color: #8b949e; font-style: italic; text-align: center; padding: 40px; } + +fieldset { border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; } +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; } + +.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 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; } + +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; } + +.hint { color: #8b949e; font-size: 0.85rem; margin-top: -12px; margin-bottom: 16px; } + +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; } + +/* Job cards */ +.jobs-list { display: flex; flex-direction: column; gap: 8px; } +.job-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; } +.job-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.job-card-header h3 { margin: 0; font-size: 1rem; word-break: break-all; } +.job-actions { display: flex; gap: 4px; } +.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-queued { background: #1a2332; color: #58a6ff; } +.status-processing { background: #1a2332; color: #58a6ff; } +.status-completed { background: #172f1e; color: #3fb950; } +.status-failed { background: #2d1518; color: #f85149; } +.status-paused { background: #2d2400; color: #d29922; } +.status-cancelled { background: #21262d; color: #8b949e; } + +.progress-bar { height: 6px; background: #21262d; border-radius: 3px; margin: 8px 0; overflow: hidden; } +.progress-fill { height: 100%; background: #1f6feb; border-radius: 3px; transition: width 0.5s ease; } +.progress-fill.completed { background: #3fb950; } +.progress-fill.failed { background: #f85149; } + +.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; } +.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; } +.segment-time { color: #58a6ff; } + +.download-links { display: flex; gap: 8px; flex-wrap: wrap; } +.download-links a { padding: 6px 12px; background: #21262d; color: #58a6ff; text-decoration: none; border-radius: 4px; font-size: 0.85rem; border: 1px solid #30363d; } +.download-links a:hover { background: #30363d; } + +.error-msg { color: #f85149; font-size: 0.85rem; background: #2d1518; padding: 8px; border-radius: 4px; margin: 8px 0; } + +/* Files table */ +#files-table { width: 100%; border-collapse: collapse; } +#files-table th, #files-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #30363d; } +#files-table th { font-size: 0.85rem; color: #8b949e; } +#files-table tbody tr:hover { background: #161b22; } + +/* Messages */ +#login-error { margin-top: 12px; } diff --git a/src/server/public/tsconfig.json b/src/server/public/tsconfig.json new file mode 100644 index 0000000..6306c37 --- /dev/null +++ b/src/server/public/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "outDir": ".", + "rootDir": ".", + "sourceMap": false, + "declaration": false, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "files": ["app.ts"] +}