From a88f4c594a5263d38dabd0c8412dfc1fcf2f7c9b Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:47:22 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20odesli=5Fresolve=20=E2=80=94=20reso?= =?UTF-8?q?lve=20any=20song=20link=20to=20metadata=20via=20song.link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- musicfetch | 41 ++++++++++++++++++++++++++ tests/test_odesli.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/test_odesli.py diff --git a/musicfetch b/musicfetch index 97cc9f2..70098fe 100755 --- a/musicfetch +++ b/musicfetch @@ -95,6 +95,21 @@ class Hit: return self.title or self.album or self.artist +@dataclass +class Resolved: + title: str = "" + artist: str = "" + thumb: str = "" + youtube_url: str = "" + + +class OdesliError(Exception): + """Raised when an Odesli link can't be resolved to usable metadata.""" + + +ODESLI_URL = "https://api.song.link/v1-alpha.1/links" + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -762,6 +777,32 @@ def _entry_to_hit(entry: dict) -> Hit: "extractor": source}) +def odesli_resolve(url: str) -> Optional[Resolved]: + """Resolve any streaming link to {title, artist, thumb, youtube_url} via the + Odesli (song.link) public API. Returns None on any failure (network, non-200, + malformed body, missing title+artist) so callers can fall back loudly.""" + try: + resp = requests.get(ODESLI_URL, + params={"url": url, "userCountry": "US"}, + timeout=8) + if resp.status_code != 200: + dbg(f"odesli {resp.status_code} for {url}") + return None + data = resp.json() + entity = data["entitiesByUniqueId"][data["entityUniqueId"]] + title = entity.get("title", "") + artist = entity.get("artistName", "") + if not title and not artist: + return None + platforms = data.get("linksByPlatform", {}) + yt = (platforms.get("youtubeMusic") or platforms.get("youtube") or {}).get("url", "") + return Resolved(title=title, artist=artist, + thumb=entity.get("thumbnailUrl", ""), youtube_url=yt) + except (RequestException, ValueError, KeyError, TypeError) as e: + dbg(f"odesli resolve failed for {url}: {e}") + return None + + def probe_url(url: str) -> tuple[str, str, list[Hit]]: """Classify a URL via yt-dlp. Returns (kind, title, hits) where kind is 'playlist' (hits populated) or 'track' (hits empty; caller downloads the URL). diff --git a/tests/test_odesli.py b/tests/test_odesli.py new file mode 100644 index 0000000..bb5cd1d --- /dev/null +++ b/tests/test_odesli.py @@ -0,0 +1,68 @@ +import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core +import musicfetch_core as mf + + +class _FakeResp: + def __init__(self, status, payload): + self.status_code = status + self._payload = payload + + def json(self): + return self._payload + + +FULL = { + "entityUniqueId": "SPOTIFY_SONG::abc", + "entitiesByUniqueId": { + "SPOTIFY_SONG::abc": { + "title": "Bloom", + "artistName": "ODESZA", + "thumbnailUrl": "https://img/cover.jpg", + } + }, + "linksByPlatform": { + "youtubeMusic": {"url": "https://music.youtube.com/watch?v=YYY"}, + "youtube": {"url": "https://youtube.com/watch?v=YYY"}, + }, +} + + +def test_odesli_resolve_full(monkeypatch): + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(200, FULL)) + r = mf.odesli_resolve("https://open.spotify.com/track/abc") + assert r.title == "Bloom" + assert r.artist == "ODESZA" + assert r.thumb == "https://img/cover.jpg" + assert r.youtube_url == "https://music.youtube.com/watch?v=YYY" + + +def test_odesli_resolve_prefers_ytmusic_then_youtube(monkeypatch): + payload = {**FULL, "linksByPlatform": {"youtube": {"url": "https://youtube.com/watch?v=ZZZ"}}} + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(200, payload)) + r = mf.odesli_resolve("https://open.spotify.com/track/abc") + assert r.youtube_url == "https://youtube.com/watch?v=ZZZ" + + +def test_odesli_resolve_no_youtube_link(monkeypatch): + payload = {**FULL, "linksByPlatform": {}} + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(200, payload)) + r = mf.odesli_resolve("https://open.spotify.com/track/abc") + assert r.youtube_url == "" + assert r.title == "Bloom" + + +def test_odesli_resolve_non_200_returns_none(monkeypatch): + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(429, {})) + assert mf.odesli_resolve("https://open.spotify.com/track/abc") is None + + +def test_odesli_resolve_malformed_returns_none(monkeypatch): + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(200, {"nope": 1})) + assert mf.odesli_resolve("https://open.spotify.com/track/abc") is None + + +def test_odesli_resolve_network_error_returns_none(monkeypatch): + def boom(*a, **k): + raise mf.RequestException("down") + monkeypatch.setattr(mf.requests, "get", boom) + assert mf.odesli_resolve("https://open.spotify.com/track/abc") is None From 9fccf9015ac024ea9350ff1dfee7d60f32f87946 Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:52:14 -0700 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=5Fis=5Fdirect=5Furl=20=E2=80=94=20?= =?UTF-8?q?route=20YouTube/SoundCloud=20links=20to=20direct=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- musicfetch | 17 +++++++++++++++++ tests/test_odesli.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/musicfetch b/musicfetch index 70098fe..47ead33 100755 --- a/musicfetch +++ b/musicfetch @@ -746,6 +746,23 @@ def _is_youtube_playlist_url(url: str) -> bool: return "list" in qs and "v" not in qs +_DIRECT_HOSTS = ("youtube.com", "youtu.be", "music.youtube.com", + "soundcloud.com", "api.soundcloud.com") + + +def _is_direct_url(url: str) -> bool: + """True for links yt-dlp downloads well directly (YouTube, SoundCloud). + These skip Odesli resolution and use the existing handle_url path.""" + if not is_url(url): + return False + host = (urlparse(url).hostname or "").lower() + if host.startswith("www."): + host = host[4:] + if host.endswith(("youtube.com", "youtu.be", "soundcloud.com")): + return True + return host in _DIRECT_HOSTS + + def _ytmusic_playlist(pid: str) -> tuple[str, list[Hit]]: """Expand a YouTube Music playlist via ytmusicapi. Returns ("", []) on failure.""" try: diff --git a/tests/test_odesli.py b/tests/test_odesli.py index bb5cd1d..62415a5 100644 --- a/tests/test_odesli.py +++ b/tests/test_odesli.py @@ -66,3 +66,23 @@ def test_odesli_resolve_network_error_returns_none(monkeypatch): raise mf.RequestException("down") monkeypatch.setattr(mf.requests, "get", boom) assert mf.odesli_resolve("https://open.spotify.com/track/abc") is None + + +def test_is_direct_url_youtube(): + assert mf._is_direct_url("https://music.youtube.com/watch?v=abc") + assert mf._is_direct_url("https://www.youtube.com/watch?v=abc") + assert mf._is_direct_url("https://youtu.be/abc") + + +def test_is_direct_url_soundcloud(): + assert mf._is_direct_url("https://soundcloud.com/dj/track") + + +def test_is_direct_url_other_platforms_false(): + assert not mf._is_direct_url("https://open.spotify.com/track/abc") + assert not mf._is_direct_url("https://music.apple.com/us/album/x/1?i=2") + assert not mf._is_direct_url("https://tidal.com/browse/track/123") + + +def test_is_direct_url_youtube_playlist_true(): + assert mf._is_direct_url("https://www.youtube.com/playlist?list=PLabc") From a4e1dc164383244824879fa0feb7622dac9a07f9 Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:53:30 -0700 Subject: [PATCH 3/9] refactor: extract _dispatch_chosen from main() for reuse Co-Authored-By: Claude Opus 4.8 --- musicfetch | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/musicfetch b/musicfetch index 47ead33..fe90979 100755 --- a/musicfetch +++ b/musicfetch @@ -1284,6 +1284,26 @@ def parse_args(): return p.parse_args() +def _dispatch_chosen(chosen: Hit, hits: list[Hit], root: str, quality: str, + dry_run: bool, lidarr_only: bool, search_all: bool) -> None: + """Act on a picked Hit: Lidarr album (add+search, fall to top YouTube hit on + no release), Lidarr artist, or a YouTube track. Shared by main() and handle_link.""" + if chosen.source == "lidarr": + if chosen.kind == "album": + handled = act_lidarr_album(chosen, root, search_all, dry_run) + if not handled and not lidarr_only: + yt_fallback = next((h for h in hits if h.source == "youtube"), None) + if yt_fallback: + print("Using top YouTube hit as fallback.") + act_youtube(yt_fallback, root, quality, dry_run) + else: + print("No YouTube fallback available.") + else: + act_lidarr_artist(chosen, root, search_all, dry_run) + else: + act_youtube(chosen, root, quality, dry_run) + + def main(): global DEBUG, COOKIES_FILE, COOKIES_FROM_BROWSER args = parse_args() @@ -1325,21 +1345,8 @@ def main(): print("Nothing selected.") return - if chosen.source == "lidarr": - if chosen.kind == "album": - handled = act_lidarr_album(chosen, args.root, args.search_all, args.dry_run) - if not handled and not args.lidarr_only: - # Fall through to the top YouTube hit for the same query. - yt_fallback = next((h for h in hits if h.source == "youtube"), None) - if yt_fallback: - print("Using top YouTube hit as fallback.") - act_youtube(yt_fallback, args.root, args.quality, args.dry_run) - else: - print("No YouTube fallback available.") - else: - act_lidarr_artist(chosen, args.root, args.search_all, args.dry_run) - else: - act_youtube(chosen, args.root, args.quality, args.dry_run) + _dispatch_chosen(chosen, hits, args.root, args.quality, args.dry_run, + args.lidarr_only, args.search_all) if __name__ == "__main__": From 9c308fefc77b2fe98f43bd4aa88e8ae8e65573fc Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:55:04 -0700 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20resolve=5Flink=5Fhits=20+=20handle?= =?UTF-8?q?=5Flink=20=E2=80=94=20Odesli=20link=20->=20Lidarr-first=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- musicfetch | 36 ++++++++++++++++++++++++++++ tests/test_odesli.py | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/musicfetch b/musicfetch index fe90979..f33a993 100755 --- a/musicfetch +++ b/musicfetch @@ -900,6 +900,42 @@ def get_artist_from_metadata(meta: dict) -> str: return "Unknown Artist" +def resolve_link_hits(url: str, limit: int) -> tuple[str, list[Hit]]: + """Resolve a non-YouTube/SoundCloud link via Odesli into a search query plus + hits: Lidarr album candidates for "Artist - Title", followed by the EXACT + YouTube track from the shared link (not a fuzzy re-search). Raises OdesliError + if the link can't be resolved.""" + r = odesli_resolve(url) + if r is None: + raise OdesliError(url) + query = f"{r.artist} - {r.title}".strip(" -") + hits = lidarr_search(query, limit) + if r.youtube_url: + hits = hits + [Hit(source="youtube", kind="track", title=r.title, + artist=r.artist, thumbnail=r.thumb, + payload={"url": r.youtube_url})] + return query, hits + + +def handle_link(url: str, root: str, quality: str, dry_run: bool, + noninteractive: bool, yt_first: bool, limit: int) -> None: + """CLI path for a non-direct link: resolve via Odesli, then run the normal + Lidarr-first pick/dispatch with the exact YouTube track as fallback.""" + try: + query, hits = resolve_link_hits(url, limit) + except OdesliError: + err(f"Couldn't resolve {url}. Try the direct YouTube/SoundCloud link.") + return + if not hits: + err(f"No Lidarr or YouTube source found for '{query}'.") + return + chosen = pick(hits, query, noninteractive, yt_first) + if not chosen: + print("Nothing selected.") + return + _dispatch_chosen(chosen, hits, root, quality, dry_run, False, False) + + def handle_url(url: str, root: str, quality: str, dry_run: bool): kind, title, hits = probe_url(url) if kind == "playlist": diff --git a/tests/test_odesli.py b/tests/test_odesli.py index 62415a5..c2edcb2 100644 --- a/tests/test_odesli.py +++ b/tests/test_odesli.py @@ -86,3 +86,60 @@ def test_is_direct_url_other_platforms_false(): def test_is_direct_url_youtube_playlist_true(): assert mf._is_direct_url("https://www.youtube.com/playlist?list=PLabc") + + +def _resolved(yt="https://music.youtube.com/watch?v=YYY"): + return mf.Resolved(title="Bloom", artist="ODESZA", + thumb="https://img/cover.jpg", youtube_url=yt) + + +def test_resolve_link_hits_builds_query_and_exact_yt(monkeypatch): + monkeypatch.setattr(mf, "odesli_resolve", lambda url: _resolved()) + lid = mf.Hit(source="lidarr", kind="album", title="A Moment Apart", + artist="ODESZA", album="A Moment Apart", year="2017", + payload={"album": {"id": 9}}) + monkeypatch.setattr(mf, "lidarr_search", lambda q, limit: [lid]) + query, hits = mf.resolve_link_hits("https://open.spotify.com/track/abc", 10) + assert query == "ODESZA - Bloom" + assert hits[0].source == "lidarr" + yt = hits[-1] + assert yt.source == "youtube" and yt.kind == "track" + assert yt.title == "Bloom" and yt.artist == "ODESZA" + assert yt.payload["url"] == "https://music.youtube.com/watch?v=YYY" + + +def test_resolve_link_hits_no_yt_link_lidarr_only(monkeypatch): + monkeypatch.setattr(mf, "odesli_resolve", lambda url: _resolved(yt="")) + lid = mf.Hit(source="lidarr", kind="album", title="X", artist="ODESZA", + payload={"album": {"id": 9}}) + monkeypatch.setattr(mf, "lidarr_search", lambda q, limit: [lid]) + query, hits = mf.resolve_link_hits("https://open.spotify.com/track/abc", 10) + assert all(h.source == "lidarr" for h in hits) + + +def test_resolve_link_hits_odesli_miss_raises(monkeypatch): + import pytest + monkeypatch.setattr(mf, "odesli_resolve", lambda url: None) + with pytest.raises(mf.OdesliError): + mf.resolve_link_hits("https://open.spotify.com/track/abc", 10) + + +def test_handle_link_miss_prints_and_returns(monkeypatch, capsys): + monkeypatch.setattr(mf, "odesli_resolve", lambda url: None) + mf.handle_link("https://open.spotify.com/track/abc", "/m", "best", + False, True, False, 10) + out = capsys.readouterr() + assert "Couldn't resolve" in (out.err + out.out) + + +def test_handle_link_dispatches_chosen(monkeypatch): + monkeypatch.setattr(mf, "odesli_resolve", lambda url: _resolved()) + monkeypatch.setattr(mf, "lidarr_search", lambda q, limit: []) + chosen = {} + monkeypatch.setattr(mf, "pick", lambda hits, q, ni, yf: hits[0]) + monkeypatch.setattr(mf, "_dispatch_chosen", + lambda c, hits, root, quality, dry, lo, sa: chosen.update(c=c, root=root)) + mf.handle_link("https://open.spotify.com/track/abc", "/m", "best", + False, True, False, 10) + assert chosen["c"].source == "youtube" + assert chosen["root"] == "/m" From 8daf78002325253b8402e5747cb0b43fa0d17a00 Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:56:02 -0700 Subject: [PATCH 5/9] feat: route non-direct CLI links through Odesli (handle_link) Co-Authored-By: Claude Opus 4.8 --- musicfetch | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/musicfetch b/musicfetch index f33a993..c07ffc7 100755 --- a/musicfetch +++ b/musicfetch @@ -1367,7 +1367,11 @@ def main(): sys.exit(1) if is_url(query): - handle_url(query, args.root, args.quality, args.dry_run) + if _is_direct_url(query): + handle_url(query, args.root, args.quality, args.dry_run) + else: + handle_link(query, args.root, args.quality, args.dry_run, + args.noninteractive, args.ytsearch, args.limit) return hits = build_combined_hits(query, args.limit, args.ytsearch, From 44aaa1f93ea7abda4723f24e381ddab559789e25 Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:56:56 -0700 Subject: [PATCH 6/9] feat: re-export odesli symbols through server/mf.py Co-Authored-By: Claude Opus 4.8 --- server/mf.py | 9 ++++++++- tests/test_mf_url_exports.py | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/server/mf.py b/server/mf.py index be9b11d..c688e3f 100644 --- a/server/mf.py +++ b/server/mf.py @@ -28,7 +28,14 @@ is_url = _mod.is_url probe_url = _mod.probe_url download_hits = _mod.download_hits download_single = _mod.download_single +_is_direct_url = _mod._is_direct_url +odesli_resolve = _mod.odesli_resolve +resolve_link_hits = _mod.resolve_link_hits +handle_link = _mod.handle_link +OdesliError = _mod.OdesliError __all__ = ["Hit", "build_combined_hits", "pick", "act_youtube", "act_lidarr_album", "act_lidarr_artist", "QUALITY_CHOICES", - "is_url", "probe_url", "download_hits", "download_single"] + "is_url", "probe_url", "download_hits", "download_single", + "_is_direct_url", "odesli_resolve", "resolve_link_hits", + "handle_link", "OdesliError"] diff --git a/tests/test_mf_url_exports.py b/tests/test_mf_url_exports.py index 8891769..9b87775 100644 --- a/tests/test_mf_url_exports.py +++ b/tests/test_mf_url_exports.py @@ -6,3 +6,11 @@ def test_url_helpers_reexported(): assert callable(smf.probe_url) assert callable(smf.download_hits) assert callable(smf.download_single) + + +def test_odesli_symbols_reexported(): + import server.mf as smf + assert callable(smf._is_direct_url) + assert callable(smf.odesli_resolve) + assert callable(smf.resolve_link_hits) + assert callable(smf.handle_link) From eb45a3680f3428933cbea6ca988f022b7d3202cf Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:59:33 -0700 Subject: [PATCH 7/9] feat: server /fetch resolves non-direct links via Odesli (Lidarr-first) Co-Authored-By: Claude Opus 4.8 --- server/app.py | 37 ++++++++++++++++++++++++++++++------- tests/test_api_url.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/server/app.py b/server/app.py index a3305cc..2b371b8 100644 --- a/server/app.py +++ b/server/app.py @@ -53,16 +53,39 @@ def fetch(q: str = Query(..., min_length=1), raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.") if mf.is_url(q): - kind, title, hits = mf.probe_url(q) - syn = mf.Hit(source="youtube", kind=kind, title=title, artist="") - job = jobs.create_job(hit=syn, message=actions.url_started_message(kind, title)) + if mf._is_direct_url(q): + kind, title, hits = mf.probe_url(q) + syn = mf.Hit(source="youtube", kind=kind, title=title, artist="") + job = jobs.create_job(hit=syn, message=actions.url_started_message(kind, title)) + response = _job_public(job) + done_msg = actions.playlist_done_message if kind == "playlist" else actions.url_done_message + jobs.run_job( + job.id, + lambda: actions.perform_url_fetch(q, kind, title, hits, quality, ROOT), + done_message=done_msg, + fail_message="Download failed.", + ) + return response + + # Non-direct link (Spotify/Apple/...): resolve via Odesli, then run the + # normal Lidarr-first pick/dispatch with the exact YouTube track fallback. + try: + query, hits = mf.resolve_link_hits(q, 10) + except mf.OdesliError: + raise HTTPException(status_code=422, + detail=f"Couldn't resolve {q}. Try the direct YouTube or SoundCloud link.") + if not hits: + raise HTTPException(status_code=404, detail=f"No results found for '{q}'.") + chosen = mf.pick(hits, query, True, False) + if chosen is None: + raise HTTPException(status_code=404, detail=f"No results found for '{q}'.") + job = jobs.create_job(hit=chosen, message=actions.started_message(chosen)) response = _job_public(job) - done_msg = actions.playlist_done_message if kind == "playlist" else actions.url_done_message jobs.run_job( job.id, - lambda: actions.perform_url_fetch(q, kind, title, hits, quality, ROOT), - done_message=done_msg, - fail_message="Download failed.", + lambda: actions.perform_fetch(chosen, hits, quality, ROOT), + done_message=actions.done_message(chosen), + fail_message=actions.failed_message(chosen), ) return response diff --git a/tests/test_api_url.py b/tests/test_api_url.py index 24a0b2f..ec841fa 100644 --- a/tests/test_api_url.py +++ b/tests/test_api_url.py @@ -73,3 +73,43 @@ def test_search_query_still_works(client, auth, monkeypatch): r = client.post("/fetch", params={"q": "Daft Punk - Discovery"}, headers=auth) assert r.status_code == 200 assert r.json()["status"] == "queued" + + +def test_non_direct_link_resolves_and_fetches(client, auth, monkeypatch): + from server import mf + lid = mf.Hit(source="lidarr", kind="album", title="A Moment Apart", + artist="ODESZA", album="A Moment Apart", year="2017", + payload={"album": {"id": 9}}) + yt = mf.Hit(source="youtube", kind="track", title="Bloom", artist="ODESZA", + payload={"url": "https://music.youtube.com/watch?v=YYY"}) + monkeypatch.setattr("server.app.mf.resolve_link_hits", + lambda url, limit: ("ODESZA - Bloom", [lid, yt])) + monkeypatch.setattr("server.app.mf.pick", lambda hits, q, ni, yf: hits[0]) + monkeypatch.setattr("server.app.actions.perform_fetch", + lambda chosen, hits, quality, root: {"path": None, "lidarr_album_id": 9}) + r = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"}, headers=auth) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "queued" + assert body["hit"]["album"] == "A Moment Apart" + done = _wait_done(client, auth, body["job_id"]) + assert done["status"] == "done" + + +def test_non_direct_link_resolve_failure_422(client, auth, monkeypatch): + from server import mf as mf_mod + + def boom(url, limit): + raise mf_mod.OdesliError(url) + + monkeypatch.setattr("server.app.mf.resolve_link_hits", boom) + r = client.post("/fetch", params={"q": "https://open.spotify.com/track/bad"}, headers=auth) + assert r.status_code == 422 + assert "resolve" in r.json()["message"].lower() + + +def test_non_direct_link_no_hits_404(client, auth, monkeypatch): + monkeypatch.setattr("server.app.mf.resolve_link_hits", + lambda url, limit: ("ODESZA - Bloom", [])) + r = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"}, headers=auth) + assert r.status_code == 404 From 32acd038c8fd1212a09613c4f202116bb84c7928 Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:59:59 -0700 Subject: [PATCH 8/9] docs: document any-link Odesli resolution in README Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 31a3ed3..1907658 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ whatever you choose. It accepts: - A **free-form query**: an artist, an album, a track title, or combos like `"Artist - Title"` or `"Artist - Album"` (e.g. `"ODESZA - Bloom"`, `"Daft Punk"`, `"Discovery"`). - A **URL** (e.g. `"https://music.youtube.com/watch?v=..."` or a regular YouTube URL). +- **Any streaming link** (Spotify, Apple Music, Tidal, Deezer, …): resolved to + metadata via [Odesli/song.link](https://odesli.co), then searched on Lidarr + first and downloaded from the matching YouTube track if no Lidarr release is + available. YouTube and SoundCloud links download directly. Lidarr is tried first by default. If you pick a Lidarr album but **no indexer release is available**, MusicFetch automatically falls through to the top From b899d759304d3d3845c4bc0767367a9068b88e2f Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 22:02:57 -0700 Subject: [PATCH 9/9] fix: _is_direct_url label-boundary host match (no notyoutube.com false positive) Review finding: bare endswith routed look-alike hosts to the direct yt-dlp path. Match on a domain-label boundary and drop the redundant _DIRECT_HOSTS. Co-Authored-By: Claude Opus 4.8 --- musicfetch | 12 ++++-------- tests/test_odesli.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/musicfetch b/musicfetch index c07ffc7..688deed 100755 --- a/musicfetch +++ b/musicfetch @@ -746,21 +746,17 @@ def _is_youtube_playlist_url(url: str) -> bool: return "list" in qs and "v" not in qs -_DIRECT_HOSTS = ("youtube.com", "youtu.be", "music.youtube.com", - "soundcloud.com", "api.soundcloud.com") +_DIRECT_DOMAINS = ("youtube.com", "youtu.be", "soundcloud.com") def _is_direct_url(url: str) -> bool: """True for links yt-dlp downloads well directly (YouTube, SoundCloud). - These skip Odesli resolution and use the existing handle_url path.""" + These skip Odesli resolution and use the existing handle_url path. Matches on + a label boundary so look-alikes (notyoutube.com) don't slip through.""" if not is_url(url): return False host = (urlparse(url).hostname or "").lower() - if host.startswith("www."): - host = host[4:] - if host.endswith(("youtube.com", "youtu.be", "soundcloud.com")): - return True - return host in _DIRECT_HOSTS + return any(host == d or host.endswith("." + d) for d in _DIRECT_DOMAINS) def _ytmusic_playlist(pid: str) -> tuple[str, list[Hit]]: diff --git a/tests/test_odesli.py b/tests/test_odesli.py index c2edcb2..c95f702 100644 --- a/tests/test_odesli.py +++ b/tests/test_odesli.py @@ -88,6 +88,20 @@ def test_is_direct_url_youtube_playlist_true(): assert mf._is_direct_url("https://www.youtube.com/playlist?list=PLabc") +def test_is_direct_url_lookalike_hosts_false(): + # Trailing-substring look-alikes must NOT be treated as direct (label boundary). + assert not mf._is_direct_url("https://notyoutube.com/watch?v=abc") + assert not mf._is_direct_url("https://myyoutube.com/x") + assert not mf._is_direct_url("https://evilyoutu.be/x") + assert not mf._is_direct_url("https://youtube.com.evil.com/x") + + +def test_is_direct_url_subdomains_true(): + assert mf._is_direct_url("https://m.youtube.com/watch?v=abc") + assert mf._is_direct_url("https://on.soundcloud.com/x") + assert mf._is_direct_url("https://api.soundcloud.com/tracks/1") + + def _resolved(yt="https://music.youtube.com/watch?v=YYY"): return mf.Resolved(title="Bloom", artist="ODESZA", thumb="https://img/cover.jpg", youtube_url=yt)