From a88f4c594a5263d38dabd0c8412dfc1fcf2f7c9b Mon Sep 17 00:00:00 2001 From: zebra Date: Sat, 13 Jun 2026 21:47:22 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20odesli=5Fresolve=20=E2=80=94=20resolve?= =?UTF-8?q?=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