diff --git a/API/HTTP.py b/API/HTTP.py index 66bb30b..f899cba 100644 --- a/API/HTTP.py +++ b/API/HTTP.py @@ -15,6 +15,7 @@ import time import traceback import re import os +import json from typing import Optional, Dict, Any, Callable, List, Union from pathlib import Path from urllib.parse import unquote, urlparse, parse_qs @@ -464,14 +465,41 @@ class HTTPClient: def _preview(value: Any, *, limit: int = 2000) -> str: if value is None: - return "" + return "" try: + # File-like objects (uploads/streams) - show compact summary instead of raw contents + if hasattr(value, "read") or hasattr(value, "fileno"): + name = getattr(value, "name", None) + total = getattr(value, "_total", None) + label = getattr(value, "_label", None) + try: + pos = value.tell() if hasattr(value, "tell") else None + except Exception: + pos = None + parts = [] + if name is not None: + parts.append(f"name={name!r}") + if total is not None: + parts.append(f"total={total}") + if label is not None: + parts.append(f"label={label!r}") + if pos is not None: + parts.append(f"pos={pos}") + summary = " ".join(parts) if parts else None + return f"" if summary else "" + if isinstance(value, (dict, list, tuple)): - text = json.dumps(value, ensure_ascii=False) + # Use default=str to avoid failing on non-serializable objects (e.g., file-like) + text = json.dumps(value, ensure_ascii=False, default=str) + elif isinstance(value, (bytes, bytearray)): + text = value.decode("utf-8", errors="replace") else: text = str(value) except Exception: - text = repr(value) + try: + text = repr(value) + except Exception: + text = "" if len(text) > limit: return text[:limit] + "..." return text @@ -497,6 +525,7 @@ class HTTPClient: ("data", _preview(kwargs.get("data"))), ("json", _preview(kwargs.get("json"))), ("content", _preview(kwargs.get("content"))), + ("files", _preview(kwargs.get("files"))), ], ) try: diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 09dd77a..a037833 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -1177,7 +1177,7 @@ class Download_File(Cmdlet): return f"https://www.youtube.com/watch?v={entry_id.strip()}" return None - table = Table() + table = Table(preserve_order=True) safe_url = str(url or "").strip() table.title = f'download-file -url "{safe_url}"' if safe_url else "download-file" if table_type: @@ -1477,7 +1477,7 @@ class Download_File(Cmdlet): actual_playlist_items = None if mode == "audio" and not actual_format: - actual_format = "bestaudio" + actual_format = "bestaudio/best" if mode == "video" and not actual_format: configured = (ytdlp_tool.default_format("video") or "").strip() @@ -1493,6 +1493,20 @@ class Download_File(Cmdlet): ): actual_format = forced_single_format_id forced_single_applied = True + + # Proactive fallback for single audio formats which might be unstable + if ( + actual_format + and isinstance(actual_format, str) + and mode == "audio" + and "/" not in actual_format + and "+" not in actual_format + and not forced_single_applied + and actual_format not in {"best", "bestaudio", "bw", "ba"} + ): + debug(f"Appending fallback to specific audio format: {actual_format} -> {actual_format}/bestaudio/best") + actual_format = f"{actual_format}/bestaudio/best" + if ( actual_format and isinstance(actual_format, str) @@ -1521,7 +1535,14 @@ class Download_File(Cmdlet): except Exception as e: pass + debug( + "[download-file] Resolved format for download: " + f"mode={mode}, format={actual_format or 'default'}, playlist_items={actual_playlist_items}" + ) + attempted_single_format_fallback = False + attempted_audio_fallback_specific = False + attempted_audio_fallback_generic = False while True: try: opts = DownloadOptions( @@ -1551,7 +1572,79 @@ class Download_File(Cmdlet): except Exception: detail = "" - if ("requested format is not available" in (detail or "").lower()) and mode != "audio": + detail_lc = (detail or "").lower() + msg_lc = "" + try: + msg_lc = str(e or "").lower() + except Exception: + msg_lc = "" + requested_format_unavailable = ( + "requested format is not available" in detail_lc + or "requested format is not available" in msg_lc + ) + + if requested_format_unavailable and mode == "audio": + # Level 1: Try to find a specific audio-only format from the list that hopefully works + if not attempted_audio_fallback_specific: + attempted_audio_fallback_specific = True + audio_format_id = None + try: + formats = self._list_formats_cached( + url, + playlist_items_value=actual_playlist_items, + formats_cache=formats_cache, + ytdlp_tool=ytdlp_tool, + ) + if formats: + audio_candidates = [] + for fmt in formats: + if not isinstance(fmt, dict): + continue + vcodec = str(fmt.get("vcodec", "none")) + acodec = str(fmt.get("acodec", "none")) + if acodec != "none" and vcodec == "none": + audio_candidates.append(fmt) + + if audio_candidates: + def _score_audio(fmt: Dict[str, Any]) -> float: + score = 0.0 + # Penalize DRC formats heavily + fid = str(fmt.get("format_id") or "").lower() + if "drc" in fid: + score -= 1000.0 + + for key in ("abr", "tbr", "filesize", "filesize_approx"): + try: + val = fmt.get(key) + if isinstance(val, (int, float)): + score += float(val) + break # Use the first valid metric found + if isinstance(val, str) and val.strip().isdigit(): + score += float(val) + break + except Exception: + continue + return score + + audio_candidates.sort(key=_score_audio, reverse=True) + audio_format_id = str(audio_candidates[0].get("format_id") or "").strip() or None + except Exception: + audio_format_id = None + + if audio_format_id: + actual_format = audio_format_id + debug(f"Requested audio format not available; retrying with best specific audio format: {actual_format}") + continue + + # Level 2: Fallback to generic 'bestaudio/best' if specific selection failed or wasn't found + if not attempted_audio_fallback_generic: + attempted_audio_fallback_generic = True + if actual_format != "bestaudio/best": + actual_format = "bestaudio/best" + debug("Requested audio format not available; retrying with generic fallback: bestaudio/best") + continue + + if requested_format_unavailable and mode != "audio": if ( forced_single_format_for_batch and forced_single_format_id @@ -1953,8 +2046,8 @@ class Download_File(Cmdlet): except Exception: height_selector = None if query_wants_audio: - # Explicit audio request should map to best-audio-only selector - ytdl_format = "bestaudio" + # Explicit audio request should map to the configured audio selector (usually '251/140/bestaudio') + ytdl_format = ytdlp_tool.default_format("audio") elif height_selector: ytdl_format = height_selector elif query_format: @@ -2045,6 +2138,51 @@ class Download_File(Cmdlet): if early_ret is not None: return int(early_ret) + # Auto-detect audio-only sources (e.g., Bandcamp albums) when no explicit format is requested. + if mode == "video" and not ytdl_format and not query_format and not query_wants_audio: + try: + sample_url = str(supported_url[0]) if supported_url else "" + fmts = self._list_formats_cached( + sample_url, + playlist_items_value=playlist_items, + formats_cache=formats_cache, + ytdlp_tool=ytdlp_tool, + ) + if fmts: + has_video = any(str(f.get("vcodec", "none")) != "none" for f in fmts if isinstance(f, dict)) + has_audio = any(str(f.get("acodec", "none")) != "none" for f in fmts if isinstance(f, dict)) + if has_audio and not has_video: + mode = "audio" + ytdl_format = ytdlp_tool.default_format("audio") + debug("[download-file] No video formats detected; switching to audio mode") + else: + if "bandcamp.com/album/" in sample_url: + mode = "audio" + ytdl_format = ytdlp_tool.default_format("audio") + debug("[download-file] Bandcamp album detected; switching to audio mode") + except Exception as e: + debug(f"[download-file] Audio-only detection error: {e}") + + # Auto-detect audio mode for explicit format IDs + if len(supported_url) == 1 and ytdl_format and mode != "audio": + try: + candidates = self._list_formats_cached( + supported_url[0], + playlist_items_value=playlist_items, + formats_cache=formats_cache, + ytdlp_tool=ytdlp_tool, + ) + if candidates: + match = next((f for f in candidates if str(f.get("format_id", "")) == str(ytdl_format)), None) + if match: + vcodec = str(match.get("vcodec", "none")) + acodec = str(match.get("acodec", "none")) + if vcodec == "none" and acodec != "none": + debug(f"[download-file] Requested format {ytdl_format} is audio-only; switching mode to audio") + mode = "audio" + except Exception as e: + debug(f"[download-file] Error validating format mode: {e}") + timeout_seconds = 300 try: override = config.get("_pipeobject_timeout_seconds") if isinstance(config, dict) else None diff --git a/tool/ytdlp.py b/tool/ytdlp.py index 7a44a96..f0b8676 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -726,7 +726,7 @@ class YtDlpDefaults: format: str = "best" video_format: str = "bestvideo+bestaudio/best" - audio_format: str = "251/140/bestaudio" + audio_format: str = "bestaudio/best" format_sort: Optional[List[str]] = None cookies_from_browser: Optional[str] = None @@ -958,6 +958,36 @@ class YtDlpTool: # Add browser cookies support "just in case" if no file found (best effort) _add_browser_cookies_if_available(base_options) + # YouTube hardening: prefer browser cookies + mobile/web clients when available + try: + netloc = urlparse(opts.url).netloc.lower() + except Exception: + netloc = "" + is_youtube = ("youtube.com" in netloc) or ("youtu.be" in netloc) + if is_youtube: + # Prefer browser cookies over a potentially stale cookiefile when available + pref = (self.defaults.cookies_from_browser or "").lower().strip() + if pref not in {"none", "off", "false"} and _has_browser_cookie_database(): + _add_browser_cookies_if_available( + base_options, + preferred_browser=None if pref in {"", "auto", "detect"} else pref, + ) + if base_options.get("cookiesfrombrowser"): + base_options.pop("cookiefile", None) + debug("[ytdlp] Using browser cookies for YouTube; ignoring cookiefile") + + extractor_args = base_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"] + debug("[ytdlp] Using YouTube player_client override: android, web") + extractor_args["youtube"] = youtube_args + base_options["extractor_args"] = extractor_args + # Special handling for format keywords explicitly passed in via options if opts.ytdl_format == "audio": try: @@ -1014,7 +1044,14 @@ class YtDlpTool: if resolved: ytdl_format = resolved - fmt = ytdl_format or self.default_format(opts.mode) + default_fmt = self.default_format(opts.mode) + fmt = ytdl_format or default_fmt + debug( + "[ytdlp] build options: " + f"mode={opts.mode}, ytdl_format={ytdl_format}, default_format={default_fmt}, final_format={fmt}, " + f"cookiefile={base_options.get('cookiefile')}, cookiesfrombrowser={base_options.get('cookiesfrombrowser')}, " + f"player_client={((base_options.get('extractor_args') or {}).get('youtube') or {}).get('player_client')}" + ) base_options["format"] = fmt if opts.mode == "audio": @@ -1870,6 +1907,12 @@ def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] = extractor_args["youtube"] = youtube_args fallback_options["extractor_args"] = extractor_args + debug( + "[ytdlp] retry options: " + f"cookiefile={fallback_options.get('cookiefile')}, cookiesfrombrowser={fallback_options.get('cookiesfrombrowser')}, " + f"player_client={((fallback_options.get('extractor_args') or {}).get('youtube') or {}).get('player_client')}" + ) + with yt_dlp.YoutubeDL(fallback_options) as ydl: # type: ignore[arg-type] info = cast(Dict[str, Any], ydl.extract_info(opts.url, download=True)) except Exception as exc2: