Spaces:
Sleeping
Sleeping
| # app/main.py | |
| import os | |
| import json | |
| import asyncio | |
| import uuid | |
| import shutil | |
| import re | |
| from pathlib import Path | |
| from typing import Dict, Any, List, Optional | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException | |
| from fastapi.responses import HTMLResponse, FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| import yt_dlp | |
| from pydantic import BaseModel, HttpUrl | |
| app = FastAPI(title="Pro Video Downloader API") | |
| # Configuration | |
| DOWNLOAD_DIR = Path("/app/downloads") | |
| DOWNLOAD_DIR.mkdir(exist_ok=True) | |
| MAX_FILE_AGE_SECONDS = 1800 # 30 minutes | |
| # Mount static files | |
| app.mount("/static", StaticFiles(directory="app/static"), name="static") | |
| # --- Constantes Communes --- | |
| # User-Agent Chrome 126 (Windows) - Mis à jour Juillet 2024 | |
| STEALTH_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' | |
| # Headers HTTP complets pour passer pour un vrai navigateur (Anti-Bot / Generic Extractor) | |
| STEALTH_HEADERS = { | |
| 'User-Agent': STEALTH_UA, | |
| 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', | |
| 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', | |
| 'Accept-Encoding': 'gzip, deflate, br, zstd', | |
| 'Sec-Fetch-Dest': 'document', | |
| 'Sec-Fetch-Mode': 'navigate', | |
| 'Sec-Fetch-Site': 'none', | |
| 'Sec-Fetch-User': '?1', | |
| 'Upgrade-Insecure-Requests': '1', | |
| 'Referer': 'https://www.google.com/', # Origine "propre" | |
| } | |
| # Headers pour le téléchargement des fragments vidéo (m3u8/mp4) - Referer = page vidéo | |
| def get_download_headers(referer_url: str) -> Dict[str, str]: | |
| origin = '/'.join(referer_url.split('/')[:3]) # https://domain.com | |
| return { | |
| 'User-Agent': STEALTH_UA, | |
| 'Accept': '*/*', | |
| 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', | |
| 'Accept-Encoding': 'gzip, deflate, br, zstd', | |
| 'Referer': referer_url, | |
| 'Origin': origin, | |
| 'Sec-Fetch-Dest': 'video', | |
| 'Sec-Fetch-Mode': 'no-cors', | |
| 'Sec-Fetch-Site': 'cross-site', | |
| } | |
| # Args pour forcer l'extracteur generic (sites sans support natif) | |
| GENERIC_EXTRACTOR_ARGS = { | |
| 'extractor_args': { | |
| 'generic': { | |
| 'force_extraction': True, # Essaye même si pattern inconnu | |
| } | |
| } | |
| } | |
| # --- Modèles Pydantic --- | |
| class AnalyzeRequest(BaseModel): | |
| url: HttpUrl | |
| class FormatInfo(BaseModel): | |
| format_id: str | |
| ext: str | |
| resolution: str | |
| filesize_mb: float | |
| note: str | |
| vcodec: str | |
| acodec: str | |
| is_auto: bool = False | |
| class VideoMeta(BaseModel): | |
| title: str | |
| duration: int | |
| uploader: str | |
| thumbnail: str | |
| formats: List[FormatInfo] | |
| # --- Nettoyage Périodique (30 min) --- | |
| async def cleanup_old_files(): | |
| while True: | |
| await asyncio.sleep(60) | |
| now = asyncio.get_event_loop().time() | |
| try: | |
| for f in DOWNLOAD_DIR.glob("*"): | |
| if f.is_dir(): | |
| try: | |
| mtime = f.stat().st_mtime | |
| if (now - mtime) > MAX_FILE_AGE_SECONDS: | |
| shutil.rmtree(f, ignore_errors=True) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| async def startup_event(): | |
| asyncio.create_task(cleanup_old_files()) | |
| # --- Helpers --- | |
| def sanitize_filename(name: str, max_len: int = 200) -> str: | |
| name = re.sub(r'[\\/*?:"<>|]', "", name) | |
| name = re.sub(r'\s+', ' ', name).strip() | |
| return name[:max_len] | |
| def get_base_ydl_opts(extra_headers: Dict = None) -> Dict: | |
| """Base configuration yt-dlp commune (Stealth + Timeouts).""" | |
| opts = { | |
| 'quiet': True, | |
| 'skip_download': True, | |
| 'noplaylist': True, | |
| 'socket_timeout': 30, | |
| 'retries': 5, | |
| 'http_headers': {**STEALTH_HEADERS, **(extra_headers or {})}, | |
| **GENERIC_EXTRACTOR_ARGS, | |
| } | |
| return opts | |
| def analyze_url_sync(url: str) -> VideoMeta: | |
| """Extraction infos + Construction formats intelligents (Auto).""" | |
| ydl_opts = get_base_ydl_opts() | |
| ydl_opts.update({ | |
| 'forcejson': True, | |
| 'simulate': True, | |
| 'format': 'bestvideo+bestaudio/best', | |
| }) | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| info = ydl.extract_info(url, download=False) | |
| title = info.get('title', 'Sans Titre') | |
| duration = info.get('duration', 0) | |
| uploader = info.get('uploader', 'Inconnu') | |
| thumbnail = info.get('thumbnail', '') | |
| raw_formats = info.get('formats', []) | |
| best_video = {} | |
| best_audio = {} | |
| for f in raw_formats: | |
| vcodec = f.get('vcodec', 'none') | |
| acodec = f.get('acodec', 'none') | |
| fid = f.get('format_id') | |
| height = f.get('height') | |
| abr = f.get('abr') | |
| tbr = f.get('tbr', 0) | |
| fs = f.get('filesize') or f.get('filesize_approx', 0) | |
| if vcodec != 'none' and acodec == 'none': # Video Only | |
| if height: | |
| if height not in best_video or tbr > best_video[height].get('tbr', 0): | |
| best_video[height] = f | |
| elif vcodec == 'none' and acodec != 'none': # Audio Only | |
| if abr: | |
| if abr not in best_audio or tbr > best_audio[abr].get('tbr', 0): | |
| best_audio[abr] = f | |
| formats_out: List[FormatInfo] = [] | |
| standard_heights = [2160, 1440, 1080, 720, 480, 360] | |
| # A. Modes "Auto" Vidéo+Son | |
| for h in standard_heights: | |
| if h in best_video: | |
| v_fmt = best_video[h] | |
| best_a_fmt = max(best_audio.values(), key=lambda x: x.get('abr', 0), default=None) | |
| if best_a_fmt: | |
| selector = f"{v_fmt['format_id']}+{best_a_fmt['format_id']}" | |
| v_size = v_fmt.get('filesize') or v_fmt.get('filesize_approx', 0) | |
| a_size = best_a_fmt.get('filesize') or best_a_fmt.get('filesize_approx', 0) | |
| est_mb = round((v_size + a_size) / 1e6, 1) if (v_size or a_size) else 0 | |
| formats_out.append(FormatInfo( | |
| format_id=selector, ext="mp4", | |
| resolution=f"{h}p (Auto • Vidéo+Son)", | |
| filesize_mb=est_mb, | |
| note=f"Meilleure vidéo {h}p + Meilleur son (Fusion FFmpeg)", | |
| vcodec=v_fmt.get('vcodec', 'none'), | |
| acodec=best_a_fmt.get('acodec', 'none'), | |
| is_auto=True | |
| )) | |
| # B. Mode Audio Only (Auto) | |
| if best_audio: | |
| best_a = max(best_audio.values(), key=lambda x: x.get('abr', 0)) | |
| a_size = best_a.get('filesize') or best_a.get('filesize_approx', 0) | |
| formats_out.append(FormatInfo( | |
| format_id=best_a['format_id'], ext="mp3", | |
| resolution="Audio MP3 (Auto • Meilleur qualité)", | |
| filesize_mb=round(a_size / 1e6, 1) if a_size else 0, | |
| note=f"Extraction audio {best_a.get('abr', '?')}kbps → MP3 192kbps", | |
| vcodec='none', acodec=best_a.get('acodec', 'none'), | |
| is_auto=True | |
| )) | |
| # C. Fallback absolu | |
| if not formats_out: | |
| formats_out.append(FormatInfo( | |
| format_id="bestvideo+bestaudio/best", ext="mp4", | |
| resolution="Auto (Meilleur dispo)", filesize_mb=0, | |
| note="Sélection automatique yt-dlp (Fallback)", | |
| vcodec='?', acodec='?', is_auto=True | |
| )) | |
| return VideoMeta(title=title, duration=duration, uploader=uploader, thumbnail=thumbnail, formats=formats_out) | |
| def download_sync(url: str, format_id: str, audio_only: bool, progress_hook, session_dir: Path, original_title: str) -> str: | |
| """Téléchargement bloquant robuste. Retourne chemin fichier final.""" | |
| safe_title = sanitize_filename(original_title) | |
| output_template = str(session_dir / f"{safe_title}.%(ext)s") | |
| # Headers spécifiques pour le DL (Referer = URL vidéo) | |
| dl_headers = get_download_headers(url) | |
| ydl_opts = { | |
| 'format': format_id, | |
| 'outtmpl': output_template, | |
| 'merge_output_format': 'mp4', | |
| 'progress_hooks': [progress_hook], | |
| 'noplaylist': True, | |
| 'quiet': True, | |
| 'no_warnings': True, | |
| # ROBUSTESSE RÉSEAU / PROXY HF : | |
| 'socket_timeout': 30, | |
| 'retries': 10, | |
| 'fragment_retries': 10, | |
| 'retry_sleep_functions': { | |
| 'http': lambda n: min(2 ** n, 60), | |
| 'fragment': lambda n: min(2 ** n, 60), | |
| }, | |
| 'concurrent_fragment_downloads': 3, | |
| # HEADERS STEALTH POUR FRAGMENTS | |
| 'http_headers': dl_headers, | |
| 'postprocessors': [] | |
| } | |
| if audio_only: | |
| ydl_opts['postprocessors'].append({ | |
| 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '192', | |
| }) | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| ydl.download([url]) | |
| candidates = list(session_dir.glob("*")) | |
| if not candidates: raise FileNotFoundError("Aucun fichier généré par yt-dlp.") | |
| return str(max(candidates, key=lambda f: f.stat().st_mtime)) | |
| # --- Routes API --- | |
| async def read_root(): | |
| with open("app/static/index.html", "r", encoding="utf-8") as f: | |
| return f.read() | |
| async def analyze_video(req: AnalyzeRequest): | |
| try: | |
| loop = asyncio.get_event_loop() | |
| meta = await loop.run_in_executor(None, analyze_url_sync, str(req.url)) | |
| return meta | |
| except yt_dlp.utils.DownloadError as e: | |
| raise HTTPException(status_code=400, detail=f"Lien invalide, vidéo privée ou site non supporté: {str(e)}") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Erreur analyse: {str(e)}") | |
| async def websocket_download(ws: WebSocket): | |
| await ws.accept() | |
| session_id = str(uuid.uuid4())[:8] | |
| session_dir = DOWNLOAD_DIR / session_id | |
| session_dir.mkdir(exist_ok=True) | |
| progress_queue: asyncio.Queue = asyncio.Queue(maxsize=50) | |
| active = True | |
| def get_progress_hook(q: asyncio.Queue): | |
| def hook(d: Dict[str, Any]): | |
| if not active: return | |
| if d['status'] == 'downloading': | |
| downloaded = d.get('downloaded_bytes', 0) | |
| total = d.get('total_bytes') or d.get('total_bytes_estimate', 0) | |
| speed = d.get('speed', 0) | |
| eta = d.get('eta', 0) | |
| percent_str = d.get('_percent_str', '0%').strip() | |
| try: | |
| q.put_nowait({ | |
| "stage": "downloading", "percent": percent_str, | |
| "downloaded_mb": round(downloaded / 1e6, 2), | |
| "total_mb": round(total / 1e6, 2) if total else 0, | |
| "speed_mbps": round(speed / 1e6, 2) if speed else 0, | |
| "eta_sec": eta, "info": f"Téléchargement... {percent_str}" | |
| }) | |
| except asyncio.QueueFull: pass | |
| elif d['status'] == 'finished': | |
| try: q.put_nowait({"stage": "post_processing", "info": "Fusion / Conversion (FFmpeg)...", "percent": "99%"}) | |
| except asyncio.QueueFull: pass | |
| elif d['status'] == 'error': | |
| try: q.put_nowait({"stage": "error", "info": str(d.get('error', 'Erreur inconnue'))}) | |
| except asyncio.QueueFull: pass | |
| return hook | |
| async def heartbeat_sender(): | |
| while active: | |
| await asyncio.sleep(10) | |
| if not active: break | |
| try: | |
| await ws.send_json({"stage": "heartbeat", "info": "keep-alive"}) | |
| except Exception: | |
| break | |
| try: | |
| config = await ws.receive_json() | |
| url = config['url'] | |
| format_id = config['format_id'] | |
| audio_only = config.get('audio_only', False) | |
| hook = get_progress_hook(progress_queue) | |
| loop = asyncio.get_event_loop() | |
| # 1. Récupérer titre original (AVEC HEADERS STEALTH) | |
| info = await loop.run_in_executor(None, lambda: yt_dlp.YoutubeDL(get_base_ydl_opts()).extract_info(url, download=False)) | |
| original_title = info.get('title', 'video') | |
| # 2. Lancer tâches parallèles | |
| hb_task = asyncio.create_task(heartbeat_sender()) | |
| async def progress_sender(): | |
| while active: | |
| try: | |
| msg = await asyncio.wait_for(progress_queue.get(), timeout=1.0) | |
| if msg.get("stage") == "finished_download": | |
| break | |
| await ws.send_json(msg) | |
| except asyncio.TimeoutError: | |
| continue | |
| except Exception: | |
| break | |
| sender_task = asyncio.create_task(progress_sender()) | |
| # 3. Download dans Executor | |
| def run_download(): | |
| try: | |
| final_path = download_sync(url, format_id, audio_only, hook, session_dir, original_title) | |
| progress_queue.put_nowait({"stage": "finished_download", "final_path": final_path, "filename": Path(final_path).name}) | |
| return final_path | |
| except Exception as e: | |
| progress_queue.put_nowait({"stage": "error", "info": f"Erreur DL: {str(e)}"}) | |
| raise | |
| await loop.run_in_executor(None, run_download) | |
| # 4. Attendre fin envoi progression | |
| await sender_task | |
| active = False | |
| hb_task.cancel() | |
| try: await hb_task | |
| except: pass | |
| # 5. Réponse Finale | |
| final_files = list(session_dir.glob("*")) | |
| if not final_files: | |
| try: await ws.send_json({"stage": "error", "info": "Fichier introuvable post-traitement."}) | |
| except: pass | |
| return | |
| final_file_path = final_files[0] | |
| final_filename = final_file_path.name | |
| download_url = f"/api/file/{session_id}/{final_filename}" | |
| try: | |
| await ws.send_json({ | |
| "stage": "complete", "info": "Terminé ! Cliquez pour sauvegarder.", | |
| "download_url": download_url, "filename": final_filename | |
| }) | |
| except Exception: | |
| pass | |
| except WebSocketDisconnect: | |
| print(f"[{session_id}] Client déconnecté.") | |
| except Exception as e: | |
| print(f"[{session_id}] Erreur critique: {e}") | |
| try: await ws.send_json({"stage": "error", "info": f"Erreur serveur: {str(e)}"}) | |
| except: pass | |
| finally: | |
| active = False | |
| async def serve_file(session_id: str, filename: str): | |
| file_path = DOWNLOAD_DIR / session_id / filename | |
| try: | |
| file_path.resolve().relative_to(DOWNLOAD_DIR.resolve()) | |
| except ValueError: | |
| raise HTTPException(403, "Accès interdit") | |
| if not file_path.exists(): | |
| raise HTTPException(404, "Fichier expiré ou introuvable (nettoyé après 30min)") | |
| return FileResponse( | |
| path=file_path, | |
| filename=filename, | |
| media_type='application/octet-stream' | |
| ) | |
| async def health(): return {"status": "ok"} |