feat: odesli_resolve — resolve any song link to metadata via song.link
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
41
musicfetch
41
musicfetch
@@ -95,6 +95,21 @@ class Hit:
|
|||||||
return self.title or self.album or self.artist
|
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
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -762,6 +777,32 @@ def _entry_to_hit(entry: dict) -> Hit:
|
|||||||
"extractor": source})
|
"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]]:
|
def probe_url(url: str) -> tuple[str, str, list[Hit]]:
|
||||||
"""Classify a URL via yt-dlp. Returns (kind, title, hits) where kind is
|
"""Classify a URL via yt-dlp. Returns (kind, title, hits) where kind is
|
||||||
'playlist' (hits populated) or 'track' (hits empty; caller downloads the URL).
|
'playlist' (hits populated) or 'track' (hits empty; caller downloads the URL).
|
||||||
|
|||||||
68
tests/test_odesli.py
Normal file
68
tests/test_odesli.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user