2 Commits

Author SHA1 Message Date
fc2d082a25 feat(server): supply YouTube cookies to the API container via .env
Set YTDLP_COOKIES_HOST in server/.env and compose mounts the host
cookies.txt read-only at /cookies.txt and wires YTDLP_COOKIES
automatically — no compose edits per host. Falls back to /dev/null when
unset so the container still starts. Add server/.env.example, gitignore
server/.env, and document the flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:33:36 -07:00
82bc524155 fix: enable yt-dlp EJS solver so YouTube formats resolve
YouTube gates media formats behind signature / "n" JS challenges that
yt-dlp's bundled solver can't reliably crack. Without the EJS
remote-component solver script yt-dlp warns "Signature/n challenge
solving failed: Some formats may be missing" and downloads break — even
with valid cookies, so it masquerades as a cookie/auth problem.

Pass --remote-components ejs:github by default (env/CLI overridable) on
all four yt-dlp call sites. Needs a recent yt-dlp + deno present; set
YTDLP_REMOTE_COMPONENTS / --remote-components '' to disable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:33:28 -07:00
6 changed files with 87 additions and 15 deletions

1
.gitignore vendored
View File

@@ -2,4 +2,5 @@ __pycache__/
*.pyc *.pyc
server/log.txt server/log.txt
server/.env
cookies.txt cookies.txt

View File

@@ -102,6 +102,7 @@ export LIDARR_API_KEY="your-lidarr-api-key"
| `--workers N` | Parallel metadata fetches during `--repair` (default 4). | | `--workers N` | Parallel metadata fetches during `--repair` (default 4). |
| `--cookies FILE` | yt-dlp `cookies.txt` for authenticated YouTube (avoids bot-check / rate limits). | | `--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`). | | `--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). | | `--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). | | `-x`, `--exclude NAME` | Folder under `--root` to skip during `--repair`/`--retag-from-path` (repeatable). |
| `--debug` | Verbose output. | | `--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** 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. 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 #### Getting YouTube cookies
> ⚠️ Use a **throwaway / secondary Google account**, not your main one — bulk automated > ⚠️ Use a **throwaway / secondary Google account**, not your main one — bulk automated
@@ -199,15 +207,17 @@ Use a Netscape-format cookie exporter, then point `--cookies` / `$YTDLP_COOKIES`
./musicfetch --repair --cookies ~/cookies.txt -o /media/music ./musicfetch --repair --cookies ~/cookies.txt -o /media/music
``` ```
For the API container, mount it and set the env var (see `server/docker-compose.yml`): For the API container, just point it at the host file via `.env` — no compose
edits needed (see `server/.env.example`):
```yaml ```bash
environment: # server/.env
YTDLP_COOKIES: "/cookies.txt" YTDLP_COOKIES_HOST=/host/path/cookies.txt
volumes:
- /host/path/cookies.txt:/cookies.txt:ro
``` ```
The compose mounts it read-only at `/cookies.txt` and sets `YTDLP_COOKIES`
automatically. Leave `YTDLP_COOKIES_HOST` unset to run without cookies.
Cookies expire — if YouTube starts rejecting them, re-export. Treat `cookies.txt` like a Cookies expire — if YouTube starts rejecting them, re-export. Treat `cookies.txt` like a
password (it *is* your logged-in session); keep it out of git (`.gitignore` it). password (it *is* your logged-in session); keep it out of git (`.gitignore` it).

View File

@@ -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. # Tunable via env or --sleep; default 1s mirrors yt-dlp's own `-t sleep` advice.
SLEEP_REQUESTS = os.environ.get("YTDLP_SLEEP", "1") 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: def _cookie_args() -> list:
"""yt-dlp cookie flags (file wins over browser); empty when neither is set.""" """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)] args += ["--sleep-interval", str(secs), "--max-sleep-interval", str(secs * 2)]
return args 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 for --quality.
QUALITY_CHOICES = ["best", "320", "m4a", "opus", "flac"] 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]: def _ytdlp_search(query: str, limit: int) -> list[Hit]:
try: try:
result = subprocess.run( 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, capture_output=True, text=True, check=True,
) )
data = json.loads(result.stdout) 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): hit: Optional[Hit] = None, outtmpl: Optional[str] = None):
cmd = ["yt-dlp", cmd = ["yt-dlp",
*_cookie_args(), *_cookie_args(),
*_ejs_args(),
*_sleep_args(download=True), *_sleep_args(download=True),
*_quality_args(quality), *_quality_args(quality),
"--embed-metadata", "--embed-metadata",
@@ -851,7 +867,7 @@ def probe_url(url: str) -> tuple[str, str, list[Hit]]:
if hits: if hits:
return "playlist", title, hits return "playlist", title, hits
try: 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) capture_output=True, text=True, check=True)
data = json.loads(result.stdout) data = json.loads(result.stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e: 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]: 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: try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True) result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(result.stdout) return json.loads(result.stdout)
@@ -1338,6 +1354,10 @@ def parse_args():
p.add_argument("--cookies-from-browser", metavar="BROWSER", p.add_argument("--cookies-from-browser", metavar="BROWSER",
help="Load YouTube cookies from a local browser, e.g. firefox or " help="Load YouTube cookies from a local browser, e.g. firefox or "
"chrome. Overrides $YTDLP_COOKIES_FROM_BROWSER.") "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", p.add_argument("--retag-from-path", action="store_true",
help="Offline: re-tag artist/title from folder + filename " help="Offline: re-tag artist/title from folder + filename "
"(fixes tags damaged by a prior --repair).") "(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(): 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() args = parse_args()
DEBUG = args.debug DEBUG = args.debug
if args.sleep is not None: if args.sleep is not None:
@@ -1378,6 +1398,8 @@ def main():
COOKIES_FILE = args.cookies COOKIES_FILE = args.cookies
if args.cookies_from_browser: if args.cookies_from_browser:
COOKIES_FROM_BROWSER = 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() query = " ".join(args.query).strip()
if args.retag_from_path: if args.retag_from_path:

13
server/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Copy to server/.env and fill in. docker-compose reads it automatically.
# Lidarr (preferred source)
LIDARR_API_KEY=
# API key clients must send to musicfetch-api
MUSICFETCH_API_KEY=
# Optional: authenticated YouTube cookies to dodge the bot-check
# ("Sign in to confirm you're not a bot") and rate limits.
# Set to the host path of a Netscape-format cookies.txt (see README
# "Getting YouTube cookies"). Leave unset to run without cookies.
# YTDLP_COOKIES_HOST=/host/path/cookies.txt

View File

@@ -13,10 +13,14 @@ services:
MUSICFETCH_API_KEY: "${MUSICFETCH_API_KEY}" MUSICFETCH_API_KEY: "${MUSICFETCH_API_KEY}"
MUSICFETCH_ROOT: "/media/music" MUSICFETCH_ROOT: "/media/music"
MUSICFETCH_PORT: "6769" MUSICFETCH_PORT: "6769"
# Optional: authenticated YouTube cookies to avoid bot-check / rate limits. # Optional: authenticated YouTube cookies to avoid bot-check ("Sign in to
# Mount a cookies.txt below and point this at it (in-container path). # confirm you're not a bot") / rate limits. To enable, set in a .env file
YTDLP_COOKIES: "${YTDLP_COOKIES:-}" # (see server/.env.example):
# YTDLP_COOKIES_HOST=/host/path/cookies.txt
# The host file is mounted read-only at /cookies.txt and used automatically.
YTDLP_COOKIES: "${YTDLP_COOKIES_HOST:+/cookies.txt}"
volumes: volumes:
- /media/music:/media/music - /media/music:/media/music
# Uncomment and set host path to supply cookies (see YTDLP_COOKIES above): # Cookies bind: maps to the host cookies.txt when YTDLP_COOKIES_HOST is set,
# - /path/to/cookies.txt:/cookies.txt:ro # otherwise /dev/null (harmless no-op so the container still starts).
- "${YTDLP_COOKIES_HOST:-/dev/null}:/cookies.txt:ro"

View File

@@ -401,6 +401,28 @@ def test_metadata_fetch_passes_cookies(monkeypatch):
assert "/cookies.txt" in captured["cmd"] 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 test_metadata_fetch_logs_stderr(monkeypatch, capsys):
def boom(cmd, **k): def boom(cmd, **k):
raise mf.subprocess.CalledProcessError( raise mf.subprocess.CalledProcessError(