feat: rework Lidarr library integration
This commit is contained in:
126
docs/superpowers/specs/2026-09-18-lidarr-rework-design.md
Normal file
126
docs/superpowers/specs/2026-09-18-lidarr-rework-design.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Lidarr Rework — Design
|
||||
|
||||
**Date:** 2026-09-18
|
||||
**Status:** Implemented
|
||||
|
||||
## Context & Goal
|
||||
|
||||
The Lidarr side of MusicFetch said things that were not true, and it always
|
||||
tried to add an artist that the library already held. It also let Lidarr decide
|
||||
which indexer release to grab. This rework makes the Lidarr path honest,
|
||||
library-aware, and able to ask the user which release to take.
|
||||
|
||||
## Bugs this fixes
|
||||
|
||||
1. **Wrong message.** `done_message` used the hit's display title. An artist hit
|
||||
puts the artist name in both slots, so the API said
|
||||
`Finished downloading 'Michael Jackson' by Michael Jackson.` An artist add
|
||||
also downloads nothing, so "Finished downloading" was wrong twice.
|
||||
2. **Always a duplicate artist.** `act_lidarr_artist` posted `/api/v1/artist`
|
||||
without a library check. `get_existing_artist` existed but only ran inside
|
||||
`ensure_album_in_library`. Lidarr then rejected the duplicate and the job
|
||||
failed.
|
||||
3. **The library was invisible to the search.** A bare name went to
|
||||
`/api/v1/artist/lookup`, which searches MusicBrainz, not the local library.
|
||||
4. **Silent wrong album.** `ensure_album_in_library` returned `albums[0]` when no
|
||||
title matched, so MusicFetch could grab an unrelated album.
|
||||
5. **Race after an artist add.** Lidarr fills an artist's album list after the
|
||||
POST returns. The old code read the list at once, found nothing, and hit the
|
||||
`albums[0]` path above.
|
||||
6. **A double search.** The code ran `/api/v1/release` (Lidarr's interactive
|
||||
search, up to 90 s), counted the results, threw the list away, then ran the
|
||||
`AlbumSearch` command, which searched again and picked on its own.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **musicfetch stays a standalone single-file script.** All new Lidarr logic
|
||||
lives in it. The API imports it, as before.
|
||||
- **Match an artist by MusicBrainz id first, then by a normalised name.**
|
||||
`find_library_artist` reads `/api/v1/artist` (the library), not the metadata
|
||||
lookup. `ensure_artist` returns a state: `existing`, `added` or `failed`.
|
||||
- **Match an album by MusicBrainz id, then exact title, then title without an
|
||||
edition suffix. Never by position.** No match returns `None`.
|
||||
- **Poll for the album list after an artist add** (up to 40 s, every 2 s).
|
||||
- **Monitor the album before a grab.** Lidarr refuses a grab for an unmonitored
|
||||
album.
|
||||
- **Grab one exact release.** `POST /api/v1/release {guid, indexerId}` replaces
|
||||
the `AlbumSearch` command, so the user's pick is the release that downloads.
|
||||
The `AlbumSearch` helper is gone — it had no caller left.
|
||||
- **Every message states the real outcome**: `artist_added`, `artist_existing`,
|
||||
`album_grabbed`, `youtube_fallback`, `no_release`.
|
||||
|
||||
## One endpoint
|
||||
|
||||
`POST /fetch` is the only entry point. It decides from `q`:
|
||||
|
||||
- **A URL is a direct grab.** No prompt. `auto=True` is forced.
|
||||
- **Free text goes through Lidarr**, then offers the releases.
|
||||
|
||||
`source` still narrows it: `youtube` skips Lidarr, `lidarr` drops the YouTube
|
||||
fallback.
|
||||
|
||||
A separate `/lidarr/find` was rejected. Two endpoints that both mean "get me
|
||||
this" is a choice the caller should not have to make, and a Shortcut would have
|
||||
to branch on whether the input looks like a URL. `/fetch` already knew how to
|
||||
tell them apart.
|
||||
|
||||
## The two-step choose flow
|
||||
|
||||
Lidarr's interactive search takes 10–90 s. A Siri Shortcut cannot hold an HTTP
|
||||
request open that long, so the search is part of the job and the client polls it.
|
||||
|
||||
```
|
||||
POST /fetch?q=Overgrown - Lay Cold
|
||||
-> {"job_id": "a1b2c3", "status": "queued", "hit": {...}} (returns at once)
|
||||
|
||||
worker: ensure_artist -> ensure_album -> monitor
|
||||
-> list_releases (interactive search)
|
||||
|
||||
GET /jobs/a1b2c3
|
||||
-> {"status": "awaiting_choice",
|
||||
"message": "Overgrown is already in Lidarr. I found 3 releases ... Which one?",
|
||||
"choices": [{"n": 1, "label": "FLAC, 312 MB, 24 seeders, Redacted",
|
||||
"pick_label": "1. FLAC, 312 MB, ...", ...}]}
|
||||
|
||||
POST /choose?job_id=a1b2c3&n=1
|
||||
-> grab_release(guid, indexerId) -> the download client
|
||||
```
|
||||
|
||||
The hit resolution stays in the request, so a query that finds nothing is still
|
||||
a `404` and the response still carries the `hit` for Siri to read back. Only the
|
||||
slow part — ensure, monitor, interactive search — moves into the job.
|
||||
|
||||
A job parks at `awaiting_choice` and holds the raw releases server-side. The
|
||||
client only ever sees `n`, a label and a numbered `pick_label`, so no `guid`
|
||||
leaves the server. A parked job expires after `MUSICFETCH_CHOICE_TTL` seconds
|
||||
(default 900). `?auto=true` grabs the best release and skips the prompt.
|
||||
|
||||
No indexer release means the job never parks. It falls back to the top YouTube
|
||||
hit, as the CLI does, unless `source=lidarr`.
|
||||
|
||||
`MUSICFETCH_MAX_CHOICES` (default 10) caps the list, because a spoken list must
|
||||
stay short. Order is: usable releases first, then most seeders, then largest
|
||||
file.
|
||||
|
||||
## Failure text
|
||||
|
||||
`actions.FetchError` marks a failure whose text is written for a person to hear
|
||||
("Overgrown is in Lidarr, but it has no album called 'Lay Cold'."). The job
|
||||
message uses it. Any other exception falls back to the generic
|
||||
`failed_message`, because a yt-dlp stack trace is not something Siri should read
|
||||
out.
|
||||
|
||||
## CLI parity
|
||||
|
||||
`musicfetch --pick-release` shows the same releases in a table and asks which
|
||||
one. It shares `list_releases`, `release_label` and `grab_release` with the API,
|
||||
so the two paths cannot drift.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Keep `AlbumSearch` after the interactive search.** It ignores the user's
|
||||
pick and searches twice.
|
||||
- **A synchronous search endpoint.** The interactive search is too slow for a
|
||||
Shortcut's HTTP action.
|
||||
- **A separate store for pending choices.** The job store already has the right
|
||||
shape, a TTL and an eviction cap.
|
||||
Reference in New Issue
Block a user