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"]].title←entity["title"]artist←entity["artistName"]thumb←entity.get("thumbnailUrl", "")youtube_url←data["linksByPlatform"]["youtubeMusic"]["url"], else["youtube"]["url"], else"".
- Returns a small
Resolvedstruct (or dict) with the above. - Returns
Noneon any failure: network error, non-200, JSON error, missingentityUniqueId/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)
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.
- If
query = f"{r.artist} - {r.title}".strip(" -").- Lidarr hits ← existing Lidarr search for
query(lidarr_search, which resolves Artist-Track → MusicBrainz album → Lidarr, filling year). - 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_urlalready preferspayload["url"]. hits = lidarr_hits + ([yt_hit] if yt_hit else []).- If empty → error
"No Lidarr or YouTube source found for <query>.".
- If empty → error
chosen = pick(hits, query, noninteractive, yt_first); then the existing dispatch (mirrorsmain()): 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_fetchjob (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:pick→actions.perform_fetch(chosen, hits, quality, ROOT)with the normal started/done/failed messages. Ifresolve_link_hitsyields 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 →hitscontains Lidarr album(s) + exact YouTube track; Lidarr-first dispatch.