Merge odesli-link-resolution: any link -> Lidarr-first, YouTube fallback
Resolve any streaming link (Spotify/Apple/Tidal/...) via the Odesli/song.link API, then run the existing Lidarr-first, exact-YouTube-fallback flow. YouTube and SoundCloud links keep direct yt-dlp download; text queries unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,10 @@ whatever you choose. It accepts:
|
|||||||
- A **free-form query**: an artist, an album, a track title, or combos like
|
- 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"`).
|
`"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).
|
- 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
|
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
|
release is available**, MusicFetch automatically falls through to the top
|
||||||
|
|||||||
131
musicfetch
131
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -731,6 +746,19 @@ def _is_youtube_playlist_url(url: str) -> bool:
|
|||||||
return "list" in qs and "v" not in qs
|
return "list" in qs and "v" not in qs
|
||||||
|
|
||||||
|
|
||||||
|
_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. 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()
|
||||||
|
return any(host == d or host.endswith("." + d) for d in _DIRECT_DOMAINS)
|
||||||
|
|
||||||
|
|
||||||
def _ytmusic_playlist(pid: str) -> tuple[str, list[Hit]]:
|
def _ytmusic_playlist(pid: str) -> tuple[str, list[Hit]]:
|
||||||
"""Expand a YouTube Music playlist via ytmusicapi. Returns ("", []) on failure."""
|
"""Expand a YouTube Music playlist via ytmusicapi. Returns ("", []) on failure."""
|
||||||
try:
|
try:
|
||||||
@@ -762,6 +790,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).
|
||||||
@@ -842,6 +896,42 @@ def get_artist_from_metadata(meta: dict) -> str:
|
|||||||
return "Unknown Artist"
|
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):
|
def handle_url(url: str, root: str, quality: str, dry_run: bool):
|
||||||
kind, title, hits = probe_url(url)
|
kind, title, hits = probe_url(url)
|
||||||
if kind == "playlist":
|
if kind == "playlist":
|
||||||
@@ -1226,6 +1316,26 @@ def parse_args():
|
|||||||
return p.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():
|
def main():
|
||||||
global DEBUG, COOKIES_FILE, COOKIES_FROM_BROWSER
|
global DEBUG, COOKIES_FILE, COOKIES_FROM_BROWSER
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
@@ -1253,7 +1363,11 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if is_url(query):
|
if is_url(query):
|
||||||
|
if _is_direct_url(query):
|
||||||
handle_url(query, args.root, args.quality, args.dry_run)
|
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
|
return
|
||||||
|
|
||||||
hits = build_combined_hits(query, args.limit, args.ytsearch,
|
hits = build_combined_hits(query, args.limit, args.ytsearch,
|
||||||
@@ -1267,21 +1381,8 @@ def main():
|
|||||||
print("Nothing selected.")
|
print("Nothing selected.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if chosen.source == "lidarr":
|
_dispatch_chosen(chosen, hits, args.root, args.quality, args.dry_run,
|
||||||
if chosen.kind == "album":
|
args.lidarr_only, args.search_all)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def fetch(q: str = Query(..., min_length=1),
|
|||||||
raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.")
|
raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.")
|
||||||
|
|
||||||
if mf.is_url(q):
|
if mf.is_url(q):
|
||||||
|
if mf._is_direct_url(q):
|
||||||
kind, title, hits = mf.probe_url(q)
|
kind, title, hits = mf.probe_url(q)
|
||||||
syn = mf.Hit(source="youtube", kind=kind, title=title, artist="")
|
syn = mf.Hit(source="youtube", kind=kind, title=title, artist="")
|
||||||
job = jobs.create_job(hit=syn, message=actions.url_started_message(kind, title))
|
job = jobs.create_job(hit=syn, message=actions.url_started_message(kind, title))
|
||||||
@@ -66,6 +67,28 @@ def fetch(q: str = Query(..., min_length=1),
|
|||||||
)
|
)
|
||||||
return response
|
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)
|
||||||
|
jobs.run_job(
|
||||||
|
job.id,
|
||||||
|
lambda: actions.perform_fetch(chosen, hits, quality, ROOT),
|
||||||
|
done_message=actions.done_message(chosen),
|
||||||
|
fail_message=actions.failed_message(chosen),
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
if source not in ("auto", "lidarr", "youtube"):
|
if source not in ("auto", "lidarr", "youtube"):
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.")
|
raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.")
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ is_url = _mod.is_url
|
|||||||
probe_url = _mod.probe_url
|
probe_url = _mod.probe_url
|
||||||
download_hits = _mod.download_hits
|
download_hits = _mod.download_hits
|
||||||
download_single = _mod.download_single
|
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",
|
__all__ = ["Hit", "build_combined_hits", "pick", "act_youtube",
|
||||||
"act_lidarr_album", "act_lidarr_artist", "QUALITY_CHOICES",
|
"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"]
|
||||||
|
|||||||
@@ -73,3 +73,43 @@ def test_search_query_still_works(client, auth, monkeypatch):
|
|||||||
r = client.post("/fetch", params={"q": "Daft Punk - Discovery"}, headers=auth)
|
r = client.post("/fetch", params={"q": "Daft Punk - Discovery"}, headers=auth)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["status"] == "queued"
|
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
|
||||||
|
|||||||
@@ -6,3 +6,11 @@ def test_url_helpers_reexported():
|
|||||||
assert callable(smf.probe_url)
|
assert callable(smf.probe_url)
|
||||||
assert callable(smf.download_hits)
|
assert callable(smf.download_hits)
|
||||||
assert callable(smf.download_single)
|
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)
|
||||||
|
|||||||
159
tests/test_odesli.py
Normal file
159
tests/test_odesli.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user