2026-05-13 16:39:46 +02:00
|
|
|
"use strict";
|
|
|
|
|
// ── Types ────────────────────────────────────────────
|
|
|
|
|
// ── State ────────────────────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
let authToken = sessionStorage.getItem('authToken');
|
2026-05-13 16:39:46 +02:00
|
|
|
let selectedFilePath = null;
|
|
|
|
|
const sseMap = new Map();
|
|
|
|
|
let pollTimer = null;
|
|
|
|
|
// ── DOM helpers ───────────────────────────────────────
|
|
|
|
|
const $$ = (sel) => document.querySelectorAll(sel);
|
2026-05-15 04:10:06 +02:00
|
|
|
const el = (id) => {
|
|
|
|
|
const e = document.getElementById(id);
|
|
|
|
|
if (!e)
|
|
|
|
|
throw new Error(`Missing element #${id}`);
|
|
|
|
|
return e;
|
|
|
|
|
};
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── API ───────────────────────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
function apiHeaders() {
|
2026-05-13 16:39:46 +02:00
|
|
|
const h = { 'Content-Type': 'application/json' };
|
|
|
|
|
if (authToken)
|
|
|
|
|
h['Authorization'] = `Basic ${authToken}`;
|
|
|
|
|
return h;
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
|
|
|
|
async function api(method, url, body) {
|
2026-05-13 16:39:46 +02:00
|
|
|
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;
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
|
|
|
|
async function apiJson(method, url, body) {
|
2026-05-13 16:39:46 +02:00
|
|
|
const res = await api(method, url, body);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (!res.ok)
|
|
|
|
|
throw new Error(data.error || 'Request failed');
|
|
|
|
|
return data;
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Screen switching ──────────────────────────────────
|
|
|
|
|
function showLoginScreen() {
|
2026-05-15 04:10:06 +02:00
|
|
|
el('login-screen').hidden = false;
|
|
|
|
|
el('main-screen').hidden = true;
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
function showMainScreen() {
|
2026-05-15 04:10:06 +02:00
|
|
|
el('login-screen').hidden = true;
|
|
|
|
|
el('main-screen').hidden = false;
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
// ── Tablist (WAI-ARIA) ────────────────────────────────
|
|
|
|
|
function activateTab(tablistId, tabId) {
|
|
|
|
|
const tablist = el(tablistId);
|
|
|
|
|
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
|
|
|
|
tabs.forEach(t => {
|
|
|
|
|
const selected = t.id === tabId;
|
|
|
|
|
t.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
|
|
|
t.setAttribute('tabindex', selected ? '0' : '-1');
|
|
|
|
|
t.classList.toggle('active', selected);
|
|
|
|
|
const panelId = t.getAttribute('aria-controls');
|
|
|
|
|
if (!panelId)
|
|
|
|
|
return;
|
|
|
|
|
const panel = document.getElementById(panelId);
|
|
|
|
|
if (panel)
|
|
|
|
|
panel.hidden = !selected;
|
|
|
|
|
});
|
|
|
|
|
const tab = tabs.find(t => t.id === tabId);
|
|
|
|
|
const tabName = tab?.getAttribute('aria-controls') || '';
|
|
|
|
|
onTabActivated(tablistId, tabName);
|
|
|
|
|
}
|
|
|
|
|
function onTabActivated(tablistId, panelId) {
|
|
|
|
|
if (tablistId !== 'main-tablist')
|
|
|
|
|
return;
|
|
|
|
|
if (panelId === 'dashboard')
|
2026-05-13 16:39:46 +02:00
|
|
|
loadJobs();
|
2026-05-15 04:10:06 +02:00
|
|
|
if (panelId === 'files')
|
2026-05-13 16:39:46 +02:00
|
|
|
loadFilesList();
|
|
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
function wireTablist(tablistId) {
|
|
|
|
|
const tablist = el(tablistId);
|
|
|
|
|
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
|
|
|
|
tabs.forEach(tab => {
|
|
|
|
|
tab.addEventListener('click', () => activateTab(tablistId, tab.id));
|
2026-05-13 16:39:46 +02:00
|
|
|
});
|
2026-05-15 04:10:06 +02:00
|
|
|
tablist.addEventListener('keydown', (e) => {
|
|
|
|
|
const ke = e;
|
|
|
|
|
const current = document.activeElement;
|
|
|
|
|
if (!current || !tabs.includes(current))
|
|
|
|
|
return;
|
|
|
|
|
let next;
|
|
|
|
|
const idx = tabs.indexOf(current);
|
|
|
|
|
if (ke.key === 'ArrowRight')
|
|
|
|
|
next = tabs[(idx + 1) % tabs.length];
|
|
|
|
|
else if (ke.key === 'ArrowLeft')
|
|
|
|
|
next = tabs[(idx - 1 + tabs.length) % tabs.length];
|
|
|
|
|
else if (ke.key === 'Home')
|
|
|
|
|
next = tabs[0];
|
|
|
|
|
else if (ke.key === 'End')
|
|
|
|
|
next = tabs[tabs.length - 1];
|
|
|
|
|
if (next) {
|
|
|
|
|
ke.preventDefault();
|
|
|
|
|
activateTab(tablistId, next.id);
|
|
|
|
|
next.focus();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Login ─────────────────────────────────────────────
|
|
|
|
|
el('login-form').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const username = el('login-username').value;
|
|
|
|
|
const password = el('login-password').value;
|
2026-05-15 04:10:06 +02:00
|
|
|
const errorEl = el('login-error');
|
2026-05-13 16:39:46 +02:00
|
|
|
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);
|
2026-05-15 04:10:06 +02:00
|
|
|
errorEl.hidden = true;
|
2026-05-13 16:39:46 +02:00
|
|
|
showMainScreen();
|
|
|
|
|
initApp();
|
|
|
|
|
}
|
|
|
|
|
else {
|
2026-05-15 04:10:06 +02:00
|
|
|
errorEl.textContent = data.error || 'Login failed';
|
|
|
|
|
errorEl.hidden = false;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch {
|
2026-05-15 04:10:06 +02:00
|
|
|
errorEl.textContent = 'Connection failed';
|
|
|
|
|
errorEl.hidden = false;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-13 16:39:46 +02:00
|
|
|
el('logout-btn').addEventListener('click', () => {
|
|
|
|
|
sessionStorage.removeItem('authToken');
|
|
|
|
|
authToken = null;
|
|
|
|
|
sseMap.forEach(s => s.close());
|
|
|
|
|
sseMap.clear();
|
|
|
|
|
if (pollTimer)
|
|
|
|
|
clearInterval(pollTimer);
|
|
|
|
|
showLoginScreen();
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Utils ─────────────────────────────────────────────
|
|
|
|
|
function escapeHtml(str) {
|
|
|
|
|
if (!str)
|
|
|
|
|
return '';
|
|
|
|
|
return String(str)
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.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) ────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
async function loadBrowseFiles() {
|
2026-05-13 16:39:46 +02:00
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/files');
|
|
|
|
|
const sel = el('video-select');
|
|
|
|
|
sel.innerHTML = '<option value="">-- Select file --</option>';
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
el('refresh-files').addEventListener('click', loadBrowseFiles);
|
|
|
|
|
el('video-select').addEventListener('change', function () {
|
|
|
|
|
if (this.value)
|
|
|
|
|
selectedFilePath = this.value;
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── File upload ───────────────────────────────────────
|
2026-05-13 17:09:33 +02:00
|
|
|
const videoUpload = el('video-upload');
|
|
|
|
|
const uploadName = el('upload-name');
|
|
|
|
|
videoUpload.addEventListener('change', function () {
|
|
|
|
|
if (this.files?.length) {
|
2026-05-15 04:10:06 +02:00
|
|
|
selectedFilePath = null;
|
2026-05-13 17:09:33 +02:00
|
|
|
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
uploadName.textContent = '';
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-15 04:10:06 +02:00
|
|
|
// ── YouTube download (SSE) ────────────────────────────
|
|
|
|
|
let youtubeStream = null;
|
|
|
|
|
el('download-url').addEventListener('click', () => {
|
|
|
|
|
const url = el('youtube-url').value.trim();
|
2026-05-13 16:39:46 +02:00
|
|
|
if (!url)
|
2026-05-13 16:23:43 +02:00
|
|
|
return;
|
2026-05-15 04:10:06 +02:00
|
|
|
if (!authToken)
|
|
|
|
|
return;
|
2026-05-13 16:39:46 +02:00
|
|
|
const status = el('download-status');
|
2026-05-15 04:10:06 +02:00
|
|
|
const progressWrap = document.querySelector('.download-progress');
|
|
|
|
|
const progressbar = el('download-progressbar');
|
|
|
|
|
const fill = el('download-fill');
|
|
|
|
|
status.textContent = 'Starting download...';
|
2026-05-13 16:39:46 +02:00
|
|
|
status.className = 'status';
|
2026-05-15 04:10:06 +02:00
|
|
|
if (progressWrap)
|
|
|
|
|
progressWrap.hidden = false;
|
|
|
|
|
progressbar.setAttribute('aria-valuenow', '0');
|
|
|
|
|
fill.style.width = '0%';
|
|
|
|
|
if (youtubeStream)
|
|
|
|
|
youtubeStream.close();
|
|
|
|
|
const streamUrl = `/api/files/youtube/stream?url=${encodeURIComponent(url)}&token=${encodeURIComponent(authToken)}`;
|
|
|
|
|
const es = new EventSource(streamUrl);
|
|
|
|
|
youtubeStream = es;
|
|
|
|
|
es.onmessage = (event) => {
|
|
|
|
|
let data;
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(event.data);
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (data.type === 'progress' && typeof data.percent === 'number') {
|
|
|
|
|
const pct = Math.max(0, Math.min(100, data.percent));
|
|
|
|
|
progressbar.setAttribute('aria-valuenow', String(Math.round(pct)));
|
|
|
|
|
fill.style.width = `${pct}%`;
|
|
|
|
|
status.textContent = `Downloading ${pct.toFixed(1)}%`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (data.type === 'done' && data.filePath && data.filename) {
|
|
|
|
|
progressbar.setAttribute('aria-valuenow', '100');
|
|
|
|
|
fill.style.width = '100%';
|
|
|
|
|
status.textContent = `Downloaded: ${data.filename}`;
|
|
|
|
|
status.className = 'status success';
|
|
|
|
|
selectedFilePath = data.filePath;
|
|
|
|
|
const sel = el('video-select');
|
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
opt.value = data.filePath;
|
|
|
|
|
opt.textContent = data.filename;
|
|
|
|
|
opt.selected = true;
|
|
|
|
|
sel.appendChild(opt);
|
|
|
|
|
es.close();
|
|
|
|
|
youtubeStream = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (data.type === 'error') {
|
|
|
|
|
status.textContent = `Error: ${data.message || 'Download failed'}`;
|
|
|
|
|
status.className = 'status error';
|
|
|
|
|
if (progressWrap)
|
|
|
|
|
progressWrap.hidden = true;
|
|
|
|
|
es.close();
|
|
|
|
|
youtubeStream = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
es.onerror = () => {
|
|
|
|
|
if (es.readyState === EventSource.CLOSED)
|
|
|
|
|
return;
|
|
|
|
|
status.textContent = 'Connection lost';
|
2026-05-13 16:39:46 +02:00
|
|
|
status.className = 'status error';
|
2026-05-15 04:10:06 +02:00
|
|
|
es.close();
|
|
|
|
|
youtubeStream = null;
|
|
|
|
|
};
|
2026-05-13 16:39:46 +02:00
|
|
|
});
|
|
|
|
|
// ── New Job form ──────────────────────────────────────
|
|
|
|
|
el('new-job-form').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!selectedFilePath) {
|
2026-05-13 17:09:33 +02:00
|
|
|
if (videoUpload.files?.length) {
|
2026-05-13 16:39:46 +02:00
|
|
|
const formData = new FormData();
|
2026-05-13 17:09:33 +02:00
|
|
|
formData.append('video', videoUpload.files[0]);
|
2026-05-13 16:39:46 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
// 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];
|
|
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
const outputOptions = {
|
|
|
|
|
audio: fd.get('output-audio') === 'on',
|
|
|
|
|
subtitles: fd.get('output-subtitles') === 'on',
|
|
|
|
|
muxed: fd.get('output-muxed') === 'on',
|
2026-05-15 04:10:06 +02:00
|
|
|
muxMode: fd.get('mux-mode') === 'mixed' ? 'mixed' : 'separate',
|
2026-05-13 16:23:43 +02:00
|
|
|
};
|
2026-05-13 16:39:46 +02:00
|
|
|
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'];
|
2026-05-15 04:10:06 +02:00
|
|
|
delete config['mux-mode'];
|
2026-05-13 16:39:46 +02:00
|
|
|
try {
|
|
|
|
|
const data = await apiJson('POST', '/api/jobs', {
|
|
|
|
|
videoPath: selectedFilePath,
|
|
|
|
|
config,
|
|
|
|
|
outputOptions,
|
|
|
|
|
});
|
|
|
|
|
await apiJson('POST', `/api/jobs/${data.job.id}/start`);
|
|
|
|
|
selectedFilePath = null;
|
2026-05-13 17:09:33 +02:00
|
|
|
videoUpload.value = '';
|
|
|
|
|
uploadName.textContent = '';
|
2026-05-13 16:39:46 +02:00
|
|
|
el('new-job-form').reset();
|
2026-05-15 04:10:06 +02:00
|
|
|
activateTab('main-tablist', 'tab-dashboard');
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
alert('Error creating job: ' + err.message);
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Job list & rendering ──────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
async function loadJobs() {
|
2026-05-15 04:10:06 +02:00
|
|
|
const container = el('jobs-list');
|
|
|
|
|
container.setAttribute('aria-busy', 'true');
|
2026-05-13 16:39:46 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
finally {
|
|
|
|
|
container.setAttribute('aria-busy', 'false');
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
|
|
|
|
function renderJobs(jobs) {
|
2026-05-13 16:39:46 +02:00
|
|
|
const container = el('jobs-list');
|
|
|
|
|
if (!jobs.length) {
|
2026-05-15 04:10:06 +02:00
|
|
|
container.innerHTML = '<p class="empty">No jobs yet. Create one from the “New Job” tab.</p>';
|
2026-05-13 16:39:46 +02:00
|
|
|
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') {
|
2026-05-15 04:10:06 +02:00
|
|
|
// Plain <a download> navigations don't send our Authorization header.
|
|
|
|
|
// Pass the token via query string — middleware/auth.ts accepts ?token=.
|
|
|
|
|
const tok = authToken ? `token=${encodeURIComponent(authToken)}` : '';
|
|
|
|
|
const sep = (qs) => qs.includes('?') ? '&' : '?';
|
|
|
|
|
const url = (path) => tok ? `${path}${sep(path)}${tok}` : path;
|
2026-05-13 16:39:46 +02:00
|
|
|
if (j.output_audio)
|
2026-05-15 04:10:06 +02:00
|
|
|
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/audio`)}" download>Audio</a>`);
|
2026-05-13 16:39:46 +02:00
|
|
|
if (j.output_subtitles_srt)
|
2026-05-15 04:10:06 +02:00
|
|
|
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=srt`)}" download>SRT</a>`);
|
2026-05-13 16:39:46 +02:00
|
|
|
if (j.output_subtitles_vtt)
|
2026-05-15 04:10:06 +02:00
|
|
|
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=vtt`)}" download>VTT</a>`);
|
2026-05-13 16:39:46 +02:00
|
|
|
if (j.output_muxed)
|
2026-05-15 04:10:06 +02:00
|
|
|
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/muxed`)}" download>Muxed</a>`);
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
let actions = '';
|
|
|
|
|
if (j.status === 'pending' || j.status === 'queued') {
|
2026-05-15 04:10:06 +02:00
|
|
|
actions += `<button type="button" class="act-start" data-id="${j.id}">Start</button>`;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
if (j.status === 'processing') {
|
2026-05-15 04:10:06 +02:00
|
|
|
actions += `<button type="button" class="act-pause" data-id="${j.id}">Pause</button>`;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
|
2026-05-15 04:10:06 +02:00
|
|
|
actions += `<button type="button" class="act-restart" data-id="${j.id}">Restart</button>`;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
if (j.status !== 'processing') {
|
2026-05-15 04:10:06 +02:00
|
|
|
actions += `<button type="button" class="act-delete danger" data-id="${j.id}">Delete</button>`;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
const pct = Math.round(j.progress);
|
2026-05-13 16:39:46 +02:00
|
|
|
return `
|
2026-05-15 04:10:06 +02:00
|
|
|
<article class="job-card" data-id="${j.id}" aria-labelledby="job-${j.id}-title">
|
2026-05-13 16:23:43 +02:00
|
|
|
<div class="job-card-header">
|
2026-05-15 04:10:06 +02:00
|
|
|
<h3 id="job-${j.id}-title">${escapeHtml(j.video_filename)}</h3>
|
2026-05-13 16:23:43 +02:00
|
|
|
<div class="job-actions">${actions}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="status-badge status-${j.status}">${j.status}</span>
|
2026-05-15 04:10:06 +02:00
|
|
|
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="Job progress" class="progress-bar">
|
|
|
|
|
<div class="progress-fill ${progressClass}" style="width:${pct}%"></div>
|
|
|
|
|
</div>
|
2026-05-13 16:23:43 +02:00
|
|
|
<div class="job-meta">
|
2026-05-15 04:10:06 +02:00
|
|
|
<span>${pct}%</span>
|
2026-05-13 16:39:46 +02:00
|
|
|
<span>Idx: ${j.current_index}/${j.total_units}</span>
|
2026-05-13 16:23:43 +02:00
|
|
|
<span>${new Date(j.created_at).toLocaleString()}</span>
|
|
|
|
|
</div>
|
2026-05-15 04:10:06 +02:00
|
|
|
${j.error ? `<div class="error-msg" role="alert">${escapeHtml(j.error)}</div>` : ''}
|
2026-05-13 16:23:43 +02:00
|
|
|
${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''}
|
2026-05-15 04:10:06 +02:00
|
|
|
<button type="button" class="toggle-detail" data-id="${j.id}" aria-expanded="false" aria-controls="job-${j.id}-detail">${segs.length} segments</button>
|
|
|
|
|
<div class="job-detail" id="job-${j.id}-detail" data-id="${j.id}" hidden>
|
|
|
|
|
<div class="segment-log">${segs.map(s => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('')}</div>
|
2026-05-13 16:23:43 +02:00
|
|
|
</div>
|
2026-05-15 04:10:06 +02:00
|
|
|
</article>`;
|
2026-05-13 16:39:46 +02:00
|
|
|
}).join('');
|
|
|
|
|
container.querySelectorAll('.act-start').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start')));
|
|
|
|
|
container.querySelectorAll('.act-pause').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'pause')));
|
|
|
|
|
container.querySelectorAll('.act-restart').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'restart')));
|
|
|
|
|
container.querySelectorAll('.act-delete').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'delete')));
|
|
|
|
|
container.querySelectorAll('.toggle-detail').forEach(b => {
|
|
|
|
|
b.addEventListener('click', () => {
|
|
|
|
|
const jobId = b.dataset.id || '';
|
|
|
|
|
const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`);
|
|
|
|
|
if (!detail)
|
|
|
|
|
return;
|
2026-05-15 04:10:06 +02:00
|
|
|
const willOpen = detail.hidden;
|
|
|
|
|
detail.hidden = !willOpen;
|
|
|
|
|
b.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
2026-05-13 16:39:46 +02:00
|
|
|
const job = jobs.find(j => j.id === jobId);
|
|
|
|
|
const segs = job ? JSON.parse(job.segments || '[]') : [];
|
2026-05-15 04:10:06 +02:00
|
|
|
b.textContent = willOpen ? 'Hide segments' : `${segs.length} segments`;
|
2026-05-13 16:39:46 +02:00
|
|
|
});
|
|
|
|
|
});
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
|
|
|
|
async function handleJobAction(id, action) {
|
2026-05-13 16:39:46 +02:00
|
|
|
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}`);
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
el('refresh-jobs').addEventListener('click', loadJobs);
|
|
|
|
|
// ── Polling ───────────────────────────────────────────
|
|
|
|
|
function startPolling() {
|
|
|
|
|
if (pollTimer)
|
|
|
|
|
return;
|
|
|
|
|
pollTimer = window.setInterval(loadJobs, 5000);
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── SSE live progress ─────────────────────────────────
|
|
|
|
|
function connectSSE(jobId) {
|
|
|
|
|
if (sseMap.has(jobId))
|
|
|
|
|
return;
|
2026-05-15 04:10:06 +02:00
|
|
|
if (!authToken)
|
|
|
|
|
return;
|
2026-05-13 16:39:46 +02:00
|
|
|
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);
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
const pct = Math.round(data.progress);
|
|
|
|
|
const bar = card.querySelector('[role="progressbar"]');
|
|
|
|
|
if (bar)
|
|
|
|
|
bar.setAttribute('aria-valuenow', String(pct));
|
2026-05-13 16:39:46 +02:00
|
|
|
const fill = card.querySelector('.progress-fill');
|
|
|
|
|
if (fill) {
|
2026-05-15 04:10:06 +02:00
|
|
|
fill.style.width = pct + '%';
|
2026-05-13 16:39:46 +02:00
|
|
|
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])
|
2026-05-15 04:10:06 +02:00
|
|
|
metaSpans[0].textContent = pct + '%';
|
2026-05-13 16:39:46 +02:00
|
|
|
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 => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('');
|
|
|
|
|
}
|
|
|
|
|
const toggleBtn = card.querySelector('.toggle-detail');
|
|
|
|
|
if (toggleBtn && data.segments) {
|
2026-05-15 04:10:06 +02:00
|
|
|
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
|
|
|
|
if (!expanded)
|
|
|
|
|
toggleBtn.textContent = `${data.segments.length} segments`;
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// ── Settings ──────────────────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
async function loadSettings() {
|
2026-05-13 16:39:46 +02:00
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/config');
|
|
|
|
|
const container = el('settings-fields');
|
|
|
|
|
const entries = Object.entries(data.config || {});
|
|
|
|
|
if (!entries.length) {
|
|
|
|
|
container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-15 04:10:06 +02:00
|
|
|
container.innerHTML = entries.map(([key, value]) => {
|
|
|
|
|
const safeKey = escapeHtml(key);
|
|
|
|
|
return `<div class="field"><label for="setting-${safeKey}">${safeKey}</label><input type="text" id="setting-${safeKey}" name="${safeKey}" value="${escapeHtml(String(value))}"></div>`;
|
|
|
|
|
}).join('');
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Files list ────────────────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
let selectedFiles = new Set();
|
|
|
|
|
async function loadFilesList() {
|
2026-05-13 16:39:46 +02:00
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/files');
|
|
|
|
|
const tbody = document.querySelector('#files-table tbody');
|
|
|
|
|
tbody.innerHTML = data.files.map(f => `
|
2026-05-13 16:23:43 +02:00
|
|
|
<tr>
|
2026-05-15 04:10:06 +02:00
|
|
|
<td><input type="checkbox" class="file-checkbox" data-filename="${escapeHtml(f.filename)}" aria-label="Select ${escapeHtml(f.filename)}"></td>
|
2026-05-13 16:23:43 +02:00
|
|
|
<td>${escapeHtml(f.filename)}</td>
|
|
|
|
|
<td>${formatSize(f.size)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`).join('');
|
2026-05-13 16:39:46 +02:00
|
|
|
tbody.querySelectorAll('.file-checkbox').forEach(cb => {
|
|
|
|
|
cb.addEventListener('change', updateFileSelection);
|
|
|
|
|
});
|
2026-05-15 04:10:06 +02:00
|
|
|
el('select-all-files').checked = false;
|
|
|
|
|
selectedFiles.clear();
|
|
|
|
|
updateFileSelection();
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
|
|
|
|
function updateFileSelection() {
|
2026-05-13 16:39:46 +02:00
|
|
|
selectedFiles.clear();
|
|
|
|
|
document.querySelectorAll('.file-checkbox:checked').forEach(cb => {
|
2026-05-15 04:10:06 +02:00
|
|
|
if (cb.dataset.filename)
|
|
|
|
|
selectedFiles.add(cb.dataset.filename);
|
2026-05-13 16:39:46 +02:00
|
|
|
});
|
|
|
|
|
el('delete-selected-files').disabled = selectedFiles.size === 0;
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
el('select-all-files').addEventListener('change', function () {
|
|
|
|
|
document.querySelectorAll('.file-checkbox').forEach(cb => {
|
|
|
|
|
cb.checked = this.checked;
|
|
|
|
|
});
|
|
|
|
|
updateFileSelection();
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-15 04:10:06 +02:00
|
|
|
el('delete-selected-files').addEventListener('click', async () => {
|
|
|
|
|
if (!selectedFiles.size)
|
|
|
|
|
return;
|
2026-05-13 16:39:46 +02:00
|
|
|
if (!confirm(`Delete ${selectedFiles.size} file(s)?`))
|
|
|
|
|
return;
|
2026-05-15 04:10:06 +02:00
|
|
|
const failures = [];
|
|
|
|
|
for (const filename of selectedFiles) {
|
|
|
|
|
try {
|
|
|
|
|
await api('DELETE', `/api/files/${encodeURIComponent(filename)}`);
|
|
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
failures.push(`${filename}: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (failures.length) {
|
|
|
|
|
alert(`Some deletions failed:\n${failures.join('\n')}`);
|
|
|
|
|
}
|
|
|
|
|
await loadFilesList();
|
|
|
|
|
await loadBrowseFiles();
|
2026-05-13 16:23:43 +02:00
|
|
|
});
|
2026-05-13 16:39:46 +02:00
|
|
|
el('refresh-files-list').addEventListener('click', loadFilesList);
|
|
|
|
|
// ── Config defaults for New Job form ─────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
async function loadConfigDefaults() {
|
2026-05-13 16:39:46 +02:00
|
|
|
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 = '<option value="openai">OpenAI</option><option value="gemini">Gemini</option><option value="ollama">Ollama</option><option value="openrouter">OpenRouter</option>';
|
|
|
|
|
sel.value = c.visionProvider;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (c.ttsProvider) {
|
|
|
|
|
const sel = document.querySelector('[name="ttsProvider"]');
|
|
|
|
|
if (sel) {
|
|
|
|
|
sel.innerHTML = '<option value="openai">OpenAI</option><option value="elevenlabs">ElevenLabs</option><option value="google">Google Cloud</option>';
|
|
|
|
|
sel.value = c.ttsProvider;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const fields = [
|
2026-05-15 04:10:06 +02:00
|
|
|
'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions',
|
|
|
|
|
'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize',
|
|
|
|
|
'defaultPrompt', 'changePrompt', 'batchPrompt',
|
2026-05-13 16:39:46 +02:00
|
|
|
];
|
2026-05-15 04:10:06 +02:00
|
|
|
for (const name of fields) {
|
|
|
|
|
const field = document.querySelector(`[name="${name}"]`);
|
|
|
|
|
if (field && c[name] !== undefined)
|
|
|
|
|
field.value = c[name];
|
2026-05-13 16:39:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
console.error(err);
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Init ──────────────────────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
function initApp() {
|
2026-05-15 04:10:06 +02:00
|
|
|
wireTablist('main-tablist');
|
|
|
|
|
wireTablist('source-tablist');
|
2026-05-13 16:39:46 +02:00
|
|
|
loadJobs();
|
|
|
|
|
loadBrowseFiles();
|
|
|
|
|
loadConfigDefaults();
|
2026-05-15 04:10:06 +02:00
|
|
|
loadSettings();
|
2026-05-13 16:39:46 +02:00
|
|
|
startPolling();
|
2026-05-13 16:23:43 +02:00
|
|
|
}
|
2026-05-13 16:39:46 +02:00
|
|
|
// ── Startup ───────────────────────────────────────────
|
2026-05-13 16:23:43 +02:00
|
|
|
(async () => {
|
2026-05-13 16:39:46 +02:00
|
|
|
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();
|
2026-05-13 16:23:43 +02:00
|
|
|
})();
|
2026-05-13 17:24:10 +02:00
|
|
|
//# sourceMappingURL=app.js.map
|