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 `
-
-
-
${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
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 `
-
+
${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 ? `${escapeHtml(j.error)}
` : ''}
${downloads.length ? `${downloads.join('')}
` : ''}
-
-
${segs.map((s, i) => `
[${s.startTime.toFixed(1)}s] ${escapeHtml(s.description)}
`).join('')}
+
+
+
${segs.map(s => `
[${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 @@
+Skip to main content
+
-
+
-
Audio Description Server
+
Audio Description Server
Please log in to continue
-
+
-
+
-
+
Audio Description Server
-
-
-
-
Jobs
-
-
-
-
No jobs yet. Create one from the "New Job" tab.
-
-
+
-
+
+ Processing settings
+
+
-
-
Server Configuration
-
These settings are stored on the server and used as defaults for new jobs.
-
-
+
+ Prompts
+
+
-
-
Uploaded Files
-
-
-
-
-
-
+
+
+
+
+
+ Server configuration
+ These settings are stored on the server and used as defaults for new jobs.
+
+
+
+
+ Uploaded files
+
+
+
+
+
+
+
+
-