diff --git a/docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md b/docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md new file mode 100644 index 00000000..2cd8ffc7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md @@ -0,0 +1,345 @@ +# M5a — Lidarr connection + search/add proxy + admin shell + +> **Status:** Draft for review · 2026-04-29 +> +> **Sub-plan of:** M5 (Lidarr integration + quarantine workflow). M5 was decomposed into three slices during brainstorming on 2026-04-29: +> +> - **M5a (this spec)** — Lidarr connection + search/add + admin shell. Foundation; ships first. +> - **M5b** — Quarantine workflow (per-user soft-hide, admin resolution UI). Depends on M5a only for the admin shell. +> - **M5c** — Radio "suggested additions" (out-of-library MBIDs surfaced in `/api/radio` responses; SPA add affordance). Depends on M5a's `lidarr_requests` table and add path. +> +> Each ships as its own PR with its own brainstorm/spec/plan cycle. + +## 1. Goal + +Connect Minstrel to a household Lidarr instance, give every user a search-and-request workflow at `/discover`, and give admins a moderation queue at `/admin/requests`. Approved requests fire Lidarr adds synchronously; a background reconciler matches the resulting downloaded tracks back to the originating request when the next library scan picks them up. + +This slice does NOT introduce quarantine, soft-hide, or radio suggested-additions — those are M5b and M5c. + +## 2. Goals and non-goals + +### Goals + +- Operator can connect, configure, test, and disconnect a Lidarr instance from `/admin/integrations` without editing YAML or restarting the server. +- Any authenticated user can search Lidarr at artist / album / track granularity from `/discover`. +- Any authenticated user can submit an add request, which gets persisted to `lidarr_requests` with status `pending`. +- Admin can review pending requests at `/admin/requests`, approve (with optional per-request override of quality profile / root folder) or reject (with optional note). +- Approved requests trigger a synchronous Lidarr add and a library scan. +- A background reconciler worker matches `approved` requests to newly scanned tracks and transitions them to `completed`. +- Hard route gating on `/admin/*` — non-admin users redirected before any admin content loads. + +### Non-goals (this slice) + +- Quarantine workflow, soft-hide on tracks, admin "delete via Lidarr" path. → M5b. +- Radio "suggested additions" surfacing out-of-library similar tracks. → M5c. +- Webhook ingestion from Lidarr (push notifications on download complete). → optional follow-up; the polling reconciler is sufficient for v1. +- "Pending too long" failure detection / requestor notification on stalled adds. → open question, deferred. +- Self-service password reset, OIDC, or any other identity work. → orthogonal. +- Per-user Lidarr accounts. Lidarr is a single household instance. + +## 3. Architecture + +### New Go packages + +- **`internal/lidarr/`** — HTTP client for Lidarr's v1 API. Mirrors `internal/scrobble/listenbrainz/` shape: `Client` struct with `BaseURL`, `APIKey`, `HTTP` fields. Methods: `LookupArtist(ctx, query)`, `LookupAlbum(ctx, query)`, `LookupTrack(ctx, query)`, `AddArtist(ctx, params)`, `AddAlbum(ctx, params)`, `ListQualityProfiles(ctx)`, `ListRootFolders(ctx)`, `Ping(ctx)`. Returns typed structs; never leaks raw JSON to callers. + +- **`internal/lidarrconfig/`** — singleton config service. Reads/writes the `lidarr_config` row, exposes `Get(ctx) (*Config, error)` returning a typed struct, `Save(ctx, *Config) error`. The `Get` method also handles the "config not yet set" case by returning a zero-value `Config{Enabled: false}` so callers can branch cleanly. + +- **`internal/lidarrrequests/`** — request lifecycle service: + - `Service` — `Create(ctx, userID, params)`, `ListPending(ctx)`, `ListByStatus(ctx, status)`, `ListForUser(ctx, userID)`, `Approve(ctx, requestID, adminID, overrides)`, `Reject(ctx, requestID, adminID, notes)`, `Cancel(ctx, requestID, userID)`. `Approve` calls `lidarr.Client.Add*` synchronously and triggers a library scan via the existing scanner package. + - `Reconciler` — background worker analogous to `internal/similarity.Worker`. `Run(ctx)` loop with `tick` interval (default 5 min) calls `tickOnce(ctx)`, which: + 1. SELECTs `lidarr_requests WHERE status = 'approved'` with row limits. + 2. For each, joins against `tracks`/`albums`/`artists` by MBID hierarchy. + 3. Transitions matched rows to `completed`, sets `matched_*_id`, `completed_at`. + +### Wiring + +- `cmd/minstrel/main.go` gains a third worker spin-up (alongside the scrobble and similarity workers). The reconciler skips its work when `lidarr_config.enabled = false`. +- New handler files: `internal/api/lidarr.go` (search proxy), `internal/api/requests.go` (user-facing endpoints), `internal/api/admin/lidarr.go` (config CRUD + profiles/folders lookups), `internal/api/admin/requests.go` (approval queue). +- New middleware: `RequireAdmin` — checks the user resolved by `RequireUser` has `is_admin = true`; 403s with `{"error":"not_authorized"}` otherwise. Mounted on the `/api/admin/*` route group. + +### Data flow — happy path + +1. User opens `/discover`, types query, SPA hits `GET /api/lidarr/search?q=…&kind=artist|album|track`. +2. Handler invokes the appropriate `lidarr.Client.Lookup*`, normalizes the response, returns JSON. +3. User clicks "Request" → `POST /api/requests` with `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`. +4. Handler validates the kind→fields invariant, creates a `lidarr_requests` row with status `pending`, returns 201. +5. Admin opens `/admin/requests`, SPA hits `GET /api/admin/requests?status=pending`. +6. Admin clicks "Approve" → `POST /api/admin/requests/:id/approve` with optional `{quality_profile_id, root_folder_path}`. +7. Handler snapshots the chosen values into the row, calls `lidarr.Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. Returns 200 (or surfaces Lidarr error in 4xx/5xx). +8. Reconciler worker on next tick (≤5 min) sees the `approved` row, joins against `tracks`, finds the new track, transitions to `completed` with `matched_track_id` set. +9. Requester's `/requests` page shows status `completed` with a "Listen" link to the now-playable track. + +### SPA route gating + +- `/admin/*` route group has a `+layout.svelte` (or `+layout.ts`) guard: if `currentUser.is_admin === false`, calls `goto('/')` before child routes load. Page-level gate, not content gating — the operator's instruction. +- Same pattern as the existing auth gate; small extension of the existing layout machinery. + +## 4. Schema + +Migration **0010_lidarr** in two files (`up.sql`, `down.sql`). + +### `lidarr_config` (singleton) + +```sql +CREATE TABLE lidarr_config ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled boolean NOT NULL DEFAULT false, + base_url text, + api_key text, + default_quality_profile_id int, + default_root_folder_path text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO lidarr_config (id, enabled) VALUES (1, false); +``` + +The `CHECK (id = 1)` plus seed row enforces "exactly one row, ever." The Settings UI shows "Connect Lidarr" instead of "Lidarr is connected" when `enabled=false` or `base_url IS NULL`. + +### `lidarr_requests` + +```sql +CREATE TYPE lidarr_request_status AS ENUM ( + 'pending', 'approved', 'rejected', 'completed', 'failed' +); +CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track'); + +CREATE TABLE lidarr_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status lidarr_request_status NOT NULL DEFAULT 'pending', + kind lidarr_request_kind NOT NULL, + + lidarr_artist_mbid text NOT NULL, + lidarr_album_mbid text, + lidarr_track_mbid text, + artist_name text NOT NULL, + album_title text, + track_title text, + + quality_profile_id int, + root_folder_path text, + + decided_at timestamptz, + decided_by uuid REFERENCES users(id) ON DELETE SET NULL, + notes text, + + completed_at timestamptz, + matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL, + matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL, + + requested_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id); +CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status); +CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid); +CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid) + WHERE lidarr_album_mbid IS NOT NULL; +``` + +**Shape notes:** +- Three matched-* FKs (one per kind) instead of polymorphic — clean SQL, `ON DELETE SET NULL` keeps the historical audit even if the matched track gets removed later. +- Track-kind requests still set `lidarr_album_mbid` (because that's what Lidarr actually adds); `lidarr_track_mbid` is preserved for "I requested this song specifically" display. +- `quality_profile_id` and `root_folder_path` are NULL until decision time. When admin approves, the override (or the config default) gets snapshotted into the row. +- `failed` is reserved in the enum for a future reconciler timeout (e.g., "approved >7 days ago, no match"), but no reconciler logic transitions to `failed` in this slice. It's a placeholder for an obvious follow-up. + +**Indexes rationale:** +- `user_id` — `/requests` page scan ("show my requests"). +- `status` — `WHERE status='pending'` for admin queue, `WHERE status='approved'` for reconciler. +- `lidarr_artist_mbid` / `lidarr_album_mbid` — reconciler joins against `tracks`, `albums` by MBID. + +### Down migration + +Drops in reverse: indexes → table → enum types → singleton row deletion (table goes anyway). + +## 5. API surface + +All endpoints under `/api/*`, JSON request/response, `{error: "", message: ""}` envelope on errors. `/api/admin/*` group passes through `RequireAdmin` middleware (403 with `not_authorized` for non-admin tokens). + +### User-facing (any authenticated user) + +| Method | Path | Behavior | +|---|---|---| +| `GET` | `/api/lidarr/search?q=&kind=artist\|album\|track` | Proxies Lidarr lookup. Returns normalized list `[{mbid, name|title, secondary_text, image_url, in_library: bool, requested: bool}]`. The `in_library` and `requested` fields are computed server-side: `in_library` joins against `artists`/`albums`/`tracks` by MBID; `requested` is true when ANY user has a non-`rejected`, non-`failed` `lidarr_requests` row with the same MBID — covers `pending`, `approved`, and `completed`. (`completed` in the DB but `in_library=false` in the response would only happen briefly between Lidarr add and library scan; both flags can be true together — UI prefers `in_library` for that case.) Returns `503 lidarr_disabled` if `lidarr_config.enabled=false`. | +| `POST` | `/api/requests` | Create a request. Body: `{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}`. Server validates kind→required-fields invariant. Returns `201 {request}`. | +| `GET` | `/api/requests` | List the caller's own requests (any status). Ordered by `requested_at desc`. Pagination: `?limit=&before=` cursor. | +| `GET` | `/api/requests/:id` | Single request detail. 404 if not caller's own and caller is not admin. | +| `DELETE` | `/api/requests/:id` | Cancel a still-pending request the caller created. 409 `request_not_pending` if status != `pending`. | + +### Admin-only + +| Method | Path | Behavior | +|---|---|---| +| `GET` | `/api/admin/lidarr/config` | Returns current config. `api_key` masked as `"***"` when set, `null` when unset. | +| `PUT` | `/api/admin/lidarr/config` | Body: `{base_url, api_key, default_quality_profile_id, default_root_folder_path, enabled}`. `api_key`: empty string = leave saved value unchanged; non-empty = update. URL validated. | +| `POST` | `/api/admin/lidarr/test` | Body: `{base_url?, api_key?}`. Each field independently falls back to the saved value when absent or empty. Calls `Client.Ping`. Always returns 200 with envelope `{ok: true, version: "..."}` or `{ok: false, error: "..."}` — never an HTTP-level error envelope, so the SPA can render results uniformly. | +| `GET` | `/api/admin/lidarr/quality-profiles` | Proxies Lidarr's quality profile list — populates the Settings dropdown. | +| `GET` | `/api/admin/lidarr/root-folders` | Proxies Lidarr's root folder list — populates the Settings dropdown. | +| `GET` | `/api/admin/requests?status=pending\|approved\|rejected\|completed\|failed&limit=` | Admin's queue view. Default `status=pending`. | +| `POST` | `/api/admin/requests/:id/approve` | Body: `{quality_profile_id?, root_folder_path?}` (override fields; absent = use config default). Snapshots chosen values, calls `Client.AddArtist|AddAlbum`, transitions to `approved`, fires scan trigger. | +| `POST` | `/api/admin/requests/:id/reject` | Body: `{notes?}`. Sets status `rejected`, records `decided_*` and `notes`. | + +### Error codes + +`lidarr_disabled`, `lidarr_unreachable`, `lidarr_auth_failed`, `lidarr_lookup_failed`, `mbid_required`, `request_not_pending`, `request_not_found`, `not_authorized`. + +## 6. UI surfaces + +All four screens land at the FabledSword design system bar (memory: `project_design_system.md`). Tokens are referenced by name; concrete values live in the design-system memory. Mockups produced during brainstorming live in `.superpowers/brainstorm//content/` (gitignored) — `discover-fs-v2.html`, `admin-integrations.html`, `admin-requests.html`, `user-requests.html`. + +### `/discover` (user-facing) + +Search input at top, kind tabs (Artists / Albums / Tracks), card grid of results. Card state derives from the search response's `in_library` and `requested` flags: + +- **Kept** (`in_library=true`) — wins over `requested`. Disabled ghost button "In library", "Kept" pill in the badge slot (forest-teal at 15% opacity bg). +- **Requested** (`in_library=false && requested=true`) — disabled ghost button "Requested", subtitle line shows "awaiting review" for pending requests, "downloading" for approved. +- **Requestable** (`in_library=false && requested=false`) — Moss `Request` button with plus icon. + +Card layout discipline: +- Card body is `display: flex; flex-direction: column`. Inside, a `.text` block has `min-height` reserving title + meta + badge-row even when fields are absent. Actions block uses `margin-top: auto` so the button always anchors to the bottom of the card. +- `.badge-row` reserves 22px regardless of badge presence — title sits at the same Y across cards. +- Grid `align-items: stretch` keeps cards on the same row equal-height. + +Implementation: `` Svelte component with props `{kind, artistName, albumTitle?, trackTitle?, imageUrl?, state: 'requestable'|'kept'|'requested', onRequest}`. Reused for any future "list of music things" surface. + +Track-kind result requests open a confirmation modal: "Requesting *Track X* will add the album *Album Y*. Continue?" Confirm = Moss, Cancel = Bronze. Disclosure is explicit, not silent. + +### `/admin` shell + sidebar + +`/admin/*` routes share `+layout.svelte`: +- Role gate: `if (!currentUser.is_admin) goto('/')` before child routes load. +- 220px sidebar with Iron card surface. Active item: 12% accent-tinted background, 2px forest-teal left strip ("you are here"). +- Nav items (this slice): Overview · **Integrations** · **Requests** · Quarantine (placeholder for M5b) · Users (future) · Library (future). + +Lucide icons at 16px, 1px stroke. Sidebar text: Vellum default, Parchment on active. + +### `/admin/integrations` + +Lidarr panel (single section in this slice; designed to host more integrations later): +- Header status pill: "Lidarr · connected" (Moss-tinted) when `enabled && reachable`, "unset" (Pewter ghost) otherwise. +- Form rows: Base URL · API key (masked) · Default quality profile (dropdown) · Default root folder (dropdown). +- Action row: **Save changes** = Moss, **Test connection** = Pewter ghost, **Disconnect** = Oxblood + trash icon (right-aligned, separated). +- Inputs on Obsidian (inset feel), 0.5px Pewter borders, 8px radius, focus = `box-shadow: 0 0 0 2px var(--fs-accent)` (no layout shift). + +A second placeholder section for "MusicBrainz overrides" with status `unset` foreshadows the panel's role as the integration hub. Not implemented in this slice. + +### `/admin/requests` + +Tabbed list (Pending / Approved / Completed / Rejected). Tab counts as accent-tinted pills. Default tab `Pending` with badge showing count. + +Row anatomy: 56px album-art square (Slate fallback when Lidarr returns no cover) · meta-row with kind pill + "requested by alice · 2h ago" small caps · title in Parchment · meta in Vellum · action cluster: **Override** (Pewter ghost, opens modal) → **Approve** (Moss + check icon) → **Reject** (Bronze + ✕ icon). + +Override modal: collapsed-by-default override of `quality_profile_id` and `root_folder_path` for the specific approval. Most approvals click "Approve" without opening this. + +Track-kind row's meta line spells out "Approving will add the album *Geogaddi*" — explicit disclosure of the album-promotion behavior. + +### `/requests` (user's own) + +Single panel listing the caller's requests, ordered by `requested_at desc`. Row anatomy mirrors `/admin/requests` but action cluster is reduced: +- **Pending** → Cancel (Pewter ghost + ✕ icon). +- **Approved** → no actions, "Approved · downloading" status pill (Info-tinted). +- **Completed** → "Listen" link in forest-teal (the page's only brand-moment), navigates to the matched track. +- **Rejected** → no actions, admin's note rendered as Vellum meta when present. + +Status pills use the doc's semantic palette (Warning · Info · Moss/Success · Error). Voice rule applied: "Kept" instead of "Completed", "Set aside" instead of "Rejected", "Awaiting review" instead of "Pending review." + +## 7. Error handling + +### Lidarr unreachable / auth-failed + +- `Client.*` methods return typed errors: `lidarr.ErrUnreachable`, `lidarr.ErrAuthFailed`, `lidarr.ErrLookupFailed`. +- Search proxy translates to `503 lidarr_unreachable` or `401 lidarr_auth_failed` — the SPA shows a callout: "Lidarr is unreachable right now. Try again, or check Settings → Integrations." (Voice rule: this is an error/waiting moment, gets the flavored register.) +- Admin approval handler same pattern: returns the error to admin so they can retry without losing the request. The request stays `pending` if the Lidarr call fails — never advances to `approved` without confirmation Lidarr accepted the add. + +### Test connection from Settings + +- `POST /api/admin/lidarr/test` always returns 200 with `{ok: bool, error?: string, version?: string}` — never an error envelope. Lets the SPA always render the result without parsing HTTP-level errors. + +### Reconciler + +- Worker errors logged at `WARN`, never propagated. A failing tick doesn't stop the worker — next tick retries. +- If `lidarr_config.enabled = false`, worker silently no-ops each tick. +- Postgres unavailability is a global concern; reconciler errors with the same backoff pattern as `internal/similarity.Worker`. + +### Form validation + +- Settings: URL must parse, API key must be non-empty if `enabled=true`. Quality profile + root folder must be present and known to Lidarr (validated against `Client.ListQualityProfiles` / `ListRootFolders`). +- Request creation: kind→required-MBID-fields invariant enforced server-side. SPA validates client-side first to avoid round-trips. + +## 8. Testing + +### Unit tests + +- `internal/lidarr/` — table-driven request/response parsing tests against canned fixtures (real Lidarr response samples committed under `internal/lidarr/testdata/`). Covers happy path + auth-failure + 5xx + bad-JSON for each method. +- `internal/lidarrconfig/` — `Get` returns sensible zero-value when row says `enabled=false`; `Save` updates `updated_at`. +- `internal/lidarrrequests.Service` — pure-logic tests: kind→required-fields validation, status-transition validation (can only approve `pending`, can only cancel `pending`, etc.). + +### Integration tests (gated on `MINSTREL_TEST_DATABASE_URL`) + +- Reconciler — seeds an `approved` request + a matching track row, runs `tickOnce`, asserts status transitions to `completed` with correct `matched_*_id`. Covers: + - artist-kind matched by `lidarr_artist_mbid` against `artists.mbid` + - album-kind matched by `lidarr_album_mbid` against `albums.mbid` + - track-kind matched by `lidarr_album_mbid` (track-promoted) against `albums.mbid` + - no match (no transition) + - already-completed row not re-processed + - `lidarr_config.enabled=false` short-circuits to no-op + +### HTTP tests (handler level) + +- `internal/api/lidarr.go` — search proxy with stubbed `Client`: 200 happy path, 503 disabled, 503 unreachable, 401 auth-failed. +- `internal/api/requests.go` — create with valid + invalid kind/MBID combinations; list-mine returns only caller's rows; cancel pending vs cancel non-pending; cross-user 404. +- `internal/api/admin/lidarr.go` — config GET masks api_key; PUT empty-string preserves api_key; test-connection always returns 200 envelope. +- `internal/api/admin/requests.go` — approve fires Lidarr stub, transitions row, captures defaults from config; approve with override snapshots override values; reject without notes works; non-admin 403 across the board. + +### Frontend tests (vitest) + +- `` — renders three states correctly; calls `onRequest` only in requestable state; reserved-slot CSS verified by computed-style assertion (badge-row min-height, button anchored). +- `/discover` page — debounce search input; renders results from store; transitions state on request submit; track-kind opens confirmation modal; modal Confirm calls API, modal Cancel does not. +- `/admin/requests` page — tab switch refetches; approve fires API and removes row from pending; override modal returns chosen values to approve handler; admin-only redirect verified at layout level. +- `/admin/integrations` panel — empty-state copy; test-connection updates status pill; Disconnect requires confirmation. +- `/requests` page — status pills render with correct semantic class; Cancel works on pending; "Listen" link only renders on completed rows. + +### Coverage target + +- `internal/lidarr/` ≥ 80% +- `internal/lidarrrequests/` ≥ 80% +- `internal/api/lidarr.go`, `internal/api/requests.go`, `internal/api/admin/*` — handler coverage measured combined ≥ 70% (matches current api package threshold) + +## 9. Decisions ledger + +| # | Decision | Rationale | +|---|---|---| +| 1 | Decompose M5 into M5a / M5b / M5c | Matches the M4 cadence; smaller PRs, faster review, clearer scope per slice | +| 2 | Permissions: search-all, add-admin (request queue) | Operator's call — "browse-and-suggest workflow lets non-trusted household members participate without giving them library-write access" | +| 3 | Config storage: DB-only, Settings UI as the entry point | Operator's product principle — "no one wants to configure yamls; this is a finished product, not a project" (memory: `project_product_not_project.md`) | +| 4 | Settings shape: dedicated `/admin/*` route group with hard route gate | Per-app product surface for admin actions; redirect at layout-level, never load admin content for non-admin (operator's instruction) | +| 5 | Search UX: standalone `/discover` route (option B), not inline-in-search | Operator preference — explicit "I want to add music" surface, separate from local-library search | +| 6 | Granularity: artist + album + track | Operator preference — track-kind resolves to album-kind under the hood (Lidarr's monitor unit is album), explicit modal disclosure rather than silent expansion | +| 7 | Lifecycle detection: library scan as source of truth | Reuses existing scanner; reconciler is a 5-min worker; webhook is a clean follow-up if latency matters | +| 8 | Quality profile / root folder: default + per-add override | Default covers >90% of approvals; override is the escape hatch. Modal is collapsed-by-default | +| 9 | Approve fires Lidarr synchronously, reconcile asynchronously (Approach 1) | Admin gets immediate "Lidarr accepted/rejected" feedback; reconciliation has to be async because downloads take minutes-to-hours | +| 10 | New `RequireAdmin` middleware on `/api/admin/*` route group | Centralized auth check; SPA route gate is UX, server middleware is the security boundary | +| 11 | UI lands at FabledSword design system bar (memory: `project_design_system.md`) | Operator's quality bar — "no more scaffolding-feel UI" (memory: `project_ui_quality.md`); accent only for brand-moments, Moss/Bronze/Oxblood for actions | +| 12 | Track-kind disclosure modal | Lidarr can't fetch a single track without its album; explicit "this will add the album" beats silently expanding the request | +| 13 | `lidarr_requests.failed` status reserved but not transitioned in this slice | Foreshadows a "stalled approval" timeout follow-up; not v1 | + +## 10. Out of scope (this slice) + +Tracked in the M5 milestone for later sub-plans: + +- **Quarantine workflow** — `lidarr_quarantine` table, soft-hide on tracks, admin "delete via Lidarr" path, `/admin/quarantine` page. → M5b. +- **Radio suggested-additions** — `/api/radio` response includes a separate field for out-of-library MBIDs from `track_similarity`; SPA shows "Would you add these?" affordance with inline request submission. → M5c. +- **Lidarr webhook ingestion** — push notifications on download complete; near-real-time status updates on `/requests`. → cheap follow-up after M5c. +- **Failed-request timeout** — reconciler transitions long-stuck `approved` requests to `failed` with operator-facing diagnostics. → operational tightening; not v1. +- **Admin requestor notifications** — toast/badge when a user logs in if their request was approved/completed/rejected. → polish, slot into Fable #349 (UI polish pass) or earlier if it becomes friction. + +## 11. Open questions + +- **Album cover art proxying** — Lidarr returns image URLs that point at MusicBrainz/Cover Art Archive. The SPA could fetch directly (extra origins, CORS), or Minstrel could proxy them through `/api/cover-art?lidarr=...`. **Decision deferred to plan time** — start with direct fetch, add proxy if CORS bites. +- **Search debounce / cache** — Lidarr's lookup endpoint is the rate-limit-sensitive one. SPA debounce of 250ms + 60s server-side LRU cache on `(query, kind)` is the conservative starting point. Tunable. +- **`failed` status promotion** — at what time threshold does `approved` → `failed`? Suggest 7 days, but no logic for it ships in this slice.