videotomp3transcriptor/public/app.js
debian.StillHammer 751a382ccd Add YouTube cookies management system with enhanced error messages
Features:
- New POST /admin/upload-cookies endpoint for cookie upload
- Enhanced YouTube bot detection error messages with solutions
- extract-and-upload-cookies.sh script for automated cookie extraction
- Improved error display in web interface with actionable solutions
- Cookie storage in both local and persistent locations

Technical changes:
- Add enhanceYouTubeError() in youtube.js service
- Add formatYouTubeError() in web app.js
- Add handleYouTubeError() helper in server.js
- Enhanced SSE error handling for all streaming endpoints
- Updated API documentation with cookies section

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 10:42:53 +00:00

1423 lines
52 KiB
JavaScript
Raw Permalink 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 CONFIGURATION ====================
const API_URL = '';
const TOKEN_STORAGE_KEY = 'video_transcriptor_api_token';
// Token management
let apiToken = localStorage.getItem(TOKEN_STORAGE_KEY) || '';
// Helper: Get API headers with token
function getHeaders(additionalHeaders = {}) {
const headers = { ...additionalHeaders };
if (apiToken) {
headers['X-API-Key'] = apiToken;
}
return headers;
}
// Helper: Authenticated fetch
async function apiFetch(url, options = {}) {
const defaultHeaders = getHeaders(options.headers || {});
const response = await fetch(url, {
...options,
headers: defaultHeaders,
});
// Check for auth errors
if (response.status === 401 || response.status === 403) {
showResult('api-error', false, `
<h3>⚠️ Authentication Error</h3>
<p>${response.status === 401 ? 'API token required' : 'Invalid API token'}</p>
<p>Please configure your API token in the configuration panel above.</p>
`);
throw new Error('Authentication failed');
}
return response;
}
// Helper: Format YouTube enhanced errors
function formatYouTubeError(errorData) {
// Check if it's an enhanced YouTube bot detection error
if (errorData.error === 'YouTube Bot Detection' || errorData.solution) {
return `
<div class="youtube-error">
<h3>🚫 ${errorData.error || 'YouTube Error'}</h3>
<p class="error-message">${errorData.message}</p>
${errorData.reason ? `<details class="error-details">
<summary>Technical Details</summary>
<pre>${errorData.reason}</pre>
</details>` : ''}
<div class="error-solution">
<h4>💡 Solution</h4>
<p><strong>${errorData.solution.quick}</strong></p>
<ol class="solution-steps">
${errorData.solution.steps.map(step => `<li>${step}</li>`).join('')}
</ol>
${errorData.solution.alternative ? `
<p class="solution-alternative"><strong>Alternative:</strong> ${errorData.solution.alternative}</p>
` : ''}
${errorData.solution.documentation ? `
<p class="solution-docs">📚 ${errorData.solution.documentation}</p>
` : ''}
</div>
${errorData.currentConfig ? `
<details class="config-status">
<summary>Current Configuration</summary>
<ul>
<li><strong>Cookies File:</strong> ${errorData.currentConfig.cookiesFile}</li>
<li><strong>Browser Extraction:</strong> ${errorData.currentConfig.cookiesBrowser}</li>
<li><strong>Status:</strong> ${errorData.currentConfig.status}</li>
</ul>
</details>
` : ''}
<div class="error-actions">
<button class="btn btn-primary btn-small" onclick="window.scrollTo(0, 0); document.getElementById('toggle-config').click();">
Upload Cookies Now
</button>
</div>
</div>
`;
}
// Generic YouTube error
if (errorData.error && errorData.error.includes('YouTube')) {
return `
<h3>❌ ${errorData.error}</h3>
<p>${errorData.message}</p>
${errorData.solution ? `<p><em>${errorData.solution}</em></p>` : ''}
`;
}
// Default error
return `<h3>Error</h3><p>${errorData.message || errorData.error || 'Unknown error'}</p>`;
}
// API Configuration Panel
const configPanel = document.getElementById('api-config-panel');
const configHeader = document.querySelector('.config-header');
const configContent = document.getElementById('config-content');
const toggleConfigBtn = document.getElementById('toggle-config');
const apiTokenInput = document.getElementById('api-token');
const toggleVisibilityBtn = document.getElementById('toggle-token-visibility');
const apiConfigForm = document.getElementById('api-config-form');
const clearTokenBtn = document.getElementById('clear-token');
const configStatus = document.getElementById('config-status');
// Load token on page load
if (apiToken) {
apiTokenInput.value = apiToken;
updateConnectionStatus(true);
}
// Toggle configuration panel
function toggleConfigPanel() {
const isExpanded = configContent.style.display !== 'none';
configContent.style.display = isExpanded ? 'none' : 'block';
configHeader.classList.toggle('expanded', !isExpanded);
}
configHeader.addEventListener('click', (e) => {
if (e.target.closest('.btn-toggle') || e.target === configHeader) {
toggleConfigPanel();
}
});
// Toggle token visibility
toggleVisibilityBtn.addEventListener('click', () => {
const isPassword = apiTokenInput.type === 'password';
apiTokenInput.type = isPassword ? 'text' : 'password';
const eyeIcon = document.getElementById('eye-icon');
if (isPassword) {
eyeIcon.innerHTML = `
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
`;
} else {
eyeIcon.innerHTML = `
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
`;
}
});
// Update connection status UI
function updateConnectionStatus(connected, message = '') {
const indicator = configStatus.querySelector('.status-indicator');
const text = configStatus.querySelector('.status-text');
if (connected) {
indicator.className = 'status-indicator status-connected';
text.textContent = message || 'Connected ✓';
} else {
indicator.className = 'status-indicator status-disconnected';
text.textContent = message || 'Not configured';
}
}
// Test API connection
async function testApiConnection(token) {
try {
const tempToken = apiToken;
apiToken = token; // Temporarily set token for test
const response = await apiFetch(`${API_URL}/health`);
const data = await response.json();
if (data.status === 'ok') {
apiToken = tempToken; // Restore original
return { success: true, message: 'Connected ✓' };
} else {
apiToken = tempToken;
return { success: false, message: 'API error' };
}
} catch (error) {
return { success: false, message: error.message };
}
}
// Save and test token
apiConfigForm.addEventListener('submit', async (e) => {
e.preventDefault();
const token = apiTokenInput.value.trim();
if (!token) {
updateConnectionStatus(false, 'Please enter a token');
return;
}
const submitBtn = apiConfigForm.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<span>Testing...</span>';
submitBtn.disabled = true;
try {
const result = await testApiConnection(token);
if (result.success) {
// Save token
localStorage.setItem(TOKEN_STORAGE_KEY, token);
apiToken = token;
updateConnectionStatus(true, result.message);
// Show success message
showNotification('✓ API token saved successfully!', 'success');
// Collapse panel after 2 seconds
setTimeout(() => {
toggleConfigPanel();
}, 2000);
} else {
updateConnectionStatus(false, 'Connection failed');
showNotification('✗ Failed to connect to API. Check your token.', 'error');
}
} catch (error) {
updateConnectionStatus(false, 'Connection error');
showNotification('✗ Error testing connection: ' + error.message, 'error');
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
});
// Clear token
clearTokenBtn.addEventListener('click', () => {
localStorage.removeItem(TOKEN_STORAGE_KEY);
apiToken = '';
apiTokenInput.value = '';
updateConnectionStatus(false, 'Token cleared');
showNotification('Token removed', 'info');
});
// Helper: Show notification toast
function showNotification(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `notification notification-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
background: ${type === 'success' ? 'rgba(16, 185, 129, 0.9)' : type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(59, 130, 246, 0.9)'};
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10000;
animation: slideIn 0.3s ease;
font-weight: 500;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Add animations to document
if (!document.getElementById('notification-styles')) {
const style = document.createElement('style');
style.id = 'notification-styles';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(400px); opacity: 0; }
}
`;
document.head.appendChild(style);
}
// ==================== TAB SWITCHING ====================
// 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 errorHtml = '<h3>Error</h3><p>Download failed</p>';
try {
const errorData = JSON.parse(e.data);
errorHtml = formatYouTubeError(errorData);
} catch {
// Keep default error
}
eventSource.close();
progressContainer.style.display = 'none';
showResult('download-result', false, errorHtml);
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);
};
});
// ==================== CONVERT TAB (Video to MP3) ====================
let convertSelectedFiles = [];
const convertDropZone = document.getElementById('convert-drop-zone');
const convertFileInput = document.getElementById('convert-file-input');
const convertSelectedFilesDiv = document.getElementById('convert-selected-files');
const convertFilesList = document.getElementById('convert-files-list');
const convertBtn = document.getElementById('convert-btn');
const convertClearFilesBtn = document.getElementById('convert-clear-files');
function updateConvertFilesList() {
if (convertSelectedFiles.length === 0) {
convertSelectedFilesDiv.style.display = 'none';
convertBtn.disabled = true;
return;
}
convertSelectedFilesDiv.style.display = 'block';
convertBtn.disabled = false;
convertFilesList.innerHTML = convertSelectedFiles.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
convertFilesList.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
convertSelectedFiles.splice(parseInt(btn.dataset.index), 1);
updateConvertFilesList();
});
});
}
function addConvertFiles(files) {
const videoAudioFiles = Array.from(files).filter(f =>
f.type.startsWith('video/') || f.type.startsWith('audio/') ||
f.name.match(/\.(mp4|avi|mkv|mov|m4a|wav|flac|ogg|webm|wmv|flv)$/i)
);
convertSelectedFiles = [...convertSelectedFiles, ...videoAudioFiles];
updateConvertFilesList();
}
// Drag & Drop events for convert
convertDropZone.addEventListener('click', () => convertFileInput.click());
convertDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
convertDropZone.classList.add('drag-over');
});
convertDropZone.addEventListener('dragleave', () => {
convertDropZone.classList.remove('drag-over');
});
convertDropZone.addEventListener('drop', (e) => {
e.preventDefault();
convertDropZone.classList.remove('drag-over');
addConvertFiles(e.dataTransfer.files);
});
convertFileInput.addEventListener('change', () => {
addConvertFiles(convertFileInput.files);
convertFileInput.value = '';
});
convertClearFilesBtn.addEventListener('click', () => {
convertSelectedFiles = [];
updateConvertFilesList();
});
// Convert form submit
document.getElementById('convert-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (convertSelectedFiles.length === 0) return;
const button = convertBtn;
const bitrate = document.getElementById('convert-bitrate').value;
const quality = document.getElementById('convert-quality').value;
const convertProgress = document.getElementById('convert-progress');
const convertProgressFill = document.getElementById('convert-progress-fill');
const convertProgressTitle = document.getElementById('convert-progress-title');
const convertProgressPercent = document.getElementById('convert-progress-percent');
const convertProgressInfo = document.getElementById('convert-progress-info');
const convertProgressCurrent = document.getElementById('convert-progress-current');
setLoading(button, true);
convertProgress.style.display = 'block';
convertProgressFill.style.width = '0%';
convertProgressTitle.textContent = 'Converting to MP3...';
convertProgressPercent.textContent = '0%';
convertProgressInfo.textContent = `0/${convertSelectedFiles.length} files`;
document.getElementById('convert-result').classList.remove('show');
const formData = new FormData();
convertSelectedFiles.forEach(file => formData.append('files', file));
formData.append('bitrate', bitrate);
formData.append('quality', quality);
try {
const response = await fetch(`${API_URL}/convert-to-mp3`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Conversion failed');
convertProgressFill.style.width = '100%';
convertProgressPercent.textContent = '100%';
convertProgressTitle.textContent = 'Conversion Complete!';
convertProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`;
showResult('convert-result', true, `
<h3>Conversion Complete!</h3>
<p>${data.successCount}/${data.totalFiles} files converted to MP3</p>
<div class="file-results">
${data.results.map(r => r.success ? `
<div class="file-result success">
<div class="file-info">
<strong>${r.fileName}</strong>
<span class="file-size">${r.size}</span>
</div>
<a href="${r.outputUrl}" download class="btn btn-small btn-primary">Download MP3</a>
</div>
` : `
<div class="file-result error">
<strong>${r.fileName}</strong>
<span class="error-msg">${r.error}</span>
</div>
`).join('')}
</div>
`);
// Clear selected files after successful conversion
convertSelectedFiles = [];
updateConvertFilesList();
} catch (error) {
showResult('convert-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
setTimeout(() => {
convertProgress.style.display = 'none';
}, 1000);
}
});
// ==================== 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 errorHtml = '<h3>Error</h3><p>Processing failed</p>';
try {
const errorData = JSON.parse(e.data);
errorHtml = formatYouTubeError(errorData);
} catch {
// Keep default error
}
eventSource.close();
processProgress.style.display = 'none';
showResult('process-result', false, errorHtml);
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);
}
});
// ==================== SUMMARIZE TAB ====================
// Mode switching
const summarizeModeBtns = document.querySelectorAll('.summarize-mode-selector .mode-btn');
const summarizeTextMode = document.getElementById('summarize-text-mode');
const summarizeFileMode = document.getElementById('summarize-file-mode');
const summarizeLinkMode = document.getElementById('summarize-link-mode');
summarizeModeBtns.forEach(btn => {
btn.addEventListener('click', () => {
summarizeModeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
summarizeTextMode.style.display = 'none';
summarizeFileMode.style.display = 'none';
if (summarizeLinkMode) summarizeLinkMode.style.display = 'none';
if (btn.dataset.mode === 'text') {
summarizeTextMode.style.display = 'block';
} else if (btn.dataset.mode === 'file') {
summarizeFileMode.style.display = 'block';
} else if (btn.dataset.mode === 'link' && summarizeLinkMode) {
summarizeLinkMode.style.display = 'block';
}
});
});
// Text summarization form
document.getElementById('summarize-text-form').addEventListener('submit', async (e) => {
e.preventDefault();
const button = document.getElementById('summarize-text-btn');
const text = document.getElementById('summarize-input').value;
const style = document.getElementById('summarize-style').value;
const language = document.getElementById('summarize-language').value;
if (!text.trim()) {
showResult('summarize-text-result', false, '<h3>Error</h3><p>Please enter text to summarize</p>');
return;
}
setLoading(button, true);
document.getElementById('summarize-text-result').classList.remove('show');
try {
const response = await fetch(`${API_URL}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, style, language })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Summarization failed');
showResult('summarize-text-result', true, `
<h3>Summary Complete!</h3>
<p><strong>Model:</strong> ${data.model} | <strong>Style:</strong> ${data.style} | <strong>Chunks:</strong> ${data.chunks}</p>
<div class="summary-output">${data.summary}</div>
`);
} catch (error) {
showResult('summarize-text-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});
// File summarization - Drag & Drop
let summarizeSelectedFiles = [];
const summarizeDropZone = document.getElementById('summarize-drop-zone');
const summarizeFileInput = document.getElementById('summarize-file-input');
const summarizeSelectedFilesDiv = document.getElementById('summarize-selected-files');
const summarizeFilesList = document.getElementById('summarize-files-list');
const summarizeFileBtn = document.getElementById('summarize-file-btn');
const summarizeClearFilesBtn = document.getElementById('summarize-clear-files');
function updateSummarizeFilesList() {
if (!summarizeSelectedFilesDiv || !summarizeFileBtn || !summarizeFilesList) return;
if (summarizeSelectedFiles.length === 0) {
summarizeSelectedFilesDiv.style.display = 'none';
summarizeFileBtn.disabled = true;
return;
}
summarizeSelectedFilesDiv.style.display = 'block';
summarizeFileBtn.disabled = false;
summarizeFilesList.innerHTML = summarizeSelectedFiles.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('');
summarizeFilesList.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
summarizeSelectedFiles.splice(parseInt(btn.dataset.index), 1);
updateSummarizeFilesList();
});
});
}
function addSummarizeFiles(files) {
console.log('Adding files:', files);
const textFiles = Array.from(files).filter(f =>
f.type === 'text/plain' || f.name.endsWith('.txt')
);
console.log('Filtered text files:', textFiles);
summarizeSelectedFiles = [...summarizeSelectedFiles, ...textFiles];
updateSummarizeFilesList();
}
if (summarizeDropZone) {
summarizeDropZone.addEventListener('click', () => {
console.log('Drop zone clicked');
summarizeFileInput.click();
});
summarizeDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
summarizeDropZone.classList.add('drag-over');
});
summarizeDropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
summarizeDropZone.classList.remove('drag-over');
});
summarizeDropZone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('Files dropped:', e.dataTransfer.files);
summarizeDropZone.classList.remove('drag-over');
addSummarizeFiles(e.dataTransfer.files);
});
}
if (summarizeFileInput) {
summarizeFileInput.addEventListener('change', () => {
console.log('File input changed:', summarizeFileInput.files);
addSummarizeFiles(summarizeFileInput.files);
summarizeFileInput.value = '';
});
}
if (summarizeClearFilesBtn) {
summarizeClearFilesBtn.addEventListener('click', () => {
summarizeSelectedFiles = [];
updateSummarizeFilesList();
});
}
// File summarization form submit
document.getElementById('summarize-file-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (summarizeSelectedFiles.length === 0) return;
const button = summarizeFileBtn;
const style = document.getElementById('summarize-file-style').value;
const language = document.getElementById('summarize-file-language').value;
setLoading(button, true);
document.getElementById('summarize-file-result').classList.remove('show');
const formData = new FormData();
summarizeSelectedFiles.forEach(file => formData.append('files', file));
formData.append('style', style);
formData.append('language', language);
try {
const response = await fetch(`${API_URL}/summarize-file`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Summarization failed');
showResult('summarize-file-result', true, `
<h3>Summarization Complete!</h3>
<p>${data.successCount}/${data.totalFiles} files summarized</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.success ? 'icon-success' : 'icon-error'}">${r.success ? '✓' : '✗'}</span>
${r.fileName || r.filePath}
${r.success && r.summaryUrl ? `<a href="${r.summaryUrl}" target="_blank">View</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.summary ? `
<h4>Preview (first file):</h4>
<div class="summary-output">${data.results[0].summary.substring(0, 1000)}${data.results[0].summary.length > 1000 ? '...' : ''}</div>
` : ''}
`);
summarizeSelectedFiles = [];
updateSummarizeFilesList();
} catch (error) {
showResult('summarize-file-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
}
});
// ==================== SUMMARIZE LINK MODE (Full Pipeline) ====================
const summarizeLinkForm = document.getElementById('summarize-link-form');
if (summarizeLinkForm) {
const linkProgress = document.getElementById('summarize-link-progress');
const linkProgressFill = document.getElementById('summarize-link-progress-fill');
const linkProgressTitle = document.getElementById('summarize-link-progress-title');
const linkProgressPercent = document.getElementById('summarize-link-progress-percent');
const linkProgressPhase = document.getElementById('summarize-link-progress-phase');
const linkProgressSpeed = document.getElementById('summarize-link-progress-speed');
const linkProgressCurrent = document.getElementById('summarize-link-progress-current');
const linkProgressEta = document.getElementById('summarize-link-progress-eta');
function resetLinkProgress() {
if (linkProgressFill) linkProgressFill.style.width = '0%';
if (linkProgressPercent) linkProgressPercent.textContent = '0%';
if (linkProgressTitle) linkProgressTitle.textContent = 'Processing...';
if (linkProgressPhase) linkProgressPhase.textContent = '';
if (linkProgressSpeed) linkProgressSpeed.textContent = '';
if (linkProgressCurrent) linkProgressCurrent.textContent = '';
if (linkProgressEta) linkProgressEta.textContent = '';
}
summarizeLinkForm.addEventListener('submit', async (e) => {
e.preventDefault();
const button = document.getElementById('summarize-link-btn');
const url = document.getElementById('summarize-url').value;
const style = document.getElementById('summarize-link-style').value;
const language = document.getElementById('summarize-link-language').value;
const resultDiv = document.getElementById('summarize-link-result');
setLoading(button, true);
resetLinkProgress();
linkProgress.style.display = 'block';
resultDiv.classList.remove('show');
const params = new URLSearchParams({ url, style, language });
const eventSource = new EventSource(`${API_URL}/summarize-stream?${params}`);
eventSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
linkProgressTitle.textContent = data.message;
if (data.percent !== undefined) {
linkProgressFill.style.width = `${data.percent}%`;
linkProgressPercent.textContent = `${Math.round(data.percent)}%`;
}
});
eventSource.addEventListener('info', (e) => {
const data = JSON.parse(e.data);
linkProgressTitle.textContent = data.totalVideos > 1
? `Processing playlist: ${data.playlistTitle} (${data.totalVideos} videos)`
: `Processing: ${data.title}`;
});
eventSource.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
linkProgressFill.style.width = `${data.percent}%`;
linkProgressPercent.textContent = `${Math.round(data.percent)}%`;
linkProgressPhase.textContent = data.phaseLabel || '';
if (data.speed) linkProgressSpeed.textContent = data.speed;
if (data.title) {
linkProgressCurrent.innerHTML = `${data.phaseLabel}: <span class="video-title">${data.title}</span>`;
}
if (data.totalVideos > 1) {
linkProgressCurrent.innerHTML += ` (${data.currentVideo}/${data.totalVideos})`;
}
});
eventSource.addEventListener('video-complete', (e) => {
const data = JSON.parse(e.data);
linkProgressCurrent.innerHTML = `Downloaded: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('transcribe-complete', (e) => {
const data = JSON.parse(e.data);
linkProgressCurrent.innerHTML = `Transcribed: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('summarize-complete', (e) => {
const data = JSON.parse(e.data);
linkProgressCurrent.innerHTML = `Summarized: <span class="video-title">${data.title}</span>`;
});
eventSource.addEventListener('complete', (e) => {
const data = JSON.parse(e.data);
eventSource.close();
linkProgressFill.style.width = '100%';
linkProgressPercent.textContent = '100%';
linkProgressTitle.textContent = 'Complete!';
linkProgressPhase.textContent = '';
linkProgressEta.textContent = `Total: ${formatTime(data.totalTime)}`;
showResult('summarize-link-result', true, `
<h3>Pipeline Complete!</h3>
${data.playlistTitle ? `<p>Playlist: ${data.playlistTitle}</p>` : ''}
<p>Downloaded: ${data.downloadedCount}/${data.totalVideos} | Transcribed: ${data.transcribedCount} | Summarized: ${data.summarizedCount}</p>
<ul>${data.results.map(r => `
<li>
<span class="${r.success ? 'icon-success' : 'icon-error'}">${r.success ? '✓' : '✗'}</span>
<strong>${r.title}</strong>
${r.summaryUrl ? `<a href="${r.summaryUrl}" target="_blank">Summary</a>` : ''}
${r.transcriptionUrl ? `<a href="${r.transcriptionUrl}" target="_blank">Transcript</a>` : ''}
${r.error ? `<small>(${r.error})</small>` : ''}
</li>
`).join('')}</ul>
${data.results[0]?.summary ? `
<h4>Summary Preview:</h4>
<div class="summary-output">${data.results[0].summary.substring(0, 2000)}${data.results[0].summary.length > 2000 ? '...' : ''}</div>
` : ''}
`);
setLoading(button, false);
});
eventSource.addEventListener('error', (e) => {
let errorHtml = '<h3>Error</h3><p>Processing failed</p>';
try {
const errorData = JSON.parse(e.data);
errorHtml = formatYouTubeError(errorData);
} catch {
// Keep default error
}
eventSource.close();
linkProgress.style.display = 'none';
showResult('summarize-link-result', false, errorHtml);
setLoading(button, false);
});
eventSource.onerror = () => {
eventSource.close();
linkProgress.style.display = 'none';
showResult('summarize-link-result', false, `<h3>Error</h3><p>Connection lost</p>`);
setLoading(button, false);
};
});
}