Files
minstrel/docs/superpowers/plans/2026-04-29-m5a-lidarr.md
T
bvandeusen d77f2d4459 docs(plan): add M5a Lidarr connection + search/add implementation plan
21-task TDD plan implementing the M5a spec (committed in 17406ee).
Tasks 1-4 are step-level (write test → run → implement → run → commit);
Tasks 5-21 are paragraph-level with template references back to 1-4
to keep the plan navigable while preserving full coverage.

Coverage:
- Task 1: migration 0010 + sqlc queries
- Task 2: internal/lidarr typed client
- Task 3: internal/lidarrconfig singleton wrapper
- Task 4: internal/lidarrrequests Service
- Task 5: internal/lidarrrequests Reconciler worker
- Task 6: RequireAdmin middleware
- Tasks 7-10: API handlers (search, requests CRUD, admin lidarr, admin requests)
- Task 11: cmd/minstrel main wires reconciler
- Tasks 12-13: web design tokens + API clients
- Tasks 14-15: DiscoverResultCard + StatusPill components
- Tasks 16-20: /discover, /requests, /admin/* routes
- Task 21: verification + branch finish

Self-review checklist confirms every spec section maps to a task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:01:20 -04:00

72 KiB

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. 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.goRequireAdmin middleware
  • internal/auth/admin_test.go — middleware tests
  • internal/api/lidarr.goGET /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<link> 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:

-- 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:

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
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:

-- 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:

-- 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
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
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:

// 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:

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:

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:

[
  {
    "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:

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
go test -race -v ./internal/lidarr/...

Expected: 4 tests pass.

  • Step 2.7: Add LookupAlbum and LookupTrack methods

Append to internal/lidarr/client.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:

// 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
go test -race -cover ./internal/lidarr/...

Expected: all tests pass; coverage ≥ 80%.

  • Step 2.12: Commit
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:

// 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:

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
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
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:

// 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:

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
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
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=artistSELECT id FROM artists WHERE mbid = $1
    • kind=albumSELECT id FROM albums WHERE mbid = $1
    • kind=trackSELECT 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_DisabledIsNoOplidarr_config.enabled=falsetickOnce 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_DisabledReturns503lidarr_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: <DiscoverResultCard> 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 <img> 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: <StatusPill> 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 <DiscoverResultCard>
  • 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/<matched_track_id> 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 <AdminSidebar> 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: <slot> 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 — <select> populated from GET /api/admin/lidarr/quality-profiles
    • Default root folder — <select> 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 <status>unset</status> 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 <StatusPill>.

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
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
golangci-lint run ./...
  • Step 21.3: Coverage check on new packages
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
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: <DiscoverResultCard>, <StatusPill>, <AdminSidebar> — 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.