This commit is contained in:
2026-02-10 15:57:33 -08:00
parent 2fd13a6b3f
commit c2449d0ba7
3 changed files with 220 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ import time
import traceback import traceback
import re import re
import os import os
import json
from typing import Optional, Dict, Any, Callable, List, Union from typing import Optional, Dict, Any, Callable, List, Union
from pathlib import Path from pathlib import Path
from urllib.parse import unquote, urlparse, parse_qs from urllib.parse import unquote, urlparse, parse_qs
@@ -464,14 +465,41 @@ class HTTPClient:
def _preview(value: Any, *, limit: int = 2000) -> str: def _preview(value: Any, *, limit: int = 2000) -> str:
if value is None: if value is None:
return "<null>" return "<not provided>"
try: 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"<file-like {summary}>" if summary else "<file-like>"
if isinstance(value, (dict, list, tuple)): 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: else:
text = str(value) text = str(value)
except Exception: except Exception:
text = repr(value) try:
text = repr(value)
except Exception:
text = "<unprintable>"
if len(text) > limit: if len(text) > limit:
return text[:limit] + "..." return text[:limit] + "..."
return text return text
@@ -497,6 +525,7 @@ class HTTPClient:
("data", _preview(kwargs.get("data"))), ("data", _preview(kwargs.get("data"))),
("json", _preview(kwargs.get("json"))), ("json", _preview(kwargs.get("json"))),
("content", _preview(kwargs.get("content"))), ("content", _preview(kwargs.get("content"))),
("files", _preview(kwargs.get("files"))),
], ],
) )
try: try:

View File

@@ -1177,7 +1177,7 @@ class Download_File(Cmdlet):
return f"https://www.youtube.com/watch?v={entry_id.strip()}" return f"https://www.youtube.com/watch?v={entry_id.strip()}"
return None return None
table = Table() table = Table(preserve_order=True)
safe_url = str(url or "").strip() safe_url = str(url or "").strip()
table.title = f'download-file -url "{safe_url}"' if safe_url else "download-file" table.title = f'download-file -url "{safe_url}"' if safe_url else "download-file"
if table_type: if table_type:
@@ -1477,7 +1477,7 @@ class Download_File(Cmdlet):
actual_playlist_items = None actual_playlist_items = None
if mode == "audio" and not actual_format: if mode == "audio" and not actual_format:
actual_format = "bestaudio" actual_format = "bestaudio/best"
if mode == "video" and not actual_format: if mode == "video" and not actual_format:
configured = (ytdlp_tool.default_format("video") or "").strip() configured = (ytdlp_tool.default_format("video") or "").strip()
@@ -1493,6 +1493,20 @@ class Download_File(Cmdlet):
): ):
actual_format = forced_single_format_id actual_format = forced_single_format_id
forced_single_applied = True 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 ( if (
actual_format actual_format
and isinstance(actual_format, str) and isinstance(actual_format, str)
@@ -1521,7 +1535,14 @@ class Download_File(Cmdlet):
except Exception as e: except Exception as e:
pass 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_single_format_fallback = False
attempted_audio_fallback_specific = False
attempted_audio_fallback_generic = False
while True: while True:
try: try:
opts = DownloadOptions( opts = DownloadOptions(
@@ -1551,7 +1572,79 @@ class Download_File(Cmdlet):
except Exception: except Exception:
detail = "" 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 ( if (
forced_single_format_for_batch forced_single_format_for_batch
and forced_single_format_id and forced_single_format_id
@@ -1953,8 +2046,8 @@ class Download_File(Cmdlet):
except Exception: except Exception:
height_selector = None height_selector = None
if query_wants_audio: if query_wants_audio:
# Explicit audio request should map to best-audio-only selector # Explicit audio request should map to the configured audio selector (usually '251/140/bestaudio')
ytdl_format = "bestaudio" ytdl_format = ytdlp_tool.default_format("audio")
elif height_selector: elif height_selector:
ytdl_format = height_selector ytdl_format = height_selector
elif query_format: elif query_format:
@@ -2045,6 +2138,51 @@ class Download_File(Cmdlet):
if early_ret is not None: if early_ret is not None:
return int(early_ret) 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 timeout_seconds = 300
try: try:
override = config.get("_pipeobject_timeout_seconds") if isinstance(config, dict) else None override = config.get("_pipeobject_timeout_seconds") if isinstance(config, dict) else None

View File

@@ -726,7 +726,7 @@ class YtDlpDefaults:
format: str = "best" format: str = "best"
video_format: str = "bestvideo+bestaudio/best" video_format: str = "bestvideo+bestaudio/best"
audio_format: str = "251/140/bestaudio" audio_format: str = "bestaudio/best"
format_sort: Optional[List[str]] = None format_sort: Optional[List[str]] = None
cookies_from_browser: Optional[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 support "just in case" if no file found (best effort)
_add_browser_cookies_if_available(base_options) _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 # Special handling for format keywords explicitly passed in via options
if opts.ytdl_format == "audio": if opts.ytdl_format == "audio":
try: try:
@@ -1014,7 +1044,14 @@ class YtDlpTool:
if resolved: if resolved:
ytdl_format = 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 base_options["format"] = fmt
if opts.mode == "audio": if opts.mode == "audio":
@@ -1870,6 +1907,12 @@ def download_media(opts: DownloadOptions, *, config: Optional[Dict[str, Any]] =
extractor_args["youtube"] = youtube_args extractor_args["youtube"] = youtube_args
fallback_options["extractor_args"] = extractor_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] with yt_dlp.YoutubeDL(fallback_options) as ydl: # type: ignore[arg-type]
info = cast(Dict[str, Any], ydl.extract_info(opts.url, download=True)) info = cast(Dict[str, Any], ydl.extract_info(opts.url, download=True))
except Exception as exc2: except Exception as exc2: