|
|
|
|
|
""" |
|
|
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 |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
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]] |
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
) |
|
|
|
|
|
|
|
|
executor = ThreadPoolExecutor(max_workers=4) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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) |
|
|
) |
|
|
|
|
|
|
|
|
semaphore = asyncio.Semaphore(3) |
|
|
|
|
|
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""" |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
tasks = [download_single(url) for url in urls] |
|
|
await asyncio.gather(*tasks) |
|
|
|
|
|
|
|
|
batch_status_store[batch_id]["status"] = "completed" |
|
|
batch_status_store[batch_id]["updated_at"] = datetime.now().isoformat() |
|
|
|
|
|
|
|
|
downloader = BatchYouTubeDownloader() |
|
|
|
|
|
@app.get("/", response_class=HTMLResponse) |
|
|
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) |
|
|
|
|
|
@app.post("/upload-cookies") |
|
|
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)}" |
|
|
} |
|
|
|
|
|
@app.get("/cookie-status") |
|
|
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 |
|
|
} |
|
|
|
|
|
@app.get("/health", response_model=HealthResponse) |
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
@app.post("/video/info", response_model=Dict[str, Any]) |
|
|
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)) |
|
|
|
|
|
@app.post("/video/download", response_model=DownloadResponse) |
|
|
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)) |
|
|
|
|
|
|
|
|
@app.post("/batch/info") |
|
|
async def batch_get_video_info(request: BatchVideoInfoRequest): |
|
|
"""Get information for multiple videos""" |
|
|
try: |
|
|
if len(request.urls) > 10000: |
|
|
raise HTTPException(status_code=400, detail="Maximum 10000 URLs allowed per batch") |
|
|
|
|
|
urls = [str(url) for url in request.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)) |
|
|
|
|
|
@app.post("/batch/download", response_model=BatchDownloadResponse) |
|
|
async def batch_download_videos(request: BatchDownloadRequest, background_tasks: BackgroundTasks): |
|
|
"""Start batch download of multiple videos""" |
|
|
try: |
|
|
if len(request.urls) > 10000: |
|
|
raise HTTPException(status_code=400, detail="Maximum 10000 URLs allowed per batch download") |
|
|
|
|
|
if request.max_concurrent > 5: |
|
|
raise HTTPException(status_code=400, detail="Maximum 5 concurrent downloads allowed") |
|
|
|
|
|
urls = [str(url) for url in request.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}") |
|
|
|
|
|
|
|
|
batch_id = str(uuid.uuid4()) |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
@app.get("/batch/status/{batch_id}", response_model=BatchStatus) |
|
|
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) |
|
|
|
|
|
@app.get("/batch/files/{batch_id}", response_model=BatchFileListResponse) |
|
|
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 |
|
|
|
|
|
@app.get("/batch/download-all/{batch_id}") |
|
|
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} |
|
|
|
|
|
|
|
|
@app.get("/batch/download-all") |
|
|
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} |
|
|
|
|
|
|
|
|
@app.get("/video/file/{filename}") |
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
|