Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 78 additions & 3 deletions anydl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down