From 8c82e67a9b6b29f8b90b35cd868dea16b834d92f Mon Sep 17 00:00:00 2001 From: Adinath Chaudhari Date: Tue, 25 Aug 2026 15:18:54 +0530 Subject: [PATCH] fix(yt-dlp): upgrade a stale copy before import, stop misrouting 403s to streamlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A yt-dlp more than a couple of weeks old still lists formats correctly and then dies partway through the transfer with a bare HTTP 403 — the site rotated its player and the signatures the old copy computes no longer validate. ensure_yt_dlp() only ever installed a *missing* copy, so a working install silently rotted. - Read the installed version with importlib.metadata (no package import) and pip install --upgrade when it is older than YT_DLP_MAX_AGE_DAYS (14). This has to run before the first `import yt_dlp`: a package replaced underneath a live process is not picked up by a re-import, so a mid-run upgrade cannot help. - A failed pip no longer raises; the run continues on whatever is installed, and a genuinely missing yt-dlp now exits with an install hint instead of a traceback. - On a download failure, _looks_stale() recognises the CDN-rejection signatures. When one hits a URL a specific (non-generic) extractor claimed, the media was found and the transfer was refused, so streamlink cannot help — it installs 8 MB of dependencies and ends in "No plugin can handle URL". Print the upgrade command instead. Every other download error still falls through to streamlink. Verified against a real URL that reproduced the 403: fails on 2026.07.04, downloads complete on 2026.08.19. Both new branches exercised — a stale version triggers the upgrade and a current one does not; a 403 on a known extractor skips streamlink while an unrelated error still reaches it. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 12 +++++++++ anydl.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 3 deletions(-) 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")