Rthur2003 commited on
Commit
13ab660
·
1 Parent(s): d33093c

feat: enhance YouTubeDownloader with cookie support and player client cycling

Browse files
Files changed (1) hide show
  1. 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 _download_with_ffmpeg(self, url: str, video_id: str) -> Optional[dict]:
 
63
  safe_video_id = sanitize_filename(video_id)
64
- options = {
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
- "postprocessors": [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  {
72
  "key": "FFmpegExtractAudio",
73
  "preferredcodec": "wav",
74
  "preferredquality": "192",
75
  }
76
- ],
77
- }
78
- try:
79
- with yt_dlp.YoutubeDL(options) as ydl:
80
- return ydl.extract_info(url, download=True)
81
- except Exception:
82
- return None
83
 
84
- def _download_without_ffmpeg(self, url: str, video_id: str) -> Optional[dict]:
85
- safe_video_id = sanitize_filename(video_id)
86
- options = {
87
- "format": "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio",
88
- "outtmpl": str(self.output_dir / f"{safe_video_id}.%(ext)s"),
89
- "noplaylist": True,
90
- "quiet": True,
91
- "no_warnings": True,
92
- }
93
- try:
94
- with yt_dlp.YoutubeDL(options) as ydl:
95
- return ydl.extract_info(url, download=True)
96
- except Exception:
97
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)