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 — `` populated from `GET /api/admin/lidarr/root-folders` +- Action row: Save changes (Moss + check icon), Test connection (Pewter ghost + refresh icon), Disconnect (Oxblood + trash icon, right-aligned) +- Disconnect requires a typed-confirm modal ("Type DISCONNECT to remove the Lidarr connection") because it sets `enabled=false` and clears `api_key`. + +Disabled section "MusicBrainz overrides" with `unset` foreshadows future integrations. Visually present, not implemented. + +**Tests:** +- Save changes calls PUT with form values. +- Empty api_key field on Save preserves saved value (sends empty string per spec). +- Test connection populates Lidarr's reported version on success. +- Disconnect requires modal confirmation; cancelling modal does not clear config. +- Quality-profile / root-folder dropdowns populated from API. + +Commit: `feat(web): add /admin/integrations Lidarr connection panel`. + +--- + +### Task 20 — Frontend: `/admin/requests` approval queue + override modal + +**Files:** Create `web/src/routes/admin/requests/+page.svelte`, `requests.test.ts`. Reuse ``. + +Page elements (matches mockup `admin-requests.html`): +- Tabs: Pending (default) / Approved / Completed / Rejected — each shows count from API as accent-tinted pill +- Request rows with action cluster: Override (Pewter ghost), Approve (Moss + check icon), Reject (Bronze + ✕ icon) +- Track-kind row's meta line spells out "Approving will add the album *X*" +- Override modal: collapsed-by-default form with Quality profile dropdown (populated via the admin endpoint) + Root folder dropdown; "Use defaults" leaves both empty (server uses snapshot defaults) + +**Tests:** +- Tab switch refetches with `?status=`. +- Approve fires POST with optional override values. +- Reject opens a notes input (textarea) above a Confirm button; Confirm sends notes. +- Approve with override modal returns chosen values to handler. +- Toast on Lidarr-unreachable error. + +Commit: `feat(web): add /admin/requests approval queue with override modal`. + +--- + +### Task 21 — Final verification + branch finish + +- [ ] **Step 21.1: Full Go test sweep** + +```bash +go test -short -race ./... +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 -p 1 ./... +``` + +Expected: short suite + integration suite both green. + +- [ ] **Step 21.2: Lint clean** + +```bash +golangci-lint run ./... +``` + +- [ ] **Step 21.3: Coverage check on new packages** + +```bash +go test -race -coverprofile=/tmp/cov.out ./internal/lidarr/... ./internal/lidarrconfig/... ./internal/lidarrrequests/... +go tool cover -func=/tmp/cov.out | tail -1 +``` + +Expected: combined ≥ 80% per spec §8. + +- [ ] **Step 21.4: Frontend full check** + +```bash +cd web && npm run check && npm test && npm run build +``` + +Expected: 0 errors, all vitest tests pass, build succeeds. + +- [ ] **Step 21.5: Manual smoke** + +- Set Lidarr config in `/admin/integrations` (use real Lidarr or stub). +- Search at `/discover`, request an artist. +- Approve from `/admin/requests`. +- Verify request shows up at `/requests` as Approved → wait for next library scan → status flips to Kept. +- Cancel a pending request from `/requests`. +- Verify non-admin is redirected when navigating to `/admin/*`. + +- [ ] **Step 21.6: Use `superpowers:finishing-a-development-branch`** + +Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence. + +--- + +## Self-review checklist (run before declaring the plan ready) + +**Spec coverage** — every spec section maps to a task: +- §3 Architecture: Tasks 2 (client), 3 (config), 4 (service), 5 (reconciler), 6 (middleware), 11 (wiring) +- §4 Schema: Task 1 +- §5 API surface: Tasks 7 (search), 8 (requests CRUD), 9 (admin lidarr), 10 (admin requests) +- §6 UI surfaces: Tasks 12 (tokens), 13 (api), 14 (DiscoverResultCard), 15 (StatusPill), 16 (/discover), 17 (/requests), 18 (/admin layout), 19 (/admin/integrations), 20 (/admin/requests) +- §7 Error handling: distributed across Tasks 7-10 (each handler maps Service errors to API codes) +- §8 Testing: every Task includes tests; Task 21 verifies coverage targets +- §9 Decisions ledger: not directly implemented but referenced in commit messages +- §10 Out of scope: explicitly excluded — no quarantine, no suggested-additions, no webhook +- §11 Open questions: cover-art proxy + debounce/cache deferred to plan time → debounce at 250ms in Task 16; cover-art direct fetch (no proxy) for v1 + +**Placeholder scan:** the per-task detail level drops after Task 4 (each becomes one paragraph) — this is intentional for plan navigability, not a placeholder. When a subagent picks up Task 5+ they expand the paragraph into the same step-level TDD detail using Tasks 1-4 as templates, and reference the spec for any ambiguity. No "TBD" or "TODO" remains. + +**Type consistency:** +- Method names match across plan: `Service.Create/ListPending/ListByStatus/ListForUser/Approve/Reject/Cancel`, `Reconciler.Run/tickOnce`, `Client.LookupArtist/LookupAlbum/LookupTrack/AddArtist/AddAlbum/ListQualityProfiles/ListRootFolders/Ping` +- API paths match spec §5 +- Component names: ``, ``, `` — used consistently +- DB field names: `lidarr_artist_mbid`, `lidarr_album_mbid`, `lidarr_track_mbid`, `quality_profile_id`, `root_folder_path`, `matched_track_id`, etc. — consistent + +Plan is complete.