Add real-time download progress bar with SSE

Backend improvements:
- Add Server-Sent Events endpoint (/api/progress/<session_id>)
- Implement _do_download_with_progress() to track yt-dlp progress in real-time
- Parse yt-dlp output to extract percentage, speed, and ETA
- Run downloads in background threads (non-blocking)
- Store progress in download_sessions dict with thread-safe locks
- Download API now returns immediately with session_id

Frontend improvements:
- Add beautiful animated progress bar with gradient
- Display real-time percentage, speed, and ETA
- Connect to SSE stream for live updates
- Show progress section during download
- Auto-hide progress and show results when completed
- Handle errors gracefully with proper cleanup

User experience:
- No more blocking downloads - instant response
- See live progress as video downloads
- Visual feedback with smooth animations
- Clean transition from progress to download results

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

Co-Authored-By: Claude <noreply@anthropic.com>
main
Raynoxis 8 months ago
parent 837abcdf44
commit e94f9637b6

190
app.py

@ -36,7 +36,7 @@ VALID_CONTAINERS = {'mp4', 'mkv', 'webm', 'm4a', 'mp3'}
VALID_AUDIO_CODECS = {'aac', 'mp3', 'opus', 'copy'}
# Stockage des sessions de téléchargement pour le suivi de progression
download_sessions = {}
download_sessions = {} # {session_id: {'progress': 0, 'status': 'downloading', 'eta': '', 'speed': ''}}
download_lock = threading.Lock()
@ -188,6 +188,146 @@ def analyze_video():
return jsonify({'error': 'Erreur serveur'}), 500
@app.route('/api/progress/<session_id>')
def progress_stream(session_id):
"""Stream de progression via Server-Sent Events"""
def generate():
# Attendre que la session soit créée (max 5 secondes)
wait_count = 0
while wait_count < 10:
with download_lock:
if session_id in download_sessions:
break
time.sleep(0.5)
wait_count += 1
# Streamer la progression
while True:
with download_lock:
session_data = download_sessions.get(session_id, {})
if not session_data:
yield f"data: {json.dumps({'progress': 0, 'status': 'waiting'})}\n\n"
else:
yield f"data: {json.dumps(session_data)}\n\n"
# Arrêter le stream si terminé ou erreur
if session_data.get('status') in ['completed', 'error']:
break
time.sleep(0.5) # Mise à jour toutes les 0.5 secondes
return Response(generate(), mimetype='text/event-stream')
def _do_download_with_progress(session_id, cmd, session_dir, output_container, audio_only,
cmd_string, format_string, postproc_added, audio_codec, audio_bitrate):
"""Fonction helper pour télécharger avec suivi de progression"""
try:
# Initialiser la session
with download_lock:
download_sessions[session_id] = {
'progress': 0,
'status': 'downloading',
'eta': '',
'speed': ''
}
# Ajouter --newline pour avoir des mises à jour ligne par ligne
cmd_with_progress = cmd + ['--newline']
# Lancer yt-dlp et capturer la sortie en temps réel
process = subprocess.Popen(
cmd_with_progress,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# Parser la sortie ligne par ligne
for line in process.stdout:
# Extraire le pourcentage (format: "[download] XX.X% of ...")
match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line)
if match:
progress = float(match.group(1))
# Extraire ETA si disponible
eta_match = re.search(r'ETA\s+([\d:]+)', line)
eta = eta_match.group(1) if eta_match else ''
# Extraire vitesse si disponible
speed_match = re.search(r'at\s+([\d.]+\s*[KMG]iB/s)', line)
speed = speed_match.group(1) if speed_match else ''
with download_lock:
download_sessions[session_id].update({
'progress': min(progress, 99), # Cap à 99% jusqu'à la fin
'status': 'downloading',
'eta': eta,
'speed': speed
})
process.wait()
if process.returncode != 0:
with download_lock:
download_sessions[session_id] = {
'progress': 0,
'status': 'error',
'error': 'Erreur lors du téléchargement'
}
logger.error(f"Download failed for session {session_id}")
return
# Recherche du fichier téléchargé
if audio_only and output_container in ['mp3', 'm4a']:
downloaded_files = list(session_dir.glob(f'*.{output_container}'))
else:
downloaded_files = list(session_dir.glob(f'*.{output_container}'))
if not downloaded_files:
downloaded_files = list(session_dir.glob('*'))
if not downloaded_files:
with download_lock:
download_sessions[session_id] = {
'progress': 0,
'status': 'error',
'error': 'Fichier téléchargé introuvable'
}
logger.error(f"No downloaded file found for session {session_id}")
return
latest_file = max(downloaded_files, key=lambda p: p.stat().st_mtime)
# Marquer comme terminé
with download_lock:
download_sessions[session_id] = {
'progress': 100,
'status': 'completed',
'filename': latest_file.name,
'size': latest_file.stat().st_size,
'command': cmd_string,
'format_used': format_string,
'postproc_applied': postproc_added,
'audio_codec': audio_codec if audio_codec != 'copy' else 'original (copy)',
'audio_bitrate': audio_bitrate if audio_codec != 'copy' else 'original',
'audio_only': audio_only
}
logger.info(f"Download successful for session {session_id}: {latest_file.name}")
except Exception as e:
with download_lock:
download_sessions[session_id] = {
'progress': 0,
'status': 'error',
'error': str(e)
}
logger.error(f"Error in download for session {session_id}: {e}", exc_info=True)
@app.route('/api/download', methods=['POST'])
def download_video():
"""Télécharge la vidéo avec les paramètres spécifiés"""
@ -293,49 +433,25 @@ def download_video():
# Conversion de la commande en string pour affichage
cmd_string = ' '.join(f'"{arg}"' if ' ' in arg else arg for arg in cmd)
logger.info(f"Executing download command for session {session_id}")
# Exécution du téléchargement
result = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_DOWNLOAD)
if result.returncode != 0:
logger.error(f"Download failed for session {session_id}: {result.stderr}")
return jsonify({'error': 'Erreur lors du téléchargement'}), 400
# Recherche du fichier téléchargé dans le répertoire de session
if audio_only and output_container in ['mp3', 'm4a']:
downloaded_files = list(session_dir.glob(f'*.{output_container}'))
else:
downloaded_files = list(session_dir.glob(f'*.{output_container}'))
if not downloaded_files:
downloaded_files = list(session_dir.glob('*'))
logger.info(f"Starting download in background for session {session_id}")
if not downloaded_files:
logger.error(f"No downloaded file found for session {session_id}")
return jsonify({'error': 'Fichier téléchargé introuvable'}), 404
# Dernier fichier modifié
latest_file = max(downloaded_files, key=lambda p: p.stat().st_mtime)
logger.info(f"Download successful for session {session_id}: {latest_file.name}")
# Lancer le téléchargement dans un thread
download_thread = threading.Thread(
target=_do_download_with_progress,
args=(session_id, cmd, session_dir, output_container, audio_only,
cmd_string, format_string, postproc_added, audio_codec, audio_bitrate),
daemon=True
)
download_thread.start()
# Retourner immédiatement avec le session_id
# Le client se connectera au SSE pour suivre la progression
return jsonify({
'success': True,
'session_id': session_id,
'filename': latest_file.name,
'size': latest_file.stat().st_size,
'command': cmd_string,
'format_used': format_string,
'postproc_applied': postproc_added,
'audio_codec': audio_codec if audio_codec != 'copy' else 'original (copy)',
'audio_bitrate': audio_bitrate if audio_codec != 'copy' else 'original',
'audio_only': audio_only
'message': 'Téléchargement démarré, utilisez /api/progress/<session_id> pour suivre la progression'
})
except subprocess.TimeoutExpired:
logger.error(f"Download timeout for session {session_id}")
return jsonify({'error': 'Timeout lors du téléchargement'}), 408
except Exception as e:
logger.error(f"Unexpected error in download_video: {e}", exc_info=True)
return jsonify({'error': 'Erreur serveur'}), 500

@ -253,6 +253,55 @@
margin-bottom: 5px;
}
.progress-section {
padding: 20px;
background: #eff6ff;
border-radius: 12px;
border: 2px solid #3b82f6;
margin-top: 20px;
}
.progress-section h3 {
margin: 0 0 15px 0;
color: #1e40af;
}
.progress-bar-container {
width: 100%;
height: 30px;
background: #dbeafe;
border-radius: 15px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.5);
}
.progress-info {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 0.9em;
color: #1e40af;
}
.progress-info span {
padding: 5px 10px;
background: white;
border-radius: 5px;
margin: 0 5px;
}
@media (max-width: 768px) {
.format-row,
.output-options {
@ -356,6 +405,19 @@
</button>
</div>
<!-- Progress Bar -->
<div class="progress-section" id="progressSection" style="display: none;">
<h3>📥 Téléchargement en cours...</h3>
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
</div>
<div class="progress-info">
<span id="progressPercent">0%</span>
<span id="progressSpeed"></span>
<span id="progressEta"></span>
</div>
</div>
<div class="download-section" id="downloadSection">
<div class="download-info" id="downloadInfo"></div>
<div class="command-display" id="commandDisplay"></div>
@ -539,8 +601,10 @@
downloadBtn.disabled = true;
showLoading(true);
document.getElementById('downloadSection').style.display = 'none';
document.getElementById('progressSection').style.display = 'none';
try {
// Démarrer le téléchargement
const response = await fetch('/api/download', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@ -561,31 +625,72 @@
throw new Error(data.error || 'Erreur lors du téléchargement');
}
currentFilename = data.filename;
// Récupérer le session_id et se connecter au SSE
currentSessionId = data.session_id;
const sizeMB = (data.size / (1024 * 1024)).toFixed(2);
document.getElementById('downloadInfo').innerHTML = `
<strong>✅ Téléchargement terminé !</strong><br>
Fichier: ${data.filename}<br>
Taille: ${sizeMB} MB<br>
${data.audio_only ? '🎵 Mode: Audio seulement<br>' : ''}
Format utilisé: ${data.format_used}<br>
Codec audio: ${data.audio_codec}<br>
Bitrate audio: ${data.audio_bitrate}<br>
Post-processing: ${data.postproc_applied ? 'Oui' : 'Non (copy direct)'}
`;
document.getElementById('commandDisplay').innerHTML = `
<div class="command-label">📋 Commande exécutée :</div>
${data.command}
`;
showLoading(false);
document.getElementById('progressSection').style.display = 'block';
// Connexion SSE pour suivre la progression
const eventSource = new EventSource(`/api/progress/${currentSessionId}`);
eventSource.onmessage = function(event) {
const progressData = JSON.parse(event.data);
if (progressData.status === 'downloading') {
// Mettre à jour la barre de progression
const percent = Math.round(progressData.progress);
document.getElementById('progressBar').style.width = percent + '%';
document.getElementById('progressPercent').textContent = percent + '%';
document.getElementById('progressSpeed').textContent = progressData.speed || '';
document.getElementById('progressEta').textContent = progressData.eta ? `ETA: ${progressData.eta}` : '';
} else if (progressData.status === 'completed') {
// Téléchargement terminé
eventSource.close();
document.getElementById('progressBar').style.width = '100%';
document.getElementById('progressPercent').textContent = '100%';
document.getElementById('progressSection').style.display = 'none';
// Afficher les infos de téléchargement
currentFilename = progressData.filename;
const sizeMB = (progressData.size / (1024 * 1024)).toFixed(2);
document.getElementById('downloadInfo').innerHTML = `
<strong>✅ Téléchargement terminé !</strong><br>
Fichier: ${progressData.filename}<br>
Taille: ${sizeMB} MB<br>
${progressData.audio_only ? '🎵 Mode: Audio seulement<br>' : ''}
Format utilisé: ${progressData.format_used}<br>
Codec audio: ${progressData.audio_codec}<br>
Bitrate audio: ${progressData.audio_bitrate}<br>
Post-processing: ${progressData.postproc_applied ? 'Oui' : 'Non (copy direct)'}
`;
document.getElementById('commandDisplay').innerHTML = `
<div class="command-label">📋 Commande exécutée :</div>
${progressData.command}
`;
document.getElementById('downloadSection').style.display = 'block';
showStatus('Vidéo téléchargée avec succès !', 'success');
downloadBtn.disabled = false;
} else if (progressData.status === 'error') {
// Erreur de téléchargement
eventSource.close();
document.getElementById('progressSection').style.display = 'none';
showStatus(progressData.error || 'Erreur lors du téléchargement', 'error');
downloadBtn.disabled = false;
}
};
eventSource.onerror = function() {
eventSource.close();
showStatus('Erreur de connexion au serveur', 'error');
downloadBtn.disabled = false;
document.getElementById('progressSection').style.display = 'none';
};
document.getElementById('downloadSection').style.display = 'block';
showStatus('Vidéo téléchargée avec succès !', 'success');
} catch (error) {
showStatus(error.message, 'error');
} finally {
downloadBtn.disabled = false;
showLoading(false);
}

Loading…
Cancel
Save