2026-05-13 16:23:43 +02:00
|
|
|
let authToken = sessionStorage.getItem('authToken');
|
|
|
|
|
let selectedFilePath = '';
|
|
|
|
|
let currentConfig = {};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
showLogin();
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Login
|
|
|
|
|
function showLogin() {
|
|
|
|
|
document.getElementById('login-screen').classList.remove('hidden');
|
|
|
|
|
document.getElementById('main-screen').classList.add('hidden');
|
|
|
|
|
document.getElementById('login-error').classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showMain() {
|
|
|
|
|
document.getElementById('login-screen').classList.add('hidden');
|
|
|
|
|
document.getElementById('main-screen').classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const username = document.getElementById('login-username').value;
|
|
|
|
|
const password = document.getElementById('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;
|
|
|
|
|
sessionStorage.setItem('authToken', authToken);
|
|
|
|
|
showMain();
|
|
|
|
|
initApp();
|
|
|
|
|
} else {
|
|
|
|
|
document.getElementById('login-error').textContent = data.error;
|
|
|
|
|
document.getElementById('login-error').classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
document.getElementById('login-error').textContent = 'Connection failed';
|
|
|
|
|
document.getElementById('login-error').classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('logout-btn').addEventListener('click', () => {
|
|
|
|
|
sessionStorage.removeItem('authToken');
|
|
|
|
|
authToken = null;
|
|
|
|
|
showLogin();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Tab navigation
|
|
|
|
|
document.querySelectorAll('button.tab').forEach(btn => {
|
|
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
document.querySelectorAll('button.tab').forEach(b => b.classList.remove('active'));
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
|
document.getElementById(btn.dataset.tab).classList.add('active');
|
|
|
|
|
if (btn.dataset.tab === 'dashboard') loadJobs();
|
|
|
|
|
if (btn.dataset.tab === 'files') loadFilesList();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mini tabs (video source)
|
|
|
|
|
document.querySelectorAll('button.tab-mini').forEach(btn => {
|
|
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
document.querySelectorAll('button.tab-mini').forEach(b => b.classList.remove('active'));
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
document.querySelectorAll('.src-panel').forEach(p => p.classList.remove('active'));
|
|
|
|
|
document.getElementById('src-' + btn.dataset.src).classList.add('active');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// File upload
|
|
|
|
|
document.getElementById('video-upload').addEventListener('change', () => {
|
|
|
|
|
const file = document.getElementById('video-upload').files[0];
|
|
|
|
|
if (file) selectedFilePath = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Refresh browse files
|
|
|
|
|
document.getElementById('refresh-files').addEventListener('click', loadBrowseFiles);
|
|
|
|
|
async function loadBrowseFiles() {
|
|
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/files');
|
|
|
|
|
const sel = document.getElementById('video-select');
|
|
|
|
|
sel.innerHTML = '<option value="">-- Select file --</option>';
|
|
|
|
|
data.files.forEach(f => {
|
|
|
|
|
sel.innerHTML += `<option value="${f.filePath}">${f.filename} (${formatSize(f.size)})</option>`;
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('video-select').addEventListener('change', (e) => {
|
|
|
|
|
if (e.target.value) selectedFilePath = e.target.value;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// YouTube download
|
|
|
|
|
document.getElementById('download-url').addEventListener('click', async () => {
|
|
|
|
|
const url = document.getElementById('youtube-url').value;
|
|
|
|
|
if (!url) return;
|
|
|
|
|
const status = document.getElementById('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;
|
|
|
|
|
document.getElementById('video-select').innerHTML += `<option value="${data.filePath}" selected>${data.filename}</option>`;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
status.textContent = `Error: ${err.message}`;
|
|
|
|
|
status.className = 'status error';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// New job form
|
|
|
|
|
document.getElementById('new-job-form').addEventListener('submit', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!selectedFilePath) {
|
|
|
|
|
const fileEl = document.getElementById('video-upload');
|
|
|
|
|
if (fileEl.files.length > 0) {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('video', fileEl.files[0]);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/files/upload', { method: 'POST', headers: { Authorization: `Basic ${authToken}` }, 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'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Build config with vision/tts providers
|
|
|
|
|
if (config.visionProvider) {
|
|
|
|
|
config.visionProviders = {};
|
|
|
|
|
config.visionProviders[config.visionProvider] = {
|
|
|
|
|
model: config.visionModel || 'gpt-4o',
|
|
|
|
|
maxTokens: config.visionMaxTokens ? parseInt(config.visionMaxTokens) : 300
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (config.ttsProvider) {
|
|
|
|
|
config.ttsProviders = {};
|
|
|
|
|
config.ttsProviders[config.ttsProvider] = {
|
|
|
|
|
model: config.ttsModel || 'tts-1',
|
|
|
|
|
voice: config.ttsVoice || 'alloy'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = '';
|
|
|
|
|
document.getElementById('video-upload').value = '';
|
|
|
|
|
document.getElementById('new-job-form').reset();
|
|
|
|
|
document.querySelector('.tab[data-tab="dashboard"]').click();
|
|
|
|
|
loadJobs();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert('Error creating job: ' + err.message);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Load jobs
|
|
|
|
|
async function loadJobs() {
|
|
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/jobs');
|
|
|
|
|
renderJobs(data.jobs);
|
2026-05-13 16:26:35 +02:00
|
|
|
data.jobs.forEach(j => {
|
|
|
|
|
if (j.status === 'processing' || j.status === 'queued') {
|
|
|
|
|
connectSSE(j.id);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-13 16:23:43 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderJobs(jobs) {
|
|
|
|
|
const container = document.getElementById('jobs-list');
|
|
|
|
|
if (!jobs.length) {
|
|
|
|
|
container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>';
|
|
|
|
|
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(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`);
|
|
|
|
|
if (j.output_subtitles_srt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`);
|
|
|
|
|
if (j.output_subtitles_vtt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`);
|
|
|
|
|
if (j.output_muxed) downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let actions = '';
|
|
|
|
|
if (j.status === 'pending' || j.status === 'queued') {
|
|
|
|
|
actions += `<button class="start-job" data-id="${j.id}">Start</button>`;
|
|
|
|
|
}
|
|
|
|
|
if (j.status === 'processing') {
|
|
|
|
|
actions += `<button class="pause-job" data-id="${j.id}">Pause</button>`;
|
|
|
|
|
}
|
|
|
|
|
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
|
|
|
|
|
actions += `<button class="restart-job" data-id="${j.id}">Restart</button>`;
|
|
|
|
|
}
|
|
|
|
|
if (j.status !== 'processing') {
|
|
|
|
|
actions += `<button class="delete-job danger" data-id="${j.id}">Delete</button>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<div class="job-card" data-id="${j.id}">
|
|
|
|
|
<div class="job-card-header">
|
|
|
|
|
<h3>${escapeHtml(j.video_filename)}</h3>
|
|
|
|
|
<div class="job-actions">${actions}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="status-badge status-${j.status}">${j.status}</span>
|
|
|
|
|
<div class="progress-bar"><div class="progress-fill ${progressClass}" style="width:${j.progress}%"></div></div>
|
|
|
|
|
<div class="job-meta">
|
|
|
|
|
<span>${Math.round(j.progress)}%</span>
|
|
|
|
|
<span>Index: ${j.current_index}/${j.total_units}</span>
|
|
|
|
|
<span>${new Date(j.created_at).toLocaleString()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''}
|
|
|
|
|
${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''}
|
|
|
|
|
<div class="job-detail" data-id="${j.id}">
|
|
|
|
|
<div class="segment-log">${segs.map((s, i) => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('')}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button>
|
|
|
|
|
</div>`;
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
// Wire up buttons
|
|
|
|
|
container.querySelectorAll('.start-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'start')));
|
|
|
|
|
container.querySelectorAll('.pause-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'pause')));
|
|
|
|
|
container.querySelectorAll('.restart-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'restart')));
|
|
|
|
|
container.querySelectorAll('.delete-job').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id, 'delete')));
|
|
|
|
|
container.querySelectorAll('.toggle-detail').forEach(b => b.addEventListener('click', () => {
|
|
|
|
|
const detail = container.querySelector(`.job-detail[data-id="${b.dataset.id}"]`);
|
|
|
|
|
detail.classList.toggle('open');
|
|
|
|
|
b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${JSON.parse((jobs.find(j => j.id === b.dataset.id) || {}).segments || '[]').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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Jobs refresh
|
|
|
|
|
document.getElementById('refresh-jobs').addEventListener('click', loadJobs);
|
|
|
|
|
|
|
|
|
|
// Auto-refresh jobs
|
|
|
|
|
let jobsInterval;
|
|
|
|
|
function startJobsPolling() {
|
|
|
|
|
jobsInterval = setInterval(loadJobs, 5000);
|
|
|
|
|
}
|
|
|
|
|
function stopJobsPolling() {
|
|
|
|
|
clearInterval(jobsInterval);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Settings
|
|
|
|
|
async function loadSettings() {
|
|
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/config');
|
|
|
|
|
const container = document.getElementById('settings-fields');
|
|
|
|
|
const config = data.config || {};
|
|
|
|
|
currentConfig = config;
|
|
|
|
|
let html = '';
|
|
|
|
|
for (const [key, value] of Object.entries(config)) {
|
|
|
|
|
html += `<label>${key} <input type="text" name="${key}" value="${escapeHtml(String(value))}"></label>`;
|
|
|
|
|
}
|
|
|
|
|
if (!Object.keys(config).length) {
|
|
|
|
|
html = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
|
|
|
|
|
}
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('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 => `
|
|
|
|
|
<tr>
|
|
|
|
|
<td><input type="checkbox" class="file-checkbox" data-path="${escapeHtml(f.filePath)}"></td>
|
|
|
|
|
<td>${escapeHtml(f.filename)}</td>
|
|
|
|
|
<td>${formatSize(f.size)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
document.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 => {
|
|
|
|
|
selectedFiles.add(cb.dataset.path);
|
|
|
|
|
});
|
|
|
|
|
document.getElementById('delete-selected-files').disabled = selectedFiles.size === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('select-all-files').addEventListener('change', (e) => {
|
|
|
|
|
document.querySelectorAll('.file-checkbox').forEach(cb => { cb.checked = e.target.checked; });
|
|
|
|
|
updateFileSelection();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('delete-selected-files').addEventListener('click', async () => {
|
|
|
|
|
if (!confirm(`Delete ${selectedFiles.size} file(s)?`)) return;
|
|
|
|
|
for (const path of selectedFiles) {
|
|
|
|
|
// Files are served from uploads dir, delete via fs on server...
|
|
|
|
|
// Not implementing server-side file deletion for now
|
|
|
|
|
}
|
|
|
|
|
alert('File deletion not yet implemented');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('refresh-files-list').addEventListener('click', loadFilesList);
|
|
|
|
|
|
|
|
|
|
// Pre-fill new job form with config defaults
|
|
|
|
|
async function loadConfigDefaults() {
|
|
|
|
|
try {
|
|
|
|
|
const data = await apiJson('GET', '/api/config');
|
|
|
|
|
const config = data.config || {};
|
|
|
|
|
|
|
|
|
|
if (config.visionProvider) {
|
|
|
|
|
const sel = document.querySelector('[name="visionProvider"]');
|
|
|
|
|
sel.innerHTML = '<option value="openai">OpenAI</option><option value="gemini">Gemini</option><option value="ollama">Ollama</option><option value="openrouter">OpenRouter</option>';
|
|
|
|
|
sel.value = config.visionProvider;
|
|
|
|
|
}
|
|
|
|
|
if (config.visionModel) document.querySelector('[name="visionModel"]').value = config.visionModel;
|
|
|
|
|
if (config.ttsProvider) {
|
|
|
|
|
const sel = document.querySelector('[name="ttsProvider"]');
|
|
|
|
|
sel.innerHTML = '<option value="openai">OpenAI</option><option value="elevenlabs">ElevenLabs</option><option value="google">Google Cloud</option>';
|
|
|
|
|
sel.value = config.ttsProvider;
|
|
|
|
|
}
|
|
|
|
|
if (config.ttsModel) document.querySelector('[name="ttsModel"]').value = config.ttsModel;
|
|
|
|
|
if (config.ttsVoice) document.querySelector('[name="ttsVoice"]').value = config.ttsVoice;
|
|
|
|
|
if (config.ttsSpeedFactor) document.querySelector('[name="ttsSpeedFactor"]').value = config.ttsSpeedFactor;
|
|
|
|
|
if (config.ttsInstructions) document.querySelector('[name="ttsInstructions"]').value = config.ttsInstructions;
|
|
|
|
|
if (config.batchWindowDuration) document.querySelector('[name="batchWindowDuration"]').value = config.batchWindowDuration;
|
|
|
|
|
if (config.framesInBatch) document.querySelector('[name="framesInBatch"]').value = config.framesInBatch;
|
|
|
|
|
if (config.captureIntervalSeconds) document.querySelector('[name="captureIntervalSeconds"]').value = config.captureIntervalSeconds;
|
|
|
|
|
if (config.contextWindowSize) document.querySelector('[name="contextWindowSize"]').value = config.contextWindowSize;
|
|
|
|
|
if (config.defaultPrompt) document.querySelector('[name="defaultPrompt"]').value = config.defaultPrompt;
|
|
|
|
|
if (config.changePrompt) document.querySelector('[name="changePrompt"]').value = config.changePrompt;
|
|
|
|
|
if (config.batchPrompt) document.querySelector('[name="batchPrompt"]').value = config.batchPrompt;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Setup SSE for live progress
|
|
|
|
|
const sseConnections = {};
|
|
|
|
|
function connectSSE(jobId) {
|
|
|
|
|
if (sseConnections[jobId]) return;
|
2026-05-13 16:26:35 +02:00
|
|
|
const source = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`);
|
2026-05-13 16:23:43 +02:00
|
|
|
source.onmessage = (event) => {
|
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
|
updateJobCard(jobId, data);
|
|
|
|
|
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
|
|
|
|
|
source.close();
|
|
|
|
|
delete sseConnections[jobId];
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
source.close();
|
|
|
|
|
delete sseConnections[jobId];
|
|
|
|
|
};
|
|
|
|
|
sseConnections[jobId] = source;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateJobCard(jobId, data) {
|
|
|
|
|
const card = document.querySelector(`.job-card[data-id="${jobId}"]`);
|
|
|
|
|
if (!card) return;
|
|
|
|
|
|
|
|
|
|
const badge = card.querySelector('.status-badge');
|
|
|
|
|
badge.className = `status-badge status-${data.status}`;
|
|
|
|
|
badge.textContent = data.status;
|
|
|
|
|
|
|
|
|
|
const fill = card.querySelector('.progress-fill');
|
|
|
|
|
fill.style.width = data.progress + '%';
|
|
|
|
|
fill.className = 'progress-fill';
|
|
|
|
|
if (data.status === 'completed') fill.classList.add('completed');
|
|
|
|
|
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 = `Index: ${data.currentIndex}/${data.totalUnits}`;
|
|
|
|
|
|
|
|
|
|
// Update segments
|
|
|
|
|
const log = card.querySelector('.segment-log');
|
|
|
|
|
if (data.segments && log) {
|
|
|
|
|
log.innerHTML = data.segments.map((s, i) => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update segment count button
|
|
|
|
|
const toggleBtn = card.querySelector('.toggle-detail');
|
|
|
|
|
if (toggleBtn && data.segments) {
|
|
|
|
|
toggleBtn.textContent = `${data.segments.length} segments`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize
|
|
|
|
|
function initApp() {
|
|
|
|
|
loadJobs();
|
|
|
|
|
loadBrowseFiles();
|
|
|
|
|
loadConfigDefaults();
|
|
|
|
|
startJobsPolling();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Escape HTML for safe rendering
|
|
|
|
|
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]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if already authenticated
|
|
|
|
|
(async () => {
|
|
|
|
|
if (authToken) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/auth/check', { headers: { Authorization: `Basic ${authToken}` } });
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (data.authenticated) {
|
|
|
|
|
showMain();
|
|
|
|
|
initApp();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
showLogin();
|
|
|
|
|
})();
|