diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 3411eb2..09dd77a 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -1954,7 +1954,7 @@ class Download_File(Cmdlet): height_selector = None if query_wants_audio: # Explicit audio request should map to best-audio-only selector - ytdl_format = "ba" + ytdl_format = "bestaudio" elif height_selector: ytdl_format = height_selector elif query_format: diff --git a/tool/ytdlp.py b/tool/ytdlp.py index c8233da..554e294 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -907,8 +907,18 @@ class YtDlpTool: "fragment_retries": 10, "http_chunk_size": 10_485_760, "restrictfilenames": True, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", + "referer": "https://www.youtube.com/", } + base_options.setdefault( + "http_headers", + { + "User-Agent": base_options.get("user_agent"), + "Referer": base_options.get("referer"), + }, + ) + try: repo_root = Path(__file__).resolve().parents[1] bundled_ffmpeg_dir = repo_root / "MPV" / "ffmpeg" / "bin" @@ -1095,7 +1105,7 @@ class YtDlpTool: base_options["playlist_items"] = opts.playlist_items if not opts.quiet: - debug(f"yt-dlp: mode={opts.mode}, format={base_options.get('format')}") + debug(f"yt-dlp: mode={opts.mode}, format={base_options.get('format')}, cookiefile={base_options.get('cookiefile')}") return base_options @@ -1729,6 +1739,31 @@ except ImportError: extract_ytdlp_tags = None # type: ignore +def _is_http_403(exc: Exception) -> bool: + msg_parts: list[str] = [] + try: + msg_parts.append(str(exc)) + except Exception: + pass + try: + cause = getattr(exc, "__cause__", None) + if cause is not None: + msg_parts.append(str(cause)) + except Exception: + pass + try: + context = getattr(exc, "__context__", None) + if context is not None: + msg_parts.append(str(context)) + except Exception: + pass + + for msg in msg_parts: + if "HTTP Error 403" in msg or "403: Forbidden" in msg or "403 Forbidden" in msg: + return True + return False + + def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] = None, debug_logger: Optional[DebugLogger] = None) -> Any: """Download streaming media exclusively via yt-dlp. @@ -1798,14 +1833,15 @@ def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] = debug_logger.write_record("ytdlp-start", {"url": opts.url}) assert yt_dlp is not None + info: Optional[Dict[str, Any]] = None + session_id = None + first_section_info: Dict[str, Any] = {} try: if not opts.quiet: if ytdl_options.get("download_sections"): debug(f"[yt-dlp] download_sections: {ytdl_options['download_sections']}") debug(f"[yt-dlp] force_keyframes_at_cuts: {ytdl_options.get('force_keyframes_at_cuts', False)}") - session_id = None - first_section_info: Dict[str, Any] = {} if ytdl_options.get("download_sections"): live_ui, _ = PipelineProgress(pipeline_context).ui_and_pipe_index() quiet_sections = bool(opts.quiet) or (live_ui is not None) @@ -1820,13 +1856,47 @@ def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] = with yt_dlp.YoutubeDL(ytdl_options) as ydl: # type: ignore[arg-type] info = ydl.extract_info(opts.url, download=True) except Exception as exc: - log(f"yt-dlp failed: {exc}", file=sys.stderr) - if debug_logger is not None: - debug_logger.write_record( - "exception", - {"phase": "yt-dlp", "error": str(exc), "traceback": traceback.format_exc()}, - ) - raise DownloadError("yt-dlp download failed") from exc + retry_attempted = False + if _is_http_403(exc) and not ytdl_options.get("download_sections"): + retry_attempted = True + try: + if not opts.quiet: + debug("yt-dlp hit HTTP 403; retrying with browser cookies + android/web player client") + + fallback_options = dict(ytdl_options) + fallback_options.pop("cookiefile", None) + _add_browser_cookies_if_available(fallback_options) + + extractor_args = fallback_options.get("extractor_args") + if not isinstance(extractor_args, dict): + extractor_args = {} + youtube_args = extractor_args.get("youtube") + if not isinstance(youtube_args, dict): + youtube_args = {} + if "player_client" not in youtube_args: + youtube_args["player_client"] = ["android", "web"] + extractor_args["youtube"] = youtube_args + fallback_options["extractor_args"] = extractor_args + + with yt_dlp.YoutubeDL(fallback_options) as ydl: # type: ignore[arg-type] + info = ydl.extract_info(opts.url, download=True) + except Exception as exc2: + log(f"yt-dlp failed: {exc2}", file=sys.stderr) + if debug_logger is not None: + debug_logger.write_record( + "exception", + {"phase": "yt-dlp", "error": str(exc2), "traceback": traceback.format_exc()}, + ) + raise DownloadError("yt-dlp download failed") from exc2 + + if not retry_attempted: + log(f"yt-dlp failed: {exc}", file=sys.stderr) + if debug_logger is not None: + debug_logger.write_record( + "exception", + {"phase": "yt-dlp", "error": str(exc), "traceback": traceback.format_exc()}, + ) + raise DownloadError("yt-dlp download failed") from exc if info is None: try: