Spaces:
Running
Running
feat: enhance YouTubeDownloader with cookie support and player client cycling
Browse files- app/services/youtube_downloader.py +109 -31
app/services/youtube_downloader.py
CHANGED
|
@@ -1,17 +1,58 @@
|
|
| 1 |
"""
|
| 2 |
YouTube audio download helper using yt-dlp with validation.
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from __future__ import annotations
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import List, Optional
|
| 10 |
|
| 11 |
import yt_dlp
|
| 12 |
|
|
|
|
| 13 |
from .validation import sanitize_filename, validate_video_id
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@dataclass
|
| 17 |
class DownloadResult:
|
|
@@ -30,23 +71,23 @@ class YouTubeDownloader:
|
|
| 30 |
def download(self, url: str, video_id: str) -> DownloadResult:
|
| 31 |
if not url or not url.strip():
|
| 32 |
raise ValueError("URL cannot be empty")
|
| 33 |
-
|
| 34 |
if not video_id or not video_id.strip():
|
| 35 |
raise ValueError("Video ID cannot be empty")
|
| 36 |
-
|
| 37 |
if not validate_video_id(video_id):
|
| 38 |
raise ValueError("Invalid video ID format")
|
| 39 |
-
|
| 40 |
warnings: List[str] = []
|
| 41 |
|
| 42 |
-
info = self._download_with_ffmpeg(url, video_id)
|
| 43 |
if info is None:
|
| 44 |
-
info = self._download_without_ffmpeg(url, video_id)
|
| 45 |
warnings.append("ffmpeg_unavailable")
|
| 46 |
|
| 47 |
file_path = self._resolve_output_path(video_id)
|
| 48 |
audio_format = file_path.suffix.lstrip(".") or (info.get("ext") if info else None)
|
| 49 |
-
|
| 50 |
title = info.get("title") if info else None
|
| 51 |
if title:
|
| 52 |
title = sanitize_filename(title)
|
|
@@ -59,42 +100,79 @@ class YouTubeDownloader:
|
|
| 59 |
warnings=warnings,
|
| 60 |
)
|
| 61 |
|
| 62 |
-
def
|
|
|
|
| 63 |
safe_video_id = sanitize_filename(video_id)
|
| 64 |
-
|
| 65 |
-
"format": "bestaudio/best",
|
| 66 |
"outtmpl": str(self.output_dir / f"{safe_video_id}.%(ext)s"),
|
| 67 |
"noplaylist": True,
|
| 68 |
"quiet": True,
|
| 69 |
"no_warnings": True,
|
| 70 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
{
|
| 72 |
"key": "FFmpegExtractAudio",
|
| 73 |
"preferredcodec": "wav",
|
| 74 |
"preferredquality": "192",
|
| 75 |
}
|
| 76 |
-
]
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
with yt_dlp.YoutubeDL(options) as ydl:
|
| 80 |
-
return ydl.extract_info(url, download=True)
|
| 81 |
-
except Exception:
|
| 82 |
-
return None
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
def _resolve_output_path(self, video_id: str) -> Path:
|
| 100 |
safe_video_id = sanitize_filename(video_id)
|
|
|
|
| 1 |
"""
|
| 2 |
YouTube audio download helper using yt-dlp with validation.
|
| 3 |
+
|
| 4 |
+
Handles YouTube bot detection by cycling through player clients
|
| 5 |
+
and supporting cookie-based authentication via environment variables.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import base64
|
| 11 |
+
import os
|
| 12 |
+
import tempfile
|
| 13 |
from dataclasses import dataclass
|
| 14 |
from pathlib import Path
|
| 15 |
from typing import List, Optional
|
| 16 |
|
| 17 |
import yt_dlp
|
| 18 |
|
| 19 |
+
from .logging_config import get_logger
|
| 20 |
from .validation import sanitize_filename, validate_video_id
|
| 21 |
|
| 22 |
+
logger = get_logger(__name__)
|
| 23 |
+
|
| 24 |
+
# Player clients to try in order — android/ios often bypass bot detection
|
| 25 |
+
_PLAYER_CLIENTS: List[List[str]] = [
|
| 26 |
+
["android", "web"],
|
| 27 |
+
["ios", "web"],
|
| 28 |
+
["tv", "web"],
|
| 29 |
+
["default"],
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_cookie_path() -> Optional[str]:
|
| 34 |
+
"""Resolve cookie file path from environment variables.
|
| 35 |
+
|
| 36 |
+
Supports:
|
| 37 |
+
- YOUTUBE_COOKIES_FILE: direct path to a Netscape-format cookies.txt
|
| 38 |
+
- YOUTUBE_COOKIES_BASE64: base64-encoded cookies.txt content (for Spaces secrets)
|
| 39 |
+
"""
|
| 40 |
+
cookie_file = os.getenv("YOUTUBE_COOKIES_FILE")
|
| 41 |
+
if cookie_file and Path(cookie_file).is_file():
|
| 42 |
+
return cookie_file
|
| 43 |
+
|
| 44 |
+
cookie_b64 = os.getenv("YOUTUBE_COOKIES_BASE64")
|
| 45 |
+
if cookie_b64:
|
| 46 |
+
try:
|
| 47 |
+
raw = base64.b64decode(cookie_b64)
|
| 48 |
+
tmp = Path(tempfile.gettempdir()) / "yt_cookies.txt"
|
| 49 |
+
tmp.write_bytes(raw)
|
| 50 |
+
return str(tmp)
|
| 51 |
+
except Exception as exc: # noqa: BLE001
|
| 52 |
+
logger.warning(f"Failed to decode YOUTUBE_COOKIES_BASE64: {exc}")
|
| 53 |
+
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
|
| 57 |
@dataclass
|
| 58 |
class DownloadResult:
|
|
|
|
| 71 |
def download(self, url: str, video_id: str) -> DownloadResult:
|
| 72 |
if not url or not url.strip():
|
| 73 |
raise ValueError("URL cannot be empty")
|
| 74 |
+
|
| 75 |
if not video_id or not video_id.strip():
|
| 76 |
raise ValueError("Video ID cannot be empty")
|
| 77 |
+
|
| 78 |
if not validate_video_id(video_id):
|
| 79 |
raise ValueError("Invalid video ID format")
|
| 80 |
+
|
| 81 |
warnings: List[str] = []
|
| 82 |
|
| 83 |
+
info = self._download_with_ffmpeg(url, video_id, warnings)
|
| 84 |
if info is None:
|
| 85 |
+
info = self._download_without_ffmpeg(url, video_id, warnings)
|
| 86 |
warnings.append("ffmpeg_unavailable")
|
| 87 |
|
| 88 |
file_path = self._resolve_output_path(video_id)
|
| 89 |
audio_format = file_path.suffix.lstrip(".") or (info.get("ext") if info else None)
|
| 90 |
+
|
| 91 |
title = info.get("title") if info else None
|
| 92 |
if title:
|
| 93 |
title = sanitize_filename(title)
|
|
|
|
| 100 |
warnings=warnings,
|
| 101 |
)
|
| 102 |
|
| 103 |
+
def _base_options(self, video_id: str) -> dict:
|
| 104 |
+
"""Build base yt-dlp options with bot-detection mitigations."""
|
| 105 |
safe_video_id = sanitize_filename(video_id)
|
| 106 |
+
opts: dict = {
|
|
|
|
| 107 |
"outtmpl": str(self.output_dir / f"{safe_video_id}.%(ext)s"),
|
| 108 |
"noplaylist": True,
|
| 109 |
"quiet": True,
|
| 110 |
"no_warnings": True,
|
| 111 |
+
"socket_timeout": 30,
|
| 112 |
+
"retries": 3,
|
| 113 |
+
"http_headers": {
|
| 114 |
+
"User-Agent": (
|
| 115 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 116 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 117 |
+
"Chrome/131.0.0.0 Safari/537.36"
|
| 118 |
+
),
|
| 119 |
+
},
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
cookie_path = _get_cookie_path()
|
| 123 |
+
if cookie_path:
|
| 124 |
+
opts["cookiefile"] = cookie_path
|
| 125 |
+
logger.debug("Using cookie file for YouTube authentication")
|
| 126 |
+
|
| 127 |
+
return opts
|
| 128 |
+
|
| 129 |
+
def _try_with_clients(
|
| 130 |
+
self, url: str, options: dict, with_postprocessor: bool
|
| 131 |
+
) -> Optional[dict]:
|
| 132 |
+
"""Try downloading with different player client combinations."""
|
| 133 |
+
if with_postprocessor:
|
| 134 |
+
options["format"] = "bestaudio/best"
|
| 135 |
+
options["postprocessors"] = [
|
| 136 |
{
|
| 137 |
"key": "FFmpegExtractAudio",
|
| 138 |
"preferredcodec": "wav",
|
| 139 |
"preferredquality": "192",
|
| 140 |
}
|
| 141 |
+
]
|
| 142 |
+
else:
|
| 143 |
+
options["format"] = "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
+
for clients in _PLAYER_CLIENTS:
|
| 146 |
+
opts = {**options}
|
| 147 |
+
opts["extractor_args"] = {"youtube": {"player_client": clients}}
|
| 148 |
+
client_label = "+".join(clients)
|
| 149 |
+
try:
|
| 150 |
+
logger.debug(f"Trying player_client={client_label}")
|
| 151 |
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 152 |
+
return ydl.extract_info(url, download=True)
|
| 153 |
+
except Exception as exc: # noqa: BLE001
|
| 154 |
+
logger.debug(f"player_client={client_label} failed: {exc}")
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
def _download_with_ffmpeg(
|
| 160 |
+
self, url: str, video_id: str, warnings: List[str]
|
| 161 |
+
) -> Optional[dict]:
|
| 162 |
+
opts = self._base_options(video_id)
|
| 163 |
+
result = self._try_with_clients(url, opts, with_postprocessor=True)
|
| 164 |
+
if result is None:
|
| 165 |
+
logger.warning("All player clients failed with ffmpeg postprocessor")
|
| 166 |
+
return result
|
| 167 |
+
|
| 168 |
+
def _download_without_ffmpeg(
|
| 169 |
+
self, url: str, video_id: str, warnings: List[str]
|
| 170 |
+
) -> Optional[dict]:
|
| 171 |
+
opts = self._base_options(video_id)
|
| 172 |
+
result = self._try_with_clients(url, opts, with_postprocessor=False)
|
| 173 |
+
if result is None:
|
| 174 |
+
logger.warning("All player clients failed without ffmpeg postprocessor")
|
| 175 |
+
return result
|
| 176 |
|
| 177 |
def _resolve_output_path(self, video_id: str) -> Path:
|
| 178 |
safe_video_id = sanitize_filename(video_id)
|