diff --git a/CLAUDE.md b/CLAUDE.md index e7b7b5b..7b08eb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,18 @@ pick per video (skipped for direct files/torrents). Playlists download into thei *remuxes* (never transcodes) and only when ffprobe confirms H.264/HEVC + AAC. - `get_full_info`/`probe` pass `remote_components: ["ejs:github"]` to yt-dlp — needed for current YouTube extraction, harmless elsewhere. +- **A stale yt-dlp is the #1 cause of "it just stopped".** Big sites rotate their player every few + weeks; an out-of-date copy still lists formats fine, then dies mid-transfer on a bare + `HTTP Error 403`. `ensure_yt_dlp()` reads the installed version via `importlib.metadata` (no + import), and upgrades when it's older than `YT_DLP_MAX_AGE_DAYS`. The upgrade **must** happen + before the first `import yt_dlp` — swapping the package under a running process is a no-op for a + re-import, so there's no mid-run rescue. A failed `pip` never aborts the run. +- **Don't fall back to streamlink on a CDN rejection.** `_looks_stale()` matches the + 403 / "unable to download video data" / "requested format is not available" signatures; when one + of those hits a URL a *specific* extractor claimed, the media was found and the transfer was + refused — streamlink has no VOD plugin to offer and just ends in "No plugin can handle URL" + after installing 8 MB of deps. Print the upgrade hint instead. Any *other* download error still + falls through to streamlink, which is the never-a-dead-end path. - **One dead item must never abort a batch.** A playlist's quality pick is sampled from a real video's format list, but long playlists routinely open with a blocked/deleted/private item. `sample_playlist_formats()` walks down to the first entry that extracts (up to diff --git a/anydl.py b/anydl.py index 9f28561..3bf66bc 100644 --- a/anydl.py +++ b/anydl.py @@ -19,6 +19,7 @@ The router + multi-engine design is inspired by ghost-downloader-3 (https://github.com/xiaoyouchr/ghost-downloader-3). """ +import datetime as dt import os import re import shutil @@ -58,13 +59,65 @@ FCP_REMUX_EXTS = {".mkv", ".ts", ".flv", ".avi", ".wmv", ".m4v", ".webm"} +# Big sites rotate their player every few weeks; a yt-dlp older than this starts +# failing mid-download with a bare HTTP 403 (see _looks_stale below). +YT_DLP_MAX_AGE_DAYS = 14 + + +def _pip_install(*args): + """pip install *args into this interpreter. False (not an exception) on failure.""" + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", *args]) + return True + except (subprocess.CalledProcessError, OSError) as e: + print(f" pip install failed ({str(e)[:120]}) — continuing with what's installed.") + return False + + +def _yt_dlp_age_days(ver): + """Days since a yt-dlp YYYY.MM.DD version was released, or None if unparseable.""" + m = re.match(r"^(\d{4})\.(\d{1,2})\.(\d{1,2})", ver or "") + if not m: + return None + try: + return (dt.date.today() - dt.date(*(int(g) for g in m.groups()))).days + except ValueError: + return None + + def ensure_yt_dlp(): + """Import yt-dlp, installing it if missing and upgrading it if it's gone stale. + + The upgrade has to happen *before* the first import — a package swapped out + underneath a running process isn't picked up by a re-import, so a mid-run + upgrade would be a no-op. Read the version via importlib.metadata, which + doesn't import the package. + """ + from importlib.metadata import PackageNotFoundError, version as pkg_version try: - import yt_dlp - except ImportError: + installed = pkg_version("yt-dlp") + except PackageNotFoundError: + installed = None + + if installed is None: print("Installing yt-dlp...") - subprocess.check_call([sys.executable, "-m", "pip", "install", "yt-dlp>=2026.3.17"]) + _pip_install("yt-dlp>=2026.3.17") + else: + age = _yt_dlp_age_days(installed) + if age is not None and age > YT_DLP_MAX_AGE_DAYS: + print(f"yt-dlp {installed} is {age} days old — upgrading first " + "(stale copies 403 mid-download)...") + if _pip_install("--upgrade", "yt-dlp"): + try: + print(f" now on yt-dlp {pkg_version('yt-dlp')}") + except PackageNotFoundError: + pass + + try: import yt_dlp + except ImportError: + sys.exit("yt-dlp isn't installed and couldn't be installed automatically.\n" + f"Install it manually: {sys.executable} -m pip install yt-dlp") return yt_dlp @@ -140,6 +193,20 @@ def _needs_cookies(err): or "confirm you" in s or "sign in to view" in s) +def _looks_stale(err): + """True if a yt-dlp *download* error is the site-broke-the-extractor signature. + + These all mean the URL resolved and the formats listed, then the CDN rejected + the actual transfer — a stale player/signature, not a stream yt-dlp can't see. + streamlink has nothing to add here, so we say what's wrong instead. + """ + s = str(err).lower() + return ("403" in s or "forbidden" in s + or "unable to download video data" in s + or "requested format is not available" in s + or "nsig" in s or "signature" in s) + + def choose_cookie_browser(): """Ask once which browser to read cookies from; cache the answer for the run.""" global _COOKIE_BROWSER @@ -827,6 +894,14 @@ def yt_dlp_flow(url, fcp_mode, yt_dlp, allow_streamlink=True): return True except DownloadError as e: print(f" yt-dlp download failed: {str(e)[:200]}") + # A CDN rejection on a site yt-dlp has a real extractor for is a stale-yt-dlp + # problem. streamlink has no VOD plugin to offer — installing it here just + # burns a minute and ends in "No plugin can handle URL". + if _looks_stale(e) and known_extractor(url, yt_dlp): + print(" This is the site rejecting the transfer, not a stream yt-dlp " + "can't see — streamlink won't help.") + print(f" Upgrade yt-dlp and retry: {sys.executable} -m pip install -U yt-dlp") + return False if allow_streamlink: return download_with_streamlink(url, info.get("title") or "download", fcp_mode, reason="yt-dlp download failed")