├── docs └── screenshots │ ├── step1.png │ └── step2.png ├── static └── img │ └── raynoxis-avatar.png ├── .dockerignore ├── .env.example ├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── README.md ├── app.py └── templates └── index.html /docs/screenshots/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raynoxis/yt-dlp-Web-Interface/HEAD/docs/screenshots/step1.png -------------------------------------------------------------------------------- /docs/screenshots/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raynoxis/yt-dlp-Web-Interface/HEAD/docs/screenshots/step2.png -------------------------------------------------------------------------------- /static/img/raynoxis-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raynoxis/yt-dlp-Web-Interface/HEAD/static/img/raynoxis-avatar.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | *.md 4 | downloads/ 5 | __pycache__/ 6 | *.pyc 7 | .vscode/ 8 | .idea/ 9 | .env 10 | docker-compose.yml 11 | LICENSE 12 | docs/ 13 | *.mp4 14 | *.mkv 15 | *.webm 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # User ID and Group ID for Docker volume permissions 2 | # This ensures the container runs with your user's permissions 3 | # avoiding the need for root access 4 | 5 | # On Linux/WSL2, use: 6 | # UID=$(id -u) 7 | # GID=$(id -g) 8 | 9 | UID=1000 10 | GID=1000 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | venv/ 9 | ENV/ 10 | .venv 11 | *.egg-info/ 12 | dist/ 13 | build/ 14 | 15 | # Downloads 16 | downloads/ 17 | downloads.old/ 18 | *.mp4 19 | *.mkv 20 | *.webm 21 | *.m4a 22 | 23 | # IDE 24 | .vscode/ 25 | .idea/ 26 | *.swp 27 | *.swo 28 | *~ 29 | 30 | # OS 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Logs 35 | *.log 36 | 37 | # Environment 38 | .env 39 | .env.local 40 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | ytdlp-webinterface: 5 | image: raynoxis/yt-dlp-web-interface:latest 6 | container_name: ytdlp-webinterface 7 | ports: 8 | - "5001:5000" 9 | volumes: 10 | - /tmp:/app/downloads 11 | restart: unless-stopped 12 | environment: 13 | - FLASK_ENV=production 14 | - PYTHONUNBUFFERED=1 15 | healthcheck: 16 | test: ["CMD", "curl", "-f", "http://localhost:5000/"] 17 | interval: 30s 18 | timeout: 10s 19 | retries: 3 20 | start_period: 40s 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | # Installation des dépendances système 4 | RUN apt-get update && apt-get install -y \ 5 | ffmpeg \ 6 | wget \ 7 | curl \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | # Installation de yt-dlp 11 | RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \ 12 | && chmod +x /usr/local/bin/yt-dlp 13 | 14 | # Installation de Flask 15 | RUN pip install --no-cache-dir flask 16 | 17 | # Création d'un utilisateur non-root 18 | RUN useradd -m -u 1000 appuser && \ 19 | mkdir -p /app/templates && \ 20 | chown -R appuser:appuser /app 21 | 22 | # Création des répertoires 23 | WORKDIR /app 24 | 25 | # Copie des fichiers de l'application 26 | COPY --chown=appuser:appuser app.py /app/ 27 | COPY --chown=appuser:appuser templates/index.html /app/templates/ 28 | 29 | # Passer à l'utilisateur non-root 30 | USER appuser 31 | 32 | # Exposition du port 33 | EXPOSE 5000 34 | 35 | # Variables d'environnement 36 | ENV FLASK_APP=app.py 37 | ENV PYTHONUNBUFFERED=1 38 | 39 | # Commande de démarrage 40 | CMD ["python", "-u", "app.py"] 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 [Votre Nom] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎬 yt-dlp Web Interface 2 | 3 | Modern, hardened web UI for downloading YouTube videos with conversion options and detailed status. Built on the excellent [yt-dlp](https://github.com/yt-dlp/yt-dlp) and fully containerized with Docker/Podman. Coded with my friend: Claude AI 4 | 5 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) 6 | ![Docker](https://img.shields.io/badge/docker-ready-blue.svg) 7 | ![Python](https://img.shields.io/badge/python-3.11-blue.svg) 8 | ![Security](https://img.shields.io/badge/security-hardened-green.svg) 9 | 10 | ## ✨ Highlights 11 | - 🎯 Choose exact video/audio formats with custom containers and codecs 12 | - 📈 Real-time progress via SSE; shows speed, ETA, and the yt-dlp command used 13 | - 🛡️ Hardened: strict URL validation, path traversal protection, non-root container, full logging 14 | - ⚡ Concurrent, non-blocking downloads with automatic cleanup after 1h 15 | - 🐳 Ready-to-run images for Docker and Podman 16 | 17 | ## 🚀 Deploy 18 | 19 | ### Docker 20 | ```bash 21 | docker pull raynoxis/yt-dlp-web-interface:latest 22 | docker run -d -p 5000:5000 --name ytdlp-web raynoxis/yt-dlp-web-interface:latest 23 | ``` 24 | 25 | ### Docker Compose (recommended) 26 | ```bash 27 | git clone https://github.com/Raynoxis/yt-dlp-Web-Interface.git 28 | cd yt-dlp-Web-Interface 29 | 30 | docker-compose up -d 31 | ``` 32 | 33 | Access the UI: **http://localhost:5001** (Compose) or **http://localhost:5000** (direct run). 34 | 35 | Persist downloads: 36 | ```bash 37 | docker run -d -p 5000:5000 \ 38 | -v ./downloads:/app/downloads \ 39 | --name ytdlp-web \ 40 | raynoxis/yt-dlp-web-interface:latest 41 | ``` 42 | 43 | Build from source (optional): 44 | ```bash 45 | git clone https://github.com/Raynoxis/yt-dlp-Web-Interface.git 46 | cd yt-dlp-Web-Interface 47 | docker build -t raynoxis/yt-dlp-web-interface . 48 | docker run -d -p 5000:5000 --name ytdlp-web raynoxis/yt-dlp-web-interface 49 | ``` 50 | 51 | ## 📸 Screenshots 52 | ### Step 1 - Analysis 53 | ![Step 1](docs/screenshots/step1.png) 54 | 55 | ### Step 2 - Format selection 56 | ![Step 2](docs/screenshots/step2.png) 57 | 58 | 59 | ## 🎯 How it works 60 | 1. Paste a YouTube URL and click **Analyze video** to list available streams. 61 | 2. Pick video and audio formats; choose container, codec, and bitrate. 62 | 3. Click **Download** and follow live progress (speed, ETA, executed yt-dlp command). 63 | 4. Download the generated file when it completes. 64 | 65 | 66 | ## 📝 License 67 | 68 | This project is under the MIT License. See [LICENSE](LICENSE) for details. 69 | 70 | ## 🙏 Credits 71 | 72 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The best video download tool 73 | - [Flask](https://flask.palletsprojects.com/) - Python web framework 74 | - [FFmpeg](https://ffmpeg.org/) - Audio/video processing 75 | - [Claude AI](https://claude.ai) - Development assistant 76 | 77 | ## ⚠️ Disclaimer 78 | 79 | This tool is for personal and educational use. Respect YouTube's terms of service and your local copyright laws. 80 | 81 | ## 📧 Contact 82 | 83 | Raynoxis - [GitHub](https://github.com/Raynoxis) 84 | 85 | Project link: [https://github.com/Raynoxis/yt-dlp-Web-Interface](https://github.com/Raynoxis/yt-dlp-Web-Interface) 86 | 87 | Docker Hub link: [https://hub.docker.com/r/raynoxis/yt-dlp-web-interface](https://hub.docker.com/r/raynoxis/yt-dlp-web-interface) 88 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, jsonify, send_file, Response 2 | import subprocess 3 | import json 4 | import os 5 | import re 6 | import logging 7 | import uuid 8 | import time 9 | from pathlib import Path 10 | from urllib.parse import urlparse 11 | import threading 12 | 13 | # Configuration du logging 14 | logging.basicConfig( 15 | level=logging.INFO, 16 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 17 | ) 18 | logger = logging.getLogger(__name__) 19 | 20 | app = Flask(__name__) 21 | DOWNLOAD_DIR = Path('/app/downloads') 22 | DOWNLOAD_DIR.mkdir(exist_ok=True) 23 | 24 | # Configuration 25 | BYTES_PER_MB = 1024 * 1024 26 | TIMEOUT_ANALYZE = 30 27 | TIMEOUT_DOWNLOAD = 600 28 | 29 | # Validation YouTube URL 30 | YOUTUBE_REGEX = re.compile( 31 | r'^(https?://)?(www\.)?(youtube\.com/(watch\?v=|shorts/|embed/)|youtu\.be/)[\w-]+([?&].*)?$' 32 | ) 33 | 34 | # Ensembles de valeurs valides 35 | VALID_CONTAINERS = {'mp4', 'mkv', 'webm', 'm4a', 'mp3'} 36 | VALID_AUDIO_CODECS = {'aac', 'mp3', 'opus', 'copy'} 37 | 38 | # Stockage des sessions de téléchargement pour le suivi de progression 39 | download_sessions = {} # {session_id: {'progress': 0, 'status': 'downloading', 'eta': '', 'speed': ''}} 40 | download_lock = threading.Lock() 41 | 42 | 43 | def validate_youtube_url(url: str) -> bool: 44 | """Valide qu'une URL est bien une URL YouTube valide""" 45 | if not url: 46 | return False 47 | return bool(YOUTUBE_REGEX.match(url)) 48 | 49 | 50 | def validate_container(container: str) -> bool: 51 | """Valide le format de conteneur""" 52 | return container in VALID_CONTAINERS 53 | 54 | 55 | def validate_audio_codec(codec: str) -> bool: 56 | """Valide le codec audio""" 57 | return codec in VALID_AUDIO_CODECS 58 | 59 | 60 | def create_session_dir(session_id: str) -> Path: 61 | """Crée un répertoire unique pour la session""" 62 | session_dir = DOWNLOAD_DIR / session_id 63 | session_dir.mkdir(exist_ok=True) 64 | return session_dir 65 | 66 | 67 | def cleanup_old_sessions(max_age_seconds: int = 3600): 68 | """Nettoie les répertoires de session de plus de max_age_seconds""" 69 | try: 70 | current_time = time.time() 71 | for item in DOWNLOAD_DIR.iterdir(): 72 | if item.is_dir(): 73 | # Vérifier l'âge du répertoire 74 | dir_age = current_time - item.stat().st_mtime 75 | if dir_age > max_age_seconds: 76 | try: 77 | for file in item.iterdir(): 78 | if file.is_file(): 79 | file.unlink() 80 | item.rmdir() 81 | logger.info(f"Cleaned up old session directory: {item.name}") 82 | except Exception as e: 83 | logger.error(f"Error cleaning up {item.name}: {e}") 84 | except Exception as e: 85 | logger.error(f"Error in cleanup_old_sessions: {e}") 86 | 87 | 88 | @app.route('/') 89 | def index(): 90 | """Page d'accueil""" 91 | return render_template('index.html') 92 | 93 | 94 | @app.route('/api/analyze', methods=['POST']) 95 | def analyze_video(): 96 | """Analyse une URL YouTube et retourne les formats disponibles""" 97 | try: 98 | data = request.get_json() 99 | url = data.get('url', '').strip() 100 | 101 | if not url: 102 | logger.warning("Analyze request with missing URL") 103 | return jsonify({'error': 'URL manquante'}), 400 104 | 105 | # Validation de l'URL 106 | if not validate_youtube_url(url): 107 | logger.warning(f"Invalid YouTube URL attempted: {url}") 108 | return jsonify({'error': 'URL YouTube invalide'}), 400 109 | 110 | logger.info(f"Analyzing video: {url}") 111 | 112 | # Commande yt-dlp pour lister les formats 113 | cmd = ['yt-dlp', '-J', url] 114 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_ANALYZE) 115 | 116 | if result.returncode != 0: 117 | logger.error(f"yt-dlp analyze failed: {result.stderr}") 118 | return jsonify({'error': 'Erreur lors de l\'analyse de la vidéo'}), 400 119 | 120 | video_info = json.loads(result.stdout) 121 | 122 | # Extraction des informations 123 | title = video_info.get('title', 'Sans titre') 124 | duration = video_info.get('duration', 0) 125 | thumbnail = video_info.get('thumbnail', '') 126 | 127 | logger.info(f"Successfully analyzed: {title}") 128 | 129 | # Traitement des formats 130 | formats = video_info.get('formats', []) 131 | video_formats = [] 132 | audio_formats = [] 133 | 134 | for fmt in formats: 135 | format_id = fmt.get('format_id', '') 136 | ext = fmt.get('ext', '') 137 | resolution = fmt.get('resolution', 'audio only') 138 | filesize = fmt.get('filesize', 0) or fmt.get('filesize_approx', 0) 139 | vcodec = fmt.get('vcodec', 'none') 140 | acodec = fmt.get('acodec', 'none') 141 | fps = fmt.get('fps', 0) 142 | 143 | # Formats vidéo (avec vidéo) 144 | if vcodec != 'none' and resolution != 'audio only': 145 | size_mb = f"{filesize / BYTES_PER_MB:.1f} MB" if filesize else "N/A" 146 | video_formats.append({ 147 | 'id': format_id, 148 | 'label': f"{resolution} - {ext} - {vcodec} - {fps}fps - {size_mb}", 149 | 'ext': ext, 150 | 'resolution': resolution, 151 | 'vcodec': vcodec, 152 | 'filesize': filesize 153 | }) 154 | 155 | # Formats audio (sans vidéo) 156 | if acodec != 'none' and vcodec == 'none': 157 | abr = fmt.get('abr', 0) 158 | size_mb = f"{filesize / BYTES_PER_MB:.1f} MB" if filesize else "N/A" 159 | audio_formats.append({ 160 | 'id': format_id, 161 | 'label': f"{acodec} - {abr}kbps - {ext} - {size_mb}", 162 | 'ext': ext, 163 | 'acodec': acodec, 164 | 'abr': abr, 165 | 'filesize': filesize 166 | }) 167 | 168 | # Tri par qualité 169 | video_formats.sort(key=lambda x: x.get('filesize', 0), reverse=True) 170 | audio_formats.sort(key=lambda x: x.get('filesize', 0), reverse=True) 171 | 172 | return jsonify({ 173 | 'title': title, 174 | 'duration': duration, 175 | 'thumbnail': thumbnail, 176 | 'video_formats': video_formats, 177 | 'audio_formats': audio_formats 178 | }) 179 | 180 | except subprocess.TimeoutExpired: 181 | logger.error(f"Analyze timeout for URL: {url}") 182 | return jsonify({'error': 'Timeout lors de l\'analyse'}), 408 183 | except json.JSONDecodeError as e: 184 | logger.error(f"JSON decode error: {e}") 185 | return jsonify({'error': 'Erreur de parsing JSON'}), 500 186 | except Exception as e: 187 | logger.error(f"Unexpected error in analyze_video: {e}", exc_info=True) 188 | return jsonify({'error': 'Erreur serveur'}), 500 189 | 190 | 191 | @app.route('/api/progress/') 192 | def progress_stream(session_id): 193 | """Stream de progression via Server-Sent Events""" 194 | def generate(): 195 | # Attendre que la session soit créée (max 5 secondes) 196 | wait_count = 0 197 | while wait_count < 10: 198 | with download_lock: 199 | if session_id in download_sessions: 200 | break 201 | time.sleep(0.5) 202 | wait_count += 1 203 | 204 | # Streamer la progression 205 | while True: 206 | with download_lock: 207 | session_data = download_sessions.get(session_id, {}) 208 | 209 | if not session_data: 210 | yield f"data: {json.dumps({'progress': 0, 'status': 'waiting'})}\n\n" 211 | else: 212 | yield f"data: {json.dumps(session_data)}\n\n" 213 | 214 | # Arrêter le stream si terminé ou erreur 215 | if session_data.get('status') in ['completed', 'error']: 216 | break 217 | 218 | time.sleep(0.5) # Mise à jour toutes les 0.5 secondes 219 | 220 | return Response(generate(), mimetype='text/event-stream') 221 | 222 | 223 | def _do_download_with_progress(session_id, cmd, session_dir, output_container, audio_only, 224 | cmd_string, format_string, postproc_added, audio_codec, audio_bitrate): 225 | """Fonction helper pour télécharger avec suivi de progression""" 226 | try: 227 | # Initialiser la session 228 | with download_lock: 229 | download_sessions[session_id] = { 230 | 'progress': 0, 231 | 'status': 'downloading', 232 | 'eta': '', 233 | 'speed': '' 234 | } 235 | 236 | # Ajouter --newline pour avoir des mises à jour ligne par ligne 237 | cmd_with_progress = cmd + ['--newline'] 238 | 239 | # Lancer yt-dlp et capturer la sortie en temps réel 240 | process = subprocess.Popen( 241 | cmd_with_progress, 242 | stdout=subprocess.PIPE, 243 | stderr=subprocess.STDOUT, 244 | text=True, 245 | bufsize=1 246 | ) 247 | 248 | # Parser la sortie ligne par ligne 249 | for line in process.stdout: 250 | # Extraire le pourcentage (format: "[download] XX.X% of ...") 251 | match = re.search(r'\[download\]\s+(\d+\.?\d*)%', line) 252 | if match: 253 | progress = float(match.group(1)) 254 | 255 | # Extraire ETA si disponible 256 | eta_match = re.search(r'ETA\s+([\d:]+)', line) 257 | eta = eta_match.group(1) if eta_match else '' 258 | 259 | # Extraire vitesse si disponible 260 | speed_match = re.search(r'at\s+([\d.]+\s*[KMG]iB/s)', line) 261 | speed = speed_match.group(1) if speed_match else '' 262 | 263 | with download_lock: 264 | download_sessions[session_id].update({ 265 | 'progress': min(progress, 99), # Cap à 99% jusqu'à la fin 266 | 'status': 'downloading', 267 | 'eta': eta, 268 | 'speed': speed 269 | }) 270 | 271 | process.wait() 272 | 273 | if process.returncode != 0: 274 | with download_lock: 275 | download_sessions[session_id] = { 276 | 'progress': 0, 277 | 'status': 'error', 278 | 'error': 'Erreur lors du téléchargement' 279 | } 280 | logger.error(f"Download failed for session {session_id}") 281 | return 282 | 283 | # Recherche du fichier téléchargé 284 | if audio_only and output_container in ['mp3', 'm4a']: 285 | downloaded_files = list(session_dir.glob(f'*.{output_container}')) 286 | else: 287 | downloaded_files = list(session_dir.glob(f'*.{output_container}')) 288 | 289 | if not downloaded_files: 290 | downloaded_files = list(session_dir.glob('*')) 291 | 292 | if not downloaded_files: 293 | with download_lock: 294 | download_sessions[session_id] = { 295 | 'progress': 0, 296 | 'status': 'error', 297 | 'error': 'Fichier téléchargé introuvable' 298 | } 299 | logger.error(f"No downloaded file found for session {session_id}") 300 | return 301 | 302 | latest_file = max(downloaded_files, key=lambda p: p.stat().st_mtime) 303 | 304 | # Marquer comme terminé 305 | with download_lock: 306 | download_sessions[session_id] = { 307 | 'progress': 100, 308 | 'status': 'completed', 309 | 'filename': latest_file.name, 310 | 'size': latest_file.stat().st_size, 311 | 'command': cmd_string, 312 | 'format_used': format_string, 313 | 'postproc_applied': postproc_added, 314 | 'audio_codec': audio_codec if audio_codec != 'copy' else 'original (copy)', 315 | 'audio_bitrate': audio_bitrate if audio_codec != 'copy' else 'original', 316 | 'audio_only': audio_only 317 | } 318 | 319 | logger.info(f"Download successful for session {session_id}: {latest_file.name}") 320 | 321 | except Exception as e: 322 | with download_lock: 323 | download_sessions[session_id] = { 324 | 'progress': 0, 325 | 'status': 'error', 326 | 'error': str(e) 327 | } 328 | logger.error(f"Error in download for session {session_id}: {e}", exc_info=True) 329 | 330 | 331 | @app.route('/api/download', methods=['POST']) 332 | def download_video(): 333 | """Télécharge la vidéo avec les paramètres spécifiés""" 334 | postproc_added = False # Initialisation au début 335 | 336 | try: 337 | # Nettoyage des anciennes sessions 338 | cleanup_old_sessions() 339 | 340 | data = request.get_json() 341 | url = data.get('url', '').strip() 342 | video_format = data.get('video_format', '') 343 | audio_format = data.get('audio_format', '') 344 | output_container = data.get('output_container', 'mp4') 345 | audio_codec = data.get('audio_codec', 'aac') 346 | audio_bitrate = data.get('audio_bitrate', '192k') 347 | audio_only = data.get('audio_only', False) 348 | 349 | # Validation des entrées 350 | if not url: 351 | logger.warning("Download request with missing URL") 352 | return jsonify({'error': 'URL manquante'}), 400 353 | 354 | if not validate_youtube_url(url): 355 | logger.warning(f"Invalid YouTube URL attempted: {url}") 356 | return jsonify({'error': 'URL YouTube invalide'}), 400 357 | 358 | if not validate_container(output_container): 359 | logger.warning(f"Invalid container attempted: {output_container}") 360 | return jsonify({'error': f'Format de conteneur invalide: {output_container}'}), 400 361 | 362 | if not validate_audio_codec(audio_codec): 363 | logger.warning(f"Invalid audio codec attempted: {audio_codec}") 364 | return jsonify({'error': f'Codec audio invalide: {audio_codec}'}), 400 365 | 366 | # Créer une session unique pour ce téléchargement 367 | session_id = str(uuid.uuid4()) 368 | session_dir = create_session_dir(session_id) 369 | 370 | logger.info(f"Starting download for session {session_id}: {url}") 371 | 372 | # Construction de la chaîne de format 373 | if audio_only: 374 | # Mode audio seulement 375 | if audio_format: 376 | format_string = audio_format 377 | else: 378 | format_string = "ba/best" 379 | else: 380 | # Mode vidéo + audio 381 | if video_format and audio_format: 382 | format_string = f"{video_format}+{audio_format}" 383 | elif video_format: 384 | format_string = f"{video_format}+ba" 385 | elif audio_format: 386 | format_string = f"bv+{audio_format}" 387 | else: 388 | format_string = "bv+ba/best" 389 | 390 | # Nom de fichier sécurisé avec timestamp 391 | timestamp = int(time.time()) 392 | output_template = str(session_dir / f'%(title)s_{timestamp}.%(ext)s') 393 | 394 | # Construction de la commande yt-dlp 395 | cmd = ['yt-dlp', '-f', format_string, '-o', output_template] 396 | 397 | # Pour l'audio seulement, utiliser -x pour extraction 398 | if audio_only: 399 | cmd.extend(['-x']) # Extraire l'audio 400 | 401 | # Spécifier le format audio de sortie 402 | if output_container == 'mp3': 403 | cmd.extend(['--audio-format', 'mp3']) 404 | elif output_container == 'm4a': 405 | cmd.extend(['--audio-format', 'm4a']) 406 | else: 407 | cmd.extend(['--audio-format', 'best']) 408 | 409 | # Gérer la qualité audio 410 | if audio_codec == 'copy': 411 | cmd.extend(['--audio-quality', '0']) 412 | else: 413 | if audio_bitrate: 414 | cmd.extend(['--audio-quality', audio_bitrate.upper()]) 415 | else: 416 | cmd.extend(['--audio-quality', '0']) 417 | postproc_added = True 418 | else: 419 | # Mode vidéo + audio : utiliser merge-output-format 420 | cmd.extend(['--merge-output-format', output_container]) 421 | 422 | # Ajout des arguments de post-processing pour l'audio 423 | if audio_codec and audio_codec != 'copy': 424 | postproc_args = f"-c:a {audio_codec}" 425 | if audio_bitrate: 426 | postproc_args += f" -b:a {audio_bitrate}" 427 | cmd.extend(['--postprocessor-args', f'ffmpeg:{postproc_args}']) 428 | postproc_added = True 429 | 430 | # Ajout de l'URL à la fin 431 | cmd.append(url) 432 | 433 | # Conversion de la commande en string pour affichage 434 | cmd_string = ' '.join(f'"{arg}"' if ' ' in arg else arg for arg in cmd) 435 | 436 | logger.info(f"Starting download in background for session {session_id}") 437 | 438 | # Lancer le téléchargement dans un thread 439 | download_thread = threading.Thread( 440 | target=_do_download_with_progress, 441 | args=(session_id, cmd, session_dir, output_container, audio_only, 442 | cmd_string, format_string, postproc_added, audio_codec, audio_bitrate), 443 | daemon=True 444 | ) 445 | download_thread.start() 446 | 447 | # Retourner immédiatement avec le session_id 448 | # Le client se connectera au SSE pour suivre la progression 449 | return jsonify({ 450 | 'success': True, 451 | 'session_id': session_id, 452 | 'message': 'Téléchargement démarré, utilisez /api/progress/ pour suivre la progression' 453 | }) 454 | 455 | except Exception as e: 456 | logger.error(f"Unexpected error in download_video: {e}", exc_info=True) 457 | return jsonify({'error': 'Erreur serveur'}), 500 458 | 459 | 460 | @app.route('/api/download-file//') 461 | def download_file(session_id, filename): 462 | """Télécharge le fichier généré - sécurisé contre path traversal""" 463 | try: 464 | # Validation du nom de fichier pour éviter path traversal 465 | safe_filename = os.path.basename(filename) 466 | safe_session_id = os.path.basename(session_id) 467 | 468 | if '..' in filename or '/' in filename or '\\' in filename: 469 | logger.warning(f"Path traversal attempt detected: {filename}") 470 | return jsonify({'error': 'Nom de fichier invalide'}), 400 471 | 472 | session_dir = DOWNLOAD_DIR / safe_session_id 473 | file_path = session_dir / safe_filename 474 | 475 | # Vérifier que le fichier est bien dans le répertoire de session 476 | if not file_path.resolve().is_relative_to(session_dir.resolve()): 477 | logger.warning(f"Path traversal attempt detected: {file_path}") 478 | return jsonify({'error': 'Chemin de fichier invalide'}), 400 479 | 480 | if not file_path.exists(): 481 | logger.warning(f"File not found: {file_path}") 482 | return jsonify({'error': 'Fichier introuvable'}), 404 483 | 484 | logger.info(f"Serving file: {file_path}") 485 | return send_file(file_path, as_attachment=True) 486 | 487 | except Exception as e: 488 | logger.error(f"Error in download_file: {e}", exc_info=True) 489 | return jsonify({'error': 'Erreur serveur'}), 500 490 | 491 | 492 | @app.route('/api/cleanup/', methods=['POST']) 493 | def cleanup_session(session_id): 494 | """Nettoie les fichiers d'une session spécifique""" 495 | try: 496 | safe_session_id = os.path.basename(session_id) 497 | session_dir = DOWNLOAD_DIR / safe_session_id 498 | 499 | if not session_dir.exists(): 500 | return jsonify({'success': True, 'message': 'Session déjà nettoyée'}) 501 | 502 | # Supprimer tous les fichiers du répertoire 503 | for file in session_dir.iterdir(): 504 | if file.is_file(): 505 | file.unlink() 506 | logger.info(f"Deleted file: {file}") 507 | 508 | # Supprimer le répertoire 509 | session_dir.rmdir() 510 | logger.info(f"Cleaned up session: {session_id}") 511 | 512 | return jsonify({'success': True}) 513 | 514 | except Exception as e: 515 | logger.error(f"Error in cleanup_session: {e}", exc_info=True) 516 | return jsonify({'error': 'Erreur lors du nettoyage'}), 500 517 | 518 | 519 | @app.route('/api/cleanup-all', methods=['POST']) 520 | def cleanup_all(): 521 | """Nettoie tous les fichiers téléchargés""" 522 | try: 523 | count = 0 524 | for item in DOWNLOAD_DIR.iterdir(): 525 | if item.is_dir(): 526 | for file in item.iterdir(): 527 | if file.is_file(): 528 | file.unlink() 529 | count += 1 530 | item.rmdir() 531 | 532 | logger.info(f"Cleaned up {count} files from all sessions") 533 | return jsonify({'success': True, 'files_deleted': count}) 534 | 535 | except Exception as e: 536 | logger.error(f"Error in cleanup_all: {e}", exc_info=True) 537 | return jsonify({'error': 'Erreur lors du nettoyage'}), 500 538 | 539 | 540 | if __name__ == '__main__': 541 | logger.info("Starting yt-dlp Web Interface") 542 | app.run(host='0.0.0.0', port=5000, debug=False) 543 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | yt-dlp Web Interface 7 | 385 | 386 | 387 |
388 |
389 |
390 | 391 | 392 |
393 |

🎬 yt-dlp Web Interface

394 |

Téléchargez des vidéos YouTube en haute qualité

395 |
396 | 397 |
398 |
399 |
400 |
401 |

Chargement en cours...

402 |
403 | 404 |
405 |
406 | 407 | 408 |
409 | 412 |
413 | 414 |
415 | Thumbnail 416 |
417 |
418 | 419 |
420 |
421 |
📹 Sélection des formats
422 | 423 |
424 | 428 |
429 | 430 |
431 |
432 | 433 | 436 |
437 |
438 | 439 | 442 |
443 |
444 |
445 | 446 |
447 |
⚙️ Options de sortie
448 |
449 |
450 | 451 | 458 |
459 |
460 | 461 | 467 |
468 |
469 | 470 | 476 |
477 |
478 |
479 | 480 | 483 |
484 | 485 | 486 | 497 | 498 |
499 |
500 |
501 | 504 |
505 |
506 |
507 | 508 | 514 | 515 | 904 | 905 | 906 | --------------------------------------------------------------------------------