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:
@@ -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.
|
||||
Reference in New Issue
Block a user