docs(spec): add M5a Lidarr connection + search/add design

M5a is the foundation slice of M5 (Lidarr integration + quarantine).
Decomposed into M5a/M5b/M5c during brainstorming on 2026-04-29:

- M5a (this spec) — Lidarr connection + search/add + admin shell
- M5b — Quarantine workflow (per-user soft-hide, admin resolution)
- M5c — Radio suggested-additions (out-of-library MBIDs surfaced)

Each ships as its own PR with its own brainstorm/spec/plan cycle.

Key decisions captured:
- Permissions: search-all, add-admin via request queue
- Config: DB-only, Settings UI is the only entry point (no YAML)
- Settings shape: dedicated /admin/* route group, hard route gate
- Search UX: standalone /discover route, all three granularities
- Lifecycle: library scan as source of truth + 5-min reconciler worker
- Quality profile/root folder: default + per-add admin override
- UI lands at FabledSword design system bar (forest-teal accent;
  Moss/Bronze/Oxblood action buttons; per project_design_system memory)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 14:25:08 -04:00
parent 2238a8a209
commit 17406eebee
@@ -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: "<code>", message: "<human>"}` 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/<session>/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: `<DiscoverResultCard>` 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)
- `<DiscoverResultCard>` — 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.