Commit Graph

164 Commits

Author SHA1 Message Date
bvandeusen 4d42e298dd feat(android+web/diagnostics): on-device debug reporter + admin timeline (M9)
test-web / test (push) Failing after 11s
android / Build + lint + test (push) Failing after 1m19s
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
2026-06-29 18:56:36 -04:00
bvandeusen e932ab438c feat(web/playlists): stale-view banner + Refresh on an open system-playlist after rebuild
test-web / test (push) Successful in 39s
#980. When the daily rebuild fires while a system-playlist detail page is
open, its cached data goes stale and can't be refetched in place — the
playlist id rotated, so the old id 404s. serverEvents now exposes a monotonic
rebuild counter; the detail page shows a "this mix was refreshed" banner with
a Refresh that re-resolves the variant (systemShuffle) to the new playlist id
and navigates there. No forced redirect, no auto-reload — the user refreshes
on their terms. Functional behaviors were already correct (tapping a song
plays it; tiles load the current mix); this closes the cosmetic list-staleness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:48:52 -04:00
bvandeusen d05264ff80 fix(web/playlists): attribute source when playing a system playlist from its detail page
test-web / test (push) Successful in 38s
Playing a system playlist from /playlists/<id> previously sent no source, so
it never advanced that playlist's rotation — inconsistent with the home tile
(and the Android detail screen, which already tags the variant). Pass
source: variant alongside the existing self-heal closure so a play is
attributed regardless of the surface it started from. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:35:45 -04:00
bvandeusen 5a80a1e460 feat(web): subscribe to SSE and refresh home/playlists on playlist.system_rebuilt
test-web / test (push) Successful in 39s
The web client only ever SENT events; it had no inbound SSE listener, so a
tab left open across the daily system-playlist rebuild kept showing
yesterday's home + playlist snapshots until a manual reload (the stale-
browse-view bug behind #968). Add useServerEvents(): opens /api/events/stream
while authenticated and, on playlist.system_rebuilt, invalidates the home,
playlists, and system-playlist-status query caches. Deliberately does not
disturb the active playback queue — that self-heals on the failure path.
Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:44:05 -04:00
bvandeusen 27766ae063 feat(web/player): self-heal a stale system-playlist / radio queue on total failure
test-web / test (push) Successful in 39s
When the whole queue proves unplayable (e.g. a tab left open across the
daily system-playlist rebuild — the exact stale-snapshot case), the player
now re-pulls the fresh snapshot and resumes instead of dead-ending on
"Try again". The seeder hands the store an opaque refetch closure so the
store stays decoupled from the playlist API and the per-artist
(songs_like_artist) identity problem: single-instance variants re-pull via
systemShuffle, per-artist mixes via getPlaylist(id), radio re-seeds from
its track. Bounded to one self-heal per exhaustion (reset on the next
successful play) so a still-broken refresh can't loop; "Try again" stays
the genuine last resort. Wired from PlaylistCard, the playlist detail page,
and playRadio. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:51:47 -04:00
bvandeusen f7278f2417 feat(web): drop "Remove from library" from the track kebab
test-web / test (push) Successful in 32s
No single-click destructive action belongs in the kebab. Removing the item
orphaned its whole path (RemoveTrackPopover was its only caller, and the
admin/tracks API client was the popover's only caller), so per the repo's
no-dead-code convention the chain is fully removed: the menu item + its
admin/isAdmin plumbing in TrackMenu, RemoveTrackPopover(.svelte/.test),
src/lib/api/admin/tracks(.ts/.test), and the now-needless transitive mocks
in the CompactTrackCard / PlaylistTrackRow / playlist specs.

The kebab is now an 8-item, admin-agnostic menu. The DELETE /api/admin/tracks
server endpoint is untouched — a future safer admin surface can rebind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:17:35 -04:00
bvandeusen b3d6785543 feat(web): match track kebab to Android + add it to full-screen Now Playing
test-web / test (push) Failing after 32s
Bring the web TrackMenu to parity with Android's canonical TrackActionsSheet
so the kebab reads the same on both clients:
- Reorder to Android's groups: queue → like/add-to-playlist/start-radio →
  go-to-album/artist → hide.
- Drop the duplicate "Flag this track…" item — it opened the very same
  FlagPopover as "Hide" (Android folds flag into a single Hide).
- Align icons (ListVideo / ListMusic / ListPlus / Disc3 / User).
- Admin-only "Remove from library" stays as a web superset (Android has no
  surface for it), past its own divider.

Mount the kebab on the full-screen /now-playing route with hideQueueActions,
mirroring Android's NowPlayingScreen — Start radio / Add to playlist / Hide
were previously unreachable there (only like + volume + queue existed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:46:54 -04:00
bvandeusen 1a7515e6ea feat(taste): phase 4 — recommendation observability (#796)
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m24s
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.

Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
  source (system-playlist surface), reporting plays / skips / avg completion
  over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
  {window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
  skip_rate / avg_completion math.

Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
  / skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).

Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:28:30 -04:00
bvandeusen 3f240bc777 feat(web): "You might like" Home row (#790 web client slice)
test-web / test (push) Successful in 32s
The web UI rendered a fixed set of Home sections and had no code for the
server's you_might_like_albums / you_might_like_artists (shipped in
v2026.06.11), so the row was absent in the web client. Adds it, mirroring
the Rediscover block, positioned directly under the system-playlists row.

- types.ts HomePayload: two new slices (server always emits them; web ships
  in lockstep with the server).
- +page.svelte: a "You might like" section (albums + artists scrollers) as the
  first section under the playlists row, with a "still learning your taste"
  empty state for the cold-start/gated case. Reuses existing AlbumCard /
  ArtistCard / HorizontalScrollRow.
- home.test.ts / page.test.ts: mock payloads gain the two fields.

Completes the You-might-like row across all three clients (server already
emits it; Android in v2026.06.11; web here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:59:58 -04:00
bvandeusen 8f29cc7414 feat: poll in-flight requests on native + refresh after submit (#369)
test-web / test (push) Successful in 33s
android / Build + lint + test (push) Successful in 3m37s
Native RequestsViewModel gains poll-while-approved (silent reloads, paused
when nothing in-flight) for parity with the web auto-poll, and the SSE
collector now reloads silently instead of flashing the loading spinner.
Web discover submit invalidates qk.myRequests() so a new request appears
on /requests immediately.
2026-06-06 23:18:44 -04:00
bvandeusen e358a92cf4 feat(web): similar-artists strip + top-tracks panel on artist detail 2026-06-06 22:34:46 -04:00
bvandeusen 7426f6c718 feat(web): remove scan-schedule admin UI (scheduler retired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:52:20 -04:00
bvandeusen 413d729711 fix(web): drift audit batch 5 — SSR redirect + radio exclude cap
test-web / test (push) Successful in 33s
Two web-side findings from the 2026-06-02 drift audit (#552):

- **#559** /library and /playlists each had a +page.server.ts
  file calling redirect(308, ...). The app is configured as
  adapter-static + ssr=false (+layout.ts:5), so +page.server.ts
  files only run at build time / dev server — NEVER at runtime in
  the deployed build. Direct navigation to /library or /playlists
  (mobile bookmarks, hand-typed URLs) hit a blank page or 404. We
  worked around this earlier today by linking the nav directly to
  /library/artists, but bookmarks stayed broken. Converted both
  files to +page.ts (universal load) — same redirect logic, runs
  client-side in the SPA, which is what actually executes.

- **#554** Radio auto-refresh built its exclude= query parameter
  from the ENTIRE queue, growing unbounded each refresh as new
  tracks were appended. UUIDs are ~36 chars + comma; with the
  common 8KB query-string limit, ~220 tracks is the ceiling. A
  multi-hour radio session eventually 414'd; the .catch() ate the
  error and the player silently stopped topping up — dead radio
  with no user-visible signal. Cap exclude to the most recent 100
  ids; the server's RecentlyPlayedHours filter already handles
  broader history dedup so the request-side cap only needs to
  cover the visible queue's recent tail.
2026-06-02 18:25:52 -04:00
bvandeusen 47d2f61161 fix: drift audit batch 1 — six small mechanical wins
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 42s
test-go / integration (push) Has been cancelled
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.
2026-06-02 18:11:25 -04:00
bvandeusen de61305fde feat(web): admin playback-errors inbox
test-web / test (push) Successful in 34s
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.
2026-06-02 11:34:46 -04:00
bvandeusen 15a545a25c feat(web): two-pane now-playing on desktop with permanent queue panel
test-web / test (push) Failing after 33s
The full player previously sat dead-center on wide viewports — cover
+ controls capped at max-w-md left the right two-thirds of the screen
empty while the queue remained behind a drawer toggle. Split the
layout at lg+:

- Extract QueueList from QueueDrawer (header + count + scrollable
  list body + empty state); the drawer now wraps QueueList for its
  slide-in chrome, and the now-playing route embeds it directly.
- now-playing: at lg+ render the player as a left section + a 384px
  (xl: 448px) aside with QueueList. Below lg the layout is unchanged
  single-column and the drawer toggle in the bottom row still opens
  it (lg:hidden on the toggle button keeps it out of the way once
  the panel is permanent).
- QueueList owns the close X conditionally — drawer passes onClose
  and the bind:closeButtonRef for focus management; embedded panel
  omits both since it has no dismiss action.

QueueTrackRow's drag-to-reorder, click-to-jump, and current-row
highlight all carry over for free since they're owned by the row
component.
2026-06-01 23:24:06 -04:00
bvandeusen 74bae74f9f fix(web): songs-like play overlay + scrubber smoothing
test-web / test (push) Failing after 45s
PlaylistCard: per-artist system variants (songs_like_artist) all share
one variant tag but exist as one playlist per seed artist; routing
their play handler through systemShuffle(variant) hit the wrong
playlist (or 404). Detect via seed_artist_id != null and fall through
to getPlaylist(playlist.id) for those; still tag the queue with the
variant so source attribution stays correct.

smoothPosition.svelte.ts: new useSmoothPosition() hook mirrors
Android's rememberSmoothPositionMs. player.position only updates
~4 Hz (HTML audio timeupdate), so the seek thumb stepped visibly;
$effect resets on each canonical tick / seek / track-change and a
rAF loop extrapolates at playback rate between ticks.

Wired into both PlayerBar.svelte (mini + expanded seek rows) and
now-playing/+page.svelte. Seek input handler still reads the raw
range value (not smoothed.value) so user drags stay authoritative.
2026-06-01 22:39:24 -04:00
bvandeusen d4c6bb3f2d feat(web): alphabet rail chases pages on click for unloaded letters
test-web / test (push) Successful in 32s
Operator: prior rail disabled empty buckets, so clicking a letter
like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now
accepts a paginated source and walks pages on click until the
target letter surfaces.

- New props: hasMore, onLoadMore. When hasMore=true, every empty
  bucket stays enabled (clicking will chase pages); when hasMore=
  false, only populated buckets are clickable.
- jumpTo(bucket): if populated, scrollIntoView. Otherwise loop
  onLoadMore + tick() until the bucket appears or no more pages.
  Loader2 spinner replaces the letter on the pending button;
  cursor:wait + aria-busy. Other rail buttons disable while one is
  pending so clicks don't stack.
- Library Artists + Albums pass hasMore + onLoadMore through to the
  grid. Existing InfiniteScrollSentinel still handles scroll-driven
  loading.

Test: new case asserts rail enables empty buckets when hasMore=true
(the click would trigger onLoadMore).
2026-06-01 22:29:13 -04:00
bvandeusen c0d5d6aebf feat(web): Most Played horizontal compact tiles + revert Rediscover
test-web / test (push) Successful in 32s
Operator clarification: the 'compact like the app' request was for
Most Played, not Rediscover.

- CompactTrackCard rewritten as a horizontal Row: cover thumbnail
  left, title + artist column right. w-72 (~288 px) matches the
  Android CompactTrackTile's 176 dp visual weight. Most Played's
  existing chunked-rows-in-shared-scroller layout now reads like
  the Android multi-row LazyHorizontalGrid.
- Rediscover steps back from the compact w-32/w-28 widths to w-40/
  w-36 (the pre-PR-#66 sizes) — the size hierarchy still works
  with the regular AlbumCard treatment.
2026-06-01 20:55:54 -04:00
bvandeusen d43719516e feat(web): drop hero row + compact Rediscover tiles
test-web / test (push) Successful in 33s
Operator UI review on the merged build:

- Hero row felt 'odd' — operator chose remove-entirely on the
  AskUserQuestion options. The Playlists row already leads with
  For-You and Recently Added leads with the newest album, so the
  hero duplicated both anchors. Pulled HomeHeroCard.svelte + all
  page-level wiring (systemShuffle/getPlaylist imports + playForYou/
  playLatestAlbum handlers) and reverted the within() test scoping
  added in 682d7a5e.

- Rediscover tiles step down to w-32 (albums) / w-28 (artists),
  matching the Most Played CompactTrackCard visual weight on web.
  Reinforces the page hierarchy: fresh content gets real estate,
  throwbacks recede.
2026-06-01 20:34:21 -04:00
bvandeusen 682d7a5ec4 test(web): scope home For-You assertions to Playlists section
test-web / test (push) Successful in 33s
The new hero card on Home renders the For-You playlist twice — once
at the top in the featured row and once in the Playlists carousel.
The two existing assertions that did screen.getByText('For You')
now match both and fail. Wrap each in within(playlistsSection) so
the test targets the carousel render specifically.
2026-06-01 20:12:59 -04:00
bvandeusen 17b276b5f5 feat(web): hero row at top of Home (For You + most recently added)
test-web / test (push) Failing after 36s
#6 — Adds a 2-up grid above the Playlists carousel:
- Today's pick: For-You playlist card with a Play button that
  invokes the system-shuffle endpoint (same path as PlaylistCard
  uses for the play overlay).
- Just added: most recently added album card with a Play button
  that fetches the album detail and queues the tracks.

Both cards share HomeHeroCard.svelte — cover left, eyebrow + title
+ subtitle + Play right, on a from-accent/15 → surface gradient so
the eye lands here before scrolling into the carousels. Each card
links to its detail page; the Play button is event-stopped so it
plays in-place. md:grid-cols-2 stacks on narrow viewports.

Only renders when at least one of the two pieces of data is
available so the page degrades cleanly on fresh installs.
2026-06-01 20:10:47 -04:00
bvandeusen 5f297c4c21 feat(web): dominant-color accent strip on PlayerBar + bigger Home tiles
test-web / test (push) Successful in 36s
#9 — new lib/media/dominantColor.ts: load cover image, downsample to
1x1 canvas, read pixel. Approximates the dominant tone via the
browser's bilinear mean — close enough for an ambient accent without
the 5KB ColorThief dependency. Same-origin cover URLs so no CORS
dance. Result cached by URL so revisits are free.

PlayerBar samples the current track's cover and pipes the resulting
rgb into a 2px accent strip above both the compact and desktop
variants. Transparent until the first resolve; 300ms transition on
colour change so track-skips fade rather than snap.

#10 — slight bump to Home tile widths so a typical viewport shows
roughly 5-6 across instead of cramming 8-9: Playlists w-56, all
remaining AlbumCard rows w-48 (Recently Added + both Rediscover
album scrollers). Replaces the wave-2 sizes that were still showing
7-8 across on wider screens.
2026-06-01 20:03:48 -04:00
bvandeusen a92f2c0121 feat(web): radial gradient + playlist title overlay + tile-size hierarchy
test-web / test (push) Failing after 33s
Second wave of Home visual polish (UI tasks 80/82/84):

- Shell main background gets a 1200x600 radial gradient at the top
  using color-mix on fs-iron so the page reads with subtle depth
  instead of as a flat slab.
- PlaylistCard moves the title onto the cover with a bottom-to-top
  gradient overlay. The server-generated 2x2 collage becomes mood
  texture; the Fraunces playlist name becomes the dominant element.
  Track count and refresh label stay below the art. Mirror albums
  shadow-sm/shadow-lg/ring-accent hover treatment on the art-wrap.
- Tile-size hierarchy: Playlists w-52, Recently added w-44,
  Rediscover w-40. Visual weight tapers as you scroll down the
  page so the freshest content reads as most important.
2026-06-01 19:57:05 -04:00
bvandeusen c466e6c317 feat(web): crossfade between tracks (Tier C11)
test-web / test (push) Successful in 31s
test-go / test (pull_request) Successful in 34s
test-web / test (pull_request) Successful in 42s
android / Build + lint + test (pull_request) Successful in 5m4s
android / Build signed release APK (pull_request) Has been skipped
test-go / integration (pull_request) Successful in 11m1s
Operator-tunable 0-12s crossfade in Settings → Playback. Default 0
(off). Most albums sound best at 0 — gapless masters, classical, and
live recordings all suffer noticeable crossfades.

Implementation: pure-function position-derived volume scalar.
`deriveFadeScalar(position, duration, crossfadeSec)` ramps from 0 to
1 over the leading X seconds, 1 to 0 over the trailing X seconds,
and stays at 1 in between. Tracks shorter than 2X don't fade.

The layout's audio-volume effect now multiplies player.volume by
the fade scalar — no timers, no AudioContext, no element swapping.
Re-renders at the ~4Hz `timeupdate` cadence give 16 discrete steps
over a 4s fade, audibly close to smooth. Smoothing to per-frame
ramps via rAF is a follow-up if needed.

Setting persists to localStorage as `minstrel.crossfade` (matches
the existing volume-storage convention).

Tests cover the pure derivation (off, too-short track, fade-in,
fade-out) + clamp/persist on setCrossfade + invalid-input recovery
on readStoredCrossfade.
2026-06-01 14:22:30 -04:00
bvandeusen cc81e0f183 feat(web): copy-link button on owned public playlists (Tier C12)
test-web / test (push) Successful in 31s
Add a Link icon button to the playlist header that copies the
absolute /playlists/[id] URL to the clipboard. Visible only when
isOwner && pl.is_public. Falls back to a window.prompt() in
insecure contexts where navigator.clipboard is unavailable.

Tests: button visibility (private vs public owner) + clipboard
write target.
2026-06-01 14:13:05 -04:00
bvandeusen 859aff8d30 feat(web): smart empty-states with action affordances (Tier B6)
test-web / test (push) Successful in 32s
Replace dead-end empty copy with EmptyState cards that include a
clear next step.

- EmptyState.svelte: reusable card with `block` (whole-tab) and
  `inline` (sub-section) variants, an actions snippet for buttons.
- Artists / Albums: when the whole library is empty, link to
  /settings (the operator can scan a folder there).
- Liked: when all three sub-sections are empty, show a single
  whole-tab card ("No likes yet" with Explore Home + Browse albums).
  When only one sub-section is empty, an inline hint with a link
  to the corresponding library tab.
- History: "Listen to something" button → Home.
- Playlists: "Create a playlist" button calls the existing create
  flow; renamed from "New playlist" to avoid colliding with the
  header's button-by-name lookup in tests.

Liked tab tests updated to match: the previous "three 'no liked X
yet'" assertion is now a single onboarding card test + a sibling
test for the mixed populated/empty case.
2026-06-01 13:53:38 -04:00
bvandeusen be7be6f617 feat(web): quick-filter on Library tabs (Tier B5)
test-web / test (push) Successful in 33s
Add a debounced QuickFilter input at the top of each Library section
to narrow the loaded list without leaving the tab. Server-side
/search remains the route for full-library searches.

- QuickFilter.svelte: 120ms debounced two-way bound input with a
  clear button and Escape-to-clear.
- Artists: filter by name; empty-match copy includes a "Search the
  full library" link to /search/artists.
- Albums: filter by title + artist_name; same fallback link.
- Liked: filters all three sub-sections (artists/albums/tracks);
  sub-section header hides when its filtered length is zero.
- History: filters flatEvents before groupByDay, so day-grouping
  reflects the filter.
- Playlists: filters owned + public by name; owners CTA stays.

Covered by QuickFilter unit tests (debounce + clear + Escape).
2026-06-01 13:48:05 -04:00
bvandeusen fd12a145c8 feat(web): multi-select + bulk actions on track lists (Tier A3)
test-web / test (push) Successful in 31s
Add a global selection store backing a checkbox per track row and a
floating SelectionBar above PlayerBar with Play next / Add to queue /
Add to playlist / Like all / Clear.

- selection/store.svelte.ts: id Set + TrackRef Map + anchor index;
  toggleOne, selectRange (shift+click), clearSelection. Singleton —
  layout effect clears on pathname change.
- TrackRow: checkbox slot replaces the track number on hover; row
  click toggles selection once any row is picked; shift+click extends
  the range. Esc clears (takes priority over closing the queue
  drawer).
- SelectionBar: floating pill above PlayerBar, mounted inside the
  QueryClientProvider so the Like-all action can resolve.
- player.playNextMany: bulk variant of playNext for "Play next" on a
  multi-track selection.

Covered by selection-store unit tests and three new TrackRow tests
(checkbox toggle, sticky select-mode row click, shift+click range).
2026-06-01 11:41:29 -04:00
bvandeusen a2466b7d9a feat(web): full-screen NowPlaying view
test-web / test (push) Failing after 30s
Mirrors Android's NowPlayingScreen. New /now-playing route renders
outside the Shell (no top bar, no PlayerBar) so it's a focused full
viewport: large square cover, title + linked artist/album, full-width
scrubber with timestamps, prominent transport (prev / play-pause /
next, with a 56dp circular play button), shuffle + repeat toggles,
like button, volume slider, and a queue button that opens the
existing queue drawer.

Tapping the cover or title area in PlayerBar (compact + desktop)
now navigates to /now-playing instead of the album page. Pattern
matches Spotify / YouTube Music. The album link stays reachable via
the album-name link inside NowPlaying itself, so no nav is lost.

Back button in the NowPlaying header uses history.back() with a
fallback to /. Empty state when no track is loaded points the user
at Home or the Library.

Scribe 528, local task #62.
2026-06-01 11:30:07 -04:00
bvandeusen 5393174a27 feat(web): surface secondary system playlists on Home
test-web / test (push) Successful in 31s
Mirrors the Android change from commit bf61d6cf. Web's Home Playlists
row gains the 5 secondary system kinds (deep_cuts, rediscover,
new_for_you, on_this_day, first_listens) after the Songs-like slots,
in server-registry order, when they exist. No placeholders for these
— they depend on library shape (Deep cuts needs deep albums, On this
day needs prior history, etc.) so a missing one means "not enough
data," not "still building."

Test coverage: new `renders secondary system kinds` test creates a
mocked owned set with for_you + deep_cuts + new_for_you and asserts
all three card names render. Existing tests (5 placeholders, building
variant, For-You + 3 placeholders) unchanged since none seed
secondary kinds.

Most Played multi-row is NOT touched — web already chunks the 75
tracks into 3 rows of 25 via the existing HorizontalScrollRow rows=
{chunk(...,25)} pattern. My initial task description was wrong on
that point.

Naming collision (`rediscover` playlist vs Rediscover recommendations
section both read "Rediscover" on the same Home) deferred — operator
follow-up. Same on Android.

Scribe 531, local task #61.
2026-06-01 11:25:25 -04:00
bvandeusen 4362233d7c feat(web): global keyboard shortcuts (Space/J/L/M/Arrows//)
test-web / test (push) Failing after 32s
Universal music-player conventions, attached at window level from
+layout.svelte:

  Space, K     play / pause
  ArrowLeft    previous track
  ArrowRight   next track
  J            seek -10s
  L            seek +10s
  ArrowUp      volume +5%
  ArrowDown    volume -5%
  M            mute / unmute (restores prior volume)
  /            focus the global search input

Skipped when focus is in an input / textarea / select /
contenteditable so typing in the search box and rename fields
behaves normally. Modifier-only chords (Ctrl/Cmd/Alt + key)
intentionally pass through to the browser.

New store helper `toggleMute()` captures the last non-zero volume
so M can toggle back without permanently losing the user's level.

shortcuts.svelte.ts is the new module; +layout.svelte wires it
alongside useMediaSession / useEventsDispatcher.

Scribe 529, local task #60.
2026-06-01 11:17:59 -04:00
bvandeusen 6e6fbc7856 feat(web): top-bar centered nav + Library tab page (replace sidebar)
test-web / test (push) Successful in 32s
Operator 2026-06-01: "navigation layout and library sections are
what I'd like to have implemented as it seems better than our
current navbar solution. I think I'd like to have these nav options
moved into the top bar centered."

Top-bar restructure:
- Centered nav (replaces the 192dp left sidebar): Home / Library /
  Discover, with icons + labels. Labels collapse below sm breakpoint
  so the bar stays icon-only on small viewports.
- Right side (search input + user dropdown) unchanged.
- Hamburger button + MobileNavDrawer + the mobileNav store all
  removed - the centered nav lives at all viewport sizes.

Library page restructure (mirrors Android LibraryScreen):
- New routes/library/+layout.svelte renders a tab bar across the
  five Library sub-pages: Artists / Albums / Liked / History /
  Playlists. Active tab gets an accent underline + onSurface text.
- routes/library/+page.server.ts redirects bare /library to
  /library/artists (Android default tab).
- /playlists (list) moved to /library/playlists; old URL gets a 308
  redirect (routes/playlists/+page.server.ts) so existing bookmarks
  land on the new location. /playlists/[id] (detail) is unchanged -
  matches the server API URL shape.

Deleted: Shell's sidebar markup, MobileNavDrawer.{svelte,test.ts},
the mobileNav store, the old routes/playlists/+page.svelte. Shell
test rewritten to assert the new 3-item centered nav; playlists
test moved next to its new +page.svelte and its test-utils import
path updated.
2026-06-01 10:07:02 -04:00
bvandeusen bb1ab3a2e3 test(web): un-skip discover/requests route tests — mock $app (#374)
Root cause: discover.test.ts and requests.test.ts were the only route
page tests NOT mocking $app/state (+ $app/navigation), so rendering
the +page.svelte pulled in SvelteKit's client runtime and threw
`TypeError: notifiable_store is not a function` at module load. They'd
been describe.skip'd AND hard-excluded in vitest.config.ts.

Fix mirrors every other route test: vi.hoisted pageState +
vi.mock('$app/state', () => pageUrlModule(state)) +
vi.mock('$app/navigation', () => ({ goto: vi.fn() })). Un-skip both
describe blocks; drop the vitest.config exclude. The web test job
now runs both suites again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:42:11 -04:00
bvandeusen 005965d6de feat(coverart): #388 remove global cover-art backfill cap
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>
2026-05-16 18:41:21 -04:00
bvandeusen d67c0de596 refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.

Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
  exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
  GET /api/playlists/system/{kind}/shuffle ({kind} = raw
  system_variant). Non-singleton/unknown kind → 404. Deleted
  playlists_{foryou,discover}_refresh.go and the per-kind shuffle
  wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
  the refresh affordance generically without hardcoding kinds.

Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
  systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
  playlist.refreshable; label is "Refresh {name}". Tests reworked;
  obsolete refresh-foryou/discover api tests deleted.

Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
  songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
  detail screen gate kebab/Regenerate/source-tagging on refreshable
  so songs_like_artist plays via get() (no by-kind endpoint).

Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:21:09 -04:00
bvandeusen efa52484d4 fix(web): add player stub to player-store mocks for TrackRow tests
#401 introduced player.current?.id reads into TrackRow.svelte and
PlaylistTrackRow.svelte, breaking 26 test cases across 6 files whose
existing vi.mock('$lib/player/store.svelte', ...) blocks only stubbed
the functions used by the original components.

Added player: { current: undefined } to each affected mock — keeps
the existing function spies and lets the new isCurrent derivation
read a defined (false-y) player.current without blowing up.

Only updated the 6 files that failed; 16 other player-store mocks
exist across the suite but their tests don't render Track/Playlist
rows so the derivation never fires. Future tests that render those
rows will need the same stub (visible at the failure point with a
clear error message).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:05:59 -04:00
bvandeusen 9acad5461e refactor(web): extract emptyPlaylistsMock + apiClientMock test helpers
The DRY pass audit (#375) found two inline mock patterns repeated across the
vitest suite: a default empty-playlists mock for $lib/api/playlists (4 exact
copies in menu/card tests) and an api-client spy mock for $lib/api/client
(9 callers split between get-only and full-RESTy shapes — unified into one
helper that always returns all four verb spies).

Mirrors the existing test-utils/mocks/likes.ts and test-utils/mocks/quarantine.ts
convention. Tests with intentionally divergent shapes (AddToPlaylistMenu's
richer createPlaylist payload, PlaylistCard's getPlaylist, route-specific
mocks, events.svelte's specific resolved value) stay inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:45:15 -04:00
bvandeusen 356f8f5d6c test(web): update home placeholder counts for new Discover slot
5-slot system row (For-You, Discover, 3× Songs-like) means:
- empty state: 5 placeholders (was 4)
- For-You only: 4 placeholders (was 3) — Discover + 3 songs-like
2026-05-11 23:46:07 -04:00
bvandeusen 96aa2407d9 feat(home): surface Discover system playlist as a tile (web + flutter)
Server has been generating system_variant='discover' playlists since
M6a (internal/playlists/system.go and POST
/api/playlists/system/discover/refresh) but neither client surfaced
it. The Flutter home filtered system playlists by exact match on
'for_you' and 'songs_like_artist', dropping Discover. The web home
did the same. Tiles were generated server-side and silently
discarded by both clients.

Add a Discover slot to the playlists row in both:
- Flutter: 5-slot row now (For You, Discover, 3× Songs-like) with
  matching placeholder state machine.
- Web: same shape, same placeholderVariant() call.

When the engine has built it, the real playlist tile renders;
otherwise a placeholder with the same building/pending/failed
status semantics as the other system tiles.
2026-05-11 23:41:01 -04:00
bvandeusen 4c4399c9bb feat(server,web): expose server version via /healthz + display in Settings
Came up debugging the in-app update flow — wasn't obvious which image
the container was actually running without exec'ing in. New flow:

### Server
- internal/server/version.go: new `var ServerVersion = "dev"`,
  overridden via -ldflags at build time.
- /healthz response gains a "version" key alongside the existing
  "status" + "min_client_version". Backward-compat: existing clients
  ignore unknown JSON fields. Endpoint stays unauthenticated.

### Build
- Dockerfile: new `ARG MINSTREL_VERSION=dev`, threaded into the
  go build -ldflags so the binary's ServerVersion is stamped at
  link time. Default "dev" preserves local `docker build` ergonomics.
- .forgejo/workflows/release.yml: tags step also emits a `version`
  output (the git tag for tag pushes, "main" for branch pushes);
  build step passes it as `--build-arg MINSTREL_VERSION=...`.

### Web
- web/src/lib/components/ServerVersion.svelte: small understated
  text ("Server v2026.05.10.2") that fetches /healthz on mount.
  Renders nothing on parse failure or pre-version images.
- Mounted at the bottom of Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:13:19 -04:00
bvandeusen 5054c46385 fix(web): collapse the entire Mobile app section when no APK bundled
When /api/client/version returns 404 (no APK in /app/client/, e.g.
v2026.05.10.0 which had a failed CI APK-attach step), the previous
shape rendered the section heading + intro paragraph with an empty
hole where the button should be. Confusing — looks like a broken
button.

Moved the section wrapper, heading, and intro paragraph INSIDE
MobileAppDownload so the whole block collapses together when info
is null. Settings page just mounts <MobileAppDownload />; nothing
visible if no APK is available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:44:38 -04:00
bvandeusen 154f415f92 fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.

### Server

- internal/api/client_assets.go:
  - clientAPKAllowDownload(): in-memory map[userID]time.Time under
    a mutex. Returns 0 (allow) or wait duration (block).
  - handleClientAPK reads user from context, checks the limit,
    returns 429 + Retry-After header if blocked.
  - testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
  the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
  TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
  different user gets a fresh slot). Existing tests updated to use
  authedRequest() helper.

### Web

- MobileAppDownload.svelte: switched from bare fetch (no credentials)
  to api.get<>() which carries the session cookie. 404 / 401 /
  network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
  never show this. Settings → Mobile app section keeps it for
  logged-in users.

Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:27 -04:00
bvandeusen 2435ef7961 feat(web): MobileAppDownload component on login + settings (#397 follow-up)
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>
2026-05-10 20:23:56 -04:00
bvandeusen 7bc254e17e feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch
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>
2026-05-10 17:43:41 -04:00
bvandeusen 1daf0b7fdf feat(web): close #358 invites RowActionsMenu wiring
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>
2026-05-10 17:14:18 -04:00
bvandeusen 9b4f907db6 refactor(web): sweep 24 test files to use makeTrack/makeTracks fixtures (#375)
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>
2026-05-10 15:58:14 -04:00
bvandeusen 2ec54bc429 refactor(web): sweep playlist.test.ts to use emptyQuarantineMock() (#375)
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>
2026-05-10 15:42:57 -04:00
bvandeusen 7e8d19606a refactor(web): sweep test files to use empty likes/quarantine mocks (#375)
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>
2026-05-10 15:41:15 -04:00
bvandeusen dd67f28745 refactor(web): sweep 8 test files to use pageUrlModule() (#375)
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>
2026-05-10 15:39:25 -04:00