Five fixes in one commit, all from the U1+U2+U3 push:
1. internal/api/library_test.go's Mount call wasn't updated when
U3-T4 added the mailer.Sender param. 17 args, sig wants 18.
Adds a trailing nil for the mailer. Same shape of test-fixture
lag the project has hit before — flagged as a recurring pattern
for the DRY-pass slice.
2. web/src/routes/settings/settings.test.ts mocked $lib/api/me with
`getAPIToken: vi.fn()` (no return value). The new /settings API
Token card's $effect calls `getAPIToken().then(...)` which threw
"Cannot read properties of undefined (reading 'then')" on every
ListenBrainz test. Default the mock to mockResolvedValue so any
test that doesn't override gets a valid resolution.
3. web/src/routes/admin/users/users.test.ts queried row buttons by
/^Delete$/ but T3 added per-user aria-labels ("Delete alice"),
so the row buttons' accessible name is no longer "Delete". The
modal's confirm button has no aria-label so its name IS "Delete"
exactly. Tests now click by per-user aria-label first, then by
/^Delete$/ for the modal confirm.
4. Same file: /Set password/i regex matched both the dialog's
submit button AND the row's "Reset password for ..." buttons
(because regex `Set` matches "ReSet" case-insensitively). Switched
to /^Set password$/ exact-match.
5. web/src/routes/reset-password/[token]/reset-password.test.ts had
/New password/i which matched both the "New password" and
"Confirm new password" labels. Switched to exact-match string
selectors.
6. web/src/routes/forgot-password/+page.svelte's onSubmit handler
had no catch — a rejected forgotPassword() bubbled as an
unhandled rejection in vitest. Added a silent catch since the
page intentionally shows the same success message regardless of
outcome (no-enumeration posture).
Three CI fixes from the U1+U2+U3 push:
- internal/api/admin_users.go imported the standalone
github.com/jackc/pgconn — that module isn't in go.sum and
go vet caught it. Switch to github.com/jackc/pgx/v5/pgconn
(the path used elsewhere in the package, e.g. auth_register.go
and me_profile.go).
- /reset-password/[token] dereferenced page.params.token without
the undefined narrowing svelte-kit's typegen requires. Coalesce
to '' on read and reject empty token at submit time with a
clear error.
- /admin/integrations SMTP card had a label without an associated
control as the TLS row's leading column header. Switched to a
span — the actual checkbox lives in the wrapping label below it.
Completes the U3 frontend that T5a's crashed dispatch left half-done.
- /reset-password/[token] — public; new password + confirm. Calls
POST /api/auth/reset-password with the URL's token. invalid_token,
password_too_short, and mismatched-passwords surface as inline
errors. Success redirects to /login?reset=ok.
- /admin/integrations gains an SMTP card (alongside Lidarr) with
enabled toggle + all fields + Save and Send-test-email buttons.
Password input is a separate state from the loaded form, so empty
submission preserves the stored server-side value (matches the
backend's empty-password-preserves contract). Test button error
codes (no_email_on_file / not_configured / send_failed) surface
as actionable toasts.
Tests cover both new pages plus extensions to settings + integrations
test files for the cards landed in T5a.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends the U1 /admin/users page with the four U2 admin endpoints.
- "New user" button opens a modal: username, optional display
name, password + confirm, optional admin checkbox. Validates
password match client-side; surface server-side errors
(username_taken, username_invalid, password_too_short).
- Per-row "Delete" opens a confirm modal explaining the cascade
(plays, likes, sessions). Last-admin guard surfaces as a clear
toast if the server refuses.
- Per-row "Reset password" opens a small modal: new password +
confirm. Toast confirms success.
- Per-row "Enable / Disable auto-approve" toggles the
per-user flag (the #355 sub-feature surface). Inline button
state reflects the current value.
AdminUser type extended with auto_approve_requests; the badge
appears next to the admin badge when enabled.
Tests cover create-user submit, delete confirm flow, last-admin
toast on delete, reset-password submit, auto-approve toggle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New admin route /admin/users with two sections:
- Accounts — table of all users (username, optional display name,
admin badge, created_at). Per-row "Make admin" / "Remove admin"
button calls PUT /api/admin/users/{id}/admin. Last-admin guard
surfaces as a toast ("Can't remove the last admin — promote
someone else first").
- Invites — list of active invites with Copy + Revoke per row.
"Generate invite" button creates a 24h token. Operator shares
the token with the invitee, who enters it on /register.
AdminTabs gains a "Users" tab (5 tabs total). New typed client
functions in admin.ts (listUsers, updateUserAdmin, listInvites,
createInvite, deleteInvite) plus query factory functions
createAdminUsersQuery / createAdminInvitesQuery and query keys
qk.adminUsers / qk.adminInvites.
Tests cover: list rendering, admin badge, promote/demote actions,
last-admin guard toast, invite generation, revoke flow, empty states.
AdminTabs.test.ts updated to assert 5 tabs and Users active state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /api/admin/cover-sources/research bumps cover_art_sources_meta.
current_version unconditionally; the next enrichment pass re-eligibles
every 'none' row. Existing positively-sourced rows are not affected —
the version-mismatch eligibility rule only kicks in for 'none'.
Admin Cover Art panel gains a "Re-search missing art" button that
calls the endpoint and shows a confirmation toast. Operator's escape
hatch when:
- They've added a Last.fm key and want to retry failed rows
immediately rather than waiting for the next scheduled scan.
- An upstream provider's catalog has updated (e.g. Deezer added a
back-catalog import).
- They want to verify a fix end-to-end before the next scheduled
scan fires.
SettingsService.BumpVersion (the unconditional version of T5's
BumpVersionIfProvidersChanged) is the single-call DB writer; both
endpoints route through it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a "Scan schedule" section to the admin overview page between
the existing Library Scan and Cover Art sections. Mode picker
(Off / Every N hours / Daily / Weekly) with conditional interval /
time / day inputs. Save button disabled until local state diverges
from the server state. "Next scan: in N hours (timestamp)"
inline indicator when mode != off.
formatRelative is a static helper (recomputes on render); operator
sees the configuration confirmation at a glance — not a countdown
timer.
admin.test.ts mock extended with createScanScheduleQuery returning
mode='off' default. New test asserts the section renders. Existing
assertions still pass.
Each of the 4 stage cards in the admin Library Scan section gains a
StageBadge in its header. The badge derives state via stageState()
from the existing ScanStatus payload — no new query, no schema
change. Active stage shows spinner + "Processing" in moss green;
done shows check + "Done" in moss green; pending and skipped show
muted text.
Card body now shows either the (possibly partial) numbers OR a
"Waiting for previous stage…" placeholder when the badge is
pending. The previous bottom-of-card "Pending…" / "Skipped."
fallback text disappears (the badge replaces it).
StageBadge.test.ts covers the four state renderings.
admin.test.ts adds one new case asserting the Processing badge
shows when scan.library is populated and in_flight: true. Existing
admin.test.ts assertions still pass (the default mock data has no
stage tallies + in_flight: false → all stages skipped, no Loader/
Check icons rendered).
Pure-function module that derives per-stage StageState (one of
pending / in_progress / done / skipped) from the existing ScanStatus
payload. No new query, no new endpoint — reuses what
createScanStatusQuery already polls.
Truth table covered by tests: scan undefined → skipped; in-flight
with no tallies → all pending; in-flight with library tally only →
library in_progress, rest pending; in-flight with library +
mbid_backfill → library done, mbid in_progress; finished with all
tallies → all done; finished with missing library tally → library
skipped, others done; in-flight with last stage tally → only last
stage in_progress (all earlier done).
Three issues from the previous push:
1. internal/api/admin_covers_test.go used t.Context() (Go 1.24+) but
go.mod declares go 1.23.0. Replace with context.Background() in
the testHandlersWithCovers helper; add "context" import.
2. internal/server/server_test.go's New() call missed the
*coverart.SettingsService argument added in T11. The new param
sits between the cover-art-backfill cap (int) and the *library.Scanner
pointer in the function signature; the test was using 12 args
instead of 13. Insert nil at the right position in all six call sites.
3. integrations.test.ts had 11 failing tests after T13 added the
cover-art-providers section. Both panels render "Save changes",
"Test connection", and "API key" controls, so the existing
Lidarr tests' role/text queries matched multiple elements. Add
helper functions (lidarrSection, coverProvidersSection,
providerCard) and scope every relevant query with within() so
each panel's tests interact only with their own controls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three failures from the prior push:
1. internal/api/library.go and 3 other callers of q.GetArtistByID
broke because T2 modified the query to use an explicit column
list, making sqlc generate GetArtistByIDRow instead of returning
dbq.Artist. dbq.Artist already has every column added by
migration 0018 (artist_thumb_path / artist_fanart_path /
artist_art_source / artist_art_sources_version), so the explicit
column list was unnecessary work. Revert the SELECT to SELECT *,
regenerate sqlc, GetArtistByIDRow disappears, all callers compile
again.
2. internal/tracks/service_test.go missed the dataDir parameter
added to NewService in T9. Add "" as the 4th arg to every test
call site (tests don't exercise the artist-art cleanup path; the
empty dataDir is fine).
3. integrations.test.ts:356's mock.calls.filter callback had a
too-narrow type annotation that svelte-check rejected. Relax to
any[] to match what mock.calls actually is.
Adds a "Cover art providers" section to the existing /admin/integrations
page with one card per registered provider (currently MBCAA + TheAudioDB).
Each card has:
- Provider name + capability badges (album cover · artist thumb · artist fanart)
- Status dot (green if enabled; muted if disabled)
- Enabled toggle + API key text field (only when requires_api_key)
- Save button (explicit save matches existing Lidarr card pattern)
- Test connection button (when testable) with inline ok/fail indicator
- Footer help line for TheAudioDB linking to the free key application
Save invalidates qk.coverProviders() always and qk.coverage() when
the PATCH response indicates version_bumped: true (so the coverage
gauge re-queries to show settled→with_art transitions on the next
scan).
API key field uses type=password with a placeholder that clarifies
the test-key fallback behaviour — never echoes back the stored key
(api_key_set bool from the GET response controls the "✓ Set"
indicator).
Local edit state is keyed by provider_id and survives query refetches
so in-flight edits aren't clobbered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an artist-art-enrich stage after cover-enrich in RunScan. The
stage drains EnrichArtistBatch with a configurable cap and the
operator's data_dir, persists processed/succeeded/failed tallies to
the scan_runs.artist_art_enrich jsonb column added in migration
0018.
The admin /admin Library Scan card grid expands from 3 to 4 columns
with the new "Artist art" card mirroring the cover-enrichment one
(Processed / Succeeded / Failed). The TS ScanStatus type gains an
optional artist_art_enrich field. The Go scanStatusResp gains the
matching ArtistArtEnrich json.RawMessage field.
cmd/minstrel/main.go (and admin scan-trigger handler) wire the new
RunScanConfig fields: ArtistEnrichCap reuses cfg.Library.CoverArtBackfillCap
for now (can be split if separate budgets are useful), DataDir is
cfg.Storage.DataDir.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a library-wide cover-art coverage gauge inline to the right of
the "Refetch missing covers" button. Three buckets: with_art ·
pending · settled. Native HTML title= tooltip on "pending" surfaces
the pending_no_mbid sub-count when > 0, telling the operator how
many "pending" rows are blocked on missing MBID and won't be moved
by another scan.
Run-scan and Refetch-missing handlers extend their TanStack
invalidation to also clear qk.coverage(), so the gauge ticks
immediately after the operator clicks instead of waiting up to 3s
for the next refetch interval.
flex-wrap on the row so the gauge drops below the button on narrow
viewports instead of overflowing.
The existing admin page vitest mock for $lib/api/admin gains a
createCoverageQuery stub returning data: undefined so the new
import doesn't break the existing page tests (which don't touch
the gauge).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Forward-fix from running the prior two commits in production. Backfill
on a real library exposed two further issues:
(1) gofmt -s flagged the column-aligned stubMeta methods in
mbids_test.go. Switched to the gofmt-canonical single-space form.
(2) Many albums failed to heal with SQLSTATE 23505 (unique constraint
albums_mbid_unique). Cause: the operator's library has duplicate
album rows in the DB — same MusicBrainz release, split into
multiple rows by the pre-MBID scanner because of subtle title or
artist disagreements between files. When backfill now correctly
extracts the MBID, two rows want to claim the same one and the
partial unique index rejects the second.
The conflict is correct DB behavior — we shouldn't have two rows
with the same MBID — but it's not a write failure to alarm the
operator about. Detect 23505 specifically:
- downgrade the log line from Warn to Info
- track these in a new BackfillMBIDsResult.Duplicates counter
(separate from Skipped, which retains its "no MBID in tag"
meaning)
- leave the duplicate row's mbid NULL; merging duplicates is a
separate (future) operator workflow
Same handling threaded through the scanner heal path so a regular
rescan doesn't generate the noise either.
JSON tally + admin UI gain a "Duplicates" line so the operator
can see how many duplicate rows their library carries.
Follow-up scope (separate task): a duplicate-album merge UX that
reparents the duplicate's tracks to the canonical row and deletes
the orphaned album row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds getScanStatus/triggerScan helpers, createScanStatusQuery factory
(3s poll), qk.scanStatus(), and a Library Scan section on the admin
overview page with per-stage tallies and inline Run scan button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds cover_art_source to AlbumRef, two admin API helpers (refetchAlbumCover,
refetchMissingCovers), a per-album retry button gated on is_admin in the album
detail page, and a bulk-refetch section in the admin overview.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add browser tab titles to all 18 static page routes using the pageTitle()
helper from $lib/branding. Titles follow the Minstrel · Section format.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three admin pages had divergent inline error-code → user-copy switch
tables (with wording drift, including a stale "Settings → Integrations"
that should have been Admin). Consolidate into web/src/lib/styles/error-copy.json,
expose via web/src/lib/api/error-copy.ts (ERROR_COPY + copyForCode),
and refactor the three pages to import the helper.
Fixes the three-way drift; the only user-visible behavior change is
the quarantine page now correctly says "Admin → Integrations".
Sets up the JSON for the M7 Flutter client (#356) to consume the same
table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Lidarr accepts a request the local reconciler imports albums and
tracks as they arrive — but until the request hit 'completed' the
operator had no way to see "is anything happening?" The /requests and
/admin/requests rows now surface a live progress line whenever the
request's matched entity has children in our library.
Backend:
- New CountAlbumsByArtist + CountTracksByArtist sqlc queries.
- requestView gains imported_album_count and imported_track_count.
- New fillProgress helper computes them from the matched entity:
- kind=artist → counts albums + tracks under matched_artist_id
- kind=album → counts tracks under matched_album_id
- kind=track → 1 once matched_track_id is set
N+1 in the list endpoints; acceptable at admin scale.
- handleListRequests, handleGetRequest, and handleListAdminRequests
populate the new fields on every response.
Frontend:
- LidarrRequest TS type extended with the two counters.
- Both the operator's /requests page and the admin /admin/requests
page render an accent-colored line under the row meta when at
least one counter is non-zero, e.g.:
Artist: "5 albums · 47 tracks ingested"
Album: "12 tracks ingested"
Track: "Track ingested"
- Updated test fixtures to include the new required fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin overview was just two stat cards. Promotes it to a proper
workbench by surfacing the top-5 of each actionable queue with inline
handlers, while keeping the dedicated /admin/requests and
/admin/quarantine pages as the deep-action surfaces.
Changes:
- Top stats grow from 2 cards to 3 (requests / quarantine / lidarr).
- New "Pending requests" preview list — top-5 rows with inline Approve
and Reject buttons that call the same approveRequest/rejectRequest
endpoints used on the dedicated page (default profiles, no override
modal — that flow stays on /admin/requests for power users).
- New "Quarantine" preview list — top-5 rows with three inline actions
matching the dedicated page: Resolve (single click), Delete file
(two-click confirm with inline "Confirm?" state), Delete via Lidarr
(two-click confirm). The two-click confirm avoids modal duplication
on the overview while still gating the irreversible operations.
- "View all <N> →" link surfaces beneath each preview only when the
full queue exceeds the preview limit, pointing to the dedicated page.
- Shared toast surface with errorCopy mapping for the same lidarr_*
codes the dedicated requests page handles, so failed actions surface
meaningful text instead of "unknown".
Test fixture migrated from per-test dynamic imports to top-level
imports — the previous pattern was producing stale-mock symptoms
where mockReturnValue was set after the page bound against the
original mocks. 12 tests cover stat counts, empty states, links,
each handler binding, the two-click confirm pattern, and the
"View all" surfacing rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persists the operator's metadata-profile choice alongside quality
profile + root folder. Defaults to the first profile Lidarr returns
(usually 'Standard' on a vanilla install) so the common case is
zero-click; operators with custom profiles like 'Singles only' can pick
explicitly.
Backend:
- Migration 0013: adds nullable default_metadata_profile_id to
lidarr_config. Existing rows get NULL and the service falls back to
fetch-and-pick-first until they save.
- Updated lidarr_config queries + sqlc + lidarrconfig.Config + admin
view/put body to round-trip the new field.
- handlePutLidarrConfig requires it (along with QP and root folder)
when enabled=true — matches the existing missing_defaults gate.
- New GET /api/admin/lidarr/metadata-profiles handler + lidarr.Client
ListMetadataProfiles (GET /api/v1/metadataprofile, same shape as
the quality-profile endpoint).
- lidarrrequests.Approve prefers cfg.DefaultMetadataProfileID; falls
back to the fetch-list path only when 0 (back-compat for upgraders).
Frontend:
- LidarrConfig type + LidarrMetadataProfile type + qk.lidarrMetadataProfiles.
- listMetadataProfiles + createMetadataProfilesQuery client helpers.
- Integrations page: third <select> picker, auto-defaults to first
profile when the saved value is 0, sends the new field on save and
clears it on disconnect.
- Updated test fixtures + the duplicate 'Standard' option string in
the dropdown-populates assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'lidarr_rejected' the operator was hitting on Approve was Lidarr 4xx-ing
the POST with two field-validation errors:
'Metadata Profile Id' must be greater than '0'.
'Artist Name' must not be empty.
Our payload was missing both. Lidarr's metadata profile is a separate
concept from quality profile (it controls which release types are
tracked: albums, singles, EPs, etc.) and is required by /api/v1/artist
and /api/v1/album. The 'already exists' line in the toast copy was an
incorrect guess at the cause; the real issue was an invalid payload.
Changes:
- internal/lidarr/types.go: AddArtistParams + AddAlbumParams gain
ArtistName and MetadataProfileID. New MetadataProfile struct mirroring
QualityProfile.
- internal/lidarr/client.go: AddArtist + AddAlbum payloads include both
new fields. New ListMetadataProfiles fetches GET /api/v1/metadataprofile.
- internal/lidarrrequests/service.go: Approve fetches the metadata
profile list and uses the first as a default (Lidarr installs ship
'Standard' at id=1 OOB, so this works for vanilla setups). Picker
on /admin/integrations is a follow-up. ArtistName flows from the
request row.
- web/src/routes/admin/requests/+page.svelte: drop the
speculative 'usually means already in library' copy on lidarr_rejected;
point operators at server logs for the field-level reason instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "I can't approve a request, the toast just says unknown" bug was
four problems compounded:
1. Lidarr config let enabled=true save with default_quality_profile_id=0
and default_root_folder_path=''. Approve then sent invalid POST bodies
to Lidarr, which 5xx'd.
2. lidarr.client.post() discarded Lidarr's response body on error, so
we couldn't tell why Lidarr 5xx'd from server logs.
3. handleApproveRequest's error switch didn't map ErrServerError or
ErrLookupFailed — both fell through to a generic 500 server_error.
4. apiFetch only parsed {error: {code, message}} envelopes, but admin
endpoints write {error: 'code_string'}. Every admin error toast
rendered as 'unknown'.
Fixes:
- internal/lidarr/client.go: capture up to 512 bytes of Lidarr's response
body when it returns 4xx/5xx; include in the wrapped error so server
logs show what Lidarr actually said instead of just the status bucket.
- internal/lidarrrequests/service.go: new ErrDefaultsIncomplete fires
before the Lidarr call when QP=0 or root_folder=''. Stops the bad
POST entirely.
- internal/api/admin_requests.go: handleApproveRequest now maps
ErrDefaultsIncomplete -> 'lidarr_defaults_incomplete' (400),
ErrServerError -> 'lidarr_server_error' (502),
ErrLookupFailed -> 'lidarr_rejected' (502).
- internal/api/admin_lidarr.go: handlePutLidarrConfig now requires
QP + root folder to be set whenever enabled=true.
- web/src/lib/api/client.ts: apiFetch handles both error envelope shapes
so admin error codes propagate to toasts.
- web/src/routes/admin/integrations/+page.svelte: auto-default to the
first quality profile and first root folder Lidarr returns when the
operator hasn't picked one yet — saves a click for typical
one-profile/one-folder home setups.
- web/src/routes/admin/requests/+page.svelte: friendly toast copy for
the new error codes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /admin layout had three nested concentric shells: the global Shell
header + main nav, then a per-admin header banner with a Sword icon, then
a left-aligned admin sub-nav (AdminSidebar) — two competing left-edge
navigation columns plus a redundant 'Admin' label that the URL and active
nav already conveyed.
Replaces the side sub-nav with a horizontal tab strip across the top of
the admin content (matches the discover-page tab pattern), drops the
duplicate header banner, and removes the disabled Users/Library
placeholder items so the tab strip only shows surfaces that actually
ship today. The component is renamed AdminSidebar -> AdminTabs to match
its new shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pending/Approved/Completed/Rejected tabs (active count only). Override modal
collapsed by default; reject modal with notes textarea. Lidarr-unreachable
toast surfaces sync-Approve failures. Cancel buttons in both modals are
Pewter ghost; Reject/Confirm reject use Bronze (the design system reserves
Oxblood for irreversible actions like Disconnect).
- Trim whitespace before checking the typed-confirm value (paste-with-spaces
was silently keeping the button disabled).
- Wrap onConfirmDisconnect in try/catch and surface disconnect_failed inline
in the modal — operators on flaky networks now see why nothing happened.
- Invalidate quality-profile + root-folder caches alongside config on both
save and disconnect, so a base_url change refetches the lists immediately.
Connection management for Lidarr — base URL, API key, default quality
profile, default root folder. Empty api_key on save preserves the
masked-saved key per backend semantics. Disconnect requires typed
"DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides
section for future integrations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hard route gate in admin/+layout.ts redirects non-admins to / before
the layout (or any child) renders. AdminSidebar reads the active route
from $app/state and applies a 12% accent-tinted bg + 2px forest-teal
left strip on the active item. Overview landing shows pending-request
count and Lidarr connected/unset, each linking to its sub-page. Shell
nav exposes the Admin link only to is_admin users.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>