Android: a gated DiagnosticsReporter taps connectivity, server-health,
UPnP drops/player-state/route, power (Doze/battery-opt/screen), and
app fg/bg, plus a heartbeat snapshotting Sonos-vs-local position — the
locked-phone desync signal. Events buffer in a Room ring buffer
(deliberately NOT the MutationQueue: high-volume best-effort telemetry
that must survive the dead zone being debugged) and DiagnosticsUploader
drains them on a tick / health-recovery / sign-in.
Gating: the account flag (users.debug_mode_enabled) reaches the device
via a new /api/me refresh in AuthController; a per-device local OFF
switch lives in Settings. Reporter runs only when enabled && !optOut;
disabling drops the unsent buffer.
Web admin: /admin/diagnostics — pick account+device+kind+time-window,
see a chronological timeline, flip an account's debug mode remotely, and
Copy-JSON / Download-NDJSON the slice for analysis.
Room schema 6→7 (new diagnostic_events table + auth_session.diagnosticsOptOut;
pre-v1 destructive fallback).
Refs Scribe M9 (#119), tasks #1174#1175#1176#1177.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
Six findings from the 2026-06-02 multi-system drift audit (Scribe
parent task #552):
- **#553 (web)** Tailwind class fix: web admin playback-errors Delete
confirm button was using `bg-action-danger`, an undefined token —
swap to `bg-action-destructive` to match every other destructive
button. Restored the Oxblood signal that distinguishes Delete from
Cancel.
- **#555 (web)** Type the `source` field on `play_started` in the
EventRequest discriminated union. Server's eventRequest accepts it;
web's TS type was missing the slot, so a "drop extra properties"
refactor could silently strip the source tag and break system-
playlist rotation attribution.
- **#556 + #557 (server)** Coverage rollup whitelist was pinned to
('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added
'deezer' and 'lastfm' as valid cover_art_source values but those
never got wired in, so albums with art from those providers
silently counted as MISSING in the admin Coverage dashboard. The
rollup test was seeding only the pre-0020 sources, masking the
gap in CI. Extend the query to include deezer + lastfm; seed the
test with one row per valid source (regression-guards future
additions).
- **#558 (web)** Auth gate was blocking /forgot-password and
/reset-password/<token> — both are entered without a session by
definition, so the email-link reset flow was bouncing signed-out
users to /login. Add /forgot-password to the public set and a
/reset-password/ prefix matcher. New tests assert both routes
reach their pages without redirect.
- **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac
/.ogg while the stream handler (media.go) had been extended to
serve .opus, .aac, and .wav. A user with .opus files in their
library never saw them in artist/album listings because the
scanner skipped indexing — silent data loss. Aligned the scanner
to match the media handler.
Scribe statuses updated to in_progress; flipping to done after the
push since these are mechanical and verified directly against the
cited file:lines.
Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.
Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
"no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
/ detail / reporter / client_id / occurred_at. Matches the
operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
/api/admin/quarantine/{track_id}/delete-file endpoint AND
auto-stamps resolution='deleted' so a single click closes both
the file and the inbox row.
Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).
Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.
- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
unbounded; response {queued:int} → {started:bool} (the count is
unknowable synchronously for a fire-and-forget drain). Web copy +
client type + Go/web tests updated to match.
No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mobile-responsive design called for all 4 admin tables to use
RowActionsMenu. Requests + Quarantine + Users users-section landed
in earlier commits; the invites section in the same users page was
still rendering inline Copy/Revoke buttons. Now uses RowActionsMenu
with primary Copy + secondary Revoke (danger).
Extend expiry from the design's secondary list deferred — no
server endpoint exists yet (lib/api/admin.ts has no
extendInviteExpiry).
Closes the in-code portion of #358. Real-device verification
walkthrough at 375/414/768 + grid/safe-area checks remain
operator-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the dominant pattern (try { await fn } catch { pushToast(errMessage,
'error') }) for the 5 admin/+page.svelte handlers that surface errMessage
directly: onApprove, onReject, onResolve, onDeleteFile, onDeleteLidarr.
Tighter scope than originally planned — the 3 handlers in
admin/users/+page.svelte (onToggleAutoApprove, onGenerateInvite,
onRevokeInvite) use errCode + verb prefix ("Generate failed: ${code}")
which is structurally different and would change UX wire output if
forced through the helper. Skipping them keeps behavior identical.
The helper SWALLOWS errors so callers no longer need try/finally for
busy-state — `saving = true; await runMutation(...); saving = false;`
is correct because runMutation can't throw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MobileNavDrawer: Svelte 5 + jsdom binds `inert` as a property, not always
an attribute. Mirror the QueueDrawer.test.ts pattern that accepts either.
Integrations Save tests: Per commit bca8622 (Save runs Test first), the
two existing Save tests need to mock testLidarrConnection before clicking
Save — otherwise the Test step returns undefined, fails the ok check,
and putLidarrConfig is never reached. The newer 'Lidarr first-time
setup' suite already does this; just bringing the older two tests in
line. Also wrapped the assertions in waitFor since the put-config call
now resolves async after the test promise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI vitest run on 3f8a2c5 surfaced 17 failures across 5 test files; this
commit addresses 15 that are this batch's responsibility.
1. RowActionsMenu now accepts ariaLabel on RowAction. Defaults to label.
Admin pages (requests/quarantine/users) pass per-row aria-labels
matching the pre-batch buttons ("Approve Geogaddi", "Resolve Roygbiv",
"Make alice admin", etc.) so screen readers + tests find them.
2. PlayerBar.test.ts — anchored regex /^(play|pause)$/i so the new
"Player options" overflow ⋮ doesn't also match /play|pause/i.
3. MobileNavDrawer.test.ts — added vi.mock for $app/state, $app/navigation,
and $lib/auth/store.svelte (mirrors Shell.test.ts pattern). Without
these, SvelteKit's notifiable_store helper isn't bootstrapped in
vitest and the suite fails to load.
The 2 remaining vitest failures are in /admin/integrations Save flow
(putLidarrConfig spy not called). Untouched by this batch and the
recent "Save runs Test first" refactor (bca8622) appears related —
flagging for operator verification, not chasing as a regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Svelte 5 requires {@const} to be a direct child of certain blocks
({#snippet}, {#if}, {#each}, etc.). Placing them inside <li>
bodies broke the build with const_tag_invalid_placement. Moved
the RowAction const declarations up so they sit between {#each}
and the <li> opener; the each-binding (u/r) is in scope for the
entire each block body, so the consts behave identically.
Caught by local docker build on dev:
src/routes/admin/users/+page.svelte:259:12
https://svelte.dev/e/const_tag_invalid_placement
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Requests: primary Approve, secondary [Override, Reject].
Quarantine: primary Resolve, secondary [Play, Delete file]; the
Delete-via-Lidarr conditional stays inline above md (preserves the
disabled-with-tooltip semantics) and hides below md to avoid
crowding — the operator can still trigger it from the desktop view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactors the new describe block to use the existing setupFresh()
helper pattern (mirrors setup() but with empty profile/folder lists
so we can verify the test response is what populates the dropdowns),
the lidarrSection() scoping helper (the cover-providers and SMTP
panels also have "Save changes" buttons that match unscoped queries),
and the correct mockQuery({ data: ... }) shape used elsewhere in the
file. Drops the duplicate afterEach (the file already has a global
one at line 131).
No behavior change in the tests themselves — same three cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new cases:
- Save on a fresh form calls test first, fills auto-defaults from the
response, then put-config with non-zero defaults
- Save aborts when test fails and surfaces the error code
- An operator-picked default value survives the test → save sequence
(auto-default $effect only fills when current value is 0/'')
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click sequence on a fresh Lidarr config: enter URL + API key → click
Save. The page now calls testLidarrConnection first, stashes the
returned profiles/folders into local state, lets the existing
auto-default $effects pick first-of-each, then sends the put-config
request with non-zero defaults. If the test fails, the save aborts
and the error surfaces in saveError.
The explicit Test connection button keeps working and additionally
populates the dropdowns from the same response so an operator can
inspect/adjust before saving.
Each dropdown reads from its test-state array when present, falling
back to the existing query data (which only works after the first
successful save). list_errors entries surface as inline notes under
their respective dropdowns.
Closes the chicken-and-egg where saving required defaults that could
only be fetched after saving.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>