f
This commit is contained in:
35
API/HTTP.py
35
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 "<null>"
|
||||
return "<not provided>"
|
||||
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)):
|
||||
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 = "<unprintable>"
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user