Files
musicfetch/tests/test_playlist.py
zebra 140bfef7c9 feat: yt-dlp cookie support + surface real failure reason; default workers 4
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>
2026-06-13 11:25:39 -07:00

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"]