Files
musicfetch/docs/superpowers/specs/2026-06-13-odesli-link-resolution-design.md

6.4 KiB

Odesli Link Resolution Design

Date: 2026-06-13 Status: Approved, pending implementation

Goal

Let users hand MusicFetch any song link (Spotify, Apple Music, Tidal, Deezer, etc.) and have it resolve to real metadata, then run the normal Lidarr-first, YouTube-fallback flow — instead of failing because yt-dlp can't download those platforms.

YouTube and SoundCloud links keep their current direct-yt-dlp behavior (they already download with good metadata). Free-text queries are unchanged.

Scope

Input Behavior
youtube / youtube-music / soundcloud URL (incl. playlists) Current handle_url direct download — unchanged
any other URL (spotify/apple/tidal/deezer/…) New: Odesli resolve → Lidarr-first → exact-YouTube fallback
free-text query Current combined search — unchanged

Approach

Approach A — resolve-then-reuse. Turn a non-direct link into a canonical "Artist - Title" query and feed the existing, battle-tested combined flow. The only metadata source added is Odesli; album/year still come from the Lidarr path (MusicBrainz) and the YouTube exact-track tags from yt-dlp/ytmusic.

Rejected:

  • B (full enrichment): Odesli + MusicBrainz to also resolve album/year and force-tag precisely. Marginal gain over yt-dlp's own tagging; more calls + complexity. Noted as an easy later add.
  • C (Odesli for every URL): uniform but pointless for youtube/soundcloud, which already work. Out of scope by decision.

Components

odesli_resolve(url) -> Optional[Resolved] (musicfetch core)

  • Request: GET https://api.song.link/v1-alpha.1/links?url=<urlencoded>&userCountry=US, ~5s timeout.
  • Parse: entity = data["entitiesByUniqueId"][data["entityUniqueId"]].
    • titleentity["title"]
    • artistentity["artistName"]
    • thumbentity.get("thumbnailUrl", "")
    • youtube_urldata["linksByPlatform"]["youtubeMusic"]["url"], else ["youtube"]["url"], else "".
  • Returns a small Resolved struct (or dict) with the above.
  • Returns None on any failure: network error, non-200, JSON error, missing entityUniqueId/entity, or missing both title and artist.
  • No API key required (Odesli public tier, ~10 req/min — fine for interactive use).

_is_direct_url(url) -> bool (musicfetch core)

True when the host is YouTube, YouTube Music, or SoundCloud, OR the URL is a playlist per the existing _is_youtube_playlist_url. These route to the existing handle_url. Everything else (that is_url) routes to handle_link.

handle_link(url, root, quality, dry_run, noninteractive, yt_first, limit) (musicfetch core)

  1. r = odesli_resolve(url).
    • If None: print error "Couldn't resolve <url>. Try the direct YouTube/SoundCloud link." and stop. No silent yt-dlp fallback — Spotify et al. aren't downloadable by yt-dlp, so a fallback would just fail confusingly.
  2. query = f"{r.artist} - {r.title}".strip(" -").
  3. Lidarr hits ← existing Lidarr search for query (lidarr_search, which resolves Artist-Track → MusicBrainz album → Lidarr, filling year).
  4. YouTube hit ← exact track from Odesli, only if r.youtube_url: Hit(source="youtube", kind="track", title=r.title, artist=r.artist, payload={"url": r.youtube_url}). (Not a fuzzy YouTube re-search — it's the same track the user shared.) act_youtube_track_url already prefers payload["url"].
  5. hits = lidarr_hits + ([yt_hit] if yt_hit else []).
    • If empty → error "No Lidarr or YouTube source found for <query>.".
  6. chosen = pick(hits, query, noninteractive, yt_first); then the existing dispatch (mirrors main()): Lidarr album → act_lidarr_album; if no indexer release, fall through to the YouTube hit; Lidarr artist → act_lidarr_artist; YouTube → act_youtube.

Default action when a Lidarr album is found: Lidarr first (auto-pick in noninteractive/server, add + indexer search, fall to exact YouTube only on no release) — consistent with the current text-query/server behavior.

Wiring

CLI main()

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

Server /fetch

In the mf.is_url(q) branch, split on mf._is_direct_url(q):

  • direct → existing probe_url + perform_url_fetch job (unchanged).
  • non-direct → resolve + build hits via a new mf.resolve_link_hits(q, limit) helper returning (query, hits); then reuse the existing combined-search job path: pickactions.perform_fetch(chosen, hits, quality, ROOT) with the normal started/done/failed messages. If resolve_link_hits yields no hits → 404 "No results found for '<url>'."; if Odesli itself fails → 422/404 with the speakable resolve-failure message.

Keep core download/search logic in musicfetch; the server stays a thin reuse layer. resolve_link_hits lives in core so both CLI and server share it (the CLI handle_link is resolve_link_hits + the existing dispatch).

server/mf.py

Export new symbols: _is_direct_url, odesli_resolve, handle_link, resolve_link_hits.

Error handling

  • Odesli miss/failure → single speakable error, no crash, no silent bad fallback.
  • Lidarr unreachable → existing loud-failure logging + YouTube fallback already applies (the exact YouTube hit is in hits).
  • Missing youtube link from Odesli but Lidarr has the album → still works (Lidarr path), just no YouTube fallback for that one.

Out of scope (YAGNI)

  • Album/year enrichment from Odesli (Approach B) — later if tags prove weak.
  • Non-yt/sc playlist links — single-track resolution only for now.
  • Caching Odesli responses.

Tests

  • odesli_resolve: mocked HTTP — full response, missing-youtube-link, missing entity / malformed JSON (→ None), non-200 (→ None).
  • _is_direct_url: youtube / music.youtube / soundcloud / playlist → True; spotify / apple / tidal → False.
  • handle_link / resolve_link_hits: Odesli miss → error/empty; Odesli hit → hits contains Lidarr album(s) + exact YouTube track; Lidarr-first dispatch.