M5a is the foundation slice of M5 (Lidarr integration + quarantine). Decomposed into M5a/M5b/M5c during brainstorming on 2026-04-29: - M5a (this spec) — Lidarr connection + search/add + admin shell - M5b — Quarantine workflow (per-user soft-hide, admin resolution) - M5c — Radio suggested-additions (out-of-library MBIDs surfaced) Each ships as its own PR with its own brainstorm/spec/plan cycle. Key decisions captured: - Permissions: search-all, add-admin via request queue - Config: DB-only, Settings UI is the only entry point (no YAML) - Settings shape: dedicated /admin/* route group, hard route gate - Search UX: standalone /discover route, all three granularities - Lifecycle: library scan as source of truth + 5-min reconciler worker - Quality profile/root folder: default + per-add admin override - UI lands at FabledSword design system bar (forest-teal accent; Moss/Bronze/Oxblood action buttons; per project_design_system memory) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
26 KiB
M5a — Lidarr connection + search/add proxy + admin shell
Status: Draft for review · 2026-04-29
Sub-plan of: M5 (Lidarr integration + quarantine workflow). M5 was decomposed into three slices during brainstorming on 2026-04-29:
- M5a (this spec) — Lidarr connection + search/add + admin shell. Foundation; ships first.
- M5b — Quarantine workflow (per-user soft-hide, admin resolution UI). Depends on M5a only for the admin shell.
- M5c — Radio "suggested additions" (out-of-library MBIDs surfaced in
/api/radioresponses; SPA add affordance). Depends on M5a'slidarr_requeststable and add path.Each ships as its own PR with its own brainstorm/spec/plan cycle.
1. Goal
Connect Minstrel to a household Lidarr instance, give every user a search-and-request workflow at /discover, and give admins a moderation queue at /admin/requests. Approved requests fire Lidarr adds synchronously; a background reconciler matches the resulting downloaded tracks back to the originating request when the next library scan picks them up.
This slice does NOT introduce quarantine, soft-hide, or radio suggested-additions — those are M5b and M5c.
2. Goals and non-goals
Goals
- Operator can connect, configure, test, and disconnect a Lidarr instance from
/admin/integrationswithout editing YAML or restarting the server. - Any authenticated user can search Lidarr at artist / album / track granularity from
/discover. - Any authenticated user can submit an add request, which gets persisted to
lidarr_requestswith statuspending. - Admin can review pending requests at
/admin/requests, approve (with optional per-request override of quality profile / root folder) or reject (with optional note). - Approved requests trigger a synchronous Lidarr add and a library scan.
- A background reconciler worker matches
approvedrequests to newly scanned tracks and transitions them tocompleted. - Hard route gating on
/admin/*— non-admin users redirected before any admin content loads.
Non-goals (this slice)
- Quarantine workflow, soft-hide on tracks, admin "delete via Lidarr" path. → M5b.
- Radio "suggested additions" surfacing out-of-library similar tracks. → M5c.
- Webhook ingestion from Lidarr (push notifications on download complete). → optional follow-up; the polling reconciler is sufficient for v1.
- "Pending too long" failure detection / requestor notification on stalled adds. → open question, deferred.
- Self-service password reset, OIDC, or any other identity work. → orthogonal.
- Per-user Lidarr accounts. Lidarr is a single household instance.
3. Architecture
New Go packages
-
internal/lidarr/— HTTP client for Lidarr's v1 API. Mirrorsinternal/scrobble/listenbrainz/shape:Clientstruct withBaseURL,APIKey,HTTPfields. Methods:LookupArtist(ctx, query),LookupAlbum(ctx, query),LookupTrack(ctx, query),AddArtist(ctx, params),AddAlbum(ctx, params),ListQualityProfiles(ctx),ListRootFolders(ctx),Ping(ctx). Returns typed structs; never leaks raw JSON to callers. -
internal/lidarrconfig/— singleton config service. Reads/writes thelidarr_configrow, exposesGet(ctx) (*Config, error)returning a typed struct,Save(ctx, *Config) error. TheGetmethod also handles the "config not yet set" case by returning a zero-valueConfig{Enabled: false}so callers can branch cleanly. -
internal/lidarrrequests/— request lifecycle service:Service—Create(ctx, userID, params),ListPending(ctx),ListByStatus(ctx, status),ListForUser(ctx, userID),Approve(ctx, requestID, adminID, overrides),Reject(ctx, requestID, adminID, notes),Cancel(ctx, requestID, userID).Approvecallslidarr.Client.Add*synchronously and triggers a library scan via the existing scanner package.Reconciler— background worker analogous tointernal/similarity.Worker.Run(ctx)loop withtickinterval (default 5 min) callstickOnce(ctx), which:- SELECTs
lidarr_requests WHERE status = 'approved'with row limits. - For each, joins against
tracks/albums/artistsby MBID hierarchy. - Transitions matched rows to
completed, setsmatched_*_id,completed_at.
- SELECTs
Wiring
cmd/minstrel/main.gogains a third worker spin-up (alongside the scrobble and similarity workers). The reconciler skips its work whenlidarr_config.enabled = false.- New handler files:
internal/api/lidarr.go(search proxy),internal/api/requests.go(user-facing endpoints),internal/api/admin/lidarr.go(config CRUD + profiles/folders lookups),internal/api/admin/requests.go(approval queue). - New middleware:
RequireAdmin— checks the user resolved byRequireUserhasis_admin = true; 403s with{"error":"not_authorized"}otherwise. Mounted on the/api/admin/*route group.
Data flow — happy path
- User opens
/discover, types query, SPA hitsGET /api/lidarr/search?q=…&kind=artist|album|track. - Handler invokes the appropriate
lidarr.Client.Lookup*, normalizes the response, returns JSON. - User clicks "Request" →
POST /api/requestswith{kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}. - Handler validates the kind→fields invariant, creates a
lidarr_requestsrow with statuspending, returns 201. - Admin opens
/admin/requests, SPA hitsGET /api/admin/requests?status=pending. - Admin clicks "Approve" →
POST /api/admin/requests/:id/approvewith optional{quality_profile_id, root_folder_path}. - Handler snapshots the chosen values into the row, calls
lidarr.Client.AddArtist|AddAlbum, transitions toapproved, fires scan trigger. Returns 200 (or surfaces Lidarr error in 4xx/5xx). - Reconciler worker on next tick (≤5 min) sees the
approvedrow, joins againsttracks, finds the new track, transitions tocompletedwithmatched_track_idset. - Requester's
/requestspage shows statuscompletedwith a "Listen" link to the now-playable track.
SPA route gating
/admin/*route group has a+layout.svelte(or+layout.ts) guard: ifcurrentUser.is_admin === false, callsgoto('/')before child routes load. Page-level gate, not content gating — the operator's instruction.- Same pattern as the existing auth gate; small extension of the existing layout machinery.
4. Schema
Migration 0010_lidarr in two files (up.sql, down.sql).
lidarr_config (singleton)
CREATE TABLE lidarr_config (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled boolean NOT NULL DEFAULT false,
base_url text,
api_key text,
default_quality_profile_id int,
default_root_folder_path text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO lidarr_config (id, enabled) VALUES (1, false);
The CHECK (id = 1) plus seed row enforces "exactly one row, ever." The Settings UI shows "Connect Lidarr" instead of "Lidarr is connected" when enabled=false or base_url IS NULL.
lidarr_requests
CREATE TYPE lidarr_request_status AS ENUM (
'pending', 'approved', 'rejected', 'completed', 'failed'
);
CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track');
CREATE TABLE lidarr_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status lidarr_request_status NOT NULL DEFAULT 'pending',
kind lidarr_request_kind NOT NULL,
lidarr_artist_mbid text NOT NULL,
lidarr_album_mbid text,
lidarr_track_mbid text,
artist_name text NOT NULL,
album_title text,
track_title text,
quality_profile_id int,
root_folder_path text,
decided_at timestamptz,
decided_by uuid REFERENCES users(id) ON DELETE SET NULL,
notes text,
completed_at timestamptz,
matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL,
matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id);
CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status);
CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid);
CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid)
WHERE lidarr_album_mbid IS NOT NULL;
Shape notes:
- Three matched-* FKs (one per kind) instead of polymorphic — clean SQL,
ON DELETE SET NULLkeeps the historical audit even if the matched track gets removed later. - Track-kind requests still set
lidarr_album_mbid(because that's what Lidarr actually adds);lidarr_track_mbidis preserved for "I requested this song specifically" display. quality_profile_idandroot_folder_pathare NULL until decision time. When admin approves, the override (or the config default) gets snapshotted into the row.failedis reserved in the enum for a future reconciler timeout (e.g., "approved >7 days ago, no match"), but no reconciler logic transitions tofailedin this slice. It's a placeholder for an obvious follow-up.
Indexes rationale:
user_id—/requestspage scan ("show my requests").status—WHERE status='pending'for admin queue,WHERE status='approved'for reconciler.lidarr_artist_mbid/lidarr_album_mbid— reconciler joins againsttracks,albumsby MBID.
Down migration
Drops in reverse: indexes → table → enum types → singleton row deletion (table goes anyway).
5. API surface
All endpoints under /api/*, JSON request/response, {error: "<code>", message: "<human>"} envelope on errors. /api/admin/* group passes through RequireAdmin middleware (403 with not_authorized for non-admin tokens).
User-facing (any authenticated user)
| Method | Path | Behavior |
|---|---|---|
GET |
/api/lidarr/search?q=&kind=artist|album|track |
Proxies Lidarr lookup. Returns normalized list `[{mbid, name |
POST |
/api/requests |
Create a request. Body: {kind, lidarr_artist_mbid, lidarr_album_mbid?, lidarr_track_mbid?, artist_name, album_title?, track_title?}. Server validates kind→required-fields invariant. Returns 201 {request}. |
GET |
/api/requests |
List the caller's own requests (any status). Ordered by requested_at desc. Pagination: ?limit=&before= cursor. |
GET |
/api/requests/:id |
Single request detail. 404 if not caller's own and caller is not admin. |
DELETE |
/api/requests/:id |
Cancel a still-pending request the caller created. 409 request_not_pending if status != pending. |
Admin-only
| Method | Path | Behavior |
|---|---|---|
GET |
/api/admin/lidarr/config |
Returns current config. api_key masked as "***" when set, null when unset. |
PUT |
/api/admin/lidarr/config |
Body: {base_url, api_key, default_quality_profile_id, default_root_folder_path, enabled}. api_key: empty string = leave saved value unchanged; non-empty = update. URL validated. |
POST |
/api/admin/lidarr/test |
Body: {base_url?, api_key?}. Each field independently falls back to the saved value when absent or empty. Calls Client.Ping. Always returns 200 with envelope {ok: true, version: "..."} or {ok: false, error: "..."} — never an HTTP-level error envelope, so the SPA can render results uniformly. |
GET |
/api/admin/lidarr/quality-profiles |
Proxies Lidarr's quality profile list — populates the Settings dropdown. |
GET |
/api/admin/lidarr/root-folders |
Proxies Lidarr's root folder list — populates the Settings dropdown. |
GET |
/api/admin/requests?status=pending|approved|rejected|completed|failed&limit= |
Admin's queue view. Default status=pending. |
POST |
/api/admin/requests/:id/approve |
Body: {quality_profile_id?, root_folder_path?} (override fields; absent = use config default). Snapshots chosen values, calls `Client.AddArtist |
POST |
/api/admin/requests/:id/reject |
Body: {notes?}. Sets status rejected, records decided_* and notes. |
Error codes
lidarr_disabled, lidarr_unreachable, lidarr_auth_failed, lidarr_lookup_failed, mbid_required, request_not_pending, request_not_found, not_authorized.
6. UI surfaces
All four screens land at the FabledSword design system bar (memory: project_design_system.md). Tokens are referenced by name; concrete values live in the design-system memory. Mockups produced during brainstorming live in .superpowers/brainstorm/<session>/content/ (gitignored) — discover-fs-v2.html, admin-integrations.html, admin-requests.html, user-requests.html.
/discover (user-facing)
Search input at top, kind tabs (Artists / Albums / Tracks), card grid of results. Card state derives from the search response's in_library and requested flags:
- Kept (
in_library=true) — wins overrequested. Disabled ghost button "In library", "Kept" pill in the badge slot (forest-teal at 15% opacity bg). - Requested (
in_library=false && requested=true) — disabled ghost button "Requested", subtitle line shows "awaiting review" for pending requests, "downloading" for approved. - Requestable (
in_library=false && requested=false) — MossRequestbutton with plus icon.
Card layout discipline:
- Card body is
display: flex; flex-direction: column. Inside, a.textblock hasmin-heightreserving title + meta + badge-row even when fields are absent. Actions block usesmargin-top: autoso the button always anchors to the bottom of the card. .badge-rowreserves 22px regardless of badge presence — title sits at the same Y across cards.- Grid
align-items: stretchkeeps cards on the same row equal-height.
Implementation: <DiscoverResultCard> Svelte component with props {kind, artistName, albumTitle?, trackTitle?, imageUrl?, state: 'requestable'|'kept'|'requested', onRequest}. Reused for any future "list of music things" surface.
Track-kind result requests open a confirmation modal: "Requesting Track X will add the album Album Y. Continue?" Confirm = Moss, Cancel = Bronze. Disclosure is explicit, not silent.
/admin shell + sidebar
/admin/* routes share +layout.svelte:
- Role gate:
if (!currentUser.is_admin) goto('/')before child routes load. - 220px sidebar with Iron card surface. Active item: 12% accent-tinted background, 2px forest-teal left strip ("you are here").
- Nav items (this slice): Overview · Integrations · Requests · Quarantine (placeholder for M5b) · Users (future) · Library (future).
Lucide icons at 16px, 1px stroke. Sidebar text: Vellum default, Parchment on active.
/admin/integrations
Lidarr panel (single section in this slice; designed to host more integrations later):
- Header status pill: "Lidarr · connected" (Moss-tinted) when
enabled && reachable, "unset" (Pewter ghost) otherwise. - Form rows: Base URL · API key (masked) · Default quality profile (dropdown) · Default root folder (dropdown).
- Action row: Save changes = Moss, Test connection = Pewter ghost, Disconnect = Oxblood + trash icon (right-aligned, separated).
- Inputs on Obsidian (inset feel), 0.5px Pewter borders, 8px radius, focus =
box-shadow: 0 0 0 2px var(--fs-accent)(no layout shift).
A second placeholder section for "MusicBrainz overrides" with status unset foreshadows the panel's role as the integration hub. Not implemented in this slice.
/admin/requests
Tabbed list (Pending / Approved / Completed / Rejected). Tab counts as accent-tinted pills. Default tab Pending with badge showing count.
Row anatomy: 56px album-art square (Slate fallback when Lidarr returns no cover) · meta-row with kind pill + "requested by alice · 2h ago" small caps · title in Parchment · meta in Vellum · action cluster: Override (Pewter ghost, opens modal) → Approve (Moss + check icon) → Reject (Bronze + ✕ icon).
Override modal: collapsed-by-default override of quality_profile_id and root_folder_path for the specific approval. Most approvals click "Approve" without opening this.
Track-kind row's meta line spells out "Approving will add the album Geogaddi" — explicit disclosure of the album-promotion behavior.
/requests (user's own)
Single panel listing the caller's requests, ordered by requested_at desc. Row anatomy mirrors /admin/requests but action cluster is reduced:
- Pending → Cancel (Pewter ghost + ✕ icon).
- Approved → no actions, "Approved · downloading" status pill (Info-tinted).
- Completed → "Listen" link in forest-teal (the page's only brand-moment), navigates to the matched track.
- Rejected → no actions, admin's note rendered as Vellum meta when present.
Status pills use the doc's semantic palette (Warning · Info · Moss/Success · Error). Voice rule applied: "Kept" instead of "Completed", "Set aside" instead of "Rejected", "Awaiting review" instead of "Pending review."
7. Error handling
Lidarr unreachable / auth-failed
Client.*methods return typed errors:lidarr.ErrUnreachable,lidarr.ErrAuthFailed,lidarr.ErrLookupFailed.- Search proxy translates to
503 lidarr_unreachableor401 lidarr_auth_failed— the SPA shows a callout: "Lidarr is unreachable right now. Try again, or check Settings → Integrations." (Voice rule: this is an error/waiting moment, gets the flavored register.) - Admin approval handler same pattern: returns the error to admin so they can retry without losing the request. The request stays
pendingif the Lidarr call fails — never advances toapprovedwithout confirmation Lidarr accepted the add.
Test connection from Settings
POST /api/admin/lidarr/testalways returns 200 with{ok: bool, error?: string, version?: string}— never an error envelope. Lets the SPA always render the result without parsing HTTP-level errors.
Reconciler
- Worker errors logged at
WARN, never propagated. A failing tick doesn't stop the worker — next tick retries. - If
lidarr_config.enabled = false, worker silently no-ops each tick. - Postgres unavailability is a global concern; reconciler errors with the same backoff pattern as
internal/similarity.Worker.
Form validation
- Settings: URL must parse, API key must be non-empty if
enabled=true. Quality profile + root folder must be present and known to Lidarr (validated againstClient.ListQualityProfiles/ListRootFolders). - Request creation: kind→required-MBID-fields invariant enforced server-side. SPA validates client-side first to avoid round-trips.
8. Testing
Unit tests
internal/lidarr/— table-driven request/response parsing tests against canned fixtures (real Lidarr response samples committed underinternal/lidarr/testdata/). Covers happy path + auth-failure + 5xx + bad-JSON for each method.internal/lidarrconfig/—Getreturns sensible zero-value when row saysenabled=false;Saveupdatesupdated_at.internal/lidarrrequests.Service— pure-logic tests: kind→required-fields validation, status-transition validation (can only approvepending, can only cancelpending, etc.).
Integration tests (gated on MINSTREL_TEST_DATABASE_URL)
- Reconciler — seeds an
approvedrequest + a matching track row, runstickOnce, asserts status transitions tocompletedwith correctmatched_*_id. Covers:- artist-kind matched by
lidarr_artist_mbidagainstartists.mbid - album-kind matched by
lidarr_album_mbidagainstalbums.mbid - track-kind matched by
lidarr_album_mbid(track-promoted) againstalbums.mbid - no match (no transition)
- already-completed row not re-processed
lidarr_config.enabled=falseshort-circuits to no-op
- artist-kind matched by
HTTP tests (handler level)
internal/api/lidarr.go— search proxy with stubbedClient: 200 happy path, 503 disabled, 503 unreachable, 401 auth-failed.internal/api/requests.go— create with valid + invalid kind/MBID combinations; list-mine returns only caller's rows; cancel pending vs cancel non-pending; cross-user 404.internal/api/admin/lidarr.go— config GET masks api_key; PUT empty-string preserves api_key; test-connection always returns 200 envelope.internal/api/admin/requests.go— approve fires Lidarr stub, transitions row, captures defaults from config; approve with override snapshots override values; reject without notes works; non-admin 403 across the board.
Frontend tests (vitest)
<DiscoverResultCard>— renders three states correctly; callsonRequestonly in requestable state; reserved-slot CSS verified by computed-style assertion (badge-row min-height, button anchored)./discoverpage — debounce search input; renders results from store; transitions state on request submit; track-kind opens confirmation modal; modal Confirm calls API, modal Cancel does not./admin/requestspage — tab switch refetches; approve fires API and removes row from pending; override modal returns chosen values to approve handler; admin-only redirect verified at layout level./admin/integrationspanel — empty-state copy; test-connection updates status pill; Disconnect requires confirmation./requestspage — status pills render with correct semantic class; Cancel works on pending; "Listen" link only renders on completed rows.
Coverage target
internal/lidarr/≥ 80%internal/lidarrrequests/≥ 80%internal/api/lidarr.go,internal/api/requests.go,internal/api/admin/*— handler coverage measured combined ≥ 70% (matches current api package threshold)
9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Decompose M5 into M5a / M5b / M5c | Matches the M4 cadence; smaller PRs, faster review, clearer scope per slice |
| 2 | Permissions: search-all, add-admin (request queue) | Operator's call — "browse-and-suggest workflow lets non-trusted household members participate without giving them library-write access" |
| 3 | Config storage: DB-only, Settings UI as the entry point | Operator's product principle — "no one wants to configure yamls; this is a finished product, not a project" (memory: project_product_not_project.md) |
| 4 | Settings shape: dedicated /admin/* route group with hard route gate |
Per-app product surface for admin actions; redirect at layout-level, never load admin content for non-admin (operator's instruction) |
| 5 | Search UX: standalone /discover route (option B), not inline-in-search |
Operator preference — explicit "I want to add music" surface, separate from local-library search |
| 6 | Granularity: artist + album + track | Operator preference — track-kind resolves to album-kind under the hood (Lidarr's monitor unit is album), explicit modal disclosure rather than silent expansion |
| 7 | Lifecycle detection: library scan as source of truth | Reuses existing scanner; reconciler is a 5-min worker; webhook is a clean follow-up if latency matters |
| 8 | Quality profile / root folder: default + per-add override | Default covers >90% of approvals; override is the escape hatch. Modal is collapsed-by-default |
| 9 | Approve fires Lidarr synchronously, reconcile asynchronously (Approach 1) | Admin gets immediate "Lidarr accepted/rejected" feedback; reconciliation has to be async because downloads take minutes-to-hours |
| 10 | New RequireAdmin middleware on /api/admin/* route group |
Centralized auth check; SPA route gate is UX, server middleware is the security boundary |
| 11 | UI lands at FabledSword design system bar (memory: project_design_system.md) |
Operator's quality bar — "no more scaffolding-feel UI" (memory: project_ui_quality.md); accent only for brand-moments, Moss/Bronze/Oxblood for actions |
| 12 | Track-kind disclosure modal | Lidarr can't fetch a single track without its album; explicit "this will add the album" beats silently expanding the request |
| 13 | lidarr_requests.failed status reserved but not transitioned in this slice |
Foreshadows a "stalled approval" timeout follow-up; not v1 |
10. Out of scope (this slice)
Tracked in the M5 milestone for later sub-plans:
- Quarantine workflow —
lidarr_quarantinetable, soft-hide on tracks, admin "delete via Lidarr" path,/admin/quarantinepage. → M5b. - Radio suggested-additions —
/api/radioresponse includes a separate field for out-of-library MBIDs fromtrack_similarity; SPA shows "Would you add these?" affordance with inline request submission. → M5c. - Lidarr webhook ingestion — push notifications on download complete; near-real-time status updates on
/requests. → cheap follow-up after M5c. - Failed-request timeout — reconciler transitions long-stuck
approvedrequests tofailedwith operator-facing diagnostics. → operational tightening; not v1. - Admin requestor notifications — toast/badge when a user logs in if their request was approved/completed/rejected. → polish, slot into Fable #349 (UI polish pass) or earlier if it becomes friction.
11. Open questions
- Album cover art proxying — Lidarr returns image URLs that point at MusicBrainz/Cover Art Archive. The SPA could fetch directly (extra origins, CORS), or Minstrel could proxy them through
/api/cover-art?lidarr=.... Decision deferred to plan time — start with direct fetch, add proxy if CORS bites. - Search debounce / cache — Lidarr's lookup endpoint is the rate-limit-sensitive one. SPA debounce of 250ms + 60s server-side LRU cache on
(query, kind)is the conservative starting point. Tunable. failedstatus promotion — at what time threshold doesapproved→failed? Suggest 7 days, but no logic for it ships in this slice.