Download main.py from Fred808/Data1: direct link, hf CLI and curl.
- Browser
- Download file 44.3 kB
-
https://huggingface.co/spaces/Fred808/Data1/resolve/main/main.py
- Command line
-
hf download hf://spaces/Fred808/Data1/main.py
-
curl -L -o main.py https://huggingface.co/spaces/Fred808/Data1/resolve/main/main.py
44.3 kB
| #!/usr/bin/env python3 | |
| """ | |
| Batch-Enhanced FastAPI YouTube Video Downloader | |
| Supports both single and batch downloads with cookie support | |
| """ | |
| import os | |
| import sys | |
| import subprocess | |
| import json | |
| import random | |
| import time | |
| import asyncio | |
| import logging | |
| import uuid | |
| from pathlib import Path | |
| from typing import Optional, Dict, Any, List | |
| from datetime import datetime | |
| from concurrent.futures import ThreadPoolExecutor | |
| from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File | |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, HttpUrl | |
| import uvicorn | |
| # Set up logging | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| logger = logging.getLogger(__name__) | |
| # Pydantic models | |
| class VideoInfoRequest(BaseModel): | |
| url: HttpUrl | |
| use_cookies: Optional[bool] = None | |
| class BatchVideoInfoRequest(BaseModel): | |
| urls: List[HttpUrl] | |
| use_cookies: Optional[bool] = None | |
| class DownloadRequest(BaseModel): | |
| url: HttpUrl | |
| quality: str = "best" | |
| audio_only: bool = False | |
| use_cookies: Optional[bool] = None | |
| class BatchDownloadRequest(BaseModel): | |
| urls: List[HttpUrl] | |
| quality: str = "best" | |
| audio_only: bool = False | |
| use_cookies: Optional[bool] = None | |
| max_concurrent: int = 2 | |
| class VideoInfo(BaseModel): | |
| title: str | |
| duration: int | |
| uploader: str | |
| view_count: int | |
| upload_date: str | |
| description: str | |
| formats: int | |
| id: str | |
| thumbnail: str | |
| webpage_url: str | |
| class BatchVideoInfo(BaseModel): | |
| url: str | |
| success: bool | |
| info: Optional[VideoInfo] = None | |
| error: Optional[str] = None | |
| class DownloadResponse(BaseModel): | |
| success: bool | |
| message: str | |
| filename: Optional[str] = None | |
| file_size: Optional[int] = None | |
| video_info: Optional[VideoInfo] = None | |
| download_path: Optional[str] = None | |
| class BatchDownloadResponse(BaseModel): | |
| batch_id: str | |
| total_urls: int | |
| status: str | |
| completed: int | |
| failed: int | |
| results: List[Dict[str, Any]] | |
| class BatchStatus(BaseModel): | |
| batch_id: str | |
| status: str | |
| total_urls: int | |
| completed: int | |
| failed: int | |
| in_progress: int | |
| results: List[Dict[str, Any]] | |
| created_at: str | |
| updated_at: str | |
| class HealthResponse(BaseModel): | |
| status: str | |
| yt_dlp_available: bool | |
| timestamp: str | |
| cookie_file_exists: bool | |
| strategies_enabled: List[str] | |
| batch_support: bool | |
| class BatchFileListResponse(BaseModel): | |
| batch_id: str | |
| total_files: int | |
| files: List[Dict[str, str]] | |
| # Initialize FastAPI app | |
| app = FastAPI( | |
| title="Batch YouTube Video Downloader", | |
| description="Download YouTube videos individually or in batches with cookie support", | |
| version="4.0.2", | |
| docs_url="/docs", | |
| redoc_url="/redoc" | |
| ) | |
| # Add CORS middleware | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Thread pool for background tasks | |
| executor = ThreadPoolExecutor(max_workers=4) | |
| # Global batch status storage (in production, use Redis or database) | |
| batch_status_store = {} | |
| class CookieManager: | |
| """Manages cookie files and validation""" | |
| def __init__(self, cookie_dir: str = None): | |
| if cookie_dir is None: | |
| if os.path.exists('/data'): | |
| cookie_dir = '/data/cookies' | |
| else: | |
| cookie_dir = '/tmp/cookies' | |
| self.cookie_dir = Path(cookie_dir) | |
| self.cookie_dir.mkdir(parents=True, exist_ok=True) | |
| self.cookie_file = self.cookie_dir / "youtube_cookies.txt" | |
| def save_cookies(self, cookie_content: str) -> bool: | |
| """Save cookie content to file with validation""" | |
| try: | |
| lines = cookie_content.strip().split('\n') | |
| data_lines = [line for line in lines if line.strip() and not line.startswith('#')] | |
| if not data_lines: | |
| logger.error("Cookie file appears to be empty or contains only comments") | |
| return False | |
| has_youtube = any('youtube.com' in line for line in data_lines) | |
| if not has_youtube: | |
| logger.warning("Cookie file doesn't contain youtube.com entries") | |
| with open(self.cookie_file, 'w', encoding='utf-8') as f: | |
| f.write(cookie_content) | |
| logger.info(f"Cookies saved to {self.cookie_file}") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to save cookies: {e}") | |
| return False | |
| def validate_cookies(self) -> Dict[str, Any]: | |
| """Validate existing cookie file""" | |
| if not self.cookie_file.exists(): | |
| return {"valid": False, "reason": "Cookie file does not exist"} | |
| try: | |
| with open(self.cookie_file, 'r', encoding='utf-8') as f: | |
| content = f.read() | |
| lines = content.strip().split('\n') | |
| data_lines = [line for line in lines if line.strip() and not line.startswith('#')] | |
| if not data_lines: | |
| return {"valid": False, "reason": "Cookie file is empty or contains only comments"} | |
| youtube_cookies = [line for line in data_lines if 'youtube.com' in line] | |
| essential_cookies = ['VISITOR_INFO1_LIVE', 'YSC', 'CONSENT'] | |
| found_essential = [] | |
| for line in data_lines: | |
| for cookie in essential_cookies: | |
| if cookie in line: | |
| found_essential.append(cookie) | |
| return { | |
| "valid": True, | |
| "total_lines": len(data_lines), | |
| "youtube_cookies": len(youtube_cookies), | |
| "essential_cookies": found_essential, | |
| "file_size": self.cookie_file.stat().st_size, | |
| "last_modified": datetime.fromtimestamp(self.cookie_file.stat().st_mtime).isoformat() | |
| } | |
| except Exception as e: | |
| return {"valid": False, "reason": f"Error reading cookie file: {e}"} | |
| def get_cookie_path(self) -> Optional[str]: | |
| """Get path to cookie file if it exists and is valid""" | |
| validation = self.validate_cookies() | |
| if validation["valid"]: | |
| return str(self.cookie_file) | |
| return None | |
| class BatchYouTubeDownloader: | |
| """Enhanced YouTube downloader with batch support and cookie handling""" | |
| def __init__(self, download_dir: str = None): | |
| if download_dir is None: | |
| if os.path.exists('/data'): | |
| download_dir = '/data/downloads' | |
| else: | |
| download_dir = '/tmp/downloads' | |
| self.download_dir = Path(download_dir) | |
| self.download_dir.mkdir(parents=True, exist_ok=True) | |
| self.cookie_manager = CookieManager() | |
| self.user_agents = [ | |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', | |
| 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', | |
| 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', | |
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', | |
| ] | |
| self._ensure_ytdlp_available() | |
| def _ensure_ytdlp_available(self): | |
| """Ensure yt-dlp is available, install if necessary""" | |
| try: | |
| subprocess.run(['yt-dlp', '--version'], capture_output=True, check=True) | |
| logger.info("yt-dlp is available") | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| logger.info("Installing yt-dlp...") | |
| try: | |
| subprocess.run([sys.executable, '-m', 'pip', 'install', 'yt-dlp'], | |
| check=True, capture_output=True) | |
| logger.info("yt-dlp installed successfully") | |
| except subprocess.CalledProcessError as e: | |
| logger.error(f"Failed to install yt-dlp: {e}") | |
| raise RuntimeError("Could not install yt-dlp") | |
| def _build_command(self, base_cmd: List[str], use_cookies: bool = False) -> List[str]: | |
| """Build yt-dlp command with proper options""" | |
| cmd = base_cmd.copy() | |
| cmd.extend(['--user-agent', random.choice(self.user_agents)]) | |
| if use_cookies: | |
| cookie_path = self.cookie_manager.get_cookie_path() | |
| if cookie_path: | |
| cmd.extend(['--cookies', cookie_path]) | |
| logger.info("Using cookie file for authentication") | |
| else: | |
| logger.warning("Cookies requested but no valid cookie file found") | |
| cmd.extend(['--no-cookies']) | |
| else: | |
| cmd.extend(['--no-cookies']) | |
| cmd.extend([ | |
| '--sleep-interval', str(random.randint(1, 3)), | |
| '--retries', '5', | |
| '--fragment-retries', '5', | |
| '--socket-timeout', '30', | |
| '--no-check-certificates', | |
| '--geo-bypass', | |
| '--add-header', 'Accept:text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| '--add-header', 'Accept-Language:en-US,en;q=0.5', | |
| '--add-header', 'Accept-Encoding:gzip, deflate', | |
| '--add-header', 'DNT:1', | |
| '--add-header', 'Connection:keep-alive', | |
| '--add-header', 'Upgrade-Insecure-Requests:1', | |
| ]) | |
| return cmd | |
| def get_video_info(self, url: str, use_cookies: Optional[bool] = None, retry_count: int = 0) -> Optional[Dict[str, Any]]: | |
| """Get video information with cookie support""" | |
| max_retries = 3 | |
| # Determine actual use_cookies value | |
| actual_use_cookies = use_cookies if use_cookies is not None else self.cookie_manager.get_cookie_path() is not None | |
| try: | |
| base_cmd = ["yt-dlp", "--dump-json", "--no-download", str(url)] | |
| cmd = self._build_command(base_cmd, actual_use_cookies) | |
| logger.info(f"Getting video info (attempt {retry_count + 1}, cookies: {actual_use_cookies})") | |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=60) | |
| video_info = json.loads(result.stdout) | |
| return { | |
| 'title': video_info.get('title', 'Unknown'), | |
| 'duration': video_info.get('duration', 0), | |
| 'uploader': video_info.get('uploader', 'Unknown'), | |
| 'view_count': video_info.get('view_count', 0), | |
| 'upload_date': video_info.get('upload_date', 'Unknown'), | |
| 'description': video_info.get('description', ''), | |
| 'formats': len(video_info.get('formats', [])), | |
| 'id': video_info.get('id', 'Unknown'), | |
| 'thumbnail': video_info.get('thumbnail', ''), | |
| 'webpage_url': video_info.get('webpage_url', str(url)) | |
| } | |
| except subprocess.CalledProcessError as e: | |
| error_msg = e.stderr.lower() if e.stderr else "" | |
| if "429" in error_msg or "too many requests" in error_msg: | |
| if retry_count < max_retries: | |
| wait_time = (retry_count + 1) * 30 | |
| logger.warning(f"Rate limited, waiting {wait_time}s before retry {retry_count + 1}") | |
| time.sleep(wait_time) | |
| return self.get_video_info(url, use_cookies, retry_count + 1) | |
| elif "sign in" in error_msg or "bot" in error_msg: | |
| if not use_cookies and retry_count == 0: | |
| logger.warning("Bot detection triggered, retrying with cookies if available") | |
| return self.get_video_info(url, True, retry_count + 1) | |
| elif retry_count < max_retries: | |
| wait_time = (retry_count + 1) * 60 | |
| logger.warning(f"Bot detection, waiting {wait_time}s before retry {retry_count + 1}") | |
| time.sleep(wait_time) | |
| return self.get_video_info(url, use_cookies, retry_count + 1) | |
| logger.error(f"Failed to get video info: {e.stderr}") | |
| return None | |
| except (json.JSONDecodeError, subprocess.TimeoutExpired) as e: | |
| logger.error(f"Error processing video info: {e}") | |
| if retry_count < max_retries: | |
| time.sleep(10) | |
| return self.get_video_info(url, use_cookies, retry_count + 1) | |
| return None | |
| def download_video(self, url: str, quality: str = "best", | |
| audio_only: bool = False, use_cookies: Optional[bool] = None, | |
| retry_count: int = 0) -> Optional[str]: | |
| """Download video with cookie support""" | |
| max_retries = 2 | |
| # Determine actual use_cookies value | |
| actual_use_cookies = use_cookies if use_cookies is not None else self.cookie_manager.get_cookie_path() is not None | |
| try: | |
| base_cmd = ["yt-dlp"] | |
| output_template = str(self.download_dir / "%(title)s_%(id)s.%(ext)s") | |
| base_cmd.extend(["-o", output_template]) | |
| if audio_only: | |
| base_cmd.extend(["-f", "bestaudio/best"]) | |
| else: | |
| if quality == "best": | |
| base_cmd.extend(["-f", "bestvideo[height<=720]+bestaudio/best[height<=720]"]) | |
| elif quality == "720p": | |
| base_cmd.extend(["-f", "bestvideo[height=720]+bestaudio/best[height=720]"]) | |
| elif quality == "worst": | |
| base_cmd.extend(["-f", "worst"]) | |
| else: | |
| base_cmd.extend(["-f", quality]) | |
| base_cmd.append(str(url)) | |
| cmd = self._build_command(base_cmd, actual_use_cookies) | |
| logger.info(f"Downloading video (attempt {retry_count + 1}, cookies: {actual_use_cookies})") | |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) | |
| logger.info("Download completed successfully") | |
| downloaded_files = [f for f in self.download_dir.glob("*") if f.is_file()] | |
| if downloaded_files: | |
| latest_file = max(downloaded_files, key=os.path.getctime) | |
| return str(latest_file) | |
| return None | |
| except subprocess.CalledProcessError as e: | |
| error_msg = e.stderr.lower() if e.stderr else "" | |
| if ("429" in error_msg or "too many requests" in error_msg or | |
| "sign in" in error_msg or "bot" in error_msg): | |
| if not use_cookies and retry_count == 0: | |
| logger.warning("Download blocked, retrying with cookies if available") | |
| return self.download_video(url, quality, audio_only, True, retry_count + 1) | |
| elif retry_count < max_retries: | |
| wait_time = (retry_count + 1) * 60 | |
| logger.warning(f"Download blocked, waiting {wait_time}s before retry {retry_count + 1}") | |
| time.sleep(wait_time) | |
| return self.download_video(url, quality, audio_only, use_cookies, retry_count + 1) | |
| logger.error(f"Download failed: {e.stderr}") | |
| return None | |
| except subprocess.TimeoutExpired: | |
| logger.error("Download timeout") | |
| if retry_count < max_retries: | |
| return self.download_video(url, quality, audio_only, use_cookies, retry_count + 1) | |
| return None | |
| async def batch_get_info(self, urls: List[str], use_cookies: bool = False) -> List[BatchVideoInfo]: | |
| """Get info for multiple videos concurrently""" | |
| results = [] | |
| async def get_single_info(url: str) -> BatchVideoInfo: | |
| try: | |
| loop = asyncio.get_event_loop() | |
| info = await loop.run_in_executor(executor, self.get_video_info, url, use_cookies) | |
| if info: | |
| return BatchVideoInfo( | |
| url=url, | |
| success=True, | |
| info=VideoInfo(**info) | |
| ) | |
| else: | |
| return BatchVideoInfo( | |
| url=url, | |
| success=False, | |
| error="Failed to get video information" | |
| ) | |
| except Exception as e: | |
| return BatchVideoInfo( | |
| url=url, | |
| success=False, | |
| error=str(e) | |
| ) | |
| # Process URLs concurrently with a limit | |
| semaphore = asyncio.Semaphore(3) # Limit concurrent requests | |
| async def limited_get_info(url: str) -> BatchVideoInfo: | |
| async with semaphore: | |
| return await get_single_info(url) | |
| tasks = [limited_get_info(url) for url in urls] | |
| results = await asyncio.gather(*tasks) | |
| return results | |
| async def batch_download(self, batch_id: str, urls: List[str], quality: str = "best", | |
| audio_only: bool = False, use_cookies: bool = False, | |
| max_concurrent: int = 2) -> None: | |
| """Download multiple videos with progress tracking""" | |
| # Initialize batch status | |
| batch_status_store[batch_id] = { | |
| "batch_id": batch_id, | |
| "status": "in_progress", | |
| "total_urls": len(urls), | |
| "completed": 0, | |
| "failed": 0, | |
| "in_progress": 0, | |
| "results": [], | |
| "created_at": datetime.now().isoformat(), | |
| "updated_at": datetime.now().isoformat() | |
| } | |
| semaphore = asyncio.Semaphore(max_concurrent) | |
| async def download_single(url: str) -> Dict[str, Any]: | |
| async with semaphore: | |
| batch_status_store[batch_id]["in_progress"] += 1 | |
| batch_status_store[batch_id]["updated_at"] = datetime.now().isoformat() | |
| try: | |
| loop = asyncio.get_event_loop() | |
| # Get video info first | |
| info = await loop.run_in_executor( | |
| executor, self.get_video_info, url, use_cookies | |
| ) | |
| if not info: | |
| result = { | |
| "url": url, | |
| "success": False, | |
| "error": "Failed to get video information", | |
| "completed_at": datetime.now().isoformat() | |
| } | |
| batch_status_store[batch_id]["failed"] += 1 | |
| batch_status_store[batch_id]["in_progress"] -= 1 | |
| batch_status_store[batch_id]["results"].append(result) | |
| batch_status_store[batch_id]["updated_at"] = datetime.now().isoformat() | |
| return result | |
| # Download the video | |
| downloaded_file = await loop.run_in_executor( | |
| executor, self.download_video, url, quality, audio_only, use_cookies | |
| ) | |
| if downloaded_file: | |
| file_size = os.path.getsize(downloaded_file) | |
| filename = os.path.basename(downloaded_file) | |
| result = { | |
| "url": url, | |
| "success": True, | |
| "filename": filename, | |
| "file_size": file_size, | |
| "video_info": info, | |
| "download_path": downloaded_file, | |
| "completed_at": datetime.now().isoformat() | |
| } | |
| batch_status_store[batch_id]["completed"] += 1 | |
| else: | |
| result = { | |
| "url": url, | |
| "success": False, | |
| "error": "Failed to download video", | |
| "completed_at": datetime.now().isoformat() | |
| } | |
| batch_status_store[batch_id]["failed"] += 1 | |
| batch_status_store[batch_id]["in_progress"] -= 1 | |
| batch_status_store[batch_id]["results"].append(result) | |
| batch_status_store[batch_id]["updated_at"] = datetime.now().isoformat() | |
| return result | |
| except Exception as e: | |
| result = { | |
| "url": url, | |
| "success": False, | |
| "error": str(e), | |
| "completed_at": datetime.now().isoformat() | |
| } | |
| batch_status_store[batch_id]["failed"] += 1 | |
| batch_status_store[batch_id]["in_progress"] -= 1 | |
| batch_status_store[batch_id]["results"].append(result) | |
| batch_status_store[batch_id]["updated_at"] = datetime.now().isoformat() | |
| return result | |
| # Process all downloads | |
| tasks = [download_single(url) for url in urls] | |
| await asyncio.gather(*tasks) | |
| # Mark batch as completed | |
| batch_status_store[batch_id]["status"] = "completed" | |
| batch_status_store[batch_id]["updated_at"] = datetime.now().isoformat() | |
| # Global downloader instance | |
| downloader = BatchYouTubeDownloader() | |
| async def read_root(): | |
| """Serve the main HTML interface with batch support and cookie upload""" | |
| html_content = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Batch YouTube Video Downloader</title> | |
| <style> | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| margin: 0; | |
| padding: 20px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .container { | |
| background: white; | |
| border-radius: 15px; | |
| box-shadow: 0 20px 40px rgba(0,0,0,0.1); | |
| padding: 40px; | |
| max-width: 1000px; | |
| width: 100%; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| } | |
| .header h1 { | |
| color: #333; | |
| margin-bottom: 10px; | |
| font-size: 2.5em; | |
| } | |
| .section { | |
| background: #f8f9fa; | |
| border-radius: 8px; | |
| padding: 20px; | |
| margin: 20px 0; | |
| } | |
| .upload-area { | |
| border: 2px dashed #dee2e6; | |
| border-radius: 8px; | |
| padding: 20px; | |
| text-align: center; | |
| margin: 15px 0; | |
| } | |
| .btn { | |
| background: linear-gradient(135deg, #667eea, #764ba2); | |
| color: white; | |
| border: none; | |
| padding: 12px 24px; | |
| border-radius: 8px; | |
| text-decoration: none; | |
| display: inline-block; | |
| margin: 10px 5px; | |
| transition: transform 0.2s ease; | |
| cursor: pointer; | |
| } | |
| .btn:hover { | |
| transform: translateY(-2px); | |
| } | |
| .feature { | |
| background: white; | |
| border: 1px solid #dee2e6; | |
| border-radius: 5px; | |
| padding: 15px; | |
| margin: 10px 0; | |
| } | |
| .new-feature { | |
| background: #e8f5e8; | |
| border: 1px solid #4caf50; | |
| } | |
| .warning { | |
| background: #fff3cd; | |
| border: 1px solid #ffeaa7; | |
| border-radius: 5px; | |
| padding: 15px; | |
| margin: 20px 0; | |
| color: #856404; | |
| } | |
| .success { | |
| background: #d4edda; | |
| border: 1px solid #c3e6cb; | |
| border-radius: 5px; | |
| padding: 15px; | |
| margin: 20px 0; | |
| color: #155724; | |
| } | |
| input[type="file"] { | |
| margin: 10px 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>π¦ Batch YouTube Video Downloader</h1> | |
| <p>Download single videos or process multiple URLs in batches with cookie support</p> | |
| </div> | |
| <div class="warning"> | |
| <strong>β οΈ Cookie Issues?</strong> If your cookies aren't working, they might be: | |
| <ul> | |
| <li>Expired (YouTube cookies expire frequently)</li> | |
| <li>Wrong format (must be Netscape format)</li> | |
| <li>Missing essential cookies</li> | |
| <li>From a different IP/location</li> | |
| </ul> | |
| </div> | |
| <div class="section"> | |
| <h3>π€ Upload Cookie File</h3> | |
| <div class="upload-area"> | |
| <form id="cookieForm" enctype="multipart/form-data"> | |
| <p>Select your YouTube cookies.txt file (Netscape format):</p> | |
| <input type="file" id="cookieFile" name="cookie_file" accept=".txt" required> | |
| <br> | |
| <button type="submit" class="btn">Upload Cookies</button> | |
| </form> | |
| </div> | |
| <div id="uploadResult"></div> | |
| </div> | |
| <div class="section"> | |
| <h3>π Features</h3> | |
| <div class="feature new-feature"> | |
| <strong>π Batch Downloads:</strong> Process multiple YouTube URLs simultaneously | |
| </div> | |
| <div class="feature new-feature"> | |
| <strong>π Progress Tracking:</strong> Monitor batch download progress in real-time | |
| </div> | |
| <div class="feature new-feature"> | |
| <strong>π Individual File Downloads:</strong> Download each file separately | |
| </div> | |
| <div class="feature"> | |
| <strong>πͺ Cookie Support:</strong> Upload cookies for better success rates | |
| </div> | |
| <div class="feature"> | |
| <strong>π Smart Retry Logic:</strong> Automatic retries with exponential backoff | |
| </div> | |
| <div class="feature"> | |
| <strong>β‘ Concurrent Processing:</strong> Configurable concurrent download limits | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <h3>π API Endpoints</h3> | |
| <h4>Cookie Management:</h4> | |
| <ul> | |
| <li><code>POST /upload-cookies</code> - Upload cookie file</li> | |
| <li><code>GET /cookie-status</code> - Check cookie status</li> | |
| </ul> | |
| <h4>Single Video Operations:</h4> | |
| <ul> | |
| <li><code>POST /video/info</code> - Get single video information</li> | |
| <li><code>POST /video/download</code> - Download single video</li> | |
| <li><code>GET /video/file/{filename}</code> - Download a specific file</li> | |
| </ul> | |
| <h4>Batch Operations:</h4> | |
| <ul> | |
| <li><code>POST /batch/info</code> - Get info for multiple videos</li> | |
| <li><code>POST /batch/download</code> - Start batch download</li> | |
| <li><code>GET /batch/status/{batch_id}</code> - Check batch progress</li> | |
| <li><code>GET /batch/files/{batch_id}</code> - Get list of downloadable files</li> | |
| </ul> | |
| </div> | |
| <div class="section"> | |
| <h3>π How to Export Cookies</h3> | |
| <ol> | |
| <li>Install a cookie export extension (like "Get cookies.txt LOCALLY")</li> | |
| <li>Go to YouTube and make sure you're logged in</li> | |
| <li>Use the extension to export cookies in Netscape format</li> | |
| <li>Save the file and upload it here</li> | |
| </ol> | |
| </div> | |
| <div style="text-align: center;"> | |
| <a href="/docs" class="btn">π Interactive API Docs</a> | |
| <a href="/health" class="btn">π₯ Health Check</a> | |
| <a href="/cookie-status" class="btn">πͺ Cookie Status</a> | |
| </div> | |
| </div> | |
| <script> | |
| document.getElementById('cookieForm').addEventListener('submit', async function(e) { | |
| e.preventDefault(); | |
| const formData = new FormData(); | |
| const fileInput = document.getElementById('cookieFile'); | |
| formData.append('cookie_file', fileInput.files[0]); | |
| const resultDiv = document.getElementById('uploadResult'); | |
| resultDiv.innerHTML = '<p>Uploading...</p>'; | |
| try { | |
| const response = await fetch('/upload-cookies', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| resultDiv.innerHTML = '<div class="success"><strong>β Success!</strong> ' + result.message + '</div>'; | |
| } else { | |
| resultDiv.innerHTML = '<div class="warning"><strong>β Error:</strong> ' + result.message + '</div>'; | |
| } | |
| } catch (error) { | |
| resultDiv.innerHTML = '<div class="warning"><strong>β Error:</strong> Failed to upload cookies</div>'; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return HTMLResponse(content=html_content) | |
| async def upload_cookies(cookie_file: UploadFile = File(...)): | |
| """Upload and validate cookie file""" | |
| try: | |
| if not cookie_file.filename.endswith('.txt'): | |
| raise HTTPException(status_code=400, detail="Cookie file must be a .txt file") | |
| content = await cookie_file.read() | |
| cookie_content = content.decode('utf-8') | |
| success = downloader.cookie_manager.save_cookies(cookie_content) | |
| if success: | |
| validation = downloader.cookie_manager.validate_cookies() | |
| return { | |
| "success": True, | |
| "message": f"Cookies uploaded successfully. Found {validation.get('youtube_cookies', 0)} YouTube cookies.", | |
| "validation": validation | |
| } | |
| else: | |
| return { | |
| "success": False, | |
| "message": "Failed to save cookie file. Please check the format." | |
| } | |
| except Exception as e: | |
| logger.error(f"Error uploading cookies: {e}") | |
| return { | |
| "success": False, | |
| "message": f"Error processing cookie file: {str(e)}" | |
| } | |
| async def cookie_status(): | |
| """Get current cookie status""" | |
| validation = downloader.cookie_manager.validate_cookies() | |
| return { | |
| "cookie_file_exists": downloader.cookie_manager.cookie_file.exists(), | |
| "validation": validation | |
| } | |
| async def health_check(): | |
| """Enhanced health check with batch support information""" | |
| try: | |
| subprocess.run(['yt-dlp', '--version'], capture_output=True, check=True) | |
| yt_dlp_available = True | |
| except: | |
| yt_dlp_available = False | |
| strategies = [ | |
| "Batch Processing", | |
| "Cookie Support", | |
| "User-Agent Rotation", | |
| "Smart Retry Logic", | |
| "Enhanced Headers", | |
| "Concurrent Downloads", | |
| "Progress Tracking", | |
| "Individual File Downloads" | |
| ] | |
| return HealthResponse( | |
| status="healthy" if yt_dlp_available else "unhealthy", | |
| yt_dlp_available=yt_dlp_available, | |
| timestamp=datetime.now().isoformat(), | |
| cookie_file_exists=downloader.cookie_manager.cookie_file.exists(), | |
| strategies_enabled=strategies, | |
| batch_support=True | |
| ) | |
| # Single video endpoints | |
| async def get_video_info(request: VideoInfoRequest): | |
| """Get video information with cookie support""" | |
| try: | |
| url_str = str(request.url) | |
| if not any(domain in url_str for domain in ['youtube.com', 'youtu.be']): | |
| raise HTTPException(status_code=400, detail="Invalid YouTube URL") | |
| loop = asyncio.get_event_loop() | |
| info = await loop.run_in_executor( | |
| executor, | |
| downloader.get_video_info, | |
| url_str, | |
| request.use_cookies | |
| ) | |
| if info: | |
| return {"success": True, "info": info} | |
| else: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Failed to get video information. Try uploading fresh cookies or wait before retrying." | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error getting video info: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def download_video(request: DownloadRequest, background_tasks: BackgroundTasks): | |
| """Download video with cookie support""" | |
| try: | |
| url_str = str(request.url) | |
| if not any(domain in url_str for domain in ['youtube.com', 'youtu.be']): | |
| raise HTTPException(status_code=400, detail="Invalid YouTube URL") | |
| loop = asyncio.get_event_loop() | |
| info = await loop.run_in_executor( | |
| executor, | |
| downloader.get_video_info, | |
| url_str, | |
| request.use_cookies | |
| ) | |
| if not info: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Failed to get video information. Try uploading fresh cookies." | |
| ) | |
| downloaded_file = await loop.run_in_executor( | |
| executor, | |
| downloader.download_video, | |
| url_str, | |
| request.quality, | |
| request.audio_only, | |
| request.use_cookies | |
| ) | |
| if downloaded_file: | |
| file_size = os.path.getsize(downloaded_file) | |
| filename = os.path.basename(downloaded_file) | |
| background_tasks.add_task(cleanup_file, downloaded_file, delay=7200) | |
| return DownloadResponse( | |
| success=True, | |
| message="Video downloaded successfully", | |
| filename=filename, | |
| file_size=file_size, | |
| video_info=VideoInfo(**info), | |
| download_path=downloaded_file | |
| ) | |
| else: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Failed to download video. Try uploading fresh cookies or wait before retrying." | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error downloading video: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # Batch endpoints | |
| async def batch_get_video_info(request: BatchVideoInfoRequest): | |
| """Get information for multiple videos""" | |
| try: | |
| if len(request.urls) > 10000: # Limit batch size | |
| raise HTTPException(status_code=400, detail="Maximum 10000 URLs allowed per batch") | |
| urls = [str(url) for url in request.urls] | |
| # Validate URLs | |
| for url in urls: | |
| if not any(domain in url for domain in ['youtube.com', 'youtu.be']): | |
| raise HTTPException(status_code=400, detail=f"Invalid YouTube URL: {url}") | |
| results = await downloader.batch_get_info(urls, request.use_cookies) | |
| success_count = sum(1 for r in results if r.success) | |
| return { | |
| "success": True, | |
| "total_urls": len(urls), | |
| "successful": success_count, | |
| "failed": len(urls) - success_count, | |
| "results": [r.dict() for r in results] | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error in batch info: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def batch_download_videos(request: BatchDownloadRequest, background_tasks: BackgroundTasks): | |
| """Start batch download of multiple videos""" | |
| try: | |
| if len(request.urls) > 10000: # Limit batch size | |
| raise HTTPException(status_code=400, detail="Maximum 10000 URLs allowed per batch download") | |
| if request.max_concurrent > 5: # Limit concurrent downloads | |
| raise HTTPException(status_code=400, detail="Maximum 5 concurrent downloads allowed") | |
| urls = [str(url) for url in request.urls] | |
| # Validate URLs | |
| for url in urls: | |
| if not any(domain in url for domain in ['youtube.com', 'youtu.be']): | |
| raise HTTPException(status_code=400, detail=f"Invalid YouTube URL: {url}") | |
| # Generate batch ID | |
| batch_id = str(uuid.uuid4()) | |
| # Start batch download in background | |
| background_tasks.add_task( | |
| downloader.batch_download, | |
| batch_id, | |
| urls, | |
| request.quality, | |
| request.audio_only, | |
| request.use_cookies, | |
| request.max_concurrent | |
| ) | |
| return BatchDownloadResponse( | |
| batch_id=batch_id, | |
| total_urls=len(urls), | |
| status="started", | |
| completed=0, | |
| failed=0, | |
| results=[] | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error starting batch download: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_batch_status(batch_id: str): | |
| """Get status of a batch download""" | |
| if batch_id not in batch_status_store: | |
| raise HTTPException(status_code=404, detail="Batch not found") | |
| status = batch_status_store[batch_id] | |
| return BatchStatus(**status) | |
| async def get_batch_files(batch_id: str): | |
| """Get list of downloadable files for a batch""" | |
| if batch_id not in batch_status_store: | |
| raise HTTPException(status_code=404, detail="Batch not found") | |
| batch_status = batch_status_store[batch_id] | |
| if batch_status["status"] != "completed": | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Batch is not completed yet. Current status: {batch_status['status']}" | |
| ) | |
| files = [] | |
| for result in batch_status["results"]: | |
| if result.get("success") and result.get("download_path"): | |
| file_path = result["download_path"] | |
| if os.path.exists(file_path): | |
| files.append({ | |
| "filename": os.path.basename(file_path), | |
| "url": f"/video/file/{os.path.basename(file_path)}" | |
| }) | |
| return BatchFileListResponse( | |
| batch_id=batch_id, | |
| total_files=len(files), | |
| files=files | |
| ) | |
| from fastapi.responses import RedirectResponse | |
| async def download_batch_files(batch_id: str): | |
| """Get JSON list of all downloadable files for a specific batch""" | |
| if batch_id not in batch_status_store: | |
| raise HTTPException(status_code=404, detail="Batch not found") | |
| batch_status = batch_status_store[batch_id] | |
| if batch_status["status"] != "completed": | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Batch not completed. Status: {batch_status['status']}" | |
| ) | |
| files = [] | |
| for result in batch_status["results"]: | |
| if result.get("success") and result.get("download_path"): | |
| file_path = result["download_path"] | |
| if os.path.exists(file_path): | |
| files.append({ | |
| "filename": os.path.basename(file_path), | |
| "url": f"/video/file/{os.path.basename(file_path)}" | |
| }) | |
| if not files: | |
| raise HTTPException(status_code=404, detail="No downloadable files found in this batch") | |
| return {"batch_id": batch_id, "total_files": len(files), "downloads": files} | |
| async def download_all_files(): | |
| """Get JSON list of all available files in the download directory""" | |
| download_dir = Path(downloader.download_dir) | |
| files = [] | |
| for file_path in download_dir.glob("*"): | |
| if file_path.is_file(): | |
| files.append({ | |
| "filename": file_path.name, | |
| "url": f"/video/file/{file_path.name}" | |
| }) | |
| if not files: | |
| raise HTTPException(status_code=404, detail="No files available for download") | |
| return {"total_files": len(files), "downloads": files} | |
| async def download_file(filename: str): | |
| """Serve downloaded files""" | |
| try: | |
| file_path = downloader.download_dir / filename | |
| if not file_path.exists() or not str(file_path.resolve()).startswith(str(downloader.download_dir.resolve())): | |
| raise HTTPException(status_code=404, detail="File not found") | |
| return FileResponse( | |
| path=str(file_path), | |
| filename=filename, | |
| media_type='application/octet-stream' | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"Error serving file: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def cleanup_file(file_path: str, delay: int = 7200): | |
| """Clean up downloaded file after delay""" | |
| await asyncio.sleep(delay) | |
| try: | |
| if os.path.exists(file_path): | |
| os.remove(file_path) | |
| logger.info(f"Cleaned up file: {file_path}") | |
| except Exception as e: | |
| logger.error(f"Failed to cleanup file {file_path}: {e}") | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| uvicorn.run( | |
| "main:app", | |
| host="0.0.0.0", | |
| port=port, | |
| reload=False, | |
| log_level="info" | |
| ) | |