diff --git a/README.md b/README.md index 1907658..6852b0d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ export LIDARR_API_KEY="your-lidarr-api-key" | `--workers N` | Parallel metadata fetches during `--repair` (default 4). | | `--cookies FILE` | yt-dlp `cookies.txt` for authenticated YouTube (avoids bot-check / rate limits). | | `--cookies-from-browser BROWSER` | Load YouTube cookies from a local browser (e.g. `firefox`). | +| `--remote-components SPEC` | yt-dlp EJS challenge-solver source (default `ejs:github`; needs `deno`). Fixes "signature/n challenge solving failed". `''` to disable. | | `--retag-from-path` | Offline: re-tag artist/title from folder + filename (see below). | | `-x`, `--exclude NAME` | Folder under `--root` to skip during `--repair`/`--retag-from-path` (repeatable). | | `--debug` | Verbose output. | @@ -163,6 +164,13 @@ throttling. Cookies also apply to normal fetches/downloads. The same can be set container via `$YTDLP_COOKIES` / `$YTDLP_COOKIES_FROM_BROWSER`. If you do get flagged, **stop** — retrying extends it; wait ~30-60 min (429) or longer for a bot-check. +**JS challenge solving (not a cookie issue).** Separately from auth, YouTube gates media formats +behind signature / "n" JS challenges. If yt-dlp warns *"Signature solving failed"* / *"n challenge +solving failed: Some formats may be missing"*, downloads break even with valid cookies. musicfetch +passes `--remote-components ejs:github` by default so yt-dlp fetches the [EJS](https://github.com/yt-dlp/yt-dlp/wiki/EJS) +solver and runs it via a local JS runtime — install **[`deno`](https://deno.land)** and use a recent +yt-dlp. Override with `--remote-components` / `$YTDLP_REMOTE_COMPONENTS` (empty disables). + #### Getting YouTube cookies > ⚠️ Use a **throwaway / secondary Google account**, not your main one — bulk automated diff --git a/musicfetch b/musicfetch index 8863e3c..a498680 100755 --- a/musicfetch +++ b/musicfetch @@ -61,6 +61,14 @@ COOKIES_FROM_BROWSER = os.environ.get("YTDLP_COOKIES_FROM_BROWSER", "") # Tunable via env or --sleep; default 1s mirrors yt-dlp's own `-t sleep` advice. SLEEP_REQUESTS = os.environ.get("YTDLP_SLEEP", "1") +# yt-dlp JS-challenge solver. YouTube now gates formats behind signature / "n" +# challenges that yt-dlp's bundled solver can't reliably crack; without the EJS +# remote-component solver script it warns "Signature/n challenge solving failed: +# Some formats may be missing" and downloads break (looks like a cookie problem, +# isn't). Enabling ejs:github makes yt-dlp fetch the solver and run it via a local +# JS runtime (deno). Needs a recent yt-dlp + deno present. Set empty to disable. +REMOTE_COMPONENTS = os.environ.get("YTDLP_REMOTE_COMPONENTS", "ejs:github") + def _cookie_args() -> list: """yt-dlp cookie flags (file wins over browser); empty when neither is set.""" @@ -86,6 +94,13 @@ def _sleep_args(download: bool = False) -> list: args += ["--sleep-interval", str(secs), "--max-sleep-interval", str(secs * 2)] return args + +def _ejs_args() -> list: + """yt-dlp EJS challenge-solver flags; empty when REMOTE_COMPONENTS is unset.""" + if REMOTE_COMPONENTS: + return ["--remote-components", REMOTE_COMPONENTS] + return [] + # Quality choices for --quality. QUALITY_CHOICES = ["best", "320", "m4a", "opus", "flac"] @@ -391,7 +406,7 @@ def _ytmusic_search(query: str, limit: int) -> list[Hit]: def _ytdlp_search(query: str, limit: int) -> list[Hit]: try: result = subprocess.run( - ["yt-dlp", *_cookie_args(), *_sleep_args(), "--flat-playlist", "-J", f"ytsearch{limit}:{query}"], + ["yt-dlp", *_cookie_args(), *_ejs_args(), *_sleep_args(), "--flat-playlist", "-J", f"ytsearch{limit}:{query}"], capture_output=True, text=True, check=True, ) data = json.loads(result.stdout) @@ -663,6 +678,7 @@ def yt_download(url_or_query: str, target_folder: Optional[str], quality: str, d hit: Optional[Hit] = None, outtmpl: Optional[str] = None): cmd = ["yt-dlp", *_cookie_args(), + *_ejs_args(), *_sleep_args(download=True), *_quality_args(quality), "--embed-metadata", @@ -851,7 +867,7 @@ def probe_url(url: str) -> tuple[str, str, list[Hit]]: if hits: return "playlist", title, hits try: - result = subprocess.run(["yt-dlp", *_cookie_args(), *_sleep_args(), "--flat-playlist", "-J", url], + result = subprocess.run(["yt-dlp", *_cookie_args(), *_ejs_args(), *_sleep_args(), "--flat-playlist", "-J", url], capture_output=True, text=True, check=True) data = json.loads(result.stdout) except (subprocess.CalledProcessError, json.JSONDecodeError) as e: @@ -890,7 +906,7 @@ def download_single(url: str, root: str, quality: str, dry_run: bool) -> dict: def run_yt_dlp_get_metadata(url: str, extra_args=None) -> Optional[dict]: - cmd = ["yt-dlp", *_cookie_args(), *_sleep_args(), "-j", "--no-playlist", *(extra_args or []), url] + cmd = ["yt-dlp", *_cookie_args(), *_ejs_args(), *_sleep_args(), "-j", "--no-playlist", *(extra_args or []), url] try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) return json.loads(result.stdout) @@ -1338,6 +1354,10 @@ def parse_args(): p.add_argument("--cookies-from-browser", metavar="BROWSER", help="Load YouTube cookies from a local browser, e.g. firefox or " "chrome. Overrides $YTDLP_COOKIES_FROM_BROWSER.") + p.add_argument("--remote-components", metavar="SPEC", + help="yt-dlp EJS challenge-solver source (default ejs:github; " + "needs deno). Fixes 'signature/n challenge solving failed'. " + "Pass '' to disable. Overrides $YTDLP_REMOTE_COMPONENTS.") p.add_argument("--retag-from-path", action="store_true", help="Offline: re-tag artist/title from folder + filename " "(fixes tags damaged by a prior --repair).") @@ -1369,7 +1389,7 @@ def _dispatch_chosen(chosen: Hit, hits: list[Hit], root: str, quality: str, def main(): - global DEBUG, COOKIES_FILE, COOKIES_FROM_BROWSER, SLEEP_REQUESTS + global DEBUG, COOKIES_FILE, COOKIES_FROM_BROWSER, SLEEP_REQUESTS, REMOTE_COMPONENTS args = parse_args() DEBUG = args.debug if args.sleep is not None: @@ -1378,6 +1398,8 @@ def main(): COOKIES_FILE = args.cookies if args.cookies_from_browser: COOKIES_FROM_BROWSER = args.cookies_from_browser + if args.remote_components is not None: # '' disables; None keeps env default + REMOTE_COMPONENTS = args.remote_components query = " ".join(args.query).strip() if args.retag_from_path: diff --git a/tests/test_repair.py b/tests/test_repair.py index 2819f19..cfdf692 100644 --- a/tests/test_repair.py +++ b/tests/test_repair.py @@ -401,6 +401,28 @@ def test_metadata_fetch_passes_cookies(monkeypatch): assert "/cookies.txt" in captured["cmd"] +def test_ejs_args_default(monkeypatch): + monkeypatch.setattr(mf, "REMOTE_COMPONENTS", "ejs:github") + assert mf._ejs_args() == ["--remote-components", "ejs:github"] + + +def test_ejs_args_disabled(monkeypatch): + monkeypatch.setattr(mf, "REMOTE_COMPONENTS", "") + assert mf._ejs_args() == [] + + +def test_metadata_fetch_passes_ejs(monkeypatch): + captured = {} + + class _R: + stdout = '{"title": "x"}' + monkeypatch.setattr(mf, "REMOTE_COMPONENTS", "ejs:github") + monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _R()) + mf.run_yt_dlp_get_metadata("http://u") + assert "--remote-components" in captured["cmd"] + assert "ejs:github" in captured["cmd"] + + def test_metadata_fetch_logs_stderr(monkeypatch, capsys): def boom(cmd, **k): raise mf.subprocess.CalledProcessError(