diff --git a/app.py b/app.py index bb356c0..d0cd5d3 100644 --- a/app.py +++ b/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/') +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/ 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 diff --git a/templates/index.html b/templates/index.html index ffe05cf..adfbfd5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 @@ + + +
@@ -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 = ` - ✅ Téléchargement terminé !
- Fichier: ${data.filename}
- Taille: ${sizeMB} MB
- ${data.audio_only ? '🎵 Mode: Audio seulement
' : ''} - Format utilisé: ${data.format_used}
- Codec audio: ${data.audio_codec}
- Bitrate audio: ${data.audio_bitrate}
- Post-processing: ${data.postproc_applied ? 'Oui' : 'Non (copy direct)'} - `; + 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 = ` + ✅ Téléchargement terminé !
+ Fichier: ${progressData.filename}
+ Taille: ${sizeMB} MB
+ ${progressData.audio_only ? '🎵 Mode: Audio seulement
' : ''} + Format utilisé: ${progressData.format_used}
+ Codec audio: ${progressData.audio_codec}
+ Bitrate audio: ${progressData.audio_bitrate}
+ Post-processing: ${progressData.postproc_applied ? 'Oui' : 'Non (copy direct)'} + `; + + document.getElementById('commandDisplay').innerHTML = ` +
📋 Commande exécutée :
+ ${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('commandDisplay').innerHTML = ` -
📋 Commande exécutée :
- ${data.command} - `; - - 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); }