Files
musicfetch/docs/superpowers/plans/2026-06-13-odesli-link-resolution.md
2026-06-13 20:57:18 -07:00

26 KiB

Odesli Link Resolution Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let users hand MusicFetch any song link (Spotify/Apple/Tidal/…); resolve it to metadata via Odesli, then run the existing Lidarr-first, exact-YouTube-fallback flow. YouTube/SoundCloud links and text queries are unchanged.

Architecture: Add three core helpers to the musicfetch script — odesli_resolve (HTTP → metadata), _is_direct_url (router), and resolve_link_hits/handle_link (turn a non-direct link into a "Artist - Title" query + Lidarr hits + the exact YouTube track, then reuse the existing pick/dispatch). Wire CLI main() and server /fetch to route non-direct URLs through it. The server stays a thin reuse layer via server/mf.py.

Tech Stack: Python 3.10+, requests, pytest, FastAPI (server). Tests import the extension-less musicfetch script as musicfetch_core via import server.mf.


File Structure

  • musicfetch — add Resolved dataclass, OdesliError, odesli_resolve, _is_direct_url, resolve_link_hits, handle_link, and extract _dispatch_chosen from main(). Wire main().
  • server/mf.py — export the new symbols.
  • server/app.py — route non-direct URLs in /fetch through resolve + existing perform_fetch job path.
  • tests/test_odesli.py — new: odesli_resolve, _is_direct_url, resolve_link_hits, handle_link.
  • tests/test_api_url.py — add: server non-direct-URL routing.
  • tests/test_mf_url_exports.py — add: new symbols re-exported.

Task 1: Resolved dataclass, OdesliError, and odesli_resolve

Files:

  • Modify: musicfetch (add dataclass + exception near the Hit dataclass ~line 82; add quote to the urllib.parse import line 23; add function in the URL section)

  • Test: tests/test_odesli.py

  • Step 1: Write the failing tests

Create tests/test_odesli.py:

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
  • Step 2: Run tests to verify they fail

Run: pytest tests/test_odesli.py -v Expected: FAIL — AttributeError: module 'musicfetch_core' has no attribute 'odesli_resolve'.

  • Step 3: Implement

In musicfetch, change the import on line 23 to add quote:

from urllib.parse import urlparse, parse_qs, quote

Add after the Hit dataclass (after its display_title property, ~line 96):

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

Add in the URL section (e.g. just above def probe_url, ~line 765):

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

(quote is imported for completeness/consistency; requests handles encoding via params. Leave quote unused-import-free by relying on params — if a linter complains, drop the quote addition.)

Correction: do not add quote to the import — params= already encodes. Keep line 23 unchanged.

  • Step 4: Run tests to verify they pass

Run: pytest tests/test_odesli.py -v Expected: 6 passed.

  • Step 5: Commit
git add musicfetch tests/test_odesli.py
git commit -m "feat: odesli_resolve — resolve any song link to metadata via song.link

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: _is_direct_url router

Files:

  • Modify: musicfetch (add near _is_youtube_playlist_url, ~line 720)

  • Test: tests/test_odesli.py

  • Step 1: Write the failing tests

Append to tests/test_odesli.py:

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")
  • Step 2: Run tests to verify they fail

Run: pytest tests/test_odesli.py -k is_direct_url -v Expected: FAIL — AttributeError: ... has no attribute '_is_direct_url'.

  • Step 3: Implement

Add after _is_youtube_playlist_url (~line 732):

_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
  • Step 4: Run tests to verify they pass

Run: pytest tests/test_odesli.py -k is_direct_url -v Expected: 4 passed.

  • Step 5: Commit
git add musicfetch tests/test_odesli.py
git commit -m "feat: _is_direct_url — route YouTube/SoundCloud links to direct download

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Extract _dispatch_chosen from main()

Refactor only — pulls the chosen-Hit dispatch out of main() so handle_link can reuse it. Behavior unchanged; existing tests must stay green.

Files:

  • Modify: musicfetch main() (lines ~1268-1284) and add _dispatch_chosen above main().

  • Step 1: Add _dispatch_chosen above main()

def _dispatch_chosen(chosen: Hit, hits: list[Hit], root: str, quality: str,
                     dry_run: bool, lidarr_only: 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, False, 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, False, dry_run)
    else:
        act_youtube(chosen, root, quality, dry_run)

Note: the original main() passed args.search_all to act_lidarr_album/act_lidarr_artist. Preserve that — add a search_all parameter:

def _dispatch_chosen(chosen, hits, root, quality, dry_run, lidarr_only, search_all):
    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)
  • Step 2: Replace the dispatch block in main()

Replace lines ~1271-1284 (the if chosen.source == "lidarr": ... else: act_youtube(...) block) with:

    _dispatch_chosen(chosen, hits, args.root, args.quality, args.dry_run,
                     args.lidarr_only, args.search_all)
  • Step 3: Run the full suite to confirm no regressions

Run: pytest -q Expected: all existing tests pass (no behavior change).

  • Step 4: Commit
git add musicfetch
git commit -m "refactor: extract _dispatch_chosen from main() for reuse

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Files:

  • Modify: musicfetch (add near handle_url, ~line 845)

  • Test: tests/test_odesli.py

  • Step 1: Write the failing tests

Append to tests/test_odesli.py:

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):
    monkeypatch.setattr(mf, "odesli_resolve", lambda url: None)
    import pytest
    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"
  • Step 2: Run tests to verify they fail

Run: pytest tests/test_odesli.py -k "resolve_link_hits or handle_link" -v Expected: FAIL — AttributeError: ... has no attribute 'resolve_link_hits'.

  • Step 3: Implement

Add near handle_url (~line 845) in musicfetch:

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,
                     lidarr_only=False, search_all=False)
  • Step 4: Run tests to verify they pass

Run: pytest tests/test_odesli.py -v Expected: all pass.

  • Step 5: Commit
git add musicfetch tests/test_odesli.py
git commit -m "feat: resolve_link_hits + handle_link — Odesli link -> Lidarr-first flow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Wire CLI main()

Files:

  • Modify: musicfetch main() (the if is_url(query): block, ~line 1255)

  • Step 1: Replace the URL branch

Replace:

    if is_url(query):
        handle_url(query, args.root, args.quality, args.dry_run)
        return

with:

    if is_url(query):
        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
  • Step 2: Manual smoke (dry-run, no network side effects expected on resolve failure)

Run: ./musicfetch -d "https://open.spotify.com/track/0000000000" 2>&1 | head -5 Expected: prints Couldn't resolve https://open.spotify.com/track/0000000000. ... (invalid id → Odesli miss) OR resolves and shows a picker/dry-run line. Either is acceptable — confirms routing, no traceback.

  • Step 3: Run the full suite

Run: pytest -q Expected: all pass.

  • Step 4: Commit
git add musicfetch
git commit -m "feat: route non-direct CLI links through Odesli (handle_link)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: Export new symbols via server/mf.py

Files:

  • Modify: server/mf.py

  • Test: tests/test_mf_url_exports.py

  • Step 1: Write the failing test

Append to tests/test_mf_url_exports.py:

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)
  • Step 2: Run to verify it fails

Run: pytest tests/test_mf_url_exports.py -v Expected: FAIL — AttributeError: module 'server.mf' has no attribute '_is_direct_url'.

  • Step 3: Implement

In server/mf.py, after the existing download_single = _mod.download_single line, add:

_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

And extend __all__ to include "_is_direct_url", "odesli_resolve", "resolve_link_hits", "handle_link", "OdesliError".

  • Step 4: Run to verify it passes

Run: pytest tests/test_mf_url_exports.py -v Expected: pass.

  • Step 5: Commit
git add server/mf.py tests/test_mf_url_exports.py
git commit -m "feat: re-export odesli symbols through server/mf.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: Wire server /fetch non-direct URL branch

Files:

  • Modify: server/app.py fetch() (the if mf.is_url(q): block)

  • Test: tests/test_api_url.py

  • Step 1: Write the failing tests

Append to tests/test_api_url.py:

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):
    def boom(url, limit):
        raise mf_mod.OdesliError(url)
    from server import mf as mf_mod
    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

Note: in test_non_direct_link_resolve_failure_422, move the from server import mf as mf_mod line above the def boom so the closure resolves it. Final form:

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()
  • Step 2: Run to verify they fail

Run: pytest tests/test_api_url.py -k non_direct -v Expected: FAIL — current code routes every URL through probe_url; the spotify URL hits download_single, so assertions on resolve_link_hits/status differ.

  • Step 3: Implement

In server/app.py, replace the URL branch:

    if mf.is_url(q):
        kind, title, hits = mf.probe_url(q)
        ...
        return response

with a direct-vs-Odesli split:

    if mf.is_url(q):
        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)
        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

The existing source/build_combined_hits text-query path below stays unchanged.

  • Step 4: Run to verify they pass

Run: pytest tests/test_api_url.py -v Expected: all pass (existing direct-URL tests still green).

  • Step 5: Commit
git add server/app.py tests/test_api_url.py
git commit -m "feat: server /fetch resolves non-direct links via Odesli (Lidarr-first)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 8: Full suite + README note

Files:

  • Modify: README.md (input description)

  • Step 1: Run the whole suite

Run: pytest -q Expected: all pass.

  • Step 2: Update README

In the "It accepts" list near the top, change the URL bullet to mention any-platform links, e.g. add:

- **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.
  • Step 3: Commit
git add README.md
git commit -m "docs: document any-link Odesli resolution in README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Self-Review Notes

  • Spec coverage: odesli_resolve (Task 1), _is_direct_url (Task 2), resolve_link_hits/handle_link (Task 4), CLI wiring (Task 5), server/mf.py exports (Task 6), server /fetch branch (Task 7), tests throughout. _dispatch_chosen extraction (Task 3) implements the "reuse existing dispatch" requirement.
  • Decision applied: Lidarr-first default — _dispatch_chosen auto-picks/acts Lidarr album, falls to the exact YouTube hit only on no release.
  • Out-of-scope honored: no album/year enrichment, no non-yt/sc playlist handling, no caching.
  • Type consistency: Resolved(title, artist, thumb, youtube_url), resolve_link_hits -> (query, hits), OdesliError, _dispatch_chosen(chosen, hits, root, quality, dry_run, lidarr_only, search_all) used identically across tasks.