diff --git a/docs/superpowers/specs/2026-06-13-odesli-link-resolution-design.md b/docs/superpowers/specs/2026-06-13-odesli-link-resolution-design.md new file mode 100644 index 0000000..628be37 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-odesli-link-resolution-design.md @@ -0,0 +1,149 @@ +# 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=&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 `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 . 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 ."`. +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: +```python +if is_url(query): + handle_url(query, args.root, args.quality, args.dry_run) + return +``` +with: +```python +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: `pick` → `actions.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 ''."`; 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.