150 lines
6.4 KiB
Markdown
150 lines
6.4 KiB
Markdown
# 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.
|