feat: rework Lidarr library integration

This commit is contained in:
2026-10-04 20:28:24 -07:00
parent 880c2217b1
commit b3e6f66097
17 changed files with 1839 additions and 127 deletions

View 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.