videotomp3transcriptor/public/app.js
StillHammer 849412c3bd Initial commit: Video to MP3 Transcriptor
- YouTube video/playlist download as MP3 (yt-dlp)
- Audio transcription with OpenAI (gpt-4o-transcribe, whisper-1)
- Translation with GPT-4o-mini (chunking for long texts)
- Web interface with progress bars and drag & drop
- CLI and REST API interfaces
- Linux shell scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 11:40:23 +08:00

637 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// API Base URL
const API_URL = '';
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
});
});
// Helper: Show result
function showResult(elementId, success, content) {
const el = document.getElementById(elementId);
el.className = `result show ${success ? 'success' : 'error'}`;
el.innerHTML = content;
}
// Helper: Set loading state
function setLoading(button, loading) {
button.disabled = loading;
button.classList.toggle('loading', loading);
}
// Format seconds to MM:SS or HH:MM:SS
function formatTime(seconds) {
if (!seconds || seconds < 0) return '--:--';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${mins}:${String(secs).padStart(2, '0')}`;
}
// Format file size
function formatSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return `${bytes.toFixed(1)} ${units[i]}`;
}
// ==================== DOWNLOAD TAB ====================
const progressContainer = document.getElementById('download-progress');
const progressFill = document.getElementById('progress-fill');
const progressPercent = document.getElementById('progress-percent');
const progressTitle = document.getElementById('progress-title');
const progressEta = document.getElementById('progress-eta');
const progressInfo = document.getElementById('progress-info');
const progressSpeed = document.getElementById('progress-speed');
const progressCurrent = document.getElementById('progress-current');
function updateDownloadProgress(data) {
progressFill.style.width = `${data.percent}%`;
progressPercent.textContent = `${data.percent}%`;
if (data.totalVideos > 1) {
progressInfo.textContent = `Video ${data.currentVideo}/${data.totalVideos}`;
} else {
progressInfo.textContent = '';
}
if (data.speed) progressSpeed.textContent = data.speed;
if (data.estimatedRemaining) {
progressEta.textContent = `ETA: ${formatTime(data.estimatedRemaining)}`;
} else if (data.eta) {
progressEta.textContent = `ETA: ${data.eta}`;
}
if (data.title) {
progressCurrent.innerHTML = `Downloading: <span class="video-title">${data.title}</span>`;
}
}
function resetDownloadProgress() {
progressFill.style.width = '0%';
progressPercent.textContent = '0%';
progressTitle.textContent = 'Downloading...';
progressEta.textContent = '';
progressInfo.textContent = '';
progressSpeed.textContent = '';
progressCurrent.textContent = '';
}
document.getElementById('download-form').addEventListener('submit', async (e) => {
e.preventDefault();
const button = e.target.querySelector('button[type="submit"]');
const url = document.getElementById('download-url').value;
const resultDiv = document.getElementById('download-result');
setLoading(button, true);
resetDownloadProgress();
progressContainer.style.display = 'block';
resultDiv.classList.remove('show');
const eventSource = new EventSource(`${API_URL}/download-stream?url=${encodeURIComponent(url)}`);
eventSource.addEventListener('status', (e) => {
progressTitle.textContent = JSON.parse(e.data).message;
});
eventSource.addEventListener('info', (e) => {
const data = JSON.parse(e.data);
progressTitle.textContent = data.totalVideos > 1
? `Downloading playlist: ${data.playlistTitle} (${data.totalVideos} videos)`
: `Downloading: ${data.title}`;
});
eventSource.addEventListener('progress', (e) => updateDownloadProgress(JSON.parse(e.data)));
eventSource.addEventListener('video-complete', (e) => {
const data = JSON.parse(e.data);
progressCurrent.innerHTML = `Completed: <span class="video-title">${data.title}</span> (${data.videosCompleted}/${data.totalVideos})`;
});
eventSource.addEventListener('complete', (e) => {
const data = JSON.parse(e.data);
eventSource.close();
progressFill.style.width = '100%';
progressPercent.textContent = '100%';
progressTitle.textContent = 'Download Complete!';
progressEta.textContent = `Total: ${formatTime(data.totalTime)}`;
showResult('download-result', true, `
<h3>Download Complete!</h3>
<p>${data.successCount}/${data.totalVideos} videos downloaded</p>
${data.playlistTitle ? `<p>Playlist: ${data.playlistTitle}</p>` : ''}
<ul>${data.videos.map(v => `
<li>
<span class="${v.success ? 'icon-success' : 'icon-error'}">${v.success ? '✓' : '✗'}</span>
${v.title}
${v.success && v.fileUrl ? `<a href="${v.fileUrl}" target="_blank">Download</a>` : ''}
${v.error ? `<small>(${v.error})</small>` : ''}
</li>
`).join('')}</ul>
`);
setLoading(button, false);
});
eventSource.addEventListener('error', (e) => {
let errorMsg = 'Download failed';
try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {}
eventSource.close();
progressContainer.style.display = 'none';
showResult('download-result', false, `<h3>Error</h3><p>${errorMsg}</p>`);
setLoading(button, false);
});
eventSource.onerror = () => {
eventSource.close();
progressContainer.style.display = 'none';
showResult('download-result', false, `<h3>Error</h3><p>Connection lost</p>`);
setLoading(button, false);
};
});
// ==================== TRANSCRIBE TAB (Drag & Drop) ====================
let selectedFiles = [];
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const selectedFilesDiv = document.getElementById('selected-files');
const filesList = document.getElementById('files-list');
const transcribeBtn = document.getElementById('transcribe-btn');
const clearFilesBtn = document.getElementById('clear-files');
function updateFilesList() {
if (selectedFiles.length === 0) {
selectedFilesDiv.style.display = 'none';
transcribeBtn.disabled = true;
return;
}
selectedFilesDiv.style.display = 'block';
transcribeBtn.disabled = false;
filesList.innerHTML = selectedFiles.map((file, index) => `
<li>
<span class="file-name">${file.name}</span>
<span class="file-size">${formatSize(file.size)}</span>
<button type="button" class="remove-file" data-index="${index}">×</button>
</li>
`).join('');
// Add remove handlers
filesList.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
selectedFiles.splice(parseInt(btn.dataset.index), 1);
updateFilesList();
});
});
}
function addFiles(files) {
const audioFiles = Array.from(files).filter(f =>
f.type.startsWith('audio/') || f.name.match(/\.(mp3|wav|m4a|ogg|flac)$/i)
);
selectedFiles = [...selectedFiles, ...audioFiles];
updateFilesList();
}
// Drag & Drop events
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
addFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => {
addFiles(fileInput.files);
fileInput.value = '';
});
clearFilesBtn.addEventListener('click', () => {
selectedFiles = [];
updateFilesList();
});
// Transcribe form submit
document.getElementById('transcribe-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (selectedFiles.length === 0) return;
const button = transcribeBtn;
const language = document.getElementById('transcribe-lang').value;
const model = document.getElementById('transcribe-model').value;
const transcribeProgress = document.getElementById('transcribe-progress');
const transcribeProgressFill = document.getElementById('transcribe-progress-fill');
const transcribeProgressTitle = document.getElementById('transcribe-progress-title');
const transcribeProgressPercent = document.getElementById('transcribe-progress-percent');
const transcribeProgressInfo = document.getElementById('transcribe-progress-info');
const transcribeProgressCurrent = document.getElementById('transcribe-progress-current');
setLoading(button, true);
transcribeProgress.style.display = 'block';
transcribeProgressFill.style.width = '0%';
transcribeProgressTitle.textContent = 'Uploading and transcribing...';
transcribeProgressPercent.textContent = '0%';
transcribeProgressInfo.textContent = `0/${selectedFiles.length} files`;
document.getElementById('transcribe-result').classList.remove('show');
const formData = new FormData();
selectedFiles.forEach(file => formData.append('files', file));
if (language) formData.append('language', language);
formData.append('model', model);
try {
const response = await fetch(`${API_URL}/upload-transcribe`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Transcription failed');
transcribeProgressFill.style.width = '100%';
transcribeProgressPercent.textContent = '100%';
transcribeProgressTitle.textContent = 'Transcription Complete!';
transcribeProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`;
showResult('transcribe-result', true, `
<h3>Transcription Complete!</h3>
<p>${data.successCount}/${data.totalFiles} files transcribed</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.success ? 'icon-success' : 'icon-error'}">${r.success ? '✓' : '✗'}</span>
${r.fileName}
${r.success && r.transcriptionUrl ? `<a href="${r.transcriptionUrl}" target="_blank">View</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.text ? `
<h4>Preview (first file):</h4>
<div class="preview">${data.results[0].text.substring(0, 1000)}${data.results[0].text.length > 1000 ? '...' : ''}</div>
` : ''}
`);
selectedFiles = [];
updateFilesList();
} catch (error) {
transcribeProgress.style.display = 'none';
showResult('transcribe-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});
// ==================== PROCESS TAB (Download + Transcribe) ====================
const processProgress = document.getElementById('process-progress');
const processProgressFill = document.getElementById('process-progress-fill');
const processProgressTitle = document.getElementById('process-progress-title');
const processProgressPercent = document.getElementById('process-progress-percent');
const processProgressPhase = document.getElementById('process-progress-phase');
const processProgressSpeed = document.getElementById('process-progress-speed');
const processProgressCurrent = document.getElementById('process-progress-current');
const processProgressEta = document.getElementById('process-progress-eta');
function resetProcessProgress() {
processProgressFill.style.width = '0%';
processProgressPercent.textContent = '0%';
processProgressTitle.textContent = 'Processing...';
processProgressPhase.textContent = '';
processProgressSpeed.textContent = '';
processProgressCurrent.textContent = '';
processProgressEta.textContent = '';
}
document.getElementById('process-form').addEventListener('submit', async (e) => {
e.preventDefault();
const button = e.target.querySelector('button[type="submit"]');
const url = document.getElementById('process-url').value;
const language = document.getElementById('process-lang').value;
const model = document.getElementById('process-model').value;
const resultDiv = document.getElementById('process-result');
setLoading(button, true);
resetProcessProgress();
processProgress.style.display = 'block';
resultDiv.classList.remove('show');
const params = new URLSearchParams({ url });
if (language) params.append('language', language);
params.append('model', model);
const eventSource = new EventSource(`${API_URL}/process-stream?${params}`);
eventSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
processProgressTitle.textContent = data.message;
if (data.phase === 'transcribing') {
processProgressPhase.textContent = 'Transcribing';
}
});
eventSource.addEventListener('info', (e) => {
const data = JSON.parse(e.data);
processProgressTitle.textContent = data.totalVideos > 1
? `Processing playlist: ${data.playlistTitle} (${data.totalVideos} videos)`
: `Processing: ${data.title}`;
});
eventSource.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
processProgressFill.style.width = `${data.percent}%`;
processProgressPercent.textContent = `${Math.round(data.percent)}%`;
processProgressPhase.textContent = data.phaseLabel || '';
if (data.speed) processProgressSpeed.textContent = data.speed;
if (data.title) {
processProgressCurrent.innerHTML = `${data.phaseLabel}: <span class="video-title">${data.title}</span>`;
}
if (data.totalVideos > 1) {
processProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`;
}
});
eventSource.addEventListener('video-complete', (e) => {
const data = JSON.parse(e.data);
processProgressCurrent.innerHTML = `Downloaded: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('transcribe-complete', (e) => {
const data = JSON.parse(e.data);
processProgressCurrent.innerHTML = `Transcribed: <span class="video-title">${data.title}</span> (${data.videosCompleted}/${data.totalFiles})`;
});
eventSource.addEventListener('complete', (e) => {
const data = JSON.parse(e.data);
eventSource.close();
processProgressFill.style.width = '100%';
processProgressPercent.textContent = '100%';
processProgressTitle.textContent = 'Processing Complete!';
processProgressPhase.textContent = '';
processProgressEta.textContent = `Total: ${formatTime(data.totalTime)}`;
showResult('process-result', true, `
<h3>Processing Complete!</h3>
${data.playlistTitle ? `<p>Playlist: ${data.playlistTitle}</p>` : ''}
<p>Downloaded: ${data.downloadedCount}/${data.totalVideos}</p>
<p>Transcribed: ${data.transcribedCount}/${data.totalVideos}</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.transcriptionSuccess ? 'icon-success' : 'icon-error'}">${r.transcriptionSuccess ? '✓' : '✗'}</span>
${r.title}
${r.audioUrl ? `<a href="${r.audioUrl}" target="_blank">MP3</a>` : ''}
${r.transcriptionUrl ? `<a href="${r.transcriptionUrl}" target="_blank">TXT</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.text ? `
<h4>Preview (first file):</h4>
<div class="preview">${data.results[0].text.substring(0, 1000)}${data.results[0].text.length > 1000 ? '...' : ''}</div>
` : ''}
`);
setLoading(button, false);
});
eventSource.addEventListener('error', (e) => {
let errorMsg = 'Processing failed';
try { errorMsg = JSON.parse(e.data).message || errorMsg; } catch {}
eventSource.close();
processProgress.style.display = 'none';
showResult('process-result', false, `<h3>Error</h3><p>${errorMsg}</p>`);
setLoading(button, false);
});
eventSource.onerror = () => {
eventSource.close();
processProgress.style.display = 'none';
showResult('process-result', false, `<h3>Error</h3><p>Connection lost</p>`);
setLoading(button, false);
};
});
// ==================== TRANSLATE CHECKBOXES (Transcribe & Process tabs) ====================
// Transcribe tab checkbox
const transcribeTranslateCheckbox = document.getElementById('transcribe-translate');
const transcribeTranslateLang = document.getElementById('transcribe-translate-lang');
transcribeTranslateCheckbox.addEventListener('change', () => {
transcribeTranslateLang.disabled = !transcribeTranslateCheckbox.checked;
});
// Process tab checkbox
const processTranslateCheckbox = document.getElementById('process-translate');
const processTranslateLang = document.getElementById('process-translate-lang');
processTranslateCheckbox.addEventListener('change', () => {
processTranslateLang.disabled = !processTranslateCheckbox.checked;
});
// ==================== TRANSLATE TAB ====================
// Mode switching
const translateModeBtns = document.querySelectorAll('.mode-btn');
const translateTextMode = document.getElementById('translate-text-mode');
const translateFileMode = document.getElementById('translate-file-mode');
translateModeBtns.forEach(btn => {
btn.addEventListener('click', () => {
translateModeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (btn.dataset.mode === 'text') {
translateTextMode.style.display = 'block';
translateFileMode.style.display = 'none';
} else {
translateTextMode.style.display = 'none';
translateFileMode.style.display = 'block';
}
});
});
// Text translation form
document.getElementById('translate-text-form').addEventListener('submit', async (e) => {
e.preventDefault();
const button = document.getElementById('translate-text-btn');
const text = document.getElementById('translate-input').value;
const sourceLang = document.getElementById('translate-source').value;
const targetLang = document.getElementById('translate-target').value;
if (!text.trim()) {
showResult('translate-text-result', false, '<h3>Error</h3><p>Please enter text to translate</p>');
return;
}
setLoading(button, true);
document.getElementById('translate-text-result').classList.remove('show');
try {
const response = await fetch(`${API_URL}/translate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, targetLang, sourceLang: sourceLang || null })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Translation failed');
showResult('translate-text-result', true, `
<h3>Translation Complete!</h3>
<p><strong>From:</strong> ${data.sourceLanguage} <strong>To:</strong> ${data.targetLanguage}</p>
<div class="translation-output">${data.translatedText}</div>
`);
} catch (error) {
showResult('translate-text-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});
// File translation - Drag & Drop
let translateSelectedFiles = [];
const translateDropZone = document.getElementById('translate-drop-zone');
const translateFileInput = document.getElementById('translate-file-input');
const translateSelectedFilesDiv = document.getElementById('translate-selected-files');
const translateFilesList = document.getElementById('translate-files-list');
const translateFileBtn = document.getElementById('translate-file-btn');
const translateClearFilesBtn = document.getElementById('translate-clear-files');
function updateTranslateFilesList() {
if (translateSelectedFiles.length === 0) {
translateSelectedFilesDiv.style.display = 'none';
translateFileBtn.disabled = true;
return;
}
translateSelectedFilesDiv.style.display = 'block';
translateFileBtn.disabled = false;
translateFilesList.innerHTML = translateSelectedFiles.map((file, index) => `
<li>
<span class="file-name">${file.name}</span>
<span class="file-size">${formatSize(file.size)}</span>
<button type="button" class="remove-file" data-index="${index}">x</button>
</li>
`).join('');
translateFilesList.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
translateSelectedFiles.splice(parseInt(btn.dataset.index), 1);
updateTranslateFilesList();
});
});
}
function addTranslateFiles(files) {
const textFiles = Array.from(files).filter(f =>
f.type === 'text/plain' || f.name.endsWith('.txt')
);
translateSelectedFiles = [...translateSelectedFiles, ...textFiles];
updateTranslateFilesList();
}
translateDropZone.addEventListener('click', () => translateFileInput.click());
translateDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
translateDropZone.classList.add('drag-over');
});
translateDropZone.addEventListener('dragleave', () => {
translateDropZone.classList.remove('drag-over');
});
translateDropZone.addEventListener('drop', (e) => {
e.preventDefault();
translateDropZone.classList.remove('drag-over');
addTranslateFiles(e.dataTransfer.files);
});
translateFileInput.addEventListener('change', () => {
addTranslateFiles(translateFileInput.files);
translateFileInput.value = '';
});
translateClearFilesBtn.addEventListener('click', () => {
translateSelectedFiles = [];
updateTranslateFilesList();
});
// File translation form submit
document.getElementById('translate-file-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (translateSelectedFiles.length === 0) return;
const button = translateFileBtn;
const sourceLang = document.getElementById('translate-file-source').value;
const targetLang = document.getElementById('translate-file-target').value;
setLoading(button, true);
document.getElementById('translate-file-result').classList.remove('show');
const formData = new FormData();
translateSelectedFiles.forEach(file => formData.append('files', file));
formData.append('targetLang', targetLang);
if (sourceLang) formData.append('sourceLang', sourceLang);
try {
const response = await fetch(`${API_URL}/translate-file`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Translation failed');
showResult('translate-file-result', true, `
<h3>Translation Complete!</h3>
<p>${data.successCount}/${data.totalFiles} files translated</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.success ? 'icon-success' : 'icon-error'}">${r.success ? '✓' : '✗'}</span>
${r.fileName || r.originalPath}
${r.success && r.translationUrl ? `<a href="${r.translationUrl}" target="_blank">View</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.translatedText ? `
<h4>Preview (first file):</h4>
<div class="translation-output">${data.results[0].translatedText.substring(0, 1000)}${data.results[0].translatedText.length > 1000 ? '...' : ''}</div>
` : ''}
`);
translateSelectedFiles = [];
updateTranslateFilesList();
} catch (error) {
showResult('translate-file-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});