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 `
+
+
+
${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 ? `
${downloads.join('')}
` : ''}
+
+
${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 `
+
+
+
${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 ? `
${downloads.join('')}
` : ''}
+
+
${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
-
Please log in to continue
-
-
-
-
-
-
-
-
-
-
- Audio Description Server
-
-
-
-
-
-
-
-
-
-
-
-
Server Config
-
Stored on server, used as defaults for new jobs.
-
-
-
-
-
-