Bulk --repair on unauthenticated YouTube trips the bot-check (HTTP 429 "Sign in to confirm you're not a bot"), after which every call fails until the IP flag clears. Add cookie support so authenticated requests bypass it: - --cookies FILE / --cookies-from-browser BROWSER (and $YTDLP_COOKIES / $YTDLP_COOKIES_FROM_BROWSER for the API container), threaded into every yt-dlp invocation (search, probe, download, repair metadata fetch). - run_yt_dlp_get_metadata now logs yt-dlp's last stderr line (the actual 429 / bot-check / network reason) instead of a bare exit code. - Default --repair workers lowered 8 -> 4 (safe without cookies; raise with). - compose: optional YTDLP_COOKIES env + commented cookies mount. - README: how to obtain cookies (Chrome/Firefox, browser-read vs cookies.txt export); gitignore cookies.txt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
import json as _json
|
|
|
|
import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core
|
|
import musicfetch_core as mf
|
|
|
|
|
|
class _CP:
|
|
def __init__(self, stdout):
|
|
self.stdout = stdout
|
|
self.returncode = 0
|
|
|
|
|
|
# ---- _is_youtube_playlist_url ----
|
|
def test_youtube_playlist_url_true():
|
|
assert mf._is_youtube_playlist_url("https://music.youtube.com/playlist?list=PLabc") is True
|
|
assert mf._is_youtube_playlist_url("https://www.youtube.com/playlist?list=PLabc") is True
|
|
|
|
|
|
def test_youtube_watch_with_list_is_not_playlist():
|
|
assert mf._is_youtube_playlist_url("https://www.youtube.com/watch?v=abc&list=PLx") is False
|
|
|
|
|
|
def test_non_youtube_url_not_youtube_playlist():
|
|
# SoundCloud sets are not matched here — probe_url handles them via yt-dlp.
|
|
assert mf._is_youtube_playlist_url("https://soundcloud.com/user/sets/mix") is False
|
|
|
|
|
|
# ---- probe_url ----
|
|
def test_probe_url_youtube_playlist_uses_ytmusic(monkeypatch):
|
|
h = mf.Hit(source="youtube", kind="track", title="A", artist="X",
|
|
payload={"videoId": "1", "extractor": "youtube"})
|
|
monkeypatch.setattr(mf, "_ytmusic_playlist", lambda pid: ("My YT Mix", [h]))
|
|
monkeypatch.setattr(mf, "YTMusic", object()) # non-None to enter ytmusic branch
|
|
kind, title, hits = mf.probe_url("https://music.youtube.com/playlist?list=PLx")
|
|
assert kind == "playlist"
|
|
assert title == "My YT Mix"
|
|
assert hits == [h]
|
|
|
|
|
|
def test_probe_url_generic_playlist_via_ytdlp(monkeypatch):
|
|
monkeypatch.setattr(mf, "YTMusic", None)
|
|
payload = {"title": "SC Set", "_type": "playlist", "entries": [
|
|
{"id": "t1", "title": "One", "uploader": "DJ", "ie_key": "Soundcloud",
|
|
"url": "https://soundcloud.com/dj/one"},
|
|
{"id": None, "url": None, "title": "skip"},
|
|
]}
|
|
monkeypatch.setattr(mf.subprocess, "run", lambda *a, **k: _CP(_json.dumps(payload)))
|
|
kind, title, hits = mf.probe_url("https://soundcloud.com/dj/sets/sc-set")
|
|
assert kind == "playlist"
|
|
assert title == "SC Set"
|
|
assert len(hits) == 1
|
|
assert hits[0].payload["extractor"] == "soundcloud"
|
|
assert hits[0].payload["url"] == "https://soundcloud.com/dj/one"
|
|
|
|
|
|
def test_probe_url_single_track(monkeypatch):
|
|
monkeypatch.setattr(mf, "YTMusic", None)
|
|
payload = {"title": "A Song", "extractor": "soundcloud"} # no entries -> single
|
|
monkeypatch.setattr(mf.subprocess, "run", lambda *a, **k: _CP(_json.dumps(payload)))
|
|
kind, title, hits = mf.probe_url("https://soundcloud.com/dj/one")
|
|
assert kind == "track"
|
|
assert title == "A Song"
|
|
assert hits == []
|
|
|
|
|
|
def test_probe_url_failure_returns_track(monkeypatch):
|
|
monkeypatch.setattr(mf, "YTMusic", None)
|
|
|
|
def boom(*a, **k):
|
|
raise mf.subprocess.CalledProcessError(1, "yt-dlp")
|
|
monkeypatch.setattr(mf.subprocess, "run", boom)
|
|
assert mf.probe_url("https://example.com/x") == ("track", "", [])
|
|
|
|
|
|
# ---- download_hits ----
|
|
def test_download_hits_counts(monkeypatch):
|
|
h1 = mf.Hit(source="youtube", kind="track", title="A", artist="X", payload={"videoId": "1"})
|
|
h2 = mf.Hit(source="youtube", kind="track", title="B", artist="Y", payload={"videoId": "2"})
|
|
h3 = mf.Hit(source="youtube", kind="track", title="C", artist="Z", payload={"videoId": "3"})
|
|
monkeypatch.setattr(mf, "act_youtube", lambda hit, root, quality, dry_run: hit.title != "B")
|
|
assert mf.download_hits([h1, h2, h3], "/tmp", "best", False) == (2, 3)
|
|
|
|
|
|
def test_download_hits_track_exception_is_failure(monkeypatch):
|
|
h1 = mf.Hit(source="youtube", kind="track", title="A", artist="X", payload={"videoId": "1"})
|
|
h2 = mf.Hit(source="youtube", kind="track", title="B", artist="Y", payload={"videoId": "2"})
|
|
|
|
def fake_act(hit, root, quality, dry_run):
|
|
if hit.title == "B":
|
|
raise RuntimeError("boom")
|
|
return True
|
|
monkeypatch.setattr(mf, "act_youtube", fake_act)
|
|
assert mf.download_hits([h1, h2], "/tmp", "best", False) == (1, 2)
|
|
|
|
|
|
# ---- yt_download bool ----
|
|
def test_yt_download_returns_true_on_zero_exit(monkeypatch):
|
|
monkeypatch.setattr(mf.os, "makedirs", lambda *a, **k: None)
|
|
monkeypatch.setattr(mf.subprocess, "run", lambda *a, **k: _CP(""))
|
|
assert mf.yt_download("u", "/tmp/x", "best", False) is True
|
|
|
|
|
|
def test_yt_download_dry_run_returns_true():
|
|
assert mf.yt_download("u", "/tmp/x", "best", True) is True
|
|
|
|
|
|
def test_yt_download_always_sets_album_default(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(mf.os, "makedirs", lambda *a, **k: None)
|
|
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
|
|
mf.yt_download("u", "/tmp/x", "best", False)
|
|
assert "%(album|Unknown Album)s:%(meta_album)s" in captured["cmd"]
|
|
|
|
|
|
def test_yt_download_single_word_tags_injected_literally(monkeypatch):
|
|
# Regression: `--parse-metadata "Cochise:%(title)s"` makes yt-dlp treat the
|
|
# bare word 'Cochise' as a FIELD name (field_to_template's r'[a-zA-Z_]+$'),
|
|
# producing 'NA'. Single-word album/title must reach yt-dlp as literals.
|
|
captured = {}
|
|
monkeypatch.setattr(mf.os, "makedirs", lambda *a, **k: None)
|
|
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
|
|
hit = mf.Hit(source="youtube", kind="track", title="Cochise",
|
|
artist="Audioslave", album="Solid", payload={"videoId": "x"})
|
|
mf.yt_download("u", "/tmp/x", "best", False, hit=hit)
|
|
cmd = captured["cmd"]
|
|
joined = " ".join(cmd)
|
|
# The buggy bare-word parse-metadata FROM must be gone.
|
|
assert "Solid:%(album)s" not in joined
|
|
assert "Cochise:%(title)s" not in joined
|
|
# Literal values must be passed as literal args (immune to template parsing).
|
|
assert "Solid" in cmd
|
|
assert "Cochise" in cmd
|
|
# A hit album must not be clobbered by the Unknown-Album default.
|
|
assert "%(album|Unknown Album)s:%(meta_album)s" not in cmd
|
|
|
|
|
|
def test_yt_download_passes_cookies(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(mf, "COOKIES_FILE", "/cookies.txt")
|
|
monkeypatch.setattr(mf, "COOKIES_FROM_BROWSER", "")
|
|
monkeypatch.setattr(mf.os, "makedirs", lambda *a, **k: None)
|
|
monkeypatch.setattr(mf.subprocess, "run", lambda cmd, **k: captured.update(cmd=cmd) or _CP(""))
|
|
mf.yt_download("u", "/tmp/x", "best", False)
|
|
assert "--cookies" in captured["cmd"] and "/cookies.txt" in captured["cmd"]
|