Closes the discoverability gap on the in-app update flow — the
/api/client/apk endpoint exists but had no web UI surface to find
it. User asked to be able to "visit the site on my phone and
download the apk from there."
- web/src/lib/components/MobileAppDownload.svelte — fetches
/api/client/version once on mount; renders a download link with
version + size when 200; renders nothing on 404 (no APK bundled,
graceful degradation in dev environments and pre-CI-wiring images).
- Mounted on the login page (below the Register link) so the link
is discoverable without authentication. The /api/client/* endpoints
are themselves unauthed, so the flow works end-to-end for any
visitor on a phone.
- Also mounted in Settings → Mobile app section for logged-in users
who want to grab the matching APK for sideloading on a different
device.
Browser handles the download via the server's existing
Content-Disposition: attachment; filename="minstrel.apk" header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Half of #369 — the auto-poll piece. Inbox deferred (separate task).
Both /requests (user) and /admin/requests (admin, 'approved' tab only)
now refetch every 12s while at least one row has status='approved'.
Stops automatically when all rows settle to pending/completed/rejected.
TanStack Query's default refetchIntervalInBackground=false handles
the visibility behavior — polling pauses when the tab is hidden and
resumes on focus.
Predicate hasInFlightRequest() is exported + tested so the polling
logic is auditable independent of TanStack Query's internals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phases 1+2 of the background-play resilience work. Net effect on
desktop: gap-free track transitions on slow networks, automatic
recovery from transient stalls / network errors mid-track.
- preload="metadata" → "auto" on the main audio element so the
browser pre-buffers more aggressively.
- attachStallRetry() — listens for stalled / waiting / network-error
events. Schedules a reload-from-current-position after delayMs
(3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK
triggers immediate retry. Bounded by maxRetries (3 default) to
prevent tight loops on persistent failures.
- audioLoader seam — `lib/player/audioLoader.ts` exposes a
resolve(track) → URL + optional prefetch(track) hint. Default
returns track.stream_url; the planned Tauri shell can swap via
setAudioLoader() to return blob URLs backed by a Rust cache,
mirroring the Flutter drift+LockCachingAudioSource pattern.
- Hidden prefetch <audio> element. When current track passes 50%,
start loading queue[index+1]. On track-end the swap is gap-free
via browser HTTP cache + audio buffer warm-up.
Phase 3 (service worker chunk cache) deliberately deferred — that
work belongs in the Tauri shell as native Rust caching, not as a
web-only investment that would compete with it.
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>
The MediaCard consolidation in the discovery doc would have needed
~14 props to cover the differentiating bits (wrapping element a vs
button, art-shape, art-fallback strategy, click semantics, title
align/size, subtitle count). That's a god-prop blob that's harder to
reason about than three explicit cards.
Narrowed to the actually-drifted bit: the action cluster
(LikeButton + Plus + bottom-right menu slot, including the
stopPropagation defensive wrappers). One small component covers
AlbumCard / ArtistCard / CompactTrackCard so the focus-ring and
button-styling drift can't recur.
Each card keeps its own data-fetching, art container, and wrapping
element since those have load-bearing differences. The consolidation
addresses drift, not LOC for its own sake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces inline TrackRef literal redefinitions with calls to the
test-utils helper. Tests that asserted on default field values
(e.g. track titles, artist names rendered in the DOM) keep explicit
overrides; tests that only need a stub for shape now use makeTrack()
with no overrides.
PlaylistCard.test.ts, PlaylistTrackRow.test.ts, and playlist.test.ts
SKIPPED — they use PlaylistTrack (with track_id/added_at), not TrackRef.
ArtistCard.svelte and +page.svelte route files (matched by initial grep)
SKIPPED — live code, not test files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After e8eff1b migrated playlists.ts from raw apiFetch to the api.*
wrapper, three test files mocked the wrong surface:
- playlists.test.ts: GET case asserted init.method === 'GET' but
api.get omits init.method entirely (fetch defaults to GET). Fall
back to 'GET' when init.method is undefined.
- playlists.refresh-discover.test.ts: re-mock ./client to expose
`api: { post: vi.fn() }` instead of `apiFetch`; assertions check
api.post call args.
- playlists.refresh-foryou.test.ts: same.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled { subscribe, data } quarantine stub with the
emptyQuarantineMock() helper. readable() satisfies the same store
contract the page actually uses (subscription via $store), and the
playlist tests don't assert on quarantine state — the mock is purely
a transitive module-load satisfier (PlaylistTrackRow → TrackMenu).
Companion to commit 7e8d196 (likes sweep), which initially skipped
this file because it shared the hand-rolled stub shape with the likes
mock; the quarantine half can safely move to the helper.
Five other files in the original 8-file batch (PlaylistTrackRow,
TrackMenu, TrackRow, PlayerBar, CompactTrackCard) were absorbed into
that likes-sweep commit; search/tracks was absorbed into commit
dd67f28 (pageUrl sweep). hidden.test.ts SKIPPED — it imports
createMyQuarantineQuery as a vi.fn() to mockReturnValue per-test and
asserts on unflagTrack call args; the helper would defeat both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces duplicated inline vi.mock('$lib/api/likes', ...) blocks with
the emptyLikesMock() helper, and (where previously left in working
tree by a parallel quarantine sweep) rolls in the matching
emptyQuarantineMock() switchovers in the same files.
Together with commit dd67f28 — which already swept the four search/*
test files for likes — this completes the 18 likes-mock conversions
called out in #375.
LikeButton.test.ts kept inline — it uses a state-driven mock to
assert the toggle behavior.
SKIPPED:
- liked.test.ts: re-exports createLikedTracksInfiniteQuery and the
album/artist variants that emptyLikesMock() doesn't provide.
- playlist.test.ts: hand-rolled { subscribe, data } stub instead of
readable(...) — different store contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralizes the inline { page: { get url() { return state.pageUrl } } }
mock shape via test-utils/mocks/appState.ts. The vi.hoisted state
declaration remains per-file (vitest mock-hoisting requires module-
level declaration). Adds a $test-utils alias to svelte.config.js so
the helper can be imported without ../../../ chains.
SKIPPED (mock exposes more than url):
- Shell.test.ts, MobileNavDrawer.test.ts, admin.test.ts: static
page: { url: ... } shape, no hoisted state to centralize.
- album.test.ts, artist.test.ts: page exposes both params and url.
- playlist.test.ts, reset-password.test.ts: page exposes params only.
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>
reconstruction (#375)
PlaylistTrackRow.svelte and PlaylistCard.toTrackRefs both rebuilt
TrackRef from PlaylistTrack with the same field-by-field literal.
Server adding a new TrackRef field would have needed both sites
updated; now one helper owns the mapping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three nearly-identical try/catch wrappers across theme + player stores
collapse to read()/write()/remove() in lib/util/safeLocalStorage.ts.
Sets the pattern for future stores. Caller still does parse/serialize
since the existing call sites store strings (theme preference, volume
number) — no JSON wrapper needed yet.
persisted.ts left alone — its JSON-payload + per-key-suffix shape is
distinct enough to keep self-contained.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop 9 hand-rolled apiFetch calls with redundant Content-Type
headers + manual JSON.stringify. The api.* wrapper in client.ts
already handles both. Identical wire shape; existing tests cover
all endpoints.
Also makes api.del generic so DELETE endpoints with typed returns
(playlist track removal) can use the wrapper instead of raw
apiFetch. Default `T = null` preserves existing callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auth/store.svelte.ts no longer imports from $lib/player. Logout
broadcasts via auth/sessionEnd.svelte.ts (a tiny tick + outgoing
userId pair); player subscribes through $effect.root and owns the
player-specific teardown (clearPersistedQueue + playQueue([]) +
closeQueueDrawer()).
Cycle fully inverted — auth is now a pure leaf for player; future
session-end consumers (download cache, recent-search history) plug
in by adding their own effect on the same signal.
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>
bind:this requires an Identifier or MemberExpression — a ternary
like {i === 0 ? firstNavLink : undefined} is invalid. Replaced
with a single bind on the <aside> root, then querySelector('nav a')
to locate the first nav link at focus time. Same behaviour, valid
Svelte.
Caught by CI svelte-check on 268e12a (failed before the @const
fix landed in 1536860):
src/lib/components/MobileNavDrawer.svelte:91:13
https://svelte.dev/e/bind_invalid_expression
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>
Without padding, max-w-md (448px) modals touch the viewport edges
on a 375px screen. p-4 on the overlay clamps them inside the safe
area. Covers all routes that use the shared <Modal> component
(discover track-confirm, quarantine typed-DELETE, users
password-reset, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- HorizontalScrollRow arrows bump 32px → 40px on coarse pointers
(touch screens) for finger-friendly hits; mouse pointers unchanged.
- AlphabeticalGrid divider letter is now <h3> so screen readers
announce it as a section heading. Margin reset to preserve layout.
- Modal overlay gets p-4 so max-w-md modals stay inside the safe
area at 375px viewport. Covers all routes that use the shared
<Modal> component (discover track-confirm, quarantine
typed-DELETE, users password-reset, etc.).
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>
Mirrors the server-side extension of POST /api/admin/lidarr/test. The
integrations page consumes these in the next commit to pre-fill the
quality/metadata/root-folder dropdowns on first-time Lidarr setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The +layout.svelte auth guard only treated /login as public, so any
unauthenticated visit to /register bounced to /login?returnTo=%2Fregister.
The "Register" link on the login page therefore looped back to itself,
making the first-user-becomes-admin bootstrap path (handleRegister +
CreateUserFirstAdminRace) unreachable in production.
Extracted isPublicRoute() into web/src/lib/auth/publicRoutes.ts so the
public-route set has one source of truth and is unit-testable. Test file
explicitly comment-tags /register as a #376 regression guard.
Closes the last gap in user-mgmt umbrella #376.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final task of PR2 DRY pass. Extracts the verbatim
`offset + items.length >= total` math repeated across 8
createInfiniteQuery sites into a single Page<T>-shape helper.
The task spec described a bare-array helper signature
(lastPage: T[], pageSize-based stop), but no call site in this
repo uses that shape — every paged endpoint returns a Page<T>
envelope. The helper is named pageGetNextPageParam and matches
the actual canonical shape so the 8 copies could collapse.
Migrated:
- likes.ts: 3 sites (TrackRef, AlbumRef, ArtistRef)
- albums.ts: 1 site (AlbumRef)
- queries.ts: 4 sites (artists + 3 search facets)
history.ts left alone — uses has_more/total, different shape.
Cleans up the 8 svelte-check warnings I deferred in prior CI runs.
Two distinct categories — handling them differently:
REAL REACTIVITY BUGS — fixed:
- AlbumMenu.svelte / ArtistMenu.svelte: cachedTracks initialized
from the `tracks` prop only at component creation. When the menu
is reused across rows (operator opens menu on one album, then
another), the cache from the first open serves stale tracks.
Fix: initialize cachedTracks to null; ensureTracks() reads the
current `tracks` prop on each call before falling through to
the network fetch.
INTENTIONAL PATTERNS LINT FLAGGED — suppressed with explanatory
comments:
- AlbumMenu / ArtistMenu / PlaylistCard menu containers: the
onclick={(e) => e.stopPropagation()} is a guard against the
window-level "any click closes the menu" listener — not
user-facing interaction. A paired keyboard handler is
intentionally absent (Escape-to-close lives on svelte:window
onkeydown). Suppressed a11y_click_events_have_key_events with
explanatory comments.
- CompactTrackCard overlay div: catches clicks on its inner
LikeButton + queue button so they don't bubble to the wrapping
play-card button. Inner buttons carry their own keyboard
handling. Suppressed both a11y_click_events_have_key_events and
a11y_no_static_element_interactions.
- theme.svelte.ts module-scope `_resolved = $state(resolve(_theme))`:
module-init reads _theme to seed _resolved; subsequent updates
go through setTheme() and the matchMedia listener. $derived
doesn't apply at module scope. Suppressed state_referenced_locally
with an explanatory comment.
Promised in the prior fix-forward: "I'll fix bugs when I see them
or open a follow-up task with a specific name, not park them in an
umbrella." Following through.
Three Go lint hits + two web test failures, all from the U3 push.
- internal/mailer/mailer.go: SentEmail.HtmlBody → HTMLBody (Go's
initialism convention; revive flagged); FakeSender.Send unused
ctx parameter renamed to _.
- internal/audit/audit_test.go: removed dead validUUID helper. It
was added speculatively in U1-T1 and never called by any test.
pgtype import stays — other tests use it.
- web/src/routes/settings/settings.test.ts: existing ListenBrainz
tests queried `getByRole('button', { name: /save/i })` which
worked when the page had only one Save button. The new Profile
card adds a "Save profile" button that also matches the regex,
triggering "found multiple elements". Anchored to /^save$/i for
exact-match. The newer Save profile tests still use
/save profile/i which is unique.
This is the same shape of test-fixture-lag I owe an answer for: I
landed new content that broke an existing test, and the existing
test had a too-loose selector. The right fix is to tighten the old
selector now (this commit) and to flag this pattern — selectors
that regex-match by partial words — as a candidate for the DRY
pass / test-utils consolidation.
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).