Compare commits

...

2 Commits

Author SHA1 Message Date
6deb883472 Fix muxing 2026-05-15 04:10:06 +02:00
05faf1ce3b Fix the frontend 2026-05-15 03:53:43 +02:00
33 changed files with 1408 additions and 1035 deletions

View File

@@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(dir D:\\\\code\\\\aidio-description *)",
"Bash(cd /d \"D:\\\\code\\\\aidio-description\")",
"Bash(Get-ChildItem -Filter \"package.json\" -Recurse)",
"Bash(Select-Object -ExpandProperty FullName)",
"Bash(Get-ChildItem -Path \"D:\\\\code\\\\aidio-description\" -Recurse -Include \".env*\")",
"Bash(Select-Object -First 20)",
"Bash(xargs grep -l \"tempDir\\\\|tempDir\\\\|frame_\\\\|batch_audio\")",
"Bash(npm run *)",
"Bash(npx tsc *)",
"Bash(curl -s -H \"Authorization: Basic YWRtaW46YWlkaW8yMDI0\" http://localhost:3000/api/config)",
"Bash(node -e \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf-8'\\)\\); const c=d.config||{}; console.log\\('keys:', Object.keys\\(c\\).sort\\(\\).join\\(', '\\)\\); console.log\\(\\); console.log\\('defaultPrompt \\(first 80 chars\\):'\\); console.log\\(' ' + \\(c.defaultPrompt||''\\).slice\\(0,80\\)\\); console.log\\(\\); console.log\\('visionProvider:', c.visionProvider\\); console.log\\('ttsProvider:', c.ttsProvider\\); console.log\\('tempDir:', c.tempDir\\); console.log\\('nested leaked?', 'visionProviders' in c, 'ttsProviders' in c\\);\")",
"Bash(powershell *)",
"Bash(AIDIO_DEFAULT_PROMPT=\"CUSTOM ENV OVERRIDE FOR TEST\" npm run *)",
"Bash(node -e \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf-8'\\)\\); console.log\\('defaultPrompt:', d.config.defaultPrompt\\);\")"
]
}
}

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@ node_modules
desc/ desc/
data/ data/
uploads/ uploads/
# Compiled frontend (built from src/server/public/app.ts)
src/server/public/app.js

View File

@@ -2,6 +2,7 @@ export interface OutputOptions {
audio: boolean; audio: boolean;
subtitles: boolean; subtitles: boolean;
muxed: boolean; muxed: boolean;
muxMode: 'separate' | 'mixed';
} }
export interface Job { export interface Job {
id: string; id: string;

View File

@@ -1 +1 @@
{"version":3,"file":"jobStore.js","sourceRoot":"","sources":["../../../src/server/db/jobStore.ts"],"names":[],"mappings":"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"} {"version":3,"file":"jobStore.js","sourceRoot":"","sources":["../../../src/server/db/jobStore.ts"],"names":[],"mappings":"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"}

View File

@@ -48,7 +48,6 @@ declare let authToken: string | null;
declare let selectedFilePath: string | null; declare let selectedFilePath: string | null;
declare const sseMap: Map<string, EventSource>; declare const sseMap: Map<string, EventSource>;
declare let pollTimer: number | null; declare let pollTimer: number | null;
declare const $: (sel: string) => HTMLElement;
declare const $$: (sel: string) => NodeListOf<HTMLElement>; declare const $$: (sel: string) => NodeListOf<HTMLElement>;
declare const el: (id: string) => HTMLElement; declare const el: (id: string) => HTMLElement;
declare function apiHeaders(): Record<string, string>; declare function apiHeaders(): Record<string, string>;
@@ -56,12 +55,15 @@ declare function api(method: string, url: string, body?: unknown): Promise<Respo
declare function apiJson<T>(method: string, url: string, body?: unknown): Promise<T>; declare function apiJson<T>(method: string, url: string, body?: unknown): Promise<T>;
declare function showLoginScreen(): void; declare function showLoginScreen(): void;
declare function showMainScreen(): void; declare function showMainScreen(): void;
declare function switchTab(name: string): void; declare function activateTab(tablistId: string, tabId: string): void;
declare function onTabActivated(tablistId: string, panelId: string): void;
declare function wireTablist(tablistId: string): void;
declare function escapeHtml(str: string | null | undefined): string; declare function escapeHtml(str: string | null | undefined): string;
declare function formatSize(bytes: number): string; declare function formatSize(bytes: number): string;
declare function loadBrowseFiles(): Promise<void>; declare function loadBrowseFiles(): Promise<void>;
declare const videoUpload: HTMLInputElement; declare const videoUpload: HTMLInputElement;
declare const uploadName: HTMLElement; declare const uploadName: HTMLElement;
declare let youtubeStream: EventSource | null;
declare function loadJobs(): Promise<void>; declare function loadJobs(): Promise<void>;
declare function renderJobs(jobs: Job[]): void; declare function renderJobs(jobs: Job[]): void;
declare function handleJobAction(id: string, action: string): Promise<void>; declare function handleJobAction(id: string, action: string): Promise<void>;

View File

@@ -6,9 +6,13 @@ let selectedFilePath = null;
const sseMap = new Map(); const sseMap = new Map();
let pollTimer = null; let pollTimer = null;
// ── DOM helpers ─────────────────────────────────────── // ── DOM helpers ───────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel); const $$ = (sel) => document.querySelectorAll(sel);
const el = (id) => document.getElementById(id); const el = (id) => {
const e = document.getElementById(id);
if (!e)
throw new Error(`Missing element #${id}`);
return e;
};
// ── API ─────────────────────────────────────────────── // ── API ───────────────────────────────────────────────
function apiHeaders() { function apiHeaders() {
const h = { 'Content-Type': 'application/json' }; const h = { 'Content-Type': 'application/json' };
@@ -39,45 +43,75 @@ async function apiJson(method, url, body) {
} }
// ── Screen switching ────────────────────────────────── // ── Screen switching ──────────────────────────────────
function showLoginScreen() { function showLoginScreen() {
el('login-screen').classList.remove('hidden'); el('login-screen').hidden = false;
el('main-screen').classList.add('hidden'); el('main-screen').hidden = true;
} }
function showMainScreen() { function showMainScreen() {
el('login-screen').classList.add('hidden'); el('login-screen').hidden = true;
el('main-screen').classList.remove('hidden'); el('main-screen').hidden = false;
} }
// ── Tab navigation ──────────────────────────────────── // ── Tablist (WAI-ARIA) ────────────────────────────────
function switchTab(name) { function activateTab(tablistId, tabId) {
$$('button.tab').forEach(b => b.classList.remove('active')); const tablist = el(tablistId);
document.querySelector(`button.tab[data-tab="${name}"]`)?.classList.add('active'); const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
$$('.tab-content').forEach(c => c.classList.remove('active')); tabs.forEach(t => {
const pane = document.getElementById(name); const selected = t.id === tabId;
if (pane) t.setAttribute('aria-selected', selected ? 'true' : 'false');
pane.classList.add('active'); t.setAttribute('tabindex', selected ? '0' : '-1');
if (name === 'dashboard') 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')
loadJobs(); loadJobs();
if (name === 'files') if (panelId === 'files')
loadFilesList(); loadFilesList();
} }
$$('button.tab').forEach(btn => { function wireTablist(tablistId) {
btn.addEventListener('click', () => switchTab(btn.dataset.tab || '')); const tablist = el(tablistId);
}); const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
// ── Mini tabs (video source) ────────────────────────── tabs.forEach(tab => {
$$('button.tab-mini').forEach(btn => { tab.addEventListener('click', () => activateTab(tablistId, tab.id));
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');
}); });
}); 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();
}
});
}
// ── Login ───────────────────────────────────────────── // ── Login ─────────────────────────────────────────────
el('login-form').addEventListener('submit', async (e) => { el('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const username = el('login-username').value; const username = el('login-username').value;
const password = el('login-password').value; const password = el('login-password').value;
const errorEl = el('login-error');
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
@@ -89,17 +123,18 @@ el('login-form').addEventListener('submit', async (e) => {
authToken = data.token; authToken = data.token;
if (authToken) if (authToken)
sessionStorage.setItem('authToken', authToken); sessionStorage.setItem('authToken', authToken);
errorEl.hidden = true;
showMainScreen(); showMainScreen();
initApp(); initApp();
} }
else { else {
el('login-error').textContent = data.error; errorEl.textContent = data.error || 'Login failed';
el('login-error').classList.remove('hidden'); errorEl.hidden = false;
} }
} }
catch { catch {
el('login-error').textContent = 'Connection failed'; errorEl.textContent = 'Connection failed';
el('login-error').classList.remove('hidden'); errorEl.hidden = false;
} }
}); });
el('logout-btn').addEventListener('click', () => { el('logout-btn').addEventListener('click', () => {
@@ -160,37 +195,84 @@ const videoUpload = el('video-upload');
const uploadName = el('upload-name'); const uploadName = el('upload-name');
videoUpload.addEventListener('change', function () { videoUpload.addEventListener('change', function () {
if (this.files?.length) { if (this.files?.length) {
selectedFilePath = null; // will upload on submit selectedFilePath = null;
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`; uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
} }
else { else {
uploadName.textContent = ''; uploadName.textContent = '';
} }
}); });
// ── YouTube download ────────────────────────────────── // ── YouTube download (SSE) ────────────────────────────
el('download-url').addEventListener('click', async () => { let youtubeStream = null;
const url = el('youtube-url').value; el('download-url').addEventListener('click', () => {
const url = el('youtube-url').value.trim();
if (!url) if (!url)
return; return;
if (!authToken)
return;
const status = el('download-status'); 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'; status.className = 'status';
try { if (progressWrap)
const data = await apiJson('POST', '/api/files/youtube', { url }); progressWrap.hidden = false;
status.textContent = `Downloaded: ${data.filename}`; progressbar.setAttribute('aria-valuenow', '0');
status.className = 'status success'; fill.style.width = '0%';
selectedFilePath = data.filePath; if (youtubeStream)
const sel = el('video-select'); youtubeStream.close();
const opt = document.createElement('option'); const streamUrl = `/api/files/youtube/stream?url=${encodeURIComponent(url)}&token=${encodeURIComponent(authToken)}`;
opt.value = data.filePath; const es = new EventSource(streamUrl);
opt.textContent = data.filename; youtubeStream = es;
opt.selected = true; es.onmessage = (event) => {
sel.appendChild(opt); let data;
} try {
catch (err) { data = JSON.parse(event.data);
status.textContent = `Error: ${err.message}`; }
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';
status.className = 'status error'; status.className = 'status error';
} es.close();
youtubeStream = null;
};
}); });
// ── New Job form ────────────────────────────────────── // ── New Job form ──────────────────────────────────────
el('new-job-form').addEventListener('submit', async (e) => { el('new-job-form').addEventListener('submit', async (e) => {
@@ -233,10 +315,18 @@ el('new-job-form').addEventListener('submit', async (e) => {
else else
config[key] = val; 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 = { const outputOptions = {
audio: fd.get('output-audio') === 'on', audio: fd.get('output-audio') === 'on',
subtitles: fd.get('output-subtitles') === 'on', subtitles: fd.get('output-subtitles') === 'on',
muxed: fd.get('output-muxed') === 'on', muxed: fd.get('output-muxed') === 'on',
muxMode: fd.get('mux-mode') === 'mixed' ? 'mixed' : 'separate',
}; };
if (config.visionProvider) { if (config.visionProvider) {
const vp = {}; const vp = {};
@@ -260,6 +350,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
delete config['output-audio']; delete config['output-audio'];
delete config['output-subtitles']; delete config['output-subtitles'];
delete config['output-muxed']; delete config['output-muxed'];
delete config['mux-mode'];
try { try {
const data = await apiJson('POST', '/api/jobs', { const data = await apiJson('POST', '/api/jobs', {
videoPath: selectedFilePath, videoPath: selectedFilePath,
@@ -271,7 +362,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
videoUpload.value = ''; videoUpload.value = '';
uploadName.textContent = ''; uploadName.textContent = '';
el('new-job-form').reset(); el('new-job-form').reset();
switchTab('dashboard'); activateTab('main-tablist', 'tab-dashboard');
} }
catch (err) { catch (err) {
alert('Error creating job: ' + err.message); alert('Error creating job: ' + err.message);
@@ -279,6 +370,8 @@ el('new-job-form').addEventListener('submit', async (e) => {
}); });
// ── Job list & rendering ────────────────────────────── // ── Job list & rendering ──────────────────────────────
async function loadJobs() { async function loadJobs() {
const container = el('jobs-list');
container.setAttribute('aria-busy', 'true');
try { try {
const data = await apiJson('GET', '/api/jobs'); const data = await apiJson('GET', '/api/jobs');
renderJobs(data.jobs); renderJobs(data.jobs);
@@ -291,11 +384,14 @@ async function loadJobs() {
catch (err) { catch (err) {
console.error(err); console.error(err);
} }
finally {
container.setAttribute('aria-busy', 'false');
}
} }
function renderJobs(jobs) { function renderJobs(jobs) {
const container = el('jobs-list'); const container = el('jobs-list');
if (!jobs.length) { if (!jobs.length) {
container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>'; container.innerHTML = '<p class="empty">No jobs yet. Create one from the &ldquo;New Job&rdquo; tab.</p>';
return; return;
} }
container.innerHTML = jobs.map(j => { container.innerHTML = jobs.map(j => {
@@ -303,50 +399,57 @@ function renderJobs(jobs) {
const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : ''; const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : '';
const downloads = []; const downloads = [];
if (j.status === 'completed') { if (j.status === 'completed') {
// 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;
if (j.output_audio) if (j.output_audio)
downloads.push(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`); downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/audio`)}" download>Audio</a>`);
if (j.output_subtitles_srt) if (j.output_subtitles_srt)
downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`); downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=srt`)}" download>SRT</a>`);
if (j.output_subtitles_vtt) if (j.output_subtitles_vtt)
downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`); downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=vtt`)}" download>VTT</a>`);
if (j.output_muxed) if (j.output_muxed)
downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`); downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/muxed`)}" download>Muxed</a>`);
} }
let actions = ''; let actions = '';
if (j.status === 'pending' || j.status === 'queued') { if (j.status === 'pending' || j.status === 'queued') {
actions += `<button class="act-start" data-id="${j.id}">Start</button>`; actions += `<button type="button" class="act-start" data-id="${j.id}">Start</button>`;
} }
if (j.status === 'processing') { if (j.status === 'processing') {
actions += `<button class="act-pause" data-id="${j.id}">Pause</button>`; actions += `<button type="button" class="act-pause" data-id="${j.id}">Pause</button>`;
} }
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
actions += `<button class="act-restart" data-id="${j.id}">Restart</button>`; actions += `<button type="button" class="act-restart" data-id="${j.id}">Restart</button>`;
} }
if (j.status !== 'processing') { if (j.status !== 'processing') {
actions += `<button class="act-delete danger" data-id="${j.id}">Delete</button>`; actions += `<button type="button" class="act-delete danger" data-id="${j.id}">Delete</button>`;
} }
const pct = Math.round(j.progress);
return ` return `
<div class="job-card" data-id="${j.id}"> <article class="job-card" data-id="${j.id}" aria-labelledby="job-${j.id}-title">
<div class="job-card-header"> <div class="job-card-header">
<h3>${escapeHtml(j.video_filename)}</h3> <h3 id="job-${j.id}-title">${escapeHtml(j.video_filename)}</h3>
<div class="job-actions">${actions}</div> <div class="job-actions">${actions}</div>
</div> </div>
<span class="status-badge status-${j.status}">${j.status}</span> <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 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>
<div class="job-meta"> <div class="job-meta">
<span>${Math.round(j.progress)}%</span> <span>${pct}%</span>
<span>Idx: ${j.current_index}/${j.total_units}</span> <span>Idx: ${j.current_index}/${j.total_units}</span>
<span>${new Date(j.created_at).toLocaleString()}</span> <span>${new Date(j.created_at).toLocaleString()}</span>
</div> </div>
${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''} ${j.error ? `<div class="error-msg" role="alert">${escapeHtml(j.error)}</div>` : ''}
${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''} ${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''}
<div class="job-detail" data-id="${j.id}"> <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="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 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>
</div> </div>
<button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button> </article>`;
</div>`;
}).join(''); }).join('');
// Wire up action buttons
container.querySelectorAll('.act-start').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start'))); 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-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-restart').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'restart')));
@@ -357,10 +460,12 @@ function renderJobs(jobs) {
const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`); const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`);
if (!detail) if (!detail)
return; 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 job = jobs.find(j => j.id === jobId);
const segs = job ? JSON.parse(job.segments || '[]') : []; const segs = job ? JSON.parse(job.segments || '[]') : [];
b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${segs.length} segments`; b.textContent = willOpen ? 'Hide segments' : `${segs.length} segments`;
}); });
}); });
} }
@@ -386,6 +491,8 @@ function startPolling() {
function connectSSE(jobId) { function connectSSE(jobId) {
if (sseMap.has(jobId)) if (sseMap.has(jobId))
return; return;
if (!authToken)
return;
const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`); const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`);
es.onmessage = (event) => { es.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -410,9 +517,13 @@ function updateJobCard(jobId, data) {
badge.className = `status-badge status-${data.status}`; badge.className = `status-badge status-${data.status}`;
badge.textContent = data.status; 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'); const fill = card.querySelector('.progress-fill');
if (fill) { if (fill) {
fill.style.width = data.progress + '%'; fill.style.width = pct + '%';
fill.className = 'progress-fill'; fill.className = 'progress-fill';
if (data.status === 'completed') if (data.status === 'completed')
fill.classList.add('completed'); fill.classList.add('completed');
@@ -421,7 +532,7 @@ function updateJobCard(jobId, data) {
} }
const metaSpans = card.querySelectorAll('.job-meta span'); const metaSpans = card.querySelectorAll('.job-meta span');
if (metaSpans[0]) if (metaSpans[0])
metaSpans[0].textContent = Math.round(data.progress) + '%'; metaSpans[0].textContent = pct + '%';
if (metaSpans[1]) if (metaSpans[1])
metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`; metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`;
const log = card.querySelector('.segment-log'); const log = card.querySelector('.segment-log');
@@ -430,7 +541,9 @@ function updateJobCard(jobId, data) {
} }
const toggleBtn = card.querySelector('.toggle-detail'); const toggleBtn = card.querySelector('.toggle-detail');
if (toggleBtn && data.segments) { 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`;
} }
} }
// ── Settings ────────────────────────────────────────── // ── Settings ──────────────────────────────────────────
@@ -443,7 +556,10 @@ async function loadSettings() {
container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>'; container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
return; return;
} }
container.innerHTML = entries.map(([key, value]) => `<label>${escapeHtml(key)} <input type="text" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}"></label>`).join(''); 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('');
} }
catch (err) { catch (err) {
console.error(err); console.error(err);
@@ -472,7 +588,7 @@ async function loadFilesList() {
const tbody = document.querySelector('#files-table tbody'); const tbody = document.querySelector('#files-table tbody');
tbody.innerHTML = data.files.map(f => ` tbody.innerHTML = data.files.map(f => `
<tr> <tr>
<td><input type="checkbox" class="file-checkbox" data-path="${escapeHtml(f.filePath)}"></td> <td><input type="checkbox" class="file-checkbox" data-filename="${escapeHtml(f.filename)}" aria-label="Select ${escapeHtml(f.filename)}"></td>
<td>${escapeHtml(f.filename)}</td> <td>${escapeHtml(f.filename)}</td>
<td>${formatSize(f.size)}</td> <td>${formatSize(f.size)}</td>
</tr> </tr>
@@ -480,6 +596,9 @@ async function loadFilesList() {
tbody.querySelectorAll('.file-checkbox').forEach(cb => { tbody.querySelectorAll('.file-checkbox').forEach(cb => {
cb.addEventListener('change', updateFileSelection); cb.addEventListener('change', updateFileSelection);
}); });
el('select-all-files').checked = false;
selectedFiles.clear();
updateFileSelection();
} }
catch (err) { catch (err) {
console.error(err); console.error(err);
@@ -488,8 +607,8 @@ async function loadFilesList() {
function updateFileSelection() { function updateFileSelection() {
selectedFiles.clear(); selectedFiles.clear();
document.querySelectorAll('.file-checkbox:checked').forEach(cb => { document.querySelectorAll('.file-checkbox:checked').forEach(cb => {
if (cb.dataset.path) if (cb.dataset.filename)
selectedFiles.add(cb.dataset.path); selectedFiles.add(cb.dataset.filename);
}); });
el('delete-selected-files').disabled = selectedFiles.size === 0; el('delete-selected-files').disabled = selectedFiles.size === 0;
} }
@@ -499,10 +618,25 @@ el('select-all-files').addEventListener('change', function () {
}); });
updateFileSelection(); 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)?`)) if (!confirm(`Delete ${selectedFiles.size} file(s)?`))
return; return;
alert('File deletion not yet implemented'); 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();
}); });
el('refresh-files-list').addEventListener('click', loadFilesList); el('refresh-files-list').addEventListener('click', loadFilesList);
// ── Config defaults for New Job form ───────────────── // ── Config defaults for New Job form ─────────────────
@@ -525,15 +659,14 @@ async function loadConfigDefaults() {
} }
} }
const fields = [ const fields = [
['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'], 'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions',
['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'], 'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize',
['captureIntervalSeconds'], ['contextWindowSize'], 'defaultPrompt', 'changePrompt', 'batchPrompt',
['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'],
]; ];
for (const [name, tag] of fields) { for (const name of fields) {
const el = document.querySelector(`[name="${name}"]`); const field = document.querySelector(`[name="${name}"]`);
if (el && c[name] !== undefined) if (field && c[name] !== undefined)
el.value = c[name]; field.value = c[name];
} }
} }
catch (err) { catch (err) {
@@ -542,9 +675,12 @@ async function loadConfigDefaults() {
} }
// ── Init ────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────
function initApp() { function initApp() {
wireTablist('main-tablist');
wireTablist('source-tablist');
loadJobs(); loadJobs();
loadBrowseFiles(); loadBrowseFiles();
loadConfigDefaults(); loadConfigDefaults();
loadSettings();
startPolling(); startPolling();
} }
// ── Startup ─────────────────────────────────────────── // ── Startup ───────────────────────────────────────────

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,43 @@
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express"); const express_1 = require("express");
const jobStore_1 = require("../db/jobStore"); const jobStore_1 = require("../db/jobStore");
const config_1 = require("../../config/config");
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
// Optional .env overrides for the (long) prompt strings — keep getDefaultConfig()'s
// hardcoded prompts as the final fallback. Users who want to tweak prompts without
// editing source can set these in .env, or set them per-job in the Settings UI.
const ENV_OVERRIDES = {
defaultPrompt: process.env.AIDIO_DEFAULT_PROMPT,
changePrompt: process.env.AIDIO_CHANGE_PROMPT,
batchPrompt: process.env.AIDIO_BATCH_PROMPT,
};
// Fields in Config that are nested objects (provider configs, etc.) and shouldn't
// be flattened into the form-facing config map. API keys live inside these — keep
// them off the wire.
const NESTED_FIELDS = new Set(['visionProviders', 'ttsProviders']);
function buildLayeredConfig() {
const defaults = (0, config_1.getDefaultConfig)();
const db = (0, jobStore_1.getAllConfig)();
const merged = {};
for (const [key, value] of Object.entries(defaults)) {
if (NESTED_FIELDS.has(key))
continue;
if (value === undefined || value === null)
continue;
merged[key] = String(value);
}
for (const [key, value] of Object.entries(ENV_OVERRIDES)) {
if (value !== undefined && value !== '')
merged[key] = value;
}
for (const [key, value] of Object.entries(db)) {
if (value !== undefined && value !== '')
merged[key] = value;
}
return merged;
}
router.get('/', (_req, res) => { router.get('/', (_req, res) => {
const config = (0, jobStore_1.getAllConfig)(); res.json({ config: buildLayeredConfig() });
res.json({ config });
}); });
router.put('/', (req, res) => { router.put('/', (req, res) => {
const updates = req.body; const updates = req.body;
@@ -16,8 +49,7 @@ router.put('/', (req, res) => {
for (const [key, value] of Object.entries(updates)) { for (const [key, value] of Object.entries(updates)) {
(0, jobStore_1.setConfigValue)(key, String(value)); (0, jobStore_1.setConfigValue)(key, String(value));
} }
const config = (0, jobStore_1.getAllConfig)(); res.json({ config: buildLayeredConfig() });
res.json({ config });
}); });
exports.default = router; exports.default = router;
//# sourceMappingURL=config.js.map //# sourceMappingURL=config.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/server/routes/config.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,6CAA8D;AAE9D,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAa,EAAE,GAAa,EAAE,EAAE;IAC/C,MAAM,MAAM,GAAG,IAAA,uBAAY,GAAE,CAAC;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;IACzB,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACpD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,+CAA+C,EAAE,CAAC,CAAC;QACjF,OAAO;IACT,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAA,yBAAc,EAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACrC,CAAC;IACD,MAAM,MAAM,GAAG,IAAA,uBAAY,GAAE,CAAC;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"} {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/server/routes/config.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,6CAA8D;AAC9D,gDAAuD;AAEvD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,oFAAoF;AACpF,mFAAmF;AACnF,gFAAgF;AAChF,MAAM,aAAa,GAAuC;IACxD,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,oBAAoB;IAC/C,YAAY,EAAE,OAAO,CAAC,GAAG,CAAC,mBAAmB;IAC7C,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,kBAAkB;CAC5C,CAAC;AAEF,kFAAkF;AAClF,kFAAkF;AAClF,qBAAqB;AACrB,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,CAAC,iBAAiB,EAAE,cAAc,CAAC,CAAC,CAAC;AAEnE,SAAS,kBAAkB;IACzB,MAAM,QAAQ,GAAG,IAAA,yBAAgB,GAAwC,CAAC;IAC1E,MAAM,EAAE,GAAG,IAAA,uBAAY,GAAE,CAAC;IAE1B,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QACpD,IAAI,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QACrC,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QACpD,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,EAAE,CAAC;QACzD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC/D,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;QAC9C,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC/D,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAa,EAAE,GAAa,EAAE,EAAE;IAC/C,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;IACzB,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACpD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,+CAA+C,EAAE,CAAC,CAAC;QACjF,OAAO;IACT,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAA,yBAAc,EAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACrC,CAAC;IACD,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}

View File

@@ -65,23 +65,69 @@ router.get('/', (_req, res) => {
.sort((a, b) => b.filePath.localeCompare(a.filePath)); .sort((a, b) => b.filePath.localeCompare(a.filePath));
res.json({ files }); res.json({ files });
}); });
router.post('/youtube', (req, res) => { router.delete('/:filename', (req, res) => {
const raw = req.params.filename;
const requested = Array.isArray(raw) ? raw[0] : raw;
if (!requested) {
res.status(400).json({ error: 'filename is required' });
return;
}
const resolved = path_1.default.resolve(UPLOADS_DIR, requested);
const uploadsWithSep = UPLOADS_DIR.endsWith(path_1.default.sep) ? UPLOADS_DIR : UPLOADS_DIR + path_1.default.sep;
if (!resolved.startsWith(uploadsWithSep)) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
if (!fs_1.default.existsSync(resolved)) {
res.status(404).json({ error: 'File not found' });
return;
}
try {
fs_1.default.unlinkSync(resolved);
res.json({ ok: true });
}
catch (err) {
res.status(500).json({ error: `Failed to delete: ${err.message}` });
}
});
// Stream yt-dlp download progress over SSE.
// Returns events: {type:'progress', percent} ... {type:'done', filePath, filename, title}
// or {type:'error', message}
router.get('/youtube/stream', (req, res) => {
const url = req.query.url || '';
if (!url) {
res.status(400).json({ error: 'url query param is required' });
return;
}
if (!(0, ytDlp_1.isYtDlpAvailable)()) { if (!(0, ytDlp_1.isYtDlpAvailable)()) {
res.status(400).json({ error: 'yt-dlp is not installed or not in PATH' }); res.status(400).json({ error: 'yt-dlp is not installed or not in PATH' });
return; return;
} }
const { url } = req.body; res.setHeader('Content-Type', 'text/event-stream');
if (!url) { res.setHeader('Cache-Control', 'no-cache');
res.status(400).json({ error: 'URL is required' }); res.setHeader('Connection', 'keep-alive');
return; res.setHeader('X-Accel-Buffering', 'no');
} res.flushHeaders?.();
try { const send = (data) => {
const result = (0, ytDlp_1.downloadVideo)(url, UPLOADS_DIR); res.write(`data: ${JSON.stringify(data)}\n\n`);
res.json(result); };
} let clientGone = false;
catch (err) { req.on('close', () => { clientGone = true; });
res.status(500).json({ error: `Failed to download: ${err.message}` }); (0, ytDlp_1.downloadVideo)(url, UPLOADS_DIR, (percent) => {
} if (clientGone)
return;
send({ type: 'progress', percent });
}).then((result) => {
if (clientGone)
return;
send({ type: 'done', ...result });
res.end();
}).catch((err) => {
if (clientGone)
return;
send({ type: 'error', message: err.message });
res.end();
});
}); });
exports.default = router; exports.default = router;
//# sourceMappingURL=files.js.map //# sourceMappingURL=files.js.map

File diff suppressed because one or more lines are too long

View File

@@ -145,10 +145,13 @@ function createJobsRouter(jobManager) {
filePath = format === 'vtt' ? job.output_subtitles_vtt : job.output_subtitles_srt; filePath = format === 'vtt' ? job.output_subtitles_vtt : job.output_subtitles_srt;
filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}_description.${format}`; filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}_description.${format}`;
break; break;
case 'muxed': case 'muxed': {
const opts = JSON.parse(job.output_options || '{}');
const suffix = opts.muxMode === 'mixed' ? '_described_mixed' : '_described';
filePath = job.output_muxed; filePath = job.output_muxed;
filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}_described.mkv`; filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}${suffix}.mkv`;
break; break;
}
default: default:
res.status(400).json({ error: 'Invalid download type' }); res.status(400).json({ error: 'Invalid download type' });
return; return;

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,20 @@ const muxer_1 = require("./muxer");
const config_1 = require("../../config/config"); const config_1 = require("../../config/config");
const mediaUtils_1 = require("../../utils/mediaUtils"); const mediaUtils_1 = require("../../utils/mediaUtils");
const events_1 = require("events"); const events_1 = require("events");
function jobTempDir(baseTempDir, jobId) {
return path_1.default.join(baseTempDir, jobId);
}
function safeCleanupJobTmp(dir) {
try {
if (!fs_1.default.existsSync(dir))
return;
(0, mediaUtils_1.cleanupTempFiles)(dir);
fs_1.default.rmSync(dir, { recursive: true, force: true });
}
catch (err) {
console.warn(`Failed to clean up tmp dir ${dir}:`, err.message);
}
}
class JobManager { class JobManager {
constructor() { constructor() {
this.queue = []; this.queue = [];
@@ -33,12 +47,22 @@ class JobManager {
} }
createJob(videoPath, configOverride = {}, outputOptions = {}) { createJob(videoPath, configOverride = {}, outputOptions = {}) {
const baseConfig = (0, config_1.getDefaultConfig)(); const baseConfig = (0, config_1.getDefaultConfig)();
const mergedConfig = { ...baseConfig, ...configOverride }; // Drop empty/undefined/null values so blank form fields don't clobber the
// baked-in defaults (a blank prompt textarea must NOT overwrite the real
// prompt with "").
const cleanedOverride = {};
for (const [k, v] of Object.entries(configOverride)) {
if (v === '' || v === null || v === undefined)
continue;
cleanedOverride[k] = v;
}
const mergedConfig = { ...baseConfig, ...cleanedOverride };
const filename = path_1.default.basename(videoPath); const filename = path_1.default.basename(videoPath);
const opts = { const opts = {
audio: outputOptions.audio !== false, audio: outputOptions.audio !== false,
subtitles: outputOptions.subtitles !== false, subtitles: outputOptions.subtitles !== false,
muxed: outputOptions.muxed || false muxed: outputOptions.muxed || false,
muxMode: outputOptions.muxMode === 'mixed' ? 'mixed' : 'separate'
}; };
return (0, jobStore_1.createJob)(videoPath, filename, mergedConfig, opts); return (0, jobStore_1.createJob)(videoPath, filename, mergedConfig, opts);
} }
@@ -92,6 +116,18 @@ class JobManager {
throw new Error('Job not found'); throw new Error('Job not found');
if (job.status === 'processing') if (job.status === 'processing')
throw new Error('Cannot delete a running job'); throw new Error('Cannot delete a running job');
try {
const config = JSON.parse(job.config);
// job.config may contain either the base tempDir (older jobs) or the
// per-job tempDir (newer jobs). Trim a trailing job-id segment if present;
// otherwise compute the per-job dir from the stored base.
const stored = config.tempDir || './desc/tmp/';
const candidate = path_1.default.basename(stored) === jobId ? stored : jobTempDir(stored, jobId);
safeCleanupJobTmp(candidate);
}
catch {
// ignore: cleanup is best-effort and must not block deletion
}
(0, jobStore_1.deleteJob)(jobId); (0, jobStore_1.deleteJob)(jobId);
} }
listJobs() { listJobs() {
@@ -156,6 +192,14 @@ class JobManager {
this.emitProgress(job.id); this.emitProgress(job.id);
const config = JSON.parse(job.config); const config = JSON.parse(job.config);
const outputOptions = JSON.parse(job.output_options); const outputOptions = JSON.parse(job.output_options);
// Isolate this job's intermediates so concurrent jobs (or future resumes)
// don't collide on filenames like frame_00001.jpg / segment_3_std.wav.
// The pipeline already reads config.tempDir, so just override it here.
const baseTempDir = config.tempDir || './desc/tmp/';
if (path_1.default.basename(baseTempDir) !== job.id) {
config.tempDir = jobTempDir(baseTempDir, job.id);
}
fs_1.default.mkdirSync(config.tempDir, { recursive: true });
const existingSegments = JSON.parse(job.segments || '[]'); const existingSegments = JSON.parse(job.segments || '[]');
const lastContext = JSON.parse(job.last_context || '{}'); const lastContext = JSON.parse(job.last_context || '{}');
const startIndex = existingSegments.length > 0 ? job.current_index : 0; const startIndex = existingSegments.length > 0 ? job.current_index : 0;
@@ -209,8 +253,14 @@ class JobManager {
outputSubtitlesVtt = vttPath; outputSubtitlesVtt = vttPath;
} }
if (outputOptions.muxed && fs_1.default.existsSync(outputAudio)) { if (outputOptions.muxed && fs_1.default.existsSync(outputAudio)) {
const muxedPath = path_1.default.join(outputDir, `${baseName}_described.mkv`); const isMixed = outputOptions.muxMode === 'mixed';
(0, muxer_1.muxAudioDescription)(job.video_path, outputAudio, muxedPath); const muxedPath = path_1.default.join(outputDir, `${baseName}${isMixed ? '_described_mixed' : '_described'}.mkv`);
if (isMixed) {
(0, muxer_1.muxMixedAudioDescription)(job.video_path, outputAudio, muxedPath);
}
else {
(0, muxer_1.muxAudioDescription)(job.video_path, outputAudio, muxedPath);
}
outputMuxed = muxedPath; outputMuxed = muxedPath;
} }
(0, jobStore_1.saveJobOutputs)(job.id, { (0, jobStore_1.saveJobOutputs)(job.id, {
@@ -222,9 +272,11 @@ class JobManager {
(0, jobStore_1.saveCheckpoint)(job.id, JSON.stringify(segments), totalUnits, totalUnits, 0, '{}', 100); (0, jobStore_1.saveCheckpoint)(job.id, JSON.stringify(segments), totalUnits, totalUnits, 0, '{}', 100);
(0, jobStore_1.updateJobStatus)(job.id, 'completed'); (0, jobStore_1.updateJobStatus)(job.id, 'completed');
this.emitProgress(job.id); this.emitProgress(job.id);
safeCleanupJobTmp(config.tempDir);
} }
catch (err) { catch (err) {
if (err.message === 'JOB_PAUSED') { if (err.message === 'JOB_PAUSED') {
// Keep config.tempDir intact — restart will resume into the same dir.
(0, jobStore_1.updateJobStatus)(job.id, 'paused'); (0, jobStore_1.updateJobStatus)(job.id, 'paused');
this.emitProgress(job.id); this.emitProgress(job.id);
return; return;
@@ -232,6 +284,7 @@ class JobManager {
const errorMsg = err.message || 'Unknown error'; const errorMsg = err.message || 'Unknown error';
(0, jobStore_1.updateJobStatus)(job.id, 'failed', errorMsg); (0, jobStore_1.updateJobStatus)(job.id, 'failed', errorMsg);
this.emitProgress(job.id); this.emitProgress(job.id);
safeCleanupJobTmp(config.tempDir);
} }
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,2 @@
export declare function muxAudioDescription(videoPath: string, audioPath: string, outputPath: string): void; export declare function muxAudioDescription(videoPath: string, audioPath: string, outputPath: string): void;
export declare function muxMixedAudioDescription(videoPath: string, audioPath: string, outputPath: string): void;

View File

@@ -4,26 +4,80 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.muxAudioDescription = muxAudioDescription; exports.muxAudioDescription = muxAudioDescription;
exports.muxMixedAudioDescription = muxMixedAudioDescription;
const child_process_1 = require("child_process"); const child_process_1 = require("child_process");
const path_1 = __importDefault(require("path")); const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
function muxAudioDescription(videoPath, audioPath, outputPath) { function muxAudioDescription(videoPath, audioPath, outputPath) {
const ext = path_1.default.extname(outputPath).toLowerCase(); if (!fs_1.default.existsSync(videoPath)) {
const isMkv = ext === '.mkv'; throw new Error(`mux: video not found: ${videoPath}`);
const cmd = [ }
'ffmpeg -v error', if (!fs_1.default.existsSync(audioPath)) {
`-i "${videoPath}"`, throw new Error(`mux: audio not found: ${audioPath}`);
`-i "${audioPath}"`, }
'-map 0:v', fs_1.default.mkdirSync(path_1.default.dirname(outputPath), { recursive: true });
'-map 0:a?', // Argv form — no shell, no quoting issues, and -y is a global option (placed
'-map 1:a', // up front, not after the output). Stderr is captured so failures aren't
'-c:v copy', // silent.
'-c:a copy', const args = [
isMkv '-y',
? '-metadata:s:a:1 title="Audio Description"' '-v', 'error',
: '-metadata:s:a:1 title="Audio Description"', '-i', videoPath,
`"${outputPath}"`, '-i', audioPath,
'-y' '-map', '0:v',
].join(' '); '-map', '0:a?',
(0, child_process_1.execSync)(cmd); '-map', '1:a',
'-c:v', 'copy',
'-c:a', 'copy',
'-metadata:s:a:1', 'title=Audio Description',
'-disposition:a:1', 'visual_impaired',
outputPath,
];
const result = (0, child_process_1.spawnSync)('ffmpeg', args, { shell: false, encoding: 'utf-8' });
if (result.error) {
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
}
if (result.status !== 0) {
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
}
}
function muxMixedAudioDescription(videoPath, audioPath, outputPath) {
if (!fs_1.default.existsSync(videoPath)) {
throw new Error(`mux: video not found: ${videoPath}`);
}
if (!fs_1.default.existsSync(audioPath)) {
throw new Error(`mux: audio not found: ${audioPath}`);
}
fs_1.default.mkdirSync(path_1.default.dirname(outputPath), { recursive: true });
// Sidechain-ducked mix: original audio dips when the AD track is speaking,
// then both are summed into a single output audio stream. The AD track is
// already a full-length file that is silent between description segments
// (built by combineAudioSegments), so asplit gives us one copy to drive the
// sidechain detector and another to mix in on top.
const filterGraph = '[1:a]asplit=2[ad_mix][ad_sc];' +
'[0:a][ad_sc]sidechaincompress=threshold=0.03:ratio=20:attack=5:release=300:level_sc=2[ducked];' +
'[ducked][ad_mix]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]';
const args = [
'-y',
'-v', 'error',
'-i', videoPath,
'-i', audioPath,
'-filter_complex', filterGraph,
'-map', '0:v',
'-map', '[aout]',
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
outputPath,
];
const result = (0, child_process_1.spawnSync)('ffmpeg', args, { shell: false, encoding: 'utf-8' });
if (result.error) {
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
}
if (result.status !== 0) {
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
}
} }
//# sourceMappingURL=muxer.js.map //# sourceMappingURL=muxer.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"muxer.js","sourceRoot":"","sources":["../../../src/server/services/muxer.ts"],"names":[],"mappings":";;;;;AAGA,kDAyBC;AA5BD,iDAAyC;AACzC,gDAAwB;AAExB,SAAgB,mBAAmB,CACjC,SAAiB,EACjB,SAAiB,EACjB,UAAkB;IAElB,MAAM,GAAG,GAAG,cAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAC;IACnD,MAAM,KAAK,GAAG,GAAG,KAAK,MAAM,CAAC;IAE7B,MAAM,GAAG,GAAG;QACV,iBAAiB;QACjB,OAAO,SAAS,GAAG;QACnB,OAAO,SAAS,GAAG;QACnB,UAAU;QACV,WAAW;QACX,UAAU;QACV,WAAW;QACX,WAAW;QACX,KAAK;YACH,CAAC,CAAC,2CAA2C;YAC7C,CAAC,CAAC,2CAA2C;QAC/C,IAAI,UAAU,GAAG;QACjB,IAAI;KACL,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAEZ,IAAA,wBAAQ,EAAC,GAAG,CAAC,CAAC;AAChB,CAAC"} {"version":3,"file":"muxer.js","sourceRoot":"","sources":["../../../src/server/services/muxer.ts"],"names":[],"mappings":"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"}

View File

@@ -3,5 +3,6 @@ export interface YtDlpResult {
filename: string; filename: string;
title: string; title: string;
} }
export type YtDlpProgress = (percent: number) => void;
export declare function isYtDlpAvailable(): boolean; export declare function isYtDlpAvailable(): boolean;
export declare function downloadVideo(url: string, outputDir: string): YtDlpResult; export declare function downloadVideo(url: string, outputDir: string, onProgress?: YtDlpProgress): Promise<YtDlpResult>;

View File

@@ -17,22 +17,98 @@ function isYtDlpAvailable() {
return false; return false;
} }
} }
function downloadVideo(url, outputDir) { const PROGRESS_PREFIX = 'PROG ';
if (!fs_1.default.existsSync(outputDir)) { function downloadVideo(url, outputDir, onProgress) {
fs_1.default.mkdirSync(outputDir, { recursive: true }); return new Promise((resolve, reject) => {
} if (!fs_1.default.existsSync(outputDir)) {
const outputTemplate = path_1.default.join(outputDir, '%(title)s.%(ext)s'); fs_1.default.mkdirSync(outputDir, { recursive: true });
const result = (0, child_process_1.execSync)(`yt-dlp -f "best[ext=mp4]/best" -o "${outputTemplate}" --print filename --print title "${url}"`, { encoding: 'utf-8', timeout: 600000 }); }
const lines = result.trim().split('\n'); const outputTemplate = path_1.default.join(outputDir, '%(title)s.%(ext)s');
const filename = lines[0]?.trim(); // Pass arguments as an array — no shell, no quoting issues, no truncation
const title = lines[1]?.trim() || filename; // on URLs containing & | % ^ etc. (the original execSync bug on Windows).
if (!filename) { const args = [
throw new Error('yt-dlp: Failed to parse downloaded filename'); '-f', 'best[ext=mp4]/best',
} '-o', outputTemplate,
const filePath = path_1.default.resolve(outputDir, filename); '--newline',
if (!fs_1.default.existsSync(filePath)) { '--progress-template', `${PROGRESS_PREFIX}%(progress._percent_str)s`,
throw new Error(`yt-dlp: Downloaded file not found at ${filePath}`); '--print', 'after_move:filepath',
} '--print', 'title',
return { filePath, filename, title }; '--no-simulate',
url,
];
const child = (0, child_process_1.spawn)('yt-dlp', args, { shell: false });
const stderrLines = [];
const outputLines = [];
let stdoutBuf = '';
let stderrBuf = '';
const handleStdoutLine = (line) => {
if (!line)
return;
if (line.startsWith(PROGRESS_PREFIX)) {
const m = line.slice(PROGRESS_PREFIX.length).match(/([\d.]+)\s*%/);
if (m && onProgress) {
const pct = parseFloat(m[1]);
if (!isNaN(pct))
onProgress(pct);
}
return;
}
outputLines.push(line);
};
child.stdout.on('data', (chunk) => {
stdoutBuf += chunk.toString('utf-8');
let nl;
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '');
stdoutBuf = stdoutBuf.slice(nl + 1);
handleStdoutLine(line.trim());
}
});
child.stderr.on('data', (chunk) => {
stderrBuf += chunk.toString('utf-8');
let nl;
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '').trim();
stderrBuf = stderrBuf.slice(nl + 1);
if (line)
stderrLines.push(line);
}
});
child.on('error', (err) => {
reject(new Error(`yt-dlp failed to start: ${err.message}`));
});
const timeoutMs = 600000;
const timer = setTimeout(() => {
child.kill();
reject(new Error(`yt-dlp timed out after ${timeoutMs / 1000}s`));
}, timeoutMs);
child.on('close', (code) => {
clearTimeout(timer);
if (stdoutBuf.trim())
handleStdoutLine(stdoutBuf.trim());
if (stderrBuf.trim())
stderrLines.push(stderrBuf.trim());
if (code !== 0) {
const tail = stderrLines.slice(-3).join(' | ') || `exit code ${code}`;
reject(new Error(tail));
return;
}
const filePath = outputLines[0];
const title = outputLines[1] || (filePath ? path_1.default.basename(filePath) : '');
if (!filePath) {
reject(new Error('yt-dlp completed but did not report a filename'));
return;
}
if (!fs_1.default.existsSync(filePath)) {
reject(new Error(`yt-dlp reported success but file not found: ${filePath}`));
return;
}
resolve({
filePath: path_1.default.resolve(filePath),
filename: path_1.default.basename(filePath),
title,
});
});
});
} }
//# sourceMappingURL=ytDlp.js.map //# sourceMappingURL=ytDlp.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"ytDlp.js","sourceRoot":"","sources":["../../../src/server/services/ytDlp.ts"],"names":[],"mappings":";;;;;AAUA,4CAOC;AAED,sCA2BC;AA9CD,iDAAyC;AACzC,gDAAwB;AACxB,4CAAoB;AAQpB,SAAgB,gBAAgB;IAC9B,IAAI,CAAC;QACH,IAAA,wBAAQ,EAAC,kBAAkB,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAgB,aAAa,CAAC,GAAW,EAAE,SAAiB;IAC1D,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9B,YAAE,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED,MAAM,cAAc,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,mBAAmB,CAAC,CAAC;IAEjE,MAAM,MAAM,GAAG,IAAA,wBAAQ,EACrB,sCAAsC,cAAc,qCAAqC,GAAG,GAAG,EAC/F,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CACvC,CAAC;IAEF,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,QAAQ,CAAC;IAE3C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,QAAQ,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAEnD,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,wCAAwC,QAAQ,EAAE,CAAC,CAAC;IACtE,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;AACvC,CAAC"} {"version":3,"file":"ytDlp.js","sourceRoot":"","sources":["../../../src/server/services/ytDlp.ts"],"names":[],"mappings":"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"}

View File

@@ -15,6 +15,8 @@
"build:frontend": "tsc -p src/server/public/tsconfig.json", "build:frontend": "tsc -p src/server/public/tsconfig.json",
"start": "node dist/cli/index.js", "start": "node dist/cli/index.js",
"dev": "ts-node src/cli/index.ts", "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": "ts-node src/server/index.ts",
"server:build": "node dist/server/index.js", "server:build": "node dist/server/index.js",
"test": "jest", "test": "jest",

View File

@@ -5,6 +5,7 @@ export interface OutputOptions {
audio: boolean; audio: boolean;
subtitles: boolean; subtitles: boolean;
muxed: boolean; muxed: boolean;
muxMode: 'separate' | 'mixed';
} }
export interface Job { export interface Job {

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = '<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);
}
}
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 = '<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="act-start" data-id="${j.id}">Start</button>`;
}
if (j.status === 'processing') {
actions += `<button class="act-pause" data-id="${j.id}">Pause</button>`;
}
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
actions += `<button class="act-restart" data-id="${j.id}">Restart</button>`;
}
if (j.status !== 'processing') {
actions += `<button class="act-delete 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>Idx: ${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 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 => `<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) {
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 = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
return;
}
container.innerHTML = entries.map(([key, value]) => `<label>${escapeHtml(key)} <input type="text" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}"></label>`).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 => `
<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('');
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 = '<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 = [
['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();
})();

View File

@@ -59,9 +59,12 @@ let pollTimer: number | null = null;
// ── DOM helpers ─────────────────────────────────────── // ── DOM helpers ───────────────────────────────────────
const $ = (sel: string): HTMLElement => document.querySelector(sel) as HTMLElement;
const $$ = (sel: string): NodeListOf<HTMLElement> => document.querySelectorAll(sel); const $$ = (sel: string): NodeListOf<HTMLElement> => 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 ─────────────────────────────────────────────── // ── API ───────────────────────────────────────────────
@@ -96,51 +99,76 @@ async function apiJson<T>(method: string, url: string, body?: unknown): Promise<
// ── Screen switching ────────────────────────────────── // ── Screen switching ──────────────────────────────────
function showLoginScreen(): void { function showLoginScreen(): void {
el('login-screen').classList.remove('hidden'); el('login-screen').hidden = false;
el('main-screen').classList.add('hidden'); el('main-screen').hidden = true;
} }
function showMainScreen(): void { function showMainScreen(): void {
el('login-screen').classList.add('hidden'); el('login-screen').hidden = true;
el('main-screen').classList.remove('hidden'); el('main-screen').hidden = false;
} }
// ── Tab navigation ──────────────────────────────────── // ── Tablist (WAI-ARIA) ────────────────────────────────
function switchTab(name: string): void { function activateTab(tablistId: string, tabId: string): void {
$$('button.tab').forEach(b => b.classList.remove('active')); const tablist = el(tablistId);
document.querySelector<HTMLElement>(`button.tab[data-tab="${name}"]`)?.classList.add('active'); const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[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 panelId = t.getAttribute('aria-controls');
const pane = document.getElementById(name); if (!panelId) return;
if (pane) pane.classList.add('active'); const panel = document.getElementById(panelId);
if (panel) panel.hidden = !selected;
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 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<HTMLElement>('[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 ───────────────────────────────────────────── // ── Login ─────────────────────────────────────────────
el('login-form').addEventListener('submit', async (e) => { (el('login-form') as HTMLFormElement).addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const username = (el('login-username') as HTMLInputElement).value; const username = (el('login-username') as HTMLInputElement).value;
const password = (el('login-password') as HTMLInputElement).value; const password = (el('login-password') as HTMLInputElement).value;
const errorEl = el('login-error');
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
@@ -151,15 +179,16 @@ el('login-form').addEventListener('submit', async (e) => {
if (data.authenticated) { if (data.authenticated) {
authToken = data.token; authToken = data.token;
if (authToken) sessionStorage.setItem('authToken', authToken); if (authToken) sessionStorage.setItem('authToken', authToken);
errorEl.hidden = true;
showMainScreen(); showMainScreen();
initApp(); initApp();
} else { } else {
el('login-error').textContent = data.error; errorEl.textContent = data.error || 'Login failed';
el('login-error').classList.remove('hidden'); errorEl.hidden = false;
} }
} catch { } catch {
el('login-error').textContent = 'Connection failed'; errorEl.textContent = 'Connection failed';
el('login-error').classList.remove('hidden'); errorEl.hidden = false;
} }
}); });
@@ -223,41 +252,91 @@ const uploadName = el('upload-name');
videoUpload.addEventListener('change', function () { videoUpload.addEventListener('change', function () {
if (this.files?.length) { if (this.files?.length) {
selectedFilePath = null; // will upload on submit selectedFilePath = null;
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`; uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
} else { } else {
uploadName.textContent = ''; uploadName.textContent = '';
} }
}); });
// ── YouTube download ────────────────────────────────── // ── YouTube download (SSE) ────────────────────────────
el('download-url').addEventListener('click', async () => { let youtubeStream: EventSource | null = null;
const url = (el('youtube-url') as HTMLInputElement).value;
el('download-url').addEventListener('click', () => {
const url = (el('youtube-url') as HTMLInputElement).value.trim();
if (!url) return; if (!url) return;
if (!authToken) return;
const status = el('download-status'); const status = el('download-status');
status.textContent = 'Downloading...'; const progressWrap = document.querySelector<HTMLElement>('.download-progress');
const progressbar = el('download-progressbar');
const fill = el('download-fill');
status.textContent = 'Starting download...';
status.className = 'status'; status.className = 'status';
try { if (progressWrap) progressWrap.hidden = false;
const data = await apiJson<{ filePath: string; filename: string }>('POST', '/api/files/youtube', { url }); progressbar.setAttribute('aria-valuenow', '0');
status.textContent = `Downloaded: ${data.filename}`; fill.style.width = '0%';
status.className = 'status success';
selectedFilePath = data.filePath; if (youtubeStream) youtubeStream.close();
const sel = el('video-select') as HTMLSelectElement;
const opt = document.createElement('option'); const streamUrl = `/api/files/youtube/stream?url=${encodeURIComponent(url)}&token=${encodeURIComponent(authToken)}`;
opt.value = data.filePath; const es = new EventSource(streamUrl);
opt.textContent = data.filename; youtubeStream = es;
opt.selected = true;
sel.appendChild(opt); es.onmessage = (event) => {
} catch (err: any) { let data: { type: string; percent?: number; filePath?: string; filename?: string; title?: string; message?: string };
status.textContent = `Error: ${err.message}`; 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'; status.className = 'status error';
} es.close();
youtubeStream = null;
};
}); });
// ── New Job form ────────────────────────────────────── // ── New Job form ──────────────────────────────────────
el('new-job-form').addEventListener('submit', async (e) => { (el('new-job-form') as HTMLFormElement).addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (!selectedFilePath) { if (!selectedFilePath) {
if (videoUpload.files?.length) { if (videoUpload.files?.length) {
@@ -289,11 +368,18 @@ el('new-job-form').addEventListener('submit', async (e) => {
else if (!isNaN(val as any) && val !== '') config[key] = parseFloat(val as string); else if (!isNaN(val as any) && val !== '') config[key] = parseFloat(val as string);
else config[key] = val; 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 = { const outputOptions = {
audio: fd.get('output-audio') === 'on', audio: fd.get('output-audio') === 'on',
subtitles: fd.get('output-subtitles') === 'on', subtitles: fd.get('output-subtitles') === 'on',
muxed: fd.get('output-muxed') === 'on', muxed: fd.get('output-muxed') === 'on',
muxMode: (fd.get('mux-mode') as string) === 'mixed' ? 'mixed' : 'separate',
}; };
if (config.visionProvider) { if (config.visionProvider) {
@@ -319,6 +405,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
delete config['output-audio']; delete config['output-audio'];
delete config['output-subtitles']; delete config['output-subtitles'];
delete config['output-muxed']; delete config['output-muxed'];
delete config['mux-mode'];
try { try {
const data = await apiJson<{ job: Job }>('POST', '/api/jobs', { const data = await apiJson<{ job: Job }>('POST', '/api/jobs', {
@@ -331,7 +418,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
videoUpload.value = ''; videoUpload.value = '';
uploadName.textContent = ''; uploadName.textContent = '';
(el('new-job-form') as HTMLFormElement).reset(); (el('new-job-form') as HTMLFormElement).reset();
switchTab('dashboard'); activateTab('main-tablist', 'tab-dashboard');
} catch (err: any) { } catch (err: any) {
alert('Error creating job: ' + err.message); alert('Error creating job: ' + err.message);
} }
@@ -340,6 +427,8 @@ el('new-job-form').addEventListener('submit', async (e) => {
// ── Job list & rendering ────────────────────────────── // ── Job list & rendering ──────────────────────────────
async function loadJobs(): Promise<void> { async function loadJobs(): Promise<void> {
const container = el('jobs-list');
container.setAttribute('aria-busy', 'true');
try { try {
const data = await apiJson<{ jobs: Job[] }>('GET', '/api/jobs'); const data = await apiJson<{ jobs: Job[] }>('GET', '/api/jobs');
renderJobs(data.jobs); renderJobs(data.jobs);
@@ -350,13 +439,15 @@ async function loadJobs(): Promise<void> {
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally {
container.setAttribute('aria-busy', 'false');
} }
} }
function renderJobs(jobs: Job[]): void { function renderJobs(jobs: Job[]): void {
const container = el('jobs-list'); const container = el('jobs-list');
if (!jobs.length) { if (!jobs.length) {
container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>'; container.innerHTML = '<p class="empty">No jobs yet. Create one from the &ldquo;New Job&rdquo; tab.</p>';
return; return;
} }
@@ -366,49 +457,56 @@ function renderJobs(jobs: Job[]): void {
const downloads: string[] = []; const downloads: string[] = [];
if (j.status === 'completed') { if (j.status === 'completed') {
if (j.output_audio) downloads.push(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`); // Plain <a download> navigations don't send our Authorization header.
if (j.output_subtitles_srt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`); // Pass the token via query string — middleware/auth.ts accepts ?token=.
if (j.output_subtitles_vtt) downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`); const tok = authToken ? `token=${encodeURIComponent(authToken)}` : '';
if (j.output_muxed) downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`); const sep = (qs: string) => qs.includes('?') ? '&' : '?';
const url = (path: string) => tok ? `${path}${sep(path)}${tok}` : path;
if (j.output_audio) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/audio`)}" download>Audio</a>`);
if (j.output_subtitles_srt) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=srt`)}" download>SRT</a>`);
if (j.output_subtitles_vtt) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=vtt`)}" download>VTT</a>`);
if (j.output_muxed) downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/muxed`)}" download>Muxed</a>`);
} }
let actions = ''; let actions = '';
if (j.status === 'pending' || j.status === 'queued') { if (j.status === 'pending' || j.status === 'queued') {
actions += `<button class="act-start" data-id="${j.id}">Start</button>`; actions += `<button type="button" class="act-start" data-id="${j.id}">Start</button>`;
} }
if (j.status === 'processing') { if (j.status === 'processing') {
actions += `<button class="act-pause" data-id="${j.id}">Pause</button>`; actions += `<button type="button" class="act-pause" data-id="${j.id}">Pause</button>`;
} }
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') { if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
actions += `<button class="act-restart" data-id="${j.id}">Restart</button>`; actions += `<button type="button" class="act-restart" data-id="${j.id}">Restart</button>`;
} }
if (j.status !== 'processing') { if (j.status !== 'processing') {
actions += `<button class="act-delete danger" data-id="${j.id}">Delete</button>`; actions += `<button type="button" class="act-delete danger" data-id="${j.id}">Delete</button>`;
} }
const pct = Math.round(j.progress);
return ` return `
<div class="job-card" data-id="${j.id}"> <article class="job-card" data-id="${j.id}" aria-labelledby="job-${j.id}-title">
<div class="job-card-header"> <div class="job-card-header">
<h3>${escapeHtml(j.video_filename)}</h3> <h3 id="job-${j.id}-title">${escapeHtml(j.video_filename)}</h3>
<div class="job-actions">${actions}</div> <div class="job-actions">${actions}</div>
</div> </div>
<span class="status-badge status-${j.status}">${j.status}</span> <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 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>
<div class="job-meta"> <div class="job-meta">
<span>${Math.round(j.progress)}%</span> <span>${pct}%</span>
<span>Idx: ${j.current_index}/${j.total_units}</span> <span>Idx: ${j.current_index}/${j.total_units}</span>
<span>${new Date(j.created_at).toLocaleString()}</span> <span>${new Date(j.created_at).toLocaleString()}</span>
</div> </div>
${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''} ${j.error ? `<div class="error-msg" role="alert">${escapeHtml(j.error)}</div>` : ''}
${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''} ${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''}
<div class="job-detail" data-id="${j.id}"> <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="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 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>
</div> </div>
<button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button> </article>`;
</div>`;
}).join(''); }).join('');
// Wire up action buttons
container.querySelectorAll<HTMLElement>('.act-start').forEach(b => container.querySelectorAll<HTMLElement>('.act-start').forEach(b =>
b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start'))); b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start')));
container.querySelectorAll<HTMLElement>('.act-pause').forEach(b => container.querySelectorAll<HTMLElement>('.act-pause').forEach(b =>
@@ -422,10 +520,12 @@ function renderJobs(jobs: Job[]): void {
const jobId = b.dataset.id || ''; const jobId = b.dataset.id || '';
const detail = container.querySelector<HTMLElement>(`.job-detail[data-id="${jobId}"]`); const detail = container.querySelector<HTMLElement>(`.job-detail[data-id="${jobId}"]`);
if (!detail) return; 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 job = jobs.find(j => j.id === jobId);
const segs: AudioSegment[] = job ? JSON.parse(job.segments || '[]') : []; 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 +554,8 @@ function startPolling(): void {
function connectSSE(jobId: string): void { function connectSSE(jobId: string): void {
if (sseMap.has(jobId)) return; 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) => { es.onmessage = (event: MessageEvent) => {
const data: ProgressData = JSON.parse(event.data); const data: ProgressData = JSON.parse(event.data);
updateJobCard(jobId, data); updateJobCard(jobId, data);
@@ -480,16 +581,20 @@ function updateJobCard(jobId: string, data: ProgressData): void {
badge.textContent = data.status; badge.textContent = data.status;
} }
const pct = Math.round(data.progress);
const bar = card.querySelector<HTMLElement>('[role="progressbar"]');
if (bar) bar.setAttribute('aria-valuenow', String(pct));
const fill = card.querySelector<HTMLElement>('.progress-fill'); const fill = card.querySelector<HTMLElement>('.progress-fill');
if (fill) { if (fill) {
fill.style.width = data.progress + '%'; fill.style.width = pct + '%';
fill.className = 'progress-fill'; fill.className = 'progress-fill';
if (data.status === 'completed') fill.classList.add('completed'); if (data.status === 'completed') fill.classList.add('completed');
else if (data.status === 'failed') fill.classList.add('failed'); else if (data.status === 'failed') fill.classList.add('failed');
} }
const metaSpans = card.querySelectorAll<HTMLElement>('.job-meta span'); const metaSpans = card.querySelectorAll<HTMLElement>('.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}`; if (metaSpans[1]) metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`;
const log = card.querySelector<HTMLElement>('.segment-log'); const log = card.querySelector<HTMLElement>('.segment-log');
@@ -501,7 +606,8 @@ function updateJobCard(jobId: string, data: ProgressData): void {
const toggleBtn = card.querySelector<HTMLElement>('.toggle-detail'); const toggleBtn = card.querySelector<HTMLElement>('.toggle-detail');
if (toggleBtn && data.segments) { 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 +622,16 @@ async function loadSettings(): Promise<void> {
container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>'; container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
return; return;
} }
container.innerHTML = entries.map(([key, value]) => container.innerHTML = entries.map(([key, value]) => {
`<label>${escapeHtml(key)} <input type="text" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}"></label>` const safeKey = escapeHtml(key);
).join(''); return `<div class="field"><label for="setting-${safeKey}">${safeKey}</label><input type="text" id="setting-${safeKey}" name="${safeKey}" value="${escapeHtml(String(value))}"></div>`;
}).join('');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
el('settings-form').addEventListener('submit', async (e) => { (el('settings-form') as HTMLFormElement).addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement); const fd = new FormData(e.target as HTMLFormElement);
const config: Record<string, string> = {}; const config: Record<string, string> = {};
@@ -549,7 +656,7 @@ async function loadFilesList(): Promise<void> {
const tbody = document.querySelector('#files-table tbody')!; const tbody = document.querySelector('#files-table tbody')!;
tbody.innerHTML = data.files.map(f => ` tbody.innerHTML = data.files.map(f => `
<tr> <tr>
<td><input type="checkbox" class="file-checkbox" data-path="${escapeHtml(f.filePath)}"></td> <td><input type="checkbox" class="file-checkbox" data-filename="${escapeHtml(f.filename)}" aria-label="Select ${escapeHtml(f.filename)}"></td>
<td>${escapeHtml(f.filename)}</td> <td>${escapeHtml(f.filename)}</td>
<td>${formatSize(f.size)}</td> <td>${formatSize(f.size)}</td>
</tr> </tr>
@@ -558,6 +665,9 @@ async function loadFilesList(): Promise<void> {
tbody.querySelectorAll<HTMLInputElement>('.file-checkbox').forEach(cb => { tbody.querySelectorAll<HTMLInputElement>('.file-checkbox').forEach(cb => {
cb.addEventListener('change', updateFileSelection); cb.addEventListener('change', updateFileSelection);
}); });
(el('select-all-files') as HTMLInputElement).checked = false;
selectedFiles.clear();
updateFileSelection();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@@ -566,7 +676,7 @@ async function loadFilesList(): Promise<void> {
function updateFileSelection(): void { function updateFileSelection(): void {
selectedFiles.clear(); selectedFiles.clear();
document.querySelectorAll<HTMLInputElement>('.file-checkbox:checked').forEach(cb => { document.querySelectorAll<HTMLInputElement>('.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; (el('delete-selected-files') as HTMLButtonElement).disabled = selectedFiles.size === 0;
} }
@@ -578,9 +688,23 @@ function updateFileSelection(): void {
updateFileSelection(); 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; 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); el('refresh-files-list').addEventListener('click', loadFilesList);
@@ -606,15 +730,14 @@ async function loadConfigDefaults(): Promise<void> {
sel.value = c.ttsProvider; sel.value = c.ttsProvider;
} }
} }
const fields: [string, string?][] = [ const fields: string[] = [
['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'], 'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions',
['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'], 'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize',
['captureIntervalSeconds'], ['contextWindowSize'], 'defaultPrompt', 'changePrompt', 'batchPrompt',
['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'],
]; ];
for (const [name, tag] of fields) { for (const name of fields) {
const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`); const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`);
if (el && c[name] !== undefined) el.value = c[name]; if (field && c[name] !== undefined) field.value = c[name];
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -624,9 +747,12 @@ async function loadConfigDefaults(): Promise<void> {
// ── Init ────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────
function initApp(): void { function initApp(): void {
wireTablist('main-tablist');
wireTablist('source-tablist');
loadJobs(); loadJobs();
loadBrowseFiles(); loadBrowseFiles();
loadConfigDefaults(); loadConfigDefaults();
loadSettings();
startPolling(); startPolling();
} }

View File

@@ -7,153 +7,228 @@
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<a class="skip-link" href="#main">Skip to main content</a>
<div id="app"> <div id="app">
<div id="login-screen" class="screen"> <section id="login-screen" class="screen" aria-labelledby="login-heading">
<div class="login-card"> <div class="login-card">
<h1>Audio Description Server</h1> <h1 id="login-heading">Audio Description Server</h1>
<p>Please log in to continue</p> <p>Please log in to continue</p>
<form id="login-form"> <form id="login-form">
<label>Username <input type="text" id="login-username" required autocomplete="username"></label> <div class="field">
<label>Password <input type="password" id="login-password" required autocomplete="current-password"></label> <label for="login-username">Username</label>
<button type="submit">Login</button> <input type="text" id="login-username" name="username" required autocomplete="username">
</div>
<div class="field">
<label for="login-password">Password</label>
<input type="password" id="login-password" name="password" required autocomplete="current-password">
</div>
<button type="submit">Log in</button>
</form> </form>
<p id="login-error" class="error hidden"></p> <p id="login-error" class="error" role="alert" hidden></p>
</div> </div>
</div> </section>
<div id="main-screen" class="screen hidden"> <div id="main-screen" class="screen" hidden>
<header> <header>
<h1>Audio Description Server</h1> <h1>Audio Description Server</h1>
<nav> <nav aria-label="Main">
<button class="tab active" data-tab="dashboard">Dashboard</button> <div role="tablist" aria-label="Sections" id="main-tablist">
<button class="tab" data-tab="new-job">New Job</button> <button role="tab" id="tab-dashboard" aria-selected="true" aria-controls="dashboard" tabindex="0" class="tab">Dashboard</button>
<button class="tab" data-tab="settings">Settings</button> <button role="tab" id="tab-new-job" aria-selected="false" aria-controls="new-job" tabindex="-1" class="tab">New Job</button>
<button class="tab" data-tab="files">Files</button> <button role="tab" id="tab-settings" aria-selected="false" aria-controls="settings" tabindex="-1" class="tab">Settings</button>
<button id="logout-btn" class="tab danger">Logout</button> <button role="tab" id="tab-files" aria-selected="false" aria-controls="files" tabindex="-1" class="tab">Files</button>
</div>
<button type="button" id="logout-btn" class="tab danger">Log out</button>
</nav> </nav>
</header> </header>
<div id="dashboard" class="tab-content active"> <main id="main">
<div class="toolbar">
<h2>Jobs</h2>
<button id="refresh-jobs">Refresh</button>
</div>
<div id="jobs-list" class="jobs-list">
<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>
</div>
</div>
<div id="new-job" class="tab-content"> <section role="tabpanel" id="dashboard" aria-labelledby="tab-dashboard" tabindex="0" class="tab-content">
<h2>Create New Job</h2> <div class="toolbar">
<form id="new-job-form"> <h2>Jobs</h2>
<fieldset> <button type="button" id="refresh-jobs" aria-label="Refresh jobs">Refresh</button>
<legend>Video Source</legend> </div>
<div class="tabs-mini"> <div id="jobs-list" class="jobs-list" aria-live="polite" aria-busy="false">
<button type="button" class="tab-mini active" data-src="upload" onclick="(function(b){document.querySelectorAll('button.tab-mini').forEach(function(x){x.classList.remove('active')});b.classList.add('active');document.querySelectorAll('.src-panel').forEach(function(p){p.classList.remove('active')});var panel=document.getElementById('src-'+b.dataset.src);if(panel)panel.classList.add('active')})(this)">Upload</button> <p class="empty">No jobs yet. Create one from the &ldquo;New Job&rdquo; tab.</p>
<button type="button" class="tab-mini" data-src="browse" onclick="(function(b){document.querySelectorAll('button.tab-mini').forEach(function(x){x.classList.remove('active')});b.classList.add('active');document.querySelectorAll('.src-panel').forEach(function(p){p.classList.remove('active')});var panel=document.getElementById('src-'+b.dataset.src);if(panel)panel.classList.add('active')})(this)">Browse Files</button> </div>
<button type="button" class="tab-mini" data-src="youtube" onclick="(function(b){document.querySelectorAll('button.tab-mini').forEach(function(x){x.classList.remove('active')});b.classList.add('active');document.querySelectorAll('.src-panel').forEach(function(p){p.classList.remove('active')});var panel=document.getElementById('src-'+b.dataset.src);if(panel)panel.classList.add('active')})(this)">YouTube / URL</button> </section>
</div>
<div id="src-upload" class="src-panel active"> <section role="tabpanel" id="new-job" aria-labelledby="tab-new-job" tabindex="0" class="tab-content" hidden>
<label class="file-label"> <h2>Create new job</h2>
Choose a video file... <form id="new-job-form">
<fieldset>
<legend>Video source</legend>
<div role="tablist" aria-label="Video source" id="source-tablist" class="tabs-mini">
<button type="button" role="tab" id="src-tab-upload" aria-selected="true" aria-controls="src-upload" tabindex="0" class="tab-mini">Upload</button>
<button type="button" role="tab" id="src-tab-browse" aria-selected="false" aria-controls="src-browse" tabindex="-1" class="tab-mini">Browse files</button>
<button type="button" role="tab" id="src-tab-youtube" aria-selected="false" aria-controls="src-youtube" tabindex="-1" class="tab-mini">YouTube / URL</button>
</div>
<div role="tabpanel" id="src-upload" aria-labelledby="src-tab-upload" class="src-panel">
<label for="video-upload" class="file-label">Choose a video file</label>
<input type="file" id="video-upload" accept="video/*" class="visually-hidden"> <input type="file" id="video-upload" accept="video/*" class="visually-hidden">
</label> <p class="file-name" id="upload-name" aria-live="polite"></p>
<p class="file-name" id="upload-name"></p> </div>
</div>
<div id="src-browse" class="src-panel">
<select id="video-select"><option value="">-- Select file --</option></select>
<button type="button" id="refresh-files">Refresh</button>
</div>
<div id="src-youtube" class="src-panel">
<input type="url" id="youtube-url" placeholder="https://www.youtube.com/watch?v=...">
<button type="button" id="download-url">Download</button>
<p id="download-status" class="status"></p>
</div>
</fieldset>
<fieldset> <div role="tabpanel" id="src-browse" aria-labelledby="src-tab-browse" class="src-panel" hidden>
<legend>Output Options</legend> <div class="field">
<label><input type="checkbox" name="output-audio" checked> Audio Description Track</label> <label for="video-select">Uploaded file</label>
<label><input type="checkbox" name="output-subtitles" checked> Subtitles (SRT + VTT)</label> <select id="video-select"><option value="">-- Select file --</option></select>
<label><input type="checkbox" name="output-muxed"> Muxed Video (MKV with 2nd audio track)</label> </div>
</fieldset> <button type="button" id="refresh-files" aria-label="Refresh uploaded files">Refresh</button>
</div>
<details> <div role="tabpanel" id="src-youtube" aria-labelledby="src-tab-youtube" class="src-panel" hidden>
<summary>Vision Settings</summary> <div class="field">
<div class="form-grid"> <label for="youtube-url">YouTube or video URL</label>
<label>Provider <select name="visionProvider"></select></label> <input type="url" id="youtube-url" placeholder="https://www.youtube.com/watch?v=...">
<label>Model <input type="text" name="visionModel"></label> </div>
<label>Max Tokens <input type="number" name="visionMaxTokens" min="10" max="10000"></label> <button type="button" id="download-url">Download</button>
</div> <div class="download-progress" hidden>
</details> <div role="progressbar" id="download-progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Download progress" class="progress-bar">
<div class="progress-fill" id="download-fill" style="width:0%"></div>
</div>
</div>
<p id="download-status" class="status" aria-live="polite"></p>
</div>
</fieldset>
<details> <fieldset>
<summary>TTS Settings</summary> <legend>Output options</legend>
<div class="form-grid"> <label><input type="checkbox" name="output-audio" checked> Audio description track</label>
<label>Provider <select name="ttsProvider"></select></label> <label><input type="checkbox" name="output-subtitles" checked> Subtitles (SRT + VTT)</label>
<label>Model <input type="text" name="ttsModel"></label> <label><input type="checkbox" name="output-muxed"> Muxed video output (MKV)</label>
<label>Voice <input type="text" name="ttsVoice"></label> <div class="mux-mode-group" style="margin-left: 1.5em;">
<label>Speed Factor <input type="number" name="ttsSpeedFactor" min="0.5" max="3" step="0.1"></label> <label><input type="radio" name="mux-mode" value="separate" checked> Separate description track (player must support track switching)</label>
<label class="full">Instructions <textarea name="ttsInstructions" rows="2"></textarea></label> <label><input type="radio" name="mux-mode" value="mixed"> Mixed into main audio with ducking (works in any player)</label>
</div> </div>
</details> </fieldset>
<details> <details>
<summary>Processing Settings</summary> <summary>Vision settings</summary>
<div class="form-grid"> <div class="form-grid">
<label>Batch Mode <input type="checkbox" name="batchTimeMode" checked></label> <div class="field">
<label>Batch Window (sec) <input type="number" name="batchWindowDuration" min="1" max="120"></label> <label for="vision-provider">Provider</label>
<label>Frames Per Batch <input type="number" name="framesInBatch" min="1" max="60"></label> <select id="vision-provider" name="visionProvider"></select>
<label>Capture Interval (sec) <input type="number" name="captureIntervalSeconds" min="1" max="120"></label> </div>
<label>Context Window Size <input type="number" name="contextWindowSize" min="1" max="20"></label> <div class="field">
</div> <label for="vision-model">Model</label>
</details> <input type="text" id="vision-model" name="visionModel">
</div>
<div class="field">
<label for="vision-max-tokens">Max tokens</label>
<input type="number" id="vision-max-tokens" name="visionMaxTokens" min="10" max="10000">
</div>
</div>
</details>
<details> <details>
<summary>Prompts</summary> <summary>TTS settings</summary>
<div class="form-grid"> <div class="form-grid">
<label class="full">Default Prompt <textarea name="defaultPrompt" rows="3"></textarea></label> <div class="field">
<label class="full">Change Prompt <textarea name="changePrompt" rows="3"></textarea></label> <label for="tts-provider">Provider</label>
<label class="full">Batch Prompt <textarea name="batchPrompt" rows="3"></textarea></label> <select id="tts-provider" name="ttsProvider"></select>
</div> </div>
</details> <div class="field">
<label for="tts-model">Model</label>
<input type="text" id="tts-model" name="ttsModel">
</div>
<div class="field">
<label for="tts-voice">Voice</label>
<input type="text" id="tts-voice" name="ttsVoice">
</div>
<div class="field">
<label for="tts-speed">Speed factor</label>
<input type="number" id="tts-speed" name="ttsSpeedFactor" min="0.5" max="3" step="0.1">
</div>
<div class="field full">
<label for="tts-instructions">Instructions</label>
<textarea id="tts-instructions" name="ttsInstructions" rows="2"></textarea>
</div>
</div>
</details>
<button type="submit" class="btn-primary">Create & Start Job</button> <details>
</form> <summary>Processing settings</summary>
</div> <div class="form-grid">
<label><input type="checkbox" name="batchTimeMode" checked> Batch mode</label>
<div class="field">
<label for="batch-window">Batch window (sec)</label>
<input type="number" id="batch-window" name="batchWindowDuration" min="1" max="120">
</div>
<div class="field">
<label for="frames-in-batch">Frames per batch</label>
<input type="number" id="frames-in-batch" name="framesInBatch" min="1" max="60">
</div>
<div class="field">
<label for="capture-interval">Capture interval (sec)</label>
<input type="number" id="capture-interval" name="captureIntervalSeconds" min="1" max="120">
</div>
<div class="field">
<label for="context-window">Context window size</label>
<input type="number" id="context-window" name="contextWindowSize" min="1" max="20">
</div>
</div>
</details>
<div id="settings" class="tab-content"> <details>
<h2>Server Configuration</h2> <summary>Prompts</summary>
<p class="hint">These settings are stored on the server and used as defaults for new jobs.</p> <div class="form-grid">
<form id="settings-form"> <div class="field full">
<div id="settings-fields" class="form-grid"></div> <label for="default-prompt">Default prompt</label>
<button type="submit" class="btn-primary">Save Settings</button> <textarea id="default-prompt" name="defaultPrompt" rows="3"></textarea>
</form> </div>
</div> <div class="field full">
<label for="change-prompt">Change prompt</label>
<textarea id="change-prompt" name="changePrompt" rows="3"></textarea>
</div>
<div class="field full">
<label for="batch-prompt">Batch prompt</label>
<textarea id="batch-prompt" name="batchPrompt" rows="3"></textarea>
</div>
</div>
</details>
<div id="files" class="tab-content"> <button type="submit" class="btn-primary">Create &amp; start job</button>
<h2>Uploaded Files</h2> </form>
<div class="toolbar"> </section>
<button id="refresh-files-list">Refresh</button>
<button id="delete-selected-files" class="danger" disabled>Delete Selected</button> <section role="tabpanel" id="settings" aria-labelledby="tab-settings" tabindex="0" class="tab-content" hidden>
</div> <h2>Server configuration</h2>
<div id="files-table-container"> <p class="hint">These settings are stored on the server and used as defaults for new jobs.</p>
<table id="files-table"><thead><tr><th><input type="checkbox" id="select-all-files"></th><th>Filename</th><th>Size</th></tr></thead><tbody></tbody></table> <form id="settings-form">
</div> <div id="settings-fields" class="form-grid"></div>
</div> <button type="submit" class="btn-primary">Save settings</button>
</form>
</section>
<section role="tabpanel" id="files" aria-labelledby="tab-files" tabindex="0" class="tab-content" hidden>
<h2>Uploaded files</h2>
<div class="toolbar">
<button type="button" id="refresh-files-list" aria-label="Refresh files list">Refresh</button>
<button type="button" id="delete-selected-files" class="danger" disabled>Delete selected</button>
</div>
<div id="files-table-container">
<table id="files-table">
<caption class="visually-hidden">Uploaded video files</caption>
<thead>
<tr>
<th scope="col"><input type="checkbox" id="select-all-files" aria-label="Select all files"></th>
<th scope="col">Filename</th>
<th scope="col">Size</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
</div> </div>
</div> </div>
<script>
console.log('INLINE: page loaded, about to load app.js');
document.addEventListener('DOMContentLoaded', function() {
var diag = document.createElement('div');
diag.id = 'js-diag';
diag.style.cssText = 'position:fixed;top:0;right:0;background:#238636;color:#fff;padding:2px 8px;font-size:11px;z-index:9999;border-radius:0 0 0 4px';
diag.textContent = 'JS ✓';
document.body.appendChild(diag);
});
</script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,31 +1,41 @@
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; }
[hidden] { display: none !important; }
.hidden { display: none !important; } .hidden { display: none !important; }
.error { color: #f85149; } .error { color: #f85149; }
.success { color: #3fb950; } .success { color: #3fb950; }
.status { font-size: 0.85rem; margin: 4px 0; } .status { font-size: 0.85rem; margin: 4px 0; }
:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 4px; }
.skip-link {
position: absolute; left: -9999px; top: 0;
background: #1f6feb; color: #fff; padding: 8px 12px; border-radius: 0 0 6px 0; z-index: 10000;
}
.skip-link:focus { left: 0; }
.screen { min-height: 100vh; } .screen { min-height: 100vh; }
#login-screen { display: flex; align-items: center; justify-content: center; } #login-screen { display: flex; align-items: center; justify-content: center; }
.login-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; text-align: center; } .login-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; text-align: center; }
.login-card h1 { margin: 0 0 8px; font-size: 1.4rem; } .login-card h1 { margin: 0 0 8px; font-size: 1.4rem; }
.login-card p { margin: 0 0 20px; color: #8b949e; } .login-card p { margin: 0 0 20px; color: #8b949e; }
.login-card label { display: block; text-align: left; font-size: 0.85rem; margin-bottom: 12px; color: #8b949e; } .login-card .field { text-align: left; margin-bottom: 12px; }
.login-card input { width: 100%; margin-top: 4px; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 1rem; } .login-card .field label { display: block; font-size: 0.85rem; color: #8b949e; margin-bottom: 4px; }
.login-card input { width: 100%; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 1rem; }
.login-card button { width: 100%; padding: 10px; background: #238636; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; margin-top: 8px; } .login-card button { width: 100%; padding: 10px; background: #238636; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; margin-top: 8px; }
.login-card button:hover { background: #2ea043; } .login-card button:hover { background: #2ea043; }
header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: #161b22; border-bottom: 1px solid #30363d; } header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: #161b22; border-bottom: 1px solid #30363d; }
header h1 { font-size: 1.1rem; margin: 0; } header h1 { font-size: 1.1rem; margin: 0; }
nav { display: flex; gap: 4px; } nav { display: flex; gap: 4px; align-items: center; }
[role="tablist"] { display: flex; gap: 4px; }
button.tab { background: transparent; color: #8b949e; border: none; padding: 8px 16px; cursor: pointer; border-radius: 6px; font-size: 0.9rem; } button.tab { background: transparent; color: #8b949e; border: none; padding: 8px 16px; cursor: pointer; border-radius: 6px; font-size: 0.9rem; }
button.tab:hover { background: #21262d; color: #c9d1d9; } button.tab:hover { background: #21262d; color: #c9d1d9; }
button.tab.active { background: #1f6feb; color: #fff; } button.tab.active { background: #1f6feb; color: #fff; }
button.tab.danger:hover { background: #da3633; color: #fff; } button.tab.danger:hover { background: #da3633; color: #fff; }
.tab-content { padding: 24px; display: none; } .tab-content { padding: 24px; }
.tab-content.active { display: block; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.toolbar h2 { margin: 0; font-size: 1.2rem; } .toolbar h2 { margin: 0; font-size: 1.2rem; }
@@ -45,17 +55,21 @@ legend { font-weight: 600; padding: 0 8px; }
.tabs-mini { display: flex; gap: 4px; margin-bottom: 12px; } .tabs-mini { display: flex; gap: 4px; margin-bottom: 12px; }
button.tab-mini { background: transparent; color: #8b949e; border: 1px solid #30363d; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; } button.tab-mini { background: transparent; color: #8b949e; border: 1px solid #30363d; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; }
button.tab-mini.active { background: #1f6feb; color: #fff; border-color: #1f6feb; } button.tab-mini.active, button.tab-mini[aria-selected="true"] { background: #1f6feb; color: #fff; border-color: #1f6feb; }
.src-panel { display: none; } .src-panel { padding: 8px 0; }
.src-panel.active { display: block; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid label.full { grid-column: 1 / -1; } .form-grid .full, .form-grid label.full { grid-column: 1 / -1; }
.form-grid label { display: flex; flex-direction: column; font-size: 0.85rem; color: #8b949e; gap: 4px; } .form-grid .field { display: flex; flex-direction: column; gap: 4px; }
.form-grid .field label, .form-grid label { font-size: 0.85rem; color: #8b949e; }
.form-grid input, .form-grid select, .form-grid textarea { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; } .form-grid input, .form-grid select, .form-grid textarea { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; }
.form-grid textarea { resize: vertical; min-height: 60px; } .form-grid textarea { resize: vertical; min-height: 60px; }
.form-grid input[type="checkbox"] { width: auto; } .form-grid input[type="checkbox"] { width: auto; }
fieldset .field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
fieldset .field label { font-size: 0.85rem; color: #8b949e; }
fieldset .field input, fieldset .field select, fieldset .field textarea { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; }
details { margin-bottom: 12px; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; } details { margin-bottom: 12px; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; }
details summary { cursor: pointer; font-weight: 600; padding: 4px 0; } details summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
details .form-grid { margin-top: 12px; } details .form-grid { margin-top: 12px; }
@@ -65,8 +79,10 @@ details .form-grid { margin-top: 12px; }
select, input[type="file"], input[type="url"] { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; } select, input[type="file"], input[type="url"] { padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.9rem; }
.file-label { display: inline-block; padding: 10px 20px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95rem; } .file-label { display: inline-block; padding: 10px 20px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95rem; }
.file-label:hover { background: #2ea043; } .file-label:hover { background: #2ea043; }
.file-name { margin: 8px 0 0; font-size: 0.85rem; color: #8b949e; } .file-label:focus-within { outline: 2px solid #58a6ff; outline-offset: 2px; }
.visually-hidden { position: absolute; opacity: 0; width: 0; height: 0; border: 0; padding: 0; } .file-name { margin: 8px 0 0; font-size: 0.85rem; color: #8b949e; min-height: 1.2em; }
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.download-progress { margin: 10px 0 6px; }
/* Job cards */ /* Job cards */
.jobs-list { display: flex; flex-direction: column; gap: 8px; } .jobs-list { display: flex; flex-direction: column; gap: 8px; }
@@ -77,7 +93,7 @@ select, input[type="file"], input[type="url"] { padding: 8px 12px; background: #
.job-actions button { font-size: 0.8rem; padding: 4px 10px; } .job-actions button { font-size: 0.8rem; padding: 4px 10px; }
.status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.status-pending { background: #21262d; color: #8b949e; } .status-pending { background: #21262d; color: #c9d1d9; }
.status-queued { background: #1a2332; color: #58a6ff; } .status-queued { background: #1a2332; color: #58a6ff; }
.status-processing { background: #1a2332; color: #58a6ff; } .status-processing { background: #1a2332; color: #58a6ff; }
.status-completed { background: #172f1e; color: #3fb950; } .status-completed { background: #172f1e; color: #3fb950; }
@@ -92,8 +108,7 @@ select, input[type="file"], input[type="url"] { padding: 8px 12px; background: #
.job-meta { display: flex; gap: 16px; font-size: 0.8rem; color: #8b949e; margin-bottom: 8px; } .job-meta { display: flex; gap: 16px; font-size: 0.8rem; color: #8b949e; margin-bottom: 8px; }
.job-detail { margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d; display: none; } .job-detail { margin-top: 12px; padding-top: 12px; border-top: 1px solid #30363d; }
.job-detail.open { display: block; }
.segment-log { max-height: 200px; overflow-y: auto; font-size: 0.8rem; color: #8b949e; background: #0d1117; padding: 8px; border-radius: 4px; margin-bottom: 8px; } .segment-log { max-height: 200px; overflow-y: auto; font-size: 0.8rem; color: #8b949e; background: #0d1117; padding: 8px; border-radius: 4px; margin-bottom: 8px; }
.segment-entry { padding: 4px 0; border-bottom: 1px solid #1c2128; } .segment-entry { padding: 4px 0; border-bottom: 1px solid #1c2128; }
.segment-entry:last-child { border-bottom: none; } .segment-entry:last-child { border-bottom: none; }

View File

@@ -1,11 +1,44 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { getAllConfig, setConfigValue } from '../db/jobStore'; import { getAllConfig, setConfigValue } from '../db/jobStore';
import { getDefaultConfig } from '../../config/config';
const router = Router(); const router = Router();
// Optional .env overrides for the (long) prompt strings — keep getDefaultConfig()'s
// hardcoded prompts as the final fallback. Users who want to tweak prompts without
// editing source can set these in .env, or set them per-job in the Settings UI.
const ENV_OVERRIDES: Record<string, string | undefined> = {
defaultPrompt: process.env.AIDIO_DEFAULT_PROMPT,
changePrompt: process.env.AIDIO_CHANGE_PROMPT,
batchPrompt: process.env.AIDIO_BATCH_PROMPT,
};
// Fields in Config that are nested objects (provider configs, etc.) and shouldn't
// be flattened into the form-facing config map. API keys live inside these — keep
// them off the wire.
const NESTED_FIELDS = new Set(['visionProviders', 'ttsProviders']);
function buildLayeredConfig(): Record<string, string> {
const defaults = getDefaultConfig() as unknown as Record<string, unknown>;
const db = getAllConfig();
const merged: Record<string, string> = {};
for (const [key, value] of Object.entries(defaults)) {
if (NESTED_FIELDS.has(key)) continue;
if (value === undefined || value === null) continue;
merged[key] = String(value);
}
for (const [key, value] of Object.entries(ENV_OVERRIDES)) {
if (value !== undefined && value !== '') merged[key] = value;
}
for (const [key, value] of Object.entries(db)) {
if (value !== undefined && value !== '') merged[key] = value;
}
return merged;
}
router.get('/', (_req: Request, res: Response) => { router.get('/', (_req: Request, res: Response) => {
const config = getAllConfig(); res.json({ config: buildLayeredConfig() });
res.json({ config });
}); });
router.put('/', (req: Request, res: Response) => { router.put('/', (req: Request, res: Response) => {
@@ -17,8 +50,7 @@ router.put('/', (req: Request, res: Response) => {
for (const [key, value] of Object.entries(updates)) { for (const [key, value] of Object.entries(updates)) {
setConfigValue(key, String(value)); setConfigValue(key, String(value));
} }
const config = getAllConfig(); res.json({ config: buildLayeredConfig() });
res.json({ config });
}); });
export default router; export default router;

View File

@@ -66,24 +66,73 @@ router.get('/', (_req: Request, res: Response) => {
res.json({ files }); res.json({ files });
}); });
router.post('/youtube', (req: Request, res: Response) => { router.delete('/:filename', (req: Request, res: Response) => {
const raw = req.params.filename;
const requested = Array.isArray(raw) ? raw[0] : raw;
if (!requested) {
res.status(400).json({ error: 'filename is required' });
return;
}
const resolved = path.resolve(UPLOADS_DIR, requested);
const uploadsWithSep = UPLOADS_DIR.endsWith(path.sep) ? UPLOADS_DIR : UPLOADS_DIR + path.sep;
if (!resolved.startsWith(uploadsWithSep)) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
if (!fs.existsSync(resolved)) {
res.status(404).json({ error: 'File not found' });
return;
}
try {
fs.unlinkSync(resolved);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: `Failed to delete: ${err.message}` });
}
});
// Stream yt-dlp download progress over SSE.
// Returns events: {type:'progress', percent} ... {type:'done', filePath, filename, title}
// or {type:'error', message}
router.get('/youtube/stream', (req: Request, res: Response) => {
const url = (req.query.url as string) || '';
if (!url) {
res.status(400).json({ error: 'url query param is required' });
return;
}
if (!isYtDlpAvailable()) { if (!isYtDlpAvailable()) {
res.status(400).json({ error: 'yt-dlp is not installed or not in PATH' }); res.status(400).json({ error: 'yt-dlp is not installed or not in PATH' });
return; return;
} }
const { url } = req.body; res.setHeader('Content-Type', 'text/event-stream');
if (!url) { res.setHeader('Cache-Control', 'no-cache');
res.status(400).json({ error: 'URL is required' }); res.setHeader('Connection', 'keep-alive');
return; res.setHeader('X-Accel-Buffering', 'no');
} res.flushHeaders?.();
try { const send = (data: Record<string, unknown>) => {
const result = downloadVideo(url, UPLOADS_DIR); res.write(`data: ${JSON.stringify(data)}\n\n`);
res.json(result); };
} catch (err: any) {
res.status(500).json({ error: `Failed to download: ${err.message}` }); let clientGone = false;
} req.on('close', () => { clientGone = true; });
downloadVideo(url, UPLOADS_DIR, (percent) => {
if (clientGone) return;
send({ type: 'progress', percent });
}).then((result) => {
if (clientGone) return;
send({ type: 'done', ...result });
res.end();
}).catch((err: Error) => {
if (clientGone) return;
send({ type: 'error', message: err.message });
res.end();
});
}); });
export default router; export default router;

View File

@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { JobManager } from '../services/jobManager'; import { JobManager } from '../services/jobManager';
import { getJob } from '../db/jobStore'; import { getJob, OutputOptions } from '../db/jobStore';
function getParam(req: Request, name: string): string { function getParam(req: Request, name: string): string {
const val = req.params[name]; const val = req.params[name];
@@ -153,10 +153,13 @@ export function createJobsRouter(jobManager: JobManager): Router {
filePath = format === 'vtt' ? job.output_subtitles_vtt : job.output_subtitles_srt; filePath = format === 'vtt' ? job.output_subtitles_vtt : job.output_subtitles_srt;
filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}_description.${format}`; filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}_description.${format}`;
break; break;
case 'muxed': case 'muxed': {
const opts = JSON.parse(job.output_options || '{}') as Partial<OutputOptions>;
const suffix = opts.muxMode === 'mixed' ? '_described_mixed' : '_described';
filePath = job.output_muxed; filePath = job.output_muxed;
filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}_described.mkv`; filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}${suffix}.mkv`;
break; break;
}
default: default:
res.status(400).json({ error: 'Invalid download type' }); res.status(400).json({ error: 'Invalid download type' });
return; return;

View File

@@ -6,12 +6,26 @@ import {
} from '../db/jobStore'; } from '../db/jobStore';
import { generateAudioDescriptionFromOptions } from '../../utils/processor'; import { generateAudioDescriptionFromOptions } from '../../utils/processor';
import { generateSRT, generateVTT } from './subtitleGenerator'; import { generateSRT, generateVTT } from './subtitleGenerator';
import { muxAudioDescription } from './muxer'; import { muxAudioDescription, muxMixedAudioDescription } from './muxer';
import { getDefaultConfig, Config } from '../../config/config'; import { getDefaultConfig, Config } from '../../config/config';
import { AudioSegment, BatchContext } from '../../interfaces'; import { AudioSegment, BatchContext } from '../../interfaces';
import { getVideoDuration } from '../../utils/mediaUtils'; import { getVideoDuration, cleanupTempFiles } from '../../utils/mediaUtils';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
function jobTempDir(baseTempDir: string, jobId: string): string {
return path.join(baseTempDir, jobId);
}
function safeCleanupJobTmp(dir: string): void {
try {
if (!fs.existsSync(dir)) return;
cleanupTempFiles(dir);
fs.rmSync(dir, { recursive: true, force: true });
} catch (err: any) {
console.warn(`Failed to clean up tmp dir ${dir}:`, err.message);
}
}
interface ProgressData { interface ProgressData {
id: string; id: string;
status: string; status: string;
@@ -49,13 +63,24 @@ export class JobManager {
createJob(videoPath: string, configOverride: Partial<Config> = {}, outputOptions: Partial<OutputOptions> = {}): Job { createJob(videoPath: string, configOverride: Partial<Config> = {}, outputOptions: Partial<OutputOptions> = {}): Job {
const baseConfig = getDefaultConfig(); const baseConfig = getDefaultConfig();
const mergedConfig: Config = { ...baseConfig, ...configOverride };
// Drop empty/undefined/null values so blank form fields don't clobber the
// baked-in defaults (a blank prompt textarea must NOT overwrite the real
// prompt with "").
const cleanedOverride: Record<string, unknown> = {};
for (const [k, v] of Object.entries(configOverride)) {
if (v === '' || v === null || v === undefined) continue;
cleanedOverride[k] = v;
}
const mergedConfig: Config = { ...baseConfig, ...(cleanedOverride as Partial<Config>) };
const filename = path.basename(videoPath); const filename = path.basename(videoPath);
const opts: OutputOptions = { const opts: OutputOptions = {
audio: outputOptions.audio !== false, audio: outputOptions.audio !== false,
subtitles: outputOptions.subtitles !== false, subtitles: outputOptions.subtitles !== false,
muxed: outputOptions.muxed || false muxed: outputOptions.muxed || false,
muxMode: outputOptions.muxMode === 'mixed' ? 'mixed' : 'separate'
}; };
return createJob(videoPath, filename, mergedConfig, opts); return createJob(videoPath, filename, mergedConfig, opts);
@@ -111,6 +136,18 @@ export class JobManager {
if (!job) throw new Error('Job not found'); if (!job) throw new Error('Job not found');
if (job.status === 'processing') throw new Error('Cannot delete a running job'); if (job.status === 'processing') throw new Error('Cannot delete a running job');
try {
const config: Config = JSON.parse(job.config);
// job.config may contain either the base tempDir (older jobs) or the
// per-job tempDir (newer jobs). Trim a trailing job-id segment if present;
// otherwise compute the per-job dir from the stored base.
const stored = config.tempDir || './desc/tmp/';
const candidate = path.basename(stored) === jobId ? stored : jobTempDir(stored, jobId);
safeCleanupJobTmp(candidate);
} catch {
// ignore: cleanup is best-effort and must not block deletion
}
deleteJobFromDb(jobId); deleteJobFromDb(jobId);
} }
@@ -184,6 +221,15 @@ export class JobManager {
const config: Config = JSON.parse(job.config); const config: Config = JSON.parse(job.config);
const outputOptions: OutputOptions = JSON.parse(job.output_options); const outputOptions: OutputOptions = JSON.parse(job.output_options);
// Isolate this job's intermediates so concurrent jobs (or future resumes)
// don't collide on filenames like frame_00001.jpg / segment_3_std.wav.
// The pipeline already reads config.tempDir, so just override it here.
const baseTempDir = config.tempDir || './desc/tmp/';
if (path.basename(baseTempDir) !== job.id) {
config.tempDir = jobTempDir(baseTempDir, job.id);
}
fs.mkdirSync(config.tempDir, { recursive: true });
const existingSegments: AudioSegment[] = JSON.parse(job.segments || '[]'); const existingSegments: AudioSegment[] = JSON.parse(job.segments || '[]');
const lastContext: BatchContext = JSON.parse(job.last_context || '{}'); const lastContext: BatchContext = JSON.parse(job.last_context || '{}');
@@ -262,8 +308,16 @@ export class JobManager {
} }
if (outputOptions.muxed && fs.existsSync(outputAudio)) { if (outputOptions.muxed && fs.existsSync(outputAudio)) {
const muxedPath = path.join(outputDir, `${baseName}_described.mkv`); const isMixed = outputOptions.muxMode === 'mixed';
muxAudioDescription(job.video_path, outputAudio, muxedPath); const muxedPath = path.join(
outputDir,
`${baseName}${isMixed ? '_described_mixed' : '_described'}.mkv`
);
if (isMixed) {
muxMixedAudioDescription(job.video_path, outputAudio, muxedPath);
} else {
muxAudioDescription(job.video_path, outputAudio, muxedPath);
}
outputMuxed = muxedPath; outputMuxed = muxedPath;
} }
@@ -278,8 +332,11 @@ export class JobManager {
updateJobStatus(job.id, 'completed'); updateJobStatus(job.id, 'completed');
this.emitProgress(job.id); this.emitProgress(job.id);
safeCleanupJobTmp(config.tempDir);
} catch (err: any) { } catch (err: any) {
if (err.message === 'JOB_PAUSED') { if (err.message === 'JOB_PAUSED') {
// Keep config.tempDir intact — restart will resume into the same dir.
updateJobStatus(job.id, 'paused'); updateJobStatus(job.id, 'paused');
this.emitProgress(job.id); this.emitProgress(job.id);
return; return;
@@ -288,6 +345,8 @@ export class JobManager {
const errorMsg = err.message || 'Unknown error'; const errorMsg = err.message || 'Unknown error';
updateJobStatus(job.id, 'failed', errorMsg); updateJobStatus(job.id, 'failed', errorMsg);
this.emitProgress(job.id); this.emitProgress(job.id);
safeCleanupJobTmp(config.tempDir);
} }
} }
} }

View File

@@ -1,29 +1,95 @@
import { execSync } from 'child_process'; import { spawnSync } from 'child_process';
import path from 'path'; import path from 'path';
import fs from 'fs';
export function muxAudioDescription( export function muxAudioDescription(
videoPath: string, videoPath: string,
audioPath: string, audioPath: string,
outputPath: string outputPath: string
): void { ): void {
const ext = path.extname(outputPath).toLowerCase(); if (!fs.existsSync(videoPath)) {
const isMkv = ext === '.mkv'; throw new Error(`mux: video not found: ${videoPath}`);
}
if (!fs.existsSync(audioPath)) {
throw new Error(`mux: audio not found: ${audioPath}`);
}
const cmd = [ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
'ffmpeg -v error',
`-i "${videoPath}"`,
`-i "${audioPath}"`,
'-map 0:v',
'-map 0:a?',
'-map 1:a',
'-c:v copy',
'-c:a copy',
isMkv
? '-metadata:s:a:1 title="Audio Description"'
: '-metadata:s:a:1 title="Audio Description"',
`"${outputPath}"`,
'-y'
].join(' ');
execSync(cmd); // Argv form — no shell, no quoting issues, and -y is a global option (placed
// up front, not after the output). Stderr is captured so failures aren't
// silent.
const args = [
'-y',
'-v', 'error',
'-i', videoPath,
'-i', audioPath,
'-map', '0:v',
'-map', '0:a?',
'-map', '1:a',
'-c:v', 'copy',
'-c:a', 'copy',
'-metadata:s:a:1', 'title=Audio Description',
'-disposition:a:1', 'visual_impaired',
outputPath,
];
const result = spawnSync('ffmpeg', args, { shell: false, encoding: 'utf-8' });
if (result.error) {
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
}
if (result.status !== 0) {
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
}
}
export function muxMixedAudioDescription(
videoPath: string,
audioPath: string,
outputPath: string
): void {
if (!fs.existsSync(videoPath)) {
throw new Error(`mux: video not found: ${videoPath}`);
}
if (!fs.existsSync(audioPath)) {
throw new Error(`mux: audio not found: ${audioPath}`);
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// Sidechain-ducked mix: original audio dips when the AD track is speaking,
// then both are summed into a single output audio stream. The AD track is
// already a full-length file that is silent between description segments
// (built by combineAudioSegments), so asplit gives us one copy to drive the
// sidechain detector and another to mix in on top.
const filterGraph =
'[1:a]asplit=2[ad_mix][ad_sc];' +
'[0:a][ad_sc]sidechaincompress=threshold=0.03:ratio=20:attack=5:release=300:level_sc=2[ducked];' +
'[ducked][ad_mix]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]';
const args = [
'-y',
'-v', 'error',
'-i', videoPath,
'-i', audioPath,
'-filter_complex', filterGraph,
'-map', '0:v',
'-map', '[aout]',
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
outputPath,
];
const result = spawnSync('ffmpeg', args, { shell: false, encoding: 'utf-8' });
if (result.error) {
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
}
if (result.status !== 0) {
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
}
} }

View File

@@ -1,4 +1,4 @@
import { execSync } from 'child_process'; import { execSync, spawn } from 'child_process';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@@ -8,6 +8,8 @@ export interface YtDlpResult {
title: string; title: string;
} }
export type YtDlpProgress = (percent: number) => void;
export function isYtDlpAvailable(): boolean { export function isYtDlpAvailable(): boolean {
try { try {
execSync('yt-dlp --version', { stdio: 'pipe' }); execSync('yt-dlp --version', { stdio: 'pipe' });
@@ -17,31 +19,113 @@ export function isYtDlpAvailable(): boolean {
} }
} }
export function downloadVideo(url: string, outputDir: string): YtDlpResult { const PROGRESS_PREFIX = 'PROG ';
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s'); export function downloadVideo(
url: string,
outputDir: string,
onProgress?: YtDlpProgress
): Promise<YtDlpResult> {
return new Promise((resolve, reject) => {
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const result = execSync( const outputTemplate = path.join(outputDir, '%(title)s.%(ext)s');
`yt-dlp -f "best[ext=mp4]/best" -o "${outputTemplate}" --print filename --print title "${url}"`,
{ encoding: 'utf-8', timeout: 600000 }
);
const lines = result.trim().split('\n'); // Pass arguments as an array — no shell, no quoting issues, no truncation
const filename = lines[0]?.trim(); // on URLs containing & | % ^ etc. (the original execSync bug on Windows).
const title = lines[1]?.trim() || filename; const args = [
'-f', 'best[ext=mp4]/best',
'-o', outputTemplate,
'--newline',
'--progress-template', `${PROGRESS_PREFIX}%(progress._percent_str)s`,
'--print', 'after_move:filepath',
'--print', 'title',
'--no-simulate',
url,
];
if (!filename) { const child = spawn('yt-dlp', args, { shell: false });
throw new Error('yt-dlp: Failed to parse downloaded filename');
}
const filePath = path.resolve(outputDir, filename); const stderrLines: string[] = [];
const outputLines: string[] = [];
let stdoutBuf = '';
let stderrBuf = '';
if (!fs.existsSync(filePath)) { const handleStdoutLine = (line: string) => {
throw new Error(`yt-dlp: Downloaded file not found at ${filePath}`); if (!line) return;
} if (line.startsWith(PROGRESS_PREFIX)) {
const m = line.slice(PROGRESS_PREFIX.length).match(/([\d.]+)\s*%/);
if (m && onProgress) {
const pct = parseFloat(m[1]);
if (!isNaN(pct)) onProgress(pct);
}
return;
}
outputLines.push(line);
};
return { filePath, filename, title }; child.stdout.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString('utf-8');
let nl;
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '');
stdoutBuf = stdoutBuf.slice(nl + 1);
handleStdoutLine(line.trim());
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf-8');
let nl;
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '').trim();
stderrBuf = stderrBuf.slice(nl + 1);
if (line) stderrLines.push(line);
}
});
child.on('error', (err) => {
reject(new Error(`yt-dlp failed to start: ${err.message}`));
});
const timeoutMs = 600000;
const timer = setTimeout(() => {
child.kill();
reject(new Error(`yt-dlp timed out after ${timeoutMs / 1000}s`));
}, timeoutMs);
child.on('close', (code) => {
clearTimeout(timer);
if (stdoutBuf.trim()) handleStdoutLine(stdoutBuf.trim());
if (stderrBuf.trim()) stderrLines.push(stderrBuf.trim());
if (code !== 0) {
const tail = stderrLines.slice(-3).join(' | ') || `exit code ${code}`;
reject(new Error(tail));
return;
}
const filePath = outputLines[0];
const title = outputLines[1] || (filePath ? path.basename(filePath) : '');
if (!filePath) {
reject(new Error('yt-dlp completed but did not report a filename'));
return;
}
if (!fs.existsSync(filePath)) {
reject(new Error(`yt-dlp reported success but file not found: ${filePath}`));
return;
}
resolve({
filePath: path.resolve(filePath),
filename: path.basename(filePath),
title,
});
});
});
} }