docs: spec for Odesli link resolution (any link -> Lidarr-first, YouTube fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<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 `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:
|
||||
```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 '<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.
|
||||
Reference in New Issue
Block a user