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

feat: enhance YouTubeDownloader with cookie handling and error normalization

Browse files
Files changed (1) hide show
  1. app/services/youtube_downloader.py +184 -26
app/services/youtube_downloader.py CHANGED
@@ -9,8 +9,9 @@ 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
 
@@ -21,7 +22,7 @@ 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"],
@@ -29,23 +30,65 @@ _PLAYER_CLIENTS: List[List[str]] = [
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
@@ -54,6 +97,30 @@ def _get_cookie_path() -> Optional[str]:
54
  return None
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @dataclass
58
  class DownloadResult:
59
  file_path: Path
@@ -63,6 +130,45 @@ class DownloadResult:
63
  warnings: List[str]
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  class YouTubeDownloader:
67
  def __init__(self, output_dir: Path) -> None:
68
  self.output_dir = output_dir
@@ -80,10 +186,20 @@ class YouTubeDownloader:
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)
@@ -123,13 +239,24 @@ class YouTubeDownloader:
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"] = [
@@ -145,35 +272,66 @@ class YouTubeDownloader:
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)
179
  candidates = list(self.output_dir.glob(f"{safe_video_id}.*"))
 
9
 
10
  import base64
11
  import os
12
+ import re
13
  import tempfile
14
+ from dataclasses import dataclass, field
15
  from pathlib import Path
16
  from typing import List, Optional
17
 
 
22
 
23
  logger = get_logger(__name__)
24
 
25
+ # Player clients to try in order. Android/iOS often bypass stricter web checks.
26
  _PLAYER_CLIENTS: List[List[str]] = [
27
  ["android", "web"],
28
  ["ios", "web"],
 
30
  ["default"],
31
  ]
32
 
33
+ _BROWSER_COOKIE_SPEC_RE = re.compile(
34
+ r"""(?x)
35
+ (?P<name>[^+:]+)
36
+ (?:\s*\+\s*(?P<keyring>[^:]+))?
37
+ (?:\s*:\s*(?!:)(?P<profile>.+?))?
38
+ (?:\s*::\s*(?P<container>.+))?
39
+ """
40
+ )
41
 
 
 
42
 
43
+ def _normalize_error_message(message: object) -> str:
44
+ text = str(message).replace("\n", " ").strip()
45
+ text = re.sub(r"^\s*ERROR:\s*", "", text, flags=re.IGNORECASE)
46
+ return " ".join(text.split())
47
+
48
+
49
+ def _normalized_error_lower(message: str) -> str:
50
+ return _normalize_error_message(message).replace("’", "'").lower()
51
+
52
+
53
+ def _is_auth_required_message(message: str) -> bool:
54
+ normalized = _normalized_error_lower(message)
55
+ return (
56
+ "sign in to confirm you're not a bot" in normalized
57
+ or "use --cookies-from-browser or --cookies" in normalized
58
+ )
59
+
60
+
61
+ def _is_ffmpeg_missing_message(message: str) -> bool:
62
+ normalized = _normalized_error_lower(message)
63
+ return "ffmpeg" in normalized and (
64
+ "not found" in normalized
65
+ or "--ffmpeg-location" in normalized
66
+ or "please install" in normalized
67
+ )
68
+
69
+
70
+ def _is_ffmpeg_postprocess_message(message: str) -> bool:
71
+ normalized = _normalized_error_lower(message)
72
+ return "ffmpeg" in normalized and (
73
+ "postprocessing" in normalized
74
+ or "post-process" in normalized
75
+ or "extractaudio" in normalized
76
+ )
77
+
78
+
79
+ def _get_cookie_path() -> Optional[str]:
80
+ """Resolve cookie file path from environment variables."""
81
  cookie_file = os.getenv("YOUTUBE_COOKIES_FILE")
82
+ if cookie_file:
83
+ if Path(cookie_file).is_file():
84
+ return cookie_file
85
+ logger.warning(f"YOUTUBE_COOKIES_FILE does not exist: {cookie_file}")
86
 
87
  cookie_b64 = os.getenv("YOUTUBE_COOKIES_BASE64")
88
  if cookie_b64:
89
  try:
90
  raw = base64.b64decode(cookie_b64)
91
+ tmp = Path(tempfile.gettempdir()) / "crowncode_yt_cookies.txt"
92
  tmp.write_bytes(raw)
93
  return str(tmp)
94
  except Exception as exc: # noqa: BLE001
 
97
  return None
98
 
99
 
100
+ def _get_browser_cookie_config() -> Optional[tuple[str, Optional[str], Optional[str], Optional[str]]]:
101
+ """Resolve yt-dlp cookies-from-browser config from environment variables."""
102
+ raw = os.getenv("YOUTUBE_COOKIES_FROM_BROWSER")
103
+ if not raw:
104
+ return None
105
+
106
+ match = _BROWSER_COOKIE_SPEC_RE.fullmatch(raw.strip())
107
+ if match is None:
108
+ raise ValueError(
109
+ "Invalid YOUTUBE_COOKIES_FROM_BROWSER format. "
110
+ "Expected BROWSER[+KEYRING][:PROFILE][::CONTAINER]."
111
+ )
112
+
113
+ browser_name, keyring, profile, container = match.group(
114
+ "name", "keyring", "profile", "container"
115
+ )
116
+ return (
117
+ browser_name.lower(),
118
+ profile,
119
+ keyring.upper() if keyring else None,
120
+ container,
121
+ )
122
+
123
+
124
  @dataclass
125
  class DownloadResult:
126
  file_path: Path
 
130
  warnings: List[str]
131
 
132
 
133
+ @dataclass
134
+ class DownloadAttemptFailure:
135
+ client_label: str
136
+ message: str
137
+
138
+
139
+ @dataclass
140
+ class DownloadAttemptResult:
141
+ info: Optional[dict]
142
+ failures: List[DownloadAttemptFailure] = field(default_factory=list)
143
+
144
+
145
+ class YouTubeDownloadError(RuntimeError):
146
+ def __init__(
147
+ self,
148
+ error_code: str,
149
+ message: str,
150
+ warnings: Optional[List[str]] = None,
151
+ ) -> None:
152
+ super().__init__(message)
153
+ self.error_code = error_code
154
+ self.warnings = warnings or []
155
+
156
+
157
+ class _YtDlpLogCollector:
158
+ def __init__(self) -> None:
159
+ self.last_error: Optional[str] = None
160
+
161
+ def debug(self, message: str) -> None:
162
+ logger.debug(f"yt-dlp: {_normalize_error_message(message)}")
163
+
164
+ def warning(self, message: str) -> None:
165
+ logger.debug(f"yt-dlp warning: {_normalize_error_message(message)}")
166
+
167
+ def error(self, message: str) -> None:
168
+ self.last_error = _normalize_error_message(message)
169
+ logger.debug(f"yt-dlp error: {self.last_error}")
170
+
171
+
172
  class YouTubeDownloader:
173
  def __init__(self, output_dir: Path) -> None:
174
  self.output_dir = output_dir
 
186
 
187
  warnings: List[str] = []
188
 
189
+ ffmpeg_result = self._download_with_ffmpeg(url, video_id)
190
+ info = ffmpeg_result.info
191
+
192
  if info is None:
193
+ raw_result = self._download_without_ffmpeg(url, video_id)
194
+ info = raw_result.info
195
+
196
+ if info is not None:
197
+ if any(_is_ffmpeg_missing_message(f.message) for f in ffmpeg_result.failures):
198
+ warnings.append("ffmpeg_unavailable")
199
+ elif any(_is_ffmpeg_postprocess_message(f.message) for f in ffmpeg_result.failures):
200
+ warnings.append("ffmpeg_postprocess_failed")
201
+ else:
202
+ self._raise_download_error(ffmpeg_result.failures + raw_result.failures)
203
 
204
  file_path = self._resolve_output_path(video_id)
205
  audio_format = file_path.suffix.lstrip(".") or (info.get("ext") if info else None)
 
239
  if cookie_path:
240
  opts["cookiefile"] = cookie_path
241
  logger.debug("Using cookie file for YouTube authentication")
242
+ return opts
243
+
244
+ browser_cookie_config = _get_browser_cookie_config()
245
+ if browser_cookie_config:
246
+ opts["cookiesfrombrowser"] = browser_cookie_config
247
+ logger.debug(
248
+ "Using browser cookies for YouTube authentication "
249
+ f"({browser_cookie_config[0]})"
250
+ )
251
 
252
  return opts
253
 
254
  def _try_with_clients(
255
  self, url: str, options: dict, with_postprocessor: bool
256
+ ) -> DownloadAttemptResult:
257
  """Try downloading with different player client combinations."""
258
+ failures: List[DownloadAttemptFailure] = []
259
+
260
  if with_postprocessor:
261
  options["format"] = "bestaudio/best"
262
  options["postprocessors"] = [
 
272
  for clients in _PLAYER_CLIENTS:
273
  opts = {**options}
274
  opts["extractor_args"] = {"youtube": {"player_client": clients}}
275
+ opts["logger"] = _YtDlpLogCollector()
276
  client_label = "+".join(clients)
277
  try:
278
  logger.debug(f"Trying player_client={client_label}")
279
  with yt_dlp.YoutubeDL(opts) as ydl:
280
+ return DownloadAttemptResult(
281
+ info=ydl.extract_info(url, download=True),
282
+ failures=failures,
283
+ )
284
  except Exception as exc: # noqa: BLE001
285
+ collector = opts["logger"]
286
+ error_message = collector.last_error or _normalize_error_message(exc)
287
+ failures.append(
288
+ DownloadAttemptFailure(
289
+ client_label=client_label,
290
+ message=error_message,
291
+ )
292
+ )
293
+ logger.debug(f"player_client={client_label} failed: {error_message}")
294
  continue
295
 
296
+ return DownloadAttemptResult(info=None, failures=failures)
297
 
298
+ def _download_with_ffmpeg(self, url: str, video_id: str) -> DownloadAttemptResult:
 
 
299
  opts = self._base_options(video_id)
300
  result = self._try_with_clients(url, opts, with_postprocessor=True)
301
+ if result.info is None:
302
  logger.warning("All player clients failed with ffmpeg postprocessor")
303
  return result
304
 
305
+ def _download_without_ffmpeg(self, url: str, video_id: str) -> DownloadAttemptResult:
 
 
306
  opts = self._base_options(video_id)
307
  result = self._try_with_clients(url, opts, with_postprocessor=False)
308
+ if result.info is None:
309
  logger.warning("All player clients failed without ffmpeg postprocessor")
310
  return result
311
 
312
+ def _raise_download_error(self, failures: List[DownloadAttemptFailure]) -> None:
313
+ if failures:
314
+ auth_failure = next(
315
+ (failure for failure in reversed(failures) if _is_auth_required_message(failure.message)),
316
+ None,
317
+ )
318
+ if auth_failure is not None:
319
+ raise YouTubeDownloadError(
320
+ error_code="youtube_authentication_required",
321
+ message=auth_failure.message,
322
+ warnings=["youtube_authentication_required"],
323
+ )
324
+
325
+ raise YouTubeDownloadError(
326
+ error_code="youtube_download_failed",
327
+ message=failures[-1].message,
328
+ )
329
+
330
+ raise YouTubeDownloadError(
331
+ error_code="youtube_download_failed",
332
+ message="YouTube download failed without an explicit yt-dlp error.",
333
+ )
334
+
335
  def _resolve_output_path(self, video_id: str) -> Path:
336
  safe_video_id = sanitize_filename(video_id)
337
  candidates = list(self.output_dir.glob(f"{safe_video_id}.*"))