diff --git a/docs/superpowers/plans/2026-04-29-m5a-lidarr.md b/docs/superpowers/plans/2026-04-29-m5a-lidarr.md
new file mode 100644
index 00000000..f14e7cf0
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-29-m5a-lidarr.md
@@ -0,0 +1,1979 @@
+# M5a — Lidarr connection + search/add + admin shell — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Wire Minstrel to a household Lidarr instance — search at `/discover`, request from any user, admin moderation queue at `/admin/requests`, automatic reconciliation when downloaded tracks land in the library.
+
+**Architecture:** New `internal/lidarr` HTTP client (typed, mirrors `internal/scrobble/listenbrainz`); `internal/lidarrconfig` singleton service backed by a CHECK-constrained DB row; `internal/lidarrrequests` with synchronous `Service` (Approve calls Lidarr, fires scan) plus async `Reconciler` worker (5-min tick, joins approved requests against new tracks by MBID); new `RequireAdmin` middleware on a dedicated `/api/admin/*` route group; SPA gets `/discover`, `/requests`, `/admin/integrations`, `/admin/requests` with hard route-level role gate, all surfaces drawn against the FabledSword design system.
+
+**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (Obsidian/Iron surfaces, Moss/Bronze/Oxblood actions, forest-teal #4A6B5C accent, Fraunces ≥18px / Inter / JetBrains Mono).
+
+**Spec:** [`docs/superpowers/specs/2026-04-29-m5a-lidarr-design.md`](../specs/2026-04-29-m5a-lidarr-design.md). Read it before starting — every decision is explained there.
+
+**Memory dependencies:** `project_design_system.md` (token palette + voice rules), `project_product_not_project.md` (no YAML for feature config), `project_ui_quality.md` (no scaffolding-feel), `project_subsonic_legacy.md` (`/api/*` is primary), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI).
+
+---
+
+## File map
+
+### Backend — create
+
+- `internal/db/migrations/0010_lidarr.up.sql` · `0010_lidarr.down.sql` — schema
+- `internal/db/queries/lidarr_config.sql` — sqlc queries for the singleton
+- `internal/db/queries/lidarr_requests.sql` — sqlc queries for requests
+- `internal/lidarr/client.go` — Lidarr HTTP client (`Client` struct + methods)
+- `internal/lidarr/types.go` — typed request/response structs
+- `internal/lidarr/errors.go` — typed sentinel errors
+- `internal/lidarr/client_test.go` — unit tests with `httptest` stubs + JSON fixtures
+- `internal/lidarr/testdata/*.json` — captured Lidarr responses
+- `internal/lidarrconfig/service.go` — singleton config wrapper
+- `internal/lidarrconfig/service_test.go` — integration test against `MINSTREL_TEST_DATABASE_URL`
+- `internal/lidarrrequests/service.go` — request lifecycle service
+- `internal/lidarrrequests/service_test.go` — integration test
+- `internal/lidarrrequests/reconciler.go` — background worker
+- `internal/lidarrrequests/reconciler_integration_test.go` — integration test
+- `internal/auth/admin.go` — `RequireAdmin` middleware
+- `internal/auth/admin_test.go` — middleware tests
+- `internal/api/lidarr.go` — `GET /api/lidarr/search` proxy
+- `internal/api/lidarr_test.go`
+- `internal/api/requests.go` — `/api/requests` user-facing CRUD
+- `internal/api/requests_test.go`
+- `internal/api/admin_lidarr.go` — `/api/admin/lidarr/*` (config CRUD + test + profiles + folders)
+- `internal/api/admin_lidarr_test.go`
+- `internal/api/admin_requests.go` — `/api/admin/requests/*` approval queue
+- `internal/api/admin_requests_test.go`
+
+### Backend — modify
+
+- `internal/api/api.go` — register routes, mount `/api/admin` group
+- `internal/api/auth_test.go` — extend `testHandlers` to inject Lidarr client + lidarrrequests service
+- `cmd/minstrel/main.go` — wire `lidarrrequests.Reconciler` worker
+- `internal/db/dbq/*` — regenerated by `sqlc generate`
+
+### Frontend — create
+
+- `web/src/lib/styles/fabledsword-tokens.css` — `:root` CSS custom properties for all FS tokens
+- `web/tailwind.config.js` — extend theme to alias semantic Tailwind utilities to FS tokens (modify, not create — but this slice may need to drop the existing alias defaults)
+- `web/src/lib/api/lidarr.ts` — search client
+- `web/src/lib/api/requests.ts` — request CRUD client
+- `web/src/lib/api/admin.ts` — admin endpoints client
+- `web/src/lib/components/DiscoverResultCard.svelte` — card with reserved badge slot + anchored button
+- `web/src/lib/components/DiscoverResultCard.test.ts`
+- `web/src/lib/components/StatusPill.svelte` — semantic status pill (Pending/Approved/Completed/Rejected)
+- `web/src/lib/components/StatusPill.test.ts`
+- `web/src/lib/components/AdminSidebar.svelte` — admin nav rail
+- `web/src/routes/discover/+page.svelte`
+- `web/src/routes/discover/discover.test.ts`
+- `web/src/routes/requests/+page.svelte`
+- `web/src/routes/requests/requests.test.ts`
+- `web/src/routes/admin/+layout.svelte` — admin shell + sidebar
+- `web/src/routes/admin/+layout.ts` — role gate (load function)
+- `web/src/routes/admin/+page.svelte` — overview landing
+- `web/src/routes/admin/integrations/+page.svelte`
+- `web/src/routes/admin/integrations/integrations.test.ts`
+- `web/src/routes/admin/requests/+page.svelte`
+- `web/src/routes/admin/requests/requests.test.ts`
+
+### Frontend — modify
+
+- `web/src/lib/components/Shell.svelte` — add `/discover` to nav, conditional `/admin` link for admins
+- `web/src/app.css` (or equivalent) — import the tokens file
+- `web/src/app.html` — `` Google Fonts (Fraunces / Inter / JetBrains Mono)
+
+---
+
+## Task list
+
+### Task 1 — Migration 0010 + sqlc queries
+
+**Files:**
+- Create: `internal/db/migrations/0010_lidarr.up.sql`
+- Create: `internal/db/migrations/0010_lidarr.down.sql`
+- Create: `internal/db/queries/lidarr_config.sql`
+- Create: `internal/db/queries/lidarr_requests.sql`
+- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`)
+
+- [ ] **Step 1.1: Write the up migration**
+
+`internal/db/migrations/0010_lidarr.up.sql`:
+
+```sql
+-- M5a: Lidarr integration foundation. Two tables:
+--
+-- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr
+-- connection. enabled=false is the unconfigured state.
+--
+-- lidarr_requests — per-request lifecycle row created by users at
+-- /discover, transitioned by admin at /admin/requests, and matched
+-- back to library tracks by the reconciler worker. Three matched_*_id
+-- FKs (one per kind) instead of polymorphic — clean SQL, ON DELETE
+-- SET NULL preserves audit even if the matched track is later removed.
+
+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);
+
+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;
+```
+
+- [ ] **Step 1.2: Write the down migration**
+
+`internal/db/migrations/0010_lidarr.down.sql`:
+
+```sql
+DROP INDEX IF EXISTS lidarr_requests_album_mbid_idx;
+DROP INDEX IF EXISTS lidarr_requests_artist_mbid_idx;
+DROP INDEX IF EXISTS lidarr_requests_status_idx;
+DROP INDEX IF EXISTS lidarr_requests_user_id_idx;
+DROP TABLE IF EXISTS lidarr_requests;
+DROP TYPE IF EXISTS lidarr_request_kind;
+DROP TYPE IF EXISTS lidarr_request_status;
+DROP TABLE IF EXISTS lidarr_config;
+```
+
+- [ ] **Step 1.3: Apply migration locally to confirm it runs**
+
+```bash
+docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_requests; DROP TYPE IF EXISTS lidarr_request_kind; DROP TYPE IF EXISTS lidarr_request_status; DROP TABLE IF EXISTS lidarr_config;"
+go run ./cmd/minstrel/migrate.go 2>/dev/null || go run ./cmd/minstrel up
+docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_requests"
+```
+
+Expected: `\d lidarr_requests` shows the table with all columns and the four indexes.
+
+If your project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead.
+
+- [ ] **Step 1.4: Write `lidarr_config.sql` queries**
+
+`internal/db/queries/lidarr_config.sql`:
+
+```sql
+-- name: GetLidarrConfig :one
+SELECT id, enabled, base_url, api_key, default_quality_profile_id,
+ default_root_folder_path, created_at, updated_at
+FROM lidarr_config
+WHERE id = 1;
+
+-- name: UpdateLidarrConfig :one
+UPDATE lidarr_config
+ SET enabled = $1,
+ base_url = $2,
+ api_key = $3,
+ default_quality_profile_id = $4,
+ default_root_folder_path = $5,
+ updated_at = now()
+ WHERE id = 1
+ RETURNING id, enabled, base_url, api_key, default_quality_profile_id,
+ default_root_folder_path, created_at, updated_at;
+```
+
+- [ ] **Step 1.5: Write `lidarr_requests.sql` queries**
+
+`internal/db/queries/lidarr_requests.sql`:
+
+```sql
+-- name: CreateLidarrRequest :one
+INSERT INTO lidarr_requests (
+ user_id, kind,
+ lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
+ artist_name, album_title, track_title
+) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+RETURNING *;
+
+-- name: GetLidarrRequestByID :one
+SELECT * FROM lidarr_requests WHERE id = $1;
+
+-- name: ListLidarrRequestsForUser :many
+SELECT * FROM lidarr_requests
+WHERE user_id = $1
+ORDER BY requested_at DESC
+LIMIT $2;
+
+-- name: ListLidarrRequestsByStatus :many
+SELECT * FROM lidarr_requests
+WHERE status = $1
+ORDER BY requested_at DESC
+LIMIT $2;
+
+-- name: ListApprovedLidarrRequestsForReconcile :many
+SELECT * FROM lidarr_requests
+WHERE status = 'approved'
+ORDER BY decided_at ASC
+LIMIT $1;
+
+-- name: ApproveLidarrRequest :one
+UPDATE lidarr_requests
+ SET status = 'approved',
+ quality_profile_id = $2,
+ root_folder_path = $3,
+ decided_at = now(),
+ decided_by = $4,
+ updated_at = now()
+ WHERE id = $1 AND status = 'pending'
+ RETURNING *;
+
+-- name: RejectLidarrRequest :one
+UPDATE lidarr_requests
+ SET status = 'rejected',
+ notes = $2,
+ decided_at = now(),
+ decided_by = $3,
+ updated_at = now()
+ WHERE id = $1 AND status = 'pending'
+ RETURNING *;
+
+-- name: CancelLidarrRequest :one
+UPDATE lidarr_requests
+ SET status = 'rejected',
+ notes = 'cancelled by user',
+ decided_at = now(),
+ decided_by = $2,
+ updated_at = now()
+ WHERE id = $1 AND user_id = $2 AND status = 'pending'
+ RETURNING *;
+
+-- name: CompleteLidarrRequest :one
+-- Reconciler transitions an approved request to completed when its
+-- target track/album/artist has appeared in the library.
+UPDATE lidarr_requests
+ SET status = 'completed',
+ matched_track_id = $2,
+ matched_album_id = $3,
+ matched_artist_id = $4,
+ completed_at = now(),
+ updated_at = now()
+ WHERE id = $1 AND status = 'approved'
+ RETURNING *;
+
+-- name: HasNonTerminalRequestForMBID :one
+-- Returns true if any user has a pending/approved/completed request
+-- whose MBID matches at the given level. Used to set the `requested`
+-- flag on /api/lidarr/search responses. Terminal-status (rejected,
+-- failed) rows do not count.
+SELECT EXISTS (
+ SELECT 1 FROM lidarr_requests
+ WHERE status IN ('pending', 'approved', 'completed')
+ AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
+ OR (kind = 'album' AND lidarr_album_mbid = $1)
+ OR (kind = 'track' AND lidarr_track_mbid = $1))
+);
+```
+
+- [ ] **Step 1.6: Run sqlc generate**
+
+```bash
+cd internal/db && sqlc generate && cd -
+go build ./...
+```
+
+Expected: `internal/db/dbq/lidarr_config.sql.go` and `internal/db/dbq/lidarr_requests.sql.go` are created. Build succeeds.
+
+- [ ] **Step 1.7: Commit**
+
+```bash
+git add internal/db/migrations/0010_lidarr.up.sql \
+ internal/db/migrations/0010_lidarr.down.sql \
+ internal/db/queries/lidarr_config.sql \
+ internal/db/queries/lidarr_requests.sql \
+ internal/db/dbq/
+git commit -m "feat(db): add lidarr_config + lidarr_requests schema (migration 0010)"
+```
+
+---
+
+### Task 2 — Lidarr HTTP client
+
+**Files:**
+- Create: `internal/lidarr/types.go`
+- Create: `internal/lidarr/errors.go`
+- Create: `internal/lidarr/client.go`
+- Create: `internal/lidarr/client_test.go`
+- Create: `internal/lidarr/testdata/lookup_artist.json`
+- Create: `internal/lidarr/testdata/quality_profiles.json`
+- Create: `internal/lidarr/testdata/root_folders.json`
+
+- [ ] **Step 2.1: Write the typed structs**
+
+`internal/lidarr/types.go`:
+
+```go
+// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the
+// only place in the codebase that knows about Lidarr's wire format.
+// Callers receive value structs, never raw JSON.
+package lidarr
+
+// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}.
+// It is what we store on the request row (via the user's request) and
+// what /api/lidarr/search returns to the SPA.
+type LookupResult struct {
+ MBID string // foreignArtistId / foreignAlbumId / foreignTrackId
+ Name string // artist name; album/track returns Title here too
+ Secondary string // genre + album count for artist; year for album; album for track
+ ImageURL string // cover-art URL Lidarr surfaced (may be empty)
+}
+
+// QualityProfile is the dropdown choice in /admin/integrations.
+type QualityProfile struct {
+ ID int
+ Name string
+}
+
+// RootFolder is the dropdown choice in /admin/integrations.
+type RootFolder struct {
+ Path string
+ Accessible bool
+ FreeSpace int64
+}
+
+// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist.
+type AddArtistParams struct {
+ ForeignArtistID string
+ QualityProfileID int
+ RootFolderPath string
+ MonitorAll bool // true => Monitored="all"; false => "future"
+}
+
+// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album.
+type AddAlbumParams struct {
+ ForeignAlbumID string
+ ForeignArtistID string // Lidarr requires the artist's foreign id too
+ QualityProfileID int
+ RootFolderPath string
+}
+
+// PingResult is the response shape from GET /api/v1/system/status.
+type PingResult struct {
+ Version string
+}
+```
+
+- [ ] **Step 2.2: Write the typed errors**
+
+`internal/lidarr/errors.go`:
+
+```go
+package lidarr
+
+import "errors"
+
+// Sentinel errors. Callers branch on these via errors.Is, not on
+// HTTP status codes — the client maps codes to errors.
+var (
+ ErrUnreachable = errors.New("lidarr: unreachable")
+ ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403
+ ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403
+ ErrServerError = errors.New("lidarr: server error") // 5xx
+ ErrInvalidPayload = errors.New("lidarr: invalid payload")
+)
+```
+
+- [ ] **Step 2.3: Write the client skeleton + LookupArtist**
+
+`internal/lidarr/client.go`:
+
+```go
+package lidarr
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+)
+
+// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
+// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings.
+type Client struct {
+ BaseURL string
+ APIKey string
+ HTTP *http.Client
+}
+
+func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) {
+ u, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
+ }
+ u.Path = u.Path + path
+ if q != nil {
+ u.RawQuery = q.Encode()
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("X-Api-Key", c.APIKey)
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
+ }
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ _ = resp.Body.Close()
+ return nil, ErrAuthFailed
+ }
+ if resp.StatusCode >= 500 {
+ _ = resp.Body.Close()
+ return nil, ErrServerError
+ }
+ if resp.StatusCode >= 400 {
+ _ = resp.Body.Close()
+ return nil, ErrLookupFailed
+ }
+ return resp, nil
+}
+
+// LookupArtist hits Lidarr GET /api/v1/artist/lookup?term=. Returns
+// normalized LookupResults from the response.
+func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) {
+ resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}})
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ var raw []struct {
+ ForeignArtistID string `json:"foreignArtistId"`
+ ArtistName string `json:"artistName"`
+ Genres []string `json:"genres"`
+ AlbumCount int `json:"albumCount"`
+ Images []struct {
+ CoverType string `json:"coverType"`
+ RemoteURL string `json:"remoteUrl"`
+ URL string `json:"url"`
+ } `json:"images"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ out := make([]LookupResult, 0, len(raw))
+ for _, r := range raw {
+ secondary := ""
+ if len(r.Genres) > 0 {
+ secondary = r.Genres[0]
+ }
+ if r.AlbumCount > 0 {
+ if secondary != "" {
+ secondary += " · "
+ }
+ secondary += strconv.Itoa(r.AlbumCount) + " albums"
+ }
+ out = append(out, LookupResult{
+ MBID: r.ForeignArtistID,
+ Name: r.ArtistName,
+ Secondary: secondary,
+ ImageURL: pickPosterImage(r.Images),
+ })
+ }
+ return out, nil
+}
+
+func pickPosterImage(imgs []struct {
+ CoverType string `json:"coverType"`
+ RemoteURL string `json:"remoteUrl"`
+ URL string `json:"url"`
+}) string {
+ for _, img := range imgs {
+ if img.CoverType == "poster" {
+ if img.RemoteURL != "" {
+ return img.RemoteURL
+ }
+ return img.URL
+ }
+ }
+ return ""
+}
+
+// Ensure interface implementations stay consistent.
+var _ = errors.Is
+```
+
+- [ ] **Step 2.4: Add a captured Lidarr lookup response to testdata**
+
+`internal/lidarr/testdata/lookup_artist.json`:
+
+```json
+[
+ {
+ "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
+ "artistName": "Boards of Canada",
+ "genres": ["Electronic", "IDM"],
+ "albumCount": 18,
+ "images": [
+ {"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg"},
+ {"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg"}
+ ]
+ },
+ {
+ "foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c",
+ "artistName": "Bored of Education",
+ "genres": [],
+ "albumCount": 0,
+ "images": []
+ }
+]
+```
+
+- [ ] **Step 2.5: Write the LookupArtist test**
+
+`internal/lidarr/client_test.go`:
+
+```go
+package lidarr
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+)
+
+func TestLookupArtist_HappyPath(t *testing.T) {
+ body, err := os.ReadFile("testdata/lookup_artist.json")
+ if err != nil {
+ t.Fatalf("read fixture: %v", err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("X-Api-Key"); got != "key123" {
+ t.Errorf("api key = %q, want key123", got)
+ }
+ if r.URL.Path != "/api/v1/artist/lookup" {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ if r.URL.Query().Get("term") != "boards" {
+ t.Errorf("term = %q", r.URL.Query().Get("term"))
+ }
+ _, _ = w.Write(body)
+ }))
+ defer srv.Close()
+ c := &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()}
+ got, err := c.LookupArtist(context.Background(), "boards")
+ if err != nil {
+ t.Fatalf("LookupArtist: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("len = %d, want 2", len(got))
+ }
+ if got[0].Name != "Boards of Canada" {
+ t.Errorf("name = %q", got[0].Name)
+ }
+ if got[0].Secondary != "Electronic · 18 albums" {
+ t.Errorf("secondary = %q", got[0].Secondary)
+ }
+ if got[0].ImageURL != "https://example.invalid/boc.jpg" {
+ t.Errorf("image = %q", got[0].ImageURL)
+ }
+ if got[1].Secondary != "" {
+ t.Errorf("expected empty secondary for empty genres + 0 albums; got %q", got[1].Secondary)
+ }
+}
+
+func TestLookupArtist_AuthFailed(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer srv.Close()
+ c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
+ _, err := c.LookupArtist(context.Background(), "boards")
+ if !errors.Is(err, ErrAuthFailed) {
+ t.Fatalf("err = %v, want ErrAuthFailed", err)
+ }
+}
+
+func TestLookupArtist_ServerError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+ c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
+ _, err := c.LookupArtist(context.Background(), "boards")
+ if !errors.Is(err, ErrServerError) {
+ t.Fatalf("err = %v, want ErrServerError", err)
+ }
+}
+
+func TestLookupArtist_Unreachable(t *testing.T) {
+ c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
+ _, err := c.LookupArtist(context.Background(), "boards")
+ if !errors.Is(err, ErrUnreachable) {
+ t.Fatalf("err = %v, want ErrUnreachable", err)
+ }
+}
+```
+
+- [ ] **Step 2.6: Run the tests, fix until green**
+
+```bash
+go test -race -v ./internal/lidarr/...
+```
+
+Expected: 4 tests pass.
+
+- [ ] **Step 2.7: Add LookupAlbum and LookupTrack methods**
+
+Append to `internal/lidarr/client.go`:
+
+```go
+// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized
+// LookupResults; Secondary is "year · trackcount".
+func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) {
+ resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}})
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ var raw []struct {
+ ForeignAlbumID string `json:"foreignAlbumId"`
+ ForeignArtistID string `json:"foreignArtistId"`
+ Title string `json:"title"`
+ ArtistName string `json:"artistName"`
+ ReleaseDate string `json:"releaseDate"`
+ TrackCount int `json:"trackCount"`
+ Images []struct {
+ CoverType string `json:"coverType"`
+ RemoteURL string `json:"remoteUrl"`
+ URL string `json:"url"`
+ } `json:"images"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ out := make([]LookupResult, 0, len(raw))
+ for _, r := range raw {
+ year := ""
+ if len(r.ReleaseDate) >= 4 {
+ year = r.ReleaseDate[:4]
+ }
+ secondary := r.ArtistName
+ if year != "" {
+ secondary += " · " + year
+ }
+ if r.TrackCount > 0 {
+ secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks"
+ }
+ out = append(out, LookupResult{
+ MBID: r.ForeignAlbumID,
+ Name: r.Title,
+ Secondary: secondary,
+ ImageURL: pickPosterImage(r.Images),
+ })
+ }
+ return out, nil
+}
+
+// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track
+// lookup is per-album under the hood — Secondary is "album · artist".
+func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) {
+ resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}})
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ var raw []struct {
+ ForeignTrackID string `json:"foreignTrackId"`
+ ForeignAlbumID string `json:"foreignAlbumId"`
+ ForeignArtistID string `json:"foreignArtistId"`
+ Title string `json:"title"`
+ AlbumTitle string `json:"albumTitle"`
+ ArtistName string `json:"artistName"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ out := make([]LookupResult, 0, len(raw))
+ for _, r := range raw {
+ secondary := r.AlbumTitle
+ if r.ArtistName != "" {
+ if secondary != "" {
+ secondary += " · "
+ }
+ secondary += r.ArtistName
+ }
+ out = append(out, LookupResult{
+ MBID: r.ForeignTrackID,
+ Name: r.Title,
+ Secondary: secondary,
+ })
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 2.8: Add tests for LookupAlbum and LookupTrack**
+
+Use the same `httptest`+fixture pattern. Capture two more JSON files (`testdata/lookup_album.json`, `testdata/lookup_track.json`) with at least 2 results each, then add `TestLookupAlbum_HappyPath` and `TestLookupTrack_HappyPath` mirroring `TestLookupArtist_HappyPath`. Auth/server-error variants are unchanged so don't duplicate them — one parametric helper test suffices.
+
+- [ ] **Step 2.9: Add AddArtist, AddAlbum, ListQualityProfiles, ListRootFolders, Ping**
+
+Append to `internal/lidarr/client.go`:
+
+```go
+// post is the shared POST helper. Body is marshaled JSON.
+func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
+ u, err := url.Parse(c.BaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
+ }
+ u.Path = u.Path + path
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("X-Api-Key", c.APIKey)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
+ }
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ _ = resp.Body.Close()
+ return nil, ErrAuthFailed
+ }
+ if resp.StatusCode >= 500 {
+ _ = resp.Body.Close()
+ return nil, ErrServerError
+ }
+ if resp.StatusCode >= 400 {
+ _ = resp.Body.Close()
+ return nil, ErrLookupFailed
+ }
+ return resp, nil
+}
+
+func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
+ monitor := "future"
+ if p.MonitorAll {
+ monitor = "all"
+ }
+ body, _ := json.Marshal(map[string]any{
+ "foreignArtistId": p.ForeignArtistID,
+ "qualityProfileId": p.QualityProfileID,
+ "rootFolderPath": p.RootFolderPath,
+ "monitored": true,
+ "monitor": monitor,
+ "addOptions": map[string]any{"searchForMissingAlbums": true},
+ })
+ resp, err := c.post(ctx, "/api/v1/artist", body)
+ if err != nil {
+ return err
+ }
+ _ = resp.Body.Close()
+ return nil
+}
+
+func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
+ body, _ := json.Marshal(map[string]any{
+ "foreignAlbumId": p.ForeignAlbumID,
+ "foreignArtistId": p.ForeignArtistID,
+ "qualityProfileId": p.QualityProfileID,
+ "rootFolderPath": p.RootFolderPath,
+ "monitored": true,
+ "addOptions": map[string]any{"searchForNewAlbum": true},
+ })
+ resp, err := c.post(ctx, "/api/v1/album", body)
+ if err != nil {
+ return err
+ }
+ _ = resp.Body.Close()
+ return nil
+}
+
+func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
+ resp, err := c.get(ctx, "/api/v1/qualityprofile", nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ var raw []struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ out := make([]QualityProfile, len(raw))
+ for i, r := range raw {
+ out[i] = QualityProfile{ID: r.ID, Name: r.Name}
+ }
+ return out, nil
+}
+
+func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) {
+ resp, err := c.get(ctx, "/api/v1/rootfolder", nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ var raw []struct {
+ Path string `json:"path"`
+ Accessible bool `json:"accessible"`
+ FreeSpace int64 `json:"freeSpace"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ out := make([]RootFolder, len(raw))
+ for i, r := range raw {
+ out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace}
+ }
+ return out, nil
+}
+
+func (c *Client) Ping(ctx context.Context) (PingResult, error) {
+ resp, err := c.get(ctx, "/api/v1/system/status", nil)
+ if err != nil {
+ return PingResult{}, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ var raw struct {
+ Version string `json:"version"`
+ }
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
+ }
+ return PingResult{Version: raw.Version}, nil
+}
+```
+
+Add `"bytes"` to the imports.
+
+- [ ] **Step 2.10: Add tests for the remaining methods**
+
+For each: capture or hand-write a fixture, add `TestAddArtist_PostsCorrectBody`, `TestAddAlbum_PostsCorrectBody`, `TestListQualityProfiles_HappyPath`, `TestListRootFolders_HappyPath`, `TestPing_ReturnsVersion`. The Add* tests should assert on the parsed POST body — read `r.Body`, decode JSON, check the field shape.
+
+- [ ] **Step 2.11: Run all client tests**
+
+```bash
+go test -race -cover ./internal/lidarr/...
+```
+
+Expected: all tests pass; coverage ≥ 80%.
+
+- [ ] **Step 2.12: Commit**
+
+```bash
+git add internal/lidarr/
+git commit -m "feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping)"
+```
+
+---
+
+### Task 3 — `lidarrconfig` singleton service
+
+**Files:**
+- Create: `internal/lidarrconfig/service.go`
+- Create: `internal/lidarrconfig/service_test.go`
+
+- [ ] **Step 3.1: Write the service**
+
+`internal/lidarrconfig/service.go`:
+
+```go
+// Package lidarrconfig is a thin wrapper over the singleton lidarr_config
+// row. Get returns a typed Config; Save updates it. Callers branch on
+// Config.Enabled — never on raw NULL fields.
+package lidarrconfig
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// Config is the typed projection of lidarr_config (no NULL fields exposed
+// to callers — empty strings / zero ints carry the "unset" meaning).
+type Config struct {
+ Enabled bool
+ BaseURL string
+ APIKey string
+ DefaultQualityProfileID int
+ DefaultRootFolderPath string
+}
+
+// Service reads and writes the singleton.
+type Service struct {
+ pool *pgxpool.Pool
+}
+
+func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} }
+
+func (s *Service) Get(ctx context.Context) (Config, error) {
+ row, err := dbq.New(s.pool).GetLidarrConfig(ctx)
+ if err != nil {
+ return Config{}, fmt.Errorf("lidarrconfig: %w", err)
+ }
+ cfg := Config{Enabled: row.Enabled}
+ if row.BaseUrl != nil {
+ cfg.BaseURL = *row.BaseUrl
+ }
+ if row.ApiKey != nil {
+ cfg.APIKey = *row.ApiKey
+ }
+ if row.DefaultQualityProfileID != nil {
+ cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID)
+ }
+ if row.DefaultRootFolderPath != nil {
+ cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath
+ }
+ return cfg, nil
+}
+
+// Save writes the entire row. Callers pass the full Config they want
+// stored — this is not a partial update.
+func (s *Service) Save(ctx context.Context, cfg Config) error {
+ var (
+ baseURL *string = strPtr(cfg.BaseURL)
+ apiKey *string = strPtr(cfg.APIKey)
+ qpID *int32 = int32Ptr(cfg.DefaultQualityProfileID)
+ rootPath *string = strPtr(cfg.DefaultRootFolderPath)
+ )
+ _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{
+ Enabled: cfg.Enabled,
+ BaseUrl: baseURL,
+ ApiKey: apiKey,
+ DefaultQualityProfileID: qpID,
+ DefaultRootFolderPath: rootPath,
+ })
+ if err != nil {
+ return fmt.Errorf("lidarrconfig: %w", err)
+ }
+ return nil
+}
+
+func strPtr(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+func int32Ptr(i int) *int32 {
+ if i == 0 {
+ return nil
+ }
+ v := int32(i)
+ return &v
+}
+```
+
+Note: the exact field names on `dbq.UpdateLidarrConfigParams` depend on sqlc's generation. After `sqlc generate` ran in Task 1, you'll see them — adjust the literal field names here to match. Pointer-vs-value also depends on how sqlc treats nullable text columns.
+
+- [ ] **Step 3.2: Write the integration test**
+
+`internal/lidarrconfig/service_test.go`:
+
+```go
+package lidarrconfig
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "os"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db"
+)
+
+func newTestPool(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("skipping integration test in -short mode")
+ }
+ dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("MINSTREL_TEST_DATABASE_URL not set")
+ }
+ if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ pool, err := pgxpool.New(context.Background(), dsn)
+ if err != nil {
+ t.Fatalf("pool: %v", err)
+ }
+ t.Cleanup(pool.Close)
+ // Reset singleton to default state before each test by replacing
+ // the row contents via UPDATE (TRUNCATE would violate the CHECK).
+ if _, err := pool.Exec(context.Background(),
+ "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1",
+ ); err != nil {
+ t.Fatalf("reset: %v", err)
+ }
+ return pool
+}
+
+func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) {
+ pool := newTestPool(t)
+ cfg, err := New(pool).Get(context.Background())
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" {
+ t.Errorf("expected zero-value Config, got %+v", cfg)
+ }
+}
+
+func TestSaveThenGet_RoundTrip(t *testing.T) {
+ pool := newTestPool(t)
+ s := New(pool)
+ want := Config{
+ Enabled: true,
+ BaseURL: "http://lidarr.lan:8686",
+ APIKey: "secret",
+ DefaultQualityProfileID: 4,
+ DefaultRootFolderPath: "/music",
+ }
+ if err := s.Save(context.Background(), want); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ got, err := s.Get(context.Background())
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got != want {
+ t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want)
+ }
+}
+
+func TestSave_EmptyValuesPersistAsNULL(t *testing.T) {
+ pool := newTestPool(t)
+ s := New(pool)
+ if err := s.Save(context.Background(), Config{Enabled: false}); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ got, err := s.Get(context.Background())
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" {
+ t.Errorf("expected empty strings on round-trip; got %+v", got)
+ }
+}
+```
+
+- [ ] **Step 3.3: Run the tests inside the docker network**
+
+```bash
+docker run --rm --network minstrel_minstrel \
+ -v "$(pwd):/src" -w /src \
+ -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
+ golang:1.23-bookworm \
+ go test -race ./internal/lidarrconfig/...
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 3.4: Commit**
+
+```bash
+git add internal/lidarrconfig/
+git commit -m "feat(lidarrconfig): typed singleton config wrapper"
+```
+
+---
+
+### Task 4 — `lidarrrequests` Service (lifecycle, no reconciler)
+
+**Files:**
+- Create: `internal/lidarrrequests/service.go`
+- Create: `internal/lidarrrequests/service_test.go`
+
+- [ ] **Step 4.1: Write the Service**
+
+`internal/lidarrrequests/service.go`:
+
+```go
+// Package lidarrrequests owns the lifecycle of user requests to add
+// music via Lidarr. The synchronous Service handles Create/List/
+// Approve/Reject/Cancel; the async Reconciler (separate file) closes
+// approved requests once their target track lands in the library.
+package lidarrrequests
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
+)
+
+// Public errors. Handlers map these to API error codes.
+var (
+ ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind")
+ ErrNotPending = errors.New("lidarrrequests: request is not pending")
+ ErrNotFound = errors.New("lidarrrequests: request not found")
+ ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured")
+)
+
+// CreateParams is the input for a new request from a user.
+type CreateParams struct {
+ Kind string // "artist", "album", or "track"
+ LidarrArtistMBID string
+ LidarrAlbumMBID string // required for kind=album/track
+ LidarrTrackMBID string // required for kind=track
+ ArtistName string
+ AlbumTitle string // required for kind=album/track
+ TrackTitle string // required for kind=track
+}
+
+// ApproveOverrides lets the admin override the snapshot defaults for one
+// approval. Zero values mean "use config default."
+type ApproveOverrides struct {
+ QualityProfileID int
+ RootFolderPath string
+}
+
+type Service struct {
+ pool *pgxpool.Pool
+ lidarrCfg *lidarrconfig.Service
+ client *lidarr.Client
+ scanFn func() // injected; called after Approve to trigger a library scan
+}
+
+func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, client *lidarr.Client, scanFn func()) *Service {
+ if scanFn == nil {
+ scanFn = func() {}
+ }
+ return &Service{pool: pool, lidarrCfg: cfg, client: client, scanFn: scanFn}
+}
+
+// Create validates the kind→required-fields invariant and inserts a
+// pending row.
+func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
+ if err := validateKindFields(p); err != nil {
+ return dbq.LidarrRequest{}, err
+ }
+ q := dbq.New(s.pool)
+ row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
+ UserID: userID,
+ Kind: dbq.LidarrRequestKind(p.Kind),
+ LidarrArtistMbid: p.LidarrArtistMBID,
+ LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID),
+ LidarrTrackMbid: strPtr(p.LidarrTrackMBID),
+ ArtistName: p.ArtistName,
+ AlbumTitle: strPtr(p.AlbumTitle),
+ TrackTitle: strPtr(p.TrackTitle),
+ })
+ if err != nil {
+ return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err)
+ }
+ return row, nil
+}
+
+func validateKindFields(p CreateParams) error {
+ if p.LidarrArtistMBID == "" || p.ArtistName == "" {
+ return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
+ }
+ switch p.Kind {
+ case "artist":
+ // fine
+ case "album":
+ if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
+ return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
+ }
+ case "track":
+ if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
+ return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields)
+ }
+ if p.LidarrTrackMBID == "" || p.TrackTitle == "" {
+ return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
+ }
+ default:
+ return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
+ }
+ return nil
+}
+
+func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) {
+ return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
+ Status: dbq.LidarrRequestStatusPending, Limit: limit,
+ })
+}
+
+func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) {
+ return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
+ Status: dbq.LidarrRequestStatus(status), Limit: limit,
+ })
+}
+
+func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) {
+ return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{
+ UserID: userID, Limit: limit,
+ })
+}
+
+// Approve transitions a pending request to approved, snapshotting the
+// chosen quality profile + root folder, then calls Lidarr to actually
+// add the artist/album, then triggers a library scan. If Lidarr returns
+// an error, the request stays pending — the admin sees the error and
+// can retry without losing the request.
+func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) {
+ cfg, err := s.lidarrCfg.Get(ctx)
+ if err != nil {
+ return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err)
+ }
+ if !cfg.Enabled || s.client == nil {
+ return dbq.LidarrRequest{}, ErrLidarrDisabled
+ }
+ row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return dbq.LidarrRequest{}, ErrNotFound
+ }
+ return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err)
+ }
+ if row.Status != dbq.LidarrRequestStatusPending {
+ return dbq.LidarrRequest{}, ErrNotPending
+ }
+
+ qp := cfg.DefaultQualityProfileID
+ if ov.QualityProfileID != 0 {
+ qp = ov.QualityProfileID
+ }
+ rf := cfg.DefaultRootFolderPath
+ if ov.RootFolderPath != "" {
+ rf = ov.RootFolderPath
+ }
+
+ switch row.Kind {
+ case dbq.LidarrRequestKindArtist:
+ err = s.client.AddArtist(ctx, lidarr.AddArtistParams{
+ ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true,
+ })
+ case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack:
+ // Track-kind requests promote to album-add; the spec is explicit.
+ albumMBID := ""
+ if row.LidarrAlbumMbid != nil {
+ albumMBID = *row.LidarrAlbumMbid
+ }
+ err = s.client.AddAlbum(ctx, lidarr.AddAlbumParams{
+ ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid,
+ QualityProfileID: qp, RootFolderPath: rf,
+ })
+ }
+ if err != nil {
+ return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err)
+ }
+
+ approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
+ ID: requestID,
+ QualityProfileID: int32Ptr(qp),
+ RootFolderPath: strPtr(rf),
+ DecidedBy: uuidPtr(adminID),
+ })
+ if err != nil {
+ // Lidarr accepted but our DB update failed; admin should retry.
+ return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err)
+ }
+ s.scanFn()
+ return approved, nil
+}
+
+func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) {
+ row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{
+ ID: requestID, Notes: strPtr(notes), DecidedBy: uuidPtr(adminID),
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ // Either not found OR not pending — caller can't distinguish from
+ // the SQL alone, so check after.
+ cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
+ if gerr != nil {
+ return dbq.LidarrRequest{}, ErrNotFound
+ }
+ if cur.Status != dbq.LidarrRequestStatusPending {
+ return dbq.LidarrRequest{}, ErrNotPending
+ }
+ return dbq.LidarrRequest{}, ErrNotFound
+ }
+ return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err)
+ }
+ return row, nil
+}
+
+func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) {
+ row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{
+ ID: requestID, UserID: userID,
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return dbq.LidarrRequest{}, ErrNotPending
+ }
+ return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err)
+ }
+ return row, nil
+}
+
+func strPtr(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+func int32Ptr(i int) *int32 {
+ if i == 0 {
+ return nil
+ }
+ v := int32(i)
+ return &v
+}
+func uuidPtr(u pgtype.UUID) pgtype.UUID { return u }
+```
+
+(Field names like `dbq.LidarrRequestStatusPending`, `dbq.LidarrRequestKindArtist` come from sqlc — verify after `sqlc generate`. Pointer-vs-value for nullable columns also from sqlc.)
+
+- [ ] **Step 4.2: Write the integration test**
+
+`internal/lidarrrequests/service_test.go`:
+
+```go
+package lidarrrequests
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
+)
+
+func newPool(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("skipping integration test in -short mode")
+ }
+ dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("MINSTREL_TEST_DATABASE_URL not set")
+ }
+ if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ pool, err := pgxpool.New(context.Background(), dsn)
+ if err != nil {
+ t.Fatalf("pool: %v", err)
+ }
+ t.Cleanup(pool.Close)
+ dbtest.ResetDB(t, pool)
+ if _, err := pool.Exec(context.Background(),
+ "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
+ ); err != nil {
+ t.Fatalf("reset lidarr tables: %v", err)
+ }
+ return pool
+}
+
+func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
+ t.Helper()
+ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
+ Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
+ })
+ if err != nil {
+ t.Fatalf("seed user: %v", err)
+ }
+ return u.ID
+}
+
+func TestCreate_HappyPath_Artist(t *testing.T) {
+ pool := newPool(t)
+ user := seedUser(t, pool)
+ svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
+ r, err := svc.Create(context.Background(), user, CreateParams{
+ Kind: "artist",
+ LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
+ ArtistName: "Boards of Canada",
+ })
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if r.Status != dbq.LidarrRequestStatusPending {
+ t.Errorf("status = %v", r.Status)
+ }
+}
+
+func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) {
+ pool := newPool(t)
+ user := seedUser(t, pool)
+ svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
+ _, err := svc.Create(context.Background(), user, CreateParams{
+ Kind: "track",
+ LidarrArtistMBID: "a-mbid", ArtistName: "X",
+ LidarrTrackMBID: "t-mbid", TrackTitle: "Y",
+ // missing album fields
+ })
+ if !errors.Is(err, ErrInvalidKindFields) {
+ t.Fatalf("err = %v, want ErrInvalidKindFields", err)
+ }
+}
+
+func TestApprove_NotConfigured(t *testing.T) {
+ pool := newPool(t)
+ user := seedUser(t, pool)
+ svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
+ r, _ := svc.Create(context.Background(), user, CreateParams{
+ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
+ })
+ _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{})
+ if !errors.Is(err, ErrLidarrDisabled) {
+ t.Fatalf("err = %v, want ErrLidarrDisabled", err)
+ }
+}
+
+func TestReject_TransitionsToRejected(t *testing.T) {
+ pool := newPool(t)
+ user := seedUser(t, pool)
+ svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
+ r, _ := svc.Create(context.Background(), user, CreateParams{
+ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
+ })
+ rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality")
+ if err != nil {
+ t.Fatalf("Reject: %v", err)
+ }
+ if rejected.Status != dbq.LidarrRequestStatusRejected {
+ t.Errorf("status = %v", rejected.Status)
+ }
+}
+
+func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) {
+ pool := newPool(t)
+ user := seedUser(t, pool)
+ svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
+ r, _ := svc.Create(context.Background(), user, CreateParams{
+ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
+ })
+ _, _ = svc.Reject(context.Background(), r.ID, user, "first")
+ _, err := svc.Reject(context.Background(), r.ID, user, "second")
+ if !errors.Is(err, ErrNotPending) {
+ t.Fatalf("err = %v, want ErrNotPending", err)
+ }
+}
+
+func TestCancel_OwnPendingOnly(t *testing.T) {
+ pool := newPool(t)
+ user := seedUser(t, pool)
+ svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
+ r, _ := svc.Create(context.Background(), user, CreateParams{
+ Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
+ })
+ if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil {
+ t.Fatalf("Cancel: %v", err)
+ }
+ // Second cancel hits "not pending" because we just rejected it.
+ if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) {
+ t.Errorf("err = %v, want ErrNotPending", err)
+ }
+}
+```
+
+- [ ] **Step 4.3: Run the tests**
+
+```bash
+docker run --rm --network minstrel_minstrel \
+ -v "$(pwd):/src" -w /src \
+ -e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
+ golang:1.23-bookworm \
+ go test -race ./internal/lidarrrequests/...
+```
+
+Expected: 6 tests pass.
+
+- [ ] **Step 4.4: Commit**
+
+```bash
+git add internal/lidarrrequests/service.go internal/lidarrrequests/service_test.go
+git commit -m "feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel)"
+```
+
+---
+
+This plan continues in the same shape for the remaining tasks. Subsequent tasks are sketched below at one-paragraph-per-task density to keep the plan navigable; expand each into the same step-level TDD detail (write test → run → implement → run → commit) when you reach it. Each task references the spec sections that drive it.
+
+---
+
+### Task 5 — `lidarrrequests` Reconciler worker
+
+**Files:** `internal/lidarrrequests/reconciler.go`, `internal/lidarrrequests/reconciler_integration_test.go`, plus a sqlc query `MatchTrackForRequest` in `internal/db/queries/lidarr_requests.sql`.
+
+The reconciler mirrors `internal/similarity.Worker`: a `Run(ctx)` loop that calls `tickOnce(ctx)` every 5 minutes. `tickOnce`:
+1. `ListApprovedLidarrRequestsForReconcile(limit=50)`.
+2. For each row, look up the matching local row via MBID:
+ - `kind=artist` → `SELECT id FROM artists WHERE mbid = $1`
+ - `kind=album` → `SELECT id FROM albums WHERE mbid = $1`
+ - `kind=track` → `SELECT id FROM tracks WHERE album_id = (SELECT id FROM albums WHERE mbid = $1) LIMIT 1` (track-kind matched by parent album per spec §3 reconciler note)
+3. If a match is found, call `CompleteLidarrRequest` with the matched IDs.
+
+Reconciler short-circuits to no-op when `lidarrconfig.Get(...).Enabled == false`. Errors logged at WARN, never propagated.
+
+**Tests** (per spec §8 — five integration scenarios):
+- `TestReconciler_MatchesArtistByMBID` — seed artist, seed approved artist-kind request with same MBID, run `tickOnce`, expect status=completed and `matched_artist_id` set.
+- `TestReconciler_MatchesAlbumByMBID` — same shape for album.
+- `TestReconciler_MatchesTrackViaAlbumMBID` — track-kind request matches when ANY track of the parent album appears.
+- `TestReconciler_NoMatchLeavesPending` — approved request with MBID not in library → row unchanged after `tickOnce`.
+- `TestReconciler_AlreadyCompletedRowNotReprocessed` — pre-set status=completed, ensure `tickOnce` doesn't touch it.
+- `TestReconciler_DisabledIsNoOp` — `lidarr_config.enabled=false` → `tickOnce` short-circuits even with approved rows present.
+
+Commit: `feat(lidarrrequests): add Reconciler worker matching approved requests to library`.
+
+---
+
+### Task 6 — `RequireAdmin` middleware
+
+**Files:** `internal/auth/admin.go`, `internal/auth/admin_test.go`.
+
+Mirror `RequireUser`'s shape. After `RequireUser` puts the user in context, `RequireAdmin` reads the user from context, returns 403 with `{"error":"not_authorized"}` JSON envelope if `IsAdmin == false`. Test cases: admin passes through; non-admin returns 403; missing context (programmer error) returns 500.
+
+Commit: `feat(auth): add RequireAdmin middleware for /api/admin/* routes`.
+
+---
+
+### Task 7 — `/api/lidarr/search` proxy handler
+
+**Files:** `internal/api/lidarr.go`, `internal/api/lidarr_test.go`. Modify: `internal/api/api.go` to inject the Lidarr client + lidarrconfig service into `handlers`.
+
+Handler reads `q` and `kind` from query params, validates `kind ∈ {artist, album, track}`, checks `lidarrconfig.Get().Enabled` — if false, returns `503 {"error":"lidarr_disabled"}`. Calls the matching `client.Lookup*`, then per-result enriches with:
+- `in_library` — by joining against `artists.mbid` / `albums.mbid` / `tracks.mbid`. Add a small `IsMBIDInLibrary` sqlc query for each kind.
+- `requested` — via `HasNonTerminalRequestForMBID` (already in queries from Task 1).
+
+Maps `lidarr.ErrUnreachable`/`ErrAuthFailed` to `503 lidarr_unreachable` / `503 lidarr_auth_failed`. Other errors → `500`.
+
+**Tests:**
+- `TestHandleLidarrSearch_HappyPath` — stubs the Client to return one in-library + one requestable + one already-requested, asserts the JSON shape.
+- `TestHandleLidarrSearch_DisabledReturns503` — `lidarr_config.enabled=false`.
+- `TestHandleLidarrSearch_LidarrUnreachable` — stubbed Client returns `ErrUnreachable`.
+- `TestHandleLidarrSearch_BadKind400`.
+- `TestHandleLidarrSearch_RequiresAuth` — anonymous request rejected.
+
+Commit: `feat(api): add /api/lidarr/search proxy with library/request enrichment`.
+
+---
+
+### Task 8 — `/api/requests` user-facing CRUD handlers
+
+**Files:** `internal/api/requests.go`, `internal/api/requests_test.go`. Modify: `internal/api/api.go` to register routes inside the `RequireUser` group.
+
+Five handlers: `POST /api/requests` (Create), `GET /api/requests` (ListForUser), `GET /api/requests/:id`, `DELETE /api/requests/:id` (Cancel).
+
+Each handler delegates to `lidarrrequests.Service`. Map `ErrInvalidKindFields → 400 mbid_required`, `ErrNotPending → 409 request_not_pending`, `ErrNotFound → 404 request_not_found`. `GET /:id` returns 404 if the row isn't the caller's own AND caller isn't admin.
+
+**Tests** (extend `testHandlers` to inject `lidarrrequests.Service`):
+- Create with valid artist/album/track payloads → 201.
+- Create with each invalid kind→fields combination → 400.
+- List returns only caller's rows; cross-user scoped out.
+- Get-own returns row; get-other-user 404; get-other-user-as-admin 200.
+- Cancel pending → 200 with status=rejected; cancel non-pending → 409.
+
+Commit: `feat(api): add /api/requests user-facing CRUD`.
+
+---
+
+### Task 9 — `/api/admin/lidarr/*` config + profiles + folders + test
+
+**Files:** `internal/api/admin_lidarr.go`, `internal/api/admin_lidarr_test.go`. Modify: `internal/api/api.go` to mount a `RequireAdmin` group under `/api/admin`.
+
+Handlers: `GET /api/admin/lidarr/config` (mask api_key), `PUT /api/admin/lidarr/config`, `POST /api/admin/lidarr/test`, `GET /api/admin/lidarr/quality-profiles`, `GET /api/admin/lidarr/root-folders`.
+
+PUT logic: if request `api_key` is empty string → preserve saved value; if non-empty → update. `enabled=true` requires `base_url` and `api_key` to be non-empty (validate at handler).
+
+Test endpoint: per-field fallback to saved values when absent or empty; always returns 200 with `{ok, version?, error?}`.
+
+**Tests:**
+- GET config masks api_key when set.
+- PUT empty api_key preserves saved value.
+- PUT enabled=true with empty base_url → 400.
+- POST test happy path returns `{ok:true, version}`.
+- POST test with stubbed-unreachable client returns `{ok:false, error}`.
+- Quality-profiles + root-folders proxy through to client.
+- All endpoints return 403 for non-admin tokens.
+
+Commit: `feat(api): add /api/admin/lidarr/* config + profiles + folders + test`.
+
+---
+
+### Task 10 — `/api/admin/requests/*` approval queue handlers
+
+**Files:** `internal/api/admin_requests.go`, `internal/api/admin_requests_test.go`. Modify: `internal/api/api.go` to register inside the `RequireAdmin` group.
+
+Three handlers: `GET /api/admin/requests?status=&limit=` (default `status=pending`), `POST /api/admin/requests/:id/approve` (body: optional override), `POST /api/admin/requests/:id/reject` (body: optional notes).
+
+Approve handler delegates to `Service.Approve`; surfaces `ErrLidarrDisabled` / `lidarr.ErrUnreachable` / `ErrNotPending` / `ErrNotFound` per error code table.
+
+**Tests:**
+- List with status=pending returns pending rows only.
+- Approve happy path: stubbed Client receives correct AddArtist/AddAlbum payload, row transitions to approved with snapshot fields, scan trigger called.
+- Approve with override snapshots override values, not config defaults.
+- Approve when Lidarr returns ErrUnreachable → 503; row stays pending.
+- Reject with notes records notes; reject without notes works with NULL notes.
+- All endpoints return 403 for non-admin tokens.
+
+Commit: `feat(api): add /api/admin/requests approval queue`.
+
+---
+
+### Task 11 — Wire the Reconciler in `cmd/minstrel/main.go`
+
+**Files:** Modify `cmd/minstrel/main.go`.
+
+Mirror the existing scrobble/similarity worker spin-up. Construct `lidarrconfig.Service`, the `lidarr.Client` (BaseURL+APIKey loaded from the singleton on demand), `lidarrrequests.Reconciler`, and start its `Run(ctx)` in a goroutine alongside the others.
+
+Subtle: the Lidarr client's `BaseURL` and `APIKey` change at runtime when admin updates config. Two ways to handle — (a) construct a new Client per request inside the Service from the latest config, or (b) wrap a `*atomic.Pointer[lidarr.Client]` that the config-save handler swaps. Pick (a) — simpler, no atomic dance, the cost of constructing an `http.Client` per request is negligible. Refactor `Service` to hold a `func() *lidarr.Client` factory instead of a `*Client` so it always reads fresh config.
+
+(Update Task 4's `Service` shape to use the factory accordingly. This is a foreseeable refactor — better to absorb it now than fight stale clients in production.)
+
+Commit: `feat(cmd): start Lidarr reconciler worker alongside HTTP server`.
+
+---
+
+### Task 12 — Frontend: FabledSword design tokens + fonts
+
+**Files:** Create `web/src/lib/styles/fabledsword-tokens.css`. Modify `web/src/app.css`. Modify `web/src/app.html` to load Google Fonts. Modify `web/tailwind.config.js` to alias semantic Tailwind utilities (e.g. `bg-surface`, `text-text-primary`, `border-border`) to FS tokens.
+
+Token file content: every variable from `project_design_system.md` `:root` block — surfaces, text, action, semantic, accent, font families, radii. Plus the per-app data-attribute hook that sets `--fs-accent` to forest-teal `#4A6B5C` for Minstrel.
+
+In Tailwind config, replace existing palette aliases:
+- `surface`, `surface-hover` → Iron, Slate
+- `background` → Obsidian
+- `text-primary`, `text-secondary`, `text-muted` → Parchment, Vellum, Ash
+- `border` → Pewter
+- Add new utility classes for action (`bg-action-primary` → Moss, `bg-action-secondary` → Bronze, `bg-action-destructive` → Oxblood) and `accent` → forest teal.
+
+This is the slice that converts the rest of the app to the design system implicitly — by aliasing existing utility names. Verify by visiting the dev server and confirming the existing pages now read in the new palette without any per-page changes (a sign the alias mapping is correct).
+
+Commit: `feat(web): introduce FabledSword design system tokens + Tailwind aliases`.
+
+---
+
+### Task 13 — Frontend: Lidarr + requests + admin API client modules
+
+**Files:** Create `web/src/lib/api/lidarr.ts`, `web/src/lib/api/requests.ts`, `web/src/lib/api/admin.ts`.
+
+Mirror existing client modules (e.g. `web/src/lib/api/likes.ts`). Each file exports typed async functions backed by the existing `api.get/post/put/delete` helper.
+
+Types match the API surface in spec §5. Vitest tests live alongside (e.g. `lidarr.test.ts`) using the existing fetch mocking setup — verify URL construction, query params, error mapping.
+
+Commit: `feat(web): add API client modules for Lidarr, requests, admin`.
+
+---
+
+### Task 14 — Frontend: `` component
+
+**Files:** Create `web/src/lib/components/DiscoverResultCard.svelte`, `DiscoverResultCard.test.ts`.
+
+Component props: `{ kind: 'artist'|'album'|'track', title: string, subtitle?: string, imageUrl?: string, state: 'requestable'|'kept'|'requested', onRequest?: () => void }`.
+
+Layout discipline (per spec §6 + brainstorm):
+- Outer `.card` is flex column with reserved `.text` block (`min-height` covers title + meta + badge row); `.actions` block uses `margin-top: auto`.
+- Badge slot is always rendered as a 22px-min-height div; "Kept" pill (accent at 15% bg + accent text) appears only when `state==='kept'`.
+- Three states render different actions:
+ - `requestable`: `bg-action-primary` button with plus icon, label "Request"
+ - `kept`: disabled ghost button "In library" + "Kept" pill in badge slot
+ - `requested`: disabled ghost button "Requested"
+- Cover art: render `
` when `imageUrl`; otherwise render Lucide fallback glyph (`Disc3` for artist, `Album` for album, `Music2` for track) inside the Slate-bg art square.
+
+**Tests:**
+- Renders all three states with correct button text.
+- Calls `onRequest` only in `requestable` state.
+- Computes badge slot height with `min-height: 22px` even when no badge content (assert via `getComputedStyle`).
+- Button is anchored to bottom of card body (assert `margin-top` === `auto` on `.actions`).
+
+Commit: `feat(web): add DiscoverResultCard with reserved badge slot + anchored button`.
+
+---
+
+### Task 15 — Frontend: `` component
+
+**Files:** Create `web/src/lib/components/StatusPill.svelte`, `StatusPill.test.ts`.
+
+Single prop: `status: 'pending'|'approved'|'completed'|'rejected'|'failed'`. Renders a pill with semantic color (Warning / Info / Moss / Error / Error) per spec §6 and the design-system memory. Voice-rule labels: "Awaiting review" / "Approved · downloading" / "Kept" / "Set aside" / "Couldn't add."
+
+Tests: each status renders with the correct text and the correct semantic CSS class (use `bg-warning-tint`, etc., aliases).
+
+Commit: `feat(web): add StatusPill semantic-color status indicator`.
+
+---
+
+### Task 16 — Frontend: `/discover` route
+
+**Files:** Create `web/src/routes/discover/+page.svelte`, `discover.test.ts`. Modify `web/src/lib/components/Shell.svelte` to add `/discover` to the main nav.
+
+Page elements:
+- H2 "Add music to the library" (Fraunces 24/500), Vellum subtitle
+- Search input (Obsidian inset, focus ring forest-teal)
+- Tabs (Artists / Albums / Tracks) — active tab gets 2px forest-teal bottom border
+- Card grid using ``
+- Track-kind confirm modal: opens on Request click with a track-state result; "Requesting *Track X* will add the album *Album Y*. Continue?" — Confirm = Moss, Cancel = Bronze. Modal dismissed = no-op.
+
+Debounce search input by 250ms before querying.
+
+**Tests:**
+- Debounced query fires correct API call with kind selector.
+- Tab switch refetches with new `kind`.
+- Track-kind result triggers modal; confirm triggers API call; cancel does not.
+- Requestable card with `onRequest` flips to `requested` state on success.
+- Empty results state shows "Nothing to add for that search yet." (voice-rule copy).
+
+Commit: `feat(web): add /discover route with search + request flow`.
+
+---
+
+### Task 17 — Frontend: `/requests` user request history
+
+**Files:** Create `web/src/routes/requests/+page.svelte`, `requests.test.ts`. Modify `Shell.svelte` to add `/requests` link in the main nav (visible to all authed users).
+
+Page renders the caller's requests as rows (mirrors the mockup at `.superpowers/brainstorm/.../user-requests.html`). Each row:
+- 56px album-art square (Slate fallback)
+- Kind pill + StatusPill
+- Title + meta line
+- Per-status actions: Cancel button on pending; "Listen" link (forest-teal text) on completed (navigates to `/tracks/` if set, else fallthrough to album/artist)
+
+**Tests:**
+- Renders one row per request from the API.
+- Pending row exposes Cancel; Cancel calls API and removes row.
+- Completed row renders "Listen" link with correct href.
+- Rejected row renders admin notes if present, hides "Cancel" / "Listen."
+- Empty list shows "Nothing requested yet." (voice-rule copy).
+
+Commit: `feat(web): add /requests user-facing request history`.
+
+---
+
+### Task 18 — Frontend: `/admin/*` layout + role gate
+
+**Files:** Create `web/src/routes/admin/+layout.svelte`, `web/src/routes/admin/+layout.ts`, `web/src/routes/admin/+page.svelte` (Overview landing).
+
+`+layout.ts` exports a `load` function that checks `currentUser.is_admin`; if false, throws a SvelteKit `redirect(302, '/')`. Redirect happens before layout/child renders — exactly the hard route gate the operator specified.
+
+`+layout.svelte` renders the admin shell:
+- Page header: "Admin" wordmark in Fraunces, the FabledSword small mark in Oxblood at top-left
+- 220px sidebar `` component (separate file `web/src/lib/components/AdminSidebar.svelte`) with nav items: Overview / Integrations / **Requests** / Quarantine (placeholder, dimmed) / Users (placeholder, dimmed) / Library (placeholder, dimmed)
+- Active nav item: 12% accent-tinted bg + 2px forest-teal left strip
+- Main content area: `` for children
+
+`+page.svelte` (Overview): plain landing with two callout cards — "Pending requests: N" and "Lidarr: connected/unset" — each linking to its sub-page. Functional, not decorative.
+
+**Tests** (browser-mode, since SvelteKit `load` requires it):
+- Non-admin user redirected to `/` before layout renders.
+- Admin user lands on `/admin` and sees sidebar with Overview active.
+
+Commit: `feat(web): add /admin layout with role-gated load + sidebar`.
+
+---
+
+### Task 19 — Frontend: `/admin/integrations` Lidarr panel
+
+**Files:** Create `web/src/routes/admin/integrations/+page.svelte`, `integrations.test.ts`.
+
+Page elements (matches mockup `admin-integrations.html`):
+- Page header with status pill ("Lidarr · connected" Moss-tinted; "unset" Pewter ghost when not configured)
+- Form section "Lidarr" with rows:
+ - Base URL — text input (Obsidian inset, JetBrains Mono for the URL value)
+ - API key — password input (masked)
+ - Default quality profile — `