Commit Graph
169 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 4.8 782f152d37 test(web/admin): AdminTabs now has seven tabs (Diagnostics added)
test-web / test (push) Successful in 31s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 19:11:39 -04:00
bvandeusenandClaude Opus 4.8 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
bvandeusenandClaude Opus 4.8 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
bvandeusenandClaude Opus 4.8 096a3c0b15 fix(clients): never leave cover tiles blank — shared web <Cover> + Android Coil placeholder/error
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 4m10s
Cover tiles (worst in the "You might like" home row, which surfaces
unplayed items whose art is often not yet backfilled) sat empty while
loading and stayed blank on a 404. The server returns a fast 404; the
gap was missing client-side loading/fallback states.

Web: new shared Cover.svelte owns the loading placeholder + onerror
fallback (static cover, or Disc3 for artists). AlbumCard, ArtistCard and
CompactTrackCard now reuse it instead of three hand-rolled <img> tags
that disagreed on fallback handling — notably ArtistCard had no onerror.

Android: ServerImage tracks Coil's load state so the per-caller fallback
doubles as a placeholder (loading) and an error state (404 / unreachable),
instead of only guarding the null-URL case. All five call sites pass an
explicit size modifier, so the new Box wrapper is layout-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:29:05 -04:00
bvandeusenandClaude Opus 4.8 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
bvandeusenandClaude Opus 4.8 9a166f9032 test(web): update TrackMenu spec for Android-parity kebab
test-web / test (push) Successful in 32s
The kebab gained "Start radio" and dropped the duplicate "Flag this track…"
(its action now lives solely under "Hide", which opens the same FlagPopover).
Net item count is unchanged (9 admin / 8 non-admin), but the named-item and
flag-entry assertions needed updating:
- mock playRadio in the store mock; assert Start radio dispatches playRadio.
- swap the flag-item presence check for start-radio.
- replace the "click Flag" test with "click Hide opens the popover".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:57 -04:00
bvandeusenandClaude Opus 4.8 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
bvandeusenandClaude Opus 4.8 773275e916 feat(web): surface "Start radio" in the track kebab menu
test-web / test (push) Failing after 31s
Radio was fully wired (playRadio → /api/radio + 80% auto-refresh) but its
only entry point was TrackRow's inline 📻 button, so it was unreachable from
the kebab — i.e. missing on the Most Played compact cards and the mini-player.
Add a "Start radio" item to TrackMenu, shown even under hideQueueActions since
reseeding a station from the current track is meaningful there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:16 -04:00
bvandeusenandClaude Opus 4.8 066616e196 fix(web): de-overlap Most Played hover controls + narrow desktop seek bar
test-web / test (push) Successful in 40s
CompactTrackCard is a short one-line row but reused CardActionCluster,
which corner-splits Like+Add (top) and the kebab menu (bottom). That split
is right for the tall square Album/Artist cards but makes the two groups
collide on the compact row's hover state. Give the compact card a single
inline, vertically-centred right cluster (Like + Add + menu in one group)
and widen its right padding reserve to match.

In the desktop PlayerBar, the left info column was a fixed w-72 (title kept
truncating) while the seek column was flex-1 (the scrubber hogged the slack
on wide screens). Let the left column grow up to max-w-md while holding its
288px floor at md, and cap the seek/transport column at max-w-xl centred, so
freed width flows to the title instead of stretching the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:26 -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 6ac36dd334 fix(web): user-playlist play omits third arg to satisfy contract test
test-web / test (push) Successful in 35s
74bae74f routed per-artist system mixes through getPlaylist and
always passed the source attribution as the third arg, falling
back to undefined for true user playlists. Vitest's toHaveBeenCalledWith
is arity-strict — playQueue(refs, 0, undefined) is not the same as
playQueue(refs, 0) — so the PlaylistCard contract test failed on
the user-playlist case.

Split the call: pass three args only when a variant tag exists,
two args for user playlists. Preserves source attribution for
songs_like_artist and keeps user playlists source-less as the
test pins.
2026-06-01 23:31:45 -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 ad1a000ad5 feat(web): alphabet rail always shows #/A-Z/& with disabled empty buckets
test-web / test (push) Successful in 33s
Operator: prior rail only emitted buttons for letters with items, so
jumping to a letter required scrolling to that section first — not
useful as a navigation tool. Rail now always renders the full set so
the page reads as a stable A-Z reference regardless of which letters
are present.

- New bucketFor() classifies the first character into '#' (digits),
  'A'-'Z' (letters), or '&' (everything else). Anchor ids switch
  from per-letter to per-bucket: alpha-#, alpha-A, ..., alpha-&.
- ALPHABET + railEntries hardcode the full # / A-Z / & order.
- Buttons for empty buckets render disabled with a low-opacity tint
  + cursor:default so they read as 'no entries here' rather than
  broken jumps. aria-label changes too — 'Jump to A' (enabled) vs
  'C — no entries' (disabled) so screen readers announce state.
- # / & get verbose labels ('numbers'/'symbols') because the bare
  glyph isn't readable.

Tests rewritten — 4 cases: full-rail-with-disabled-buckets,
DOM-order, populated-bucket ids, and a separate fixture confirming
digit-starting items bucket under '#'.
2026-06-01 21:47:10 -04:00
bvandeusen 93aa37c7b6 feat(web): Library — continuous grid + sticky alphabet rail
test-web / test (push) Successful in 33s
Operator UI review: artists/albums layout had a full-row letter
divider per first-character, so a section like '2' (one artist)
left 9 empty cells before '8' (one artist) started a new row. Lots
of dead space across letters with few entries.

Operator chose option 2: drop the dividers, add a side jump-bar.

- AlphabeticalGrid no longer renders per-letter row-spanning
  dividers. Items flow continuously across the grid; first item
  of each letter gets id='alpha-<letter>' so the rail can target
  it via scrollIntoView.
- New sticky vertical alphabet rail on the right. position:sticky
  + top:50% + translateY keeps it vertically centered in the
  viewport while the grid scrolls underneath.
- Each rail entry is a focusable button with aria-label='Jump to
  <letter>'. Hover/focus tinted with accent.
- Only renders when there's more than one distinct letter so
  small libraries (or filter-narrowed views) don't get an empty
  rail.

Test rewritten — letters are buttons on the rail, not dividers in
the grid. Three assertions:
- one jump button per distinct first-letter
- DOM order is items first then rail (was rail-then-items before)
- first item of each letter carries the alpha-<letter> id
2026-06-01 21:23:23 -04:00
bvandeusen 185b8fa9b5 test(web): Shell Library link asserts /library/artists
test-web / test (push) Successful in 33s
2026-06-01 21:00:07 -04:00
bvandeusen 27f1cefd55 fix(web): center nav in window + Library link + Search placeholder
test-web / test (push) Failing after 31s
Three operator-reported issues:

- Header was flex with the nav inside a flex-1 span — when the
  right side (search + user menu) grew wider than the left
  (wordmark), the nav's centered position drifted off page-center.
  Switched to grid-cols-3 with justify-self-{start,center,end} so
  the middle column pins to true window-center regardless of side
  widths.
- Library nav link pointed to /library, which 308-redirects via
  +page.server.ts. Operator reported it didn't navigate. Linked the
  nav button directly to /library/artists so SPA navigation skips
  the redirect roundtrip. matchPrefix='/library' keeps isActive
  matching every Library tab.
- SearchInput placeholder was 'Search artists, albums, tracks…' —
  shortened to 'Search' and added a Lucide Search icon inside the
  input on the left. Padding adjusted (pl-7) so the input text
  clears the icon.
2026-06-01 20:58:05 -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 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 5b25f89c01 test(web): update AlbumCard test for dropped year line
test-web / test (push) Successful in 32s
2026-06-01 19:59:40 -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 c9afd4f584 feat(web): card hover-reveal + drop year + accent rule on section headers
test-web / test (push) Failing after 33s
First wave of Home visual polish (UI tasks 78/79/81/85):

- CardActionCluster like/queue/menu cluster is now opacity-0 and fades
  in on group-hover or focus-within. The group class moves from the
  inner anchor to the outer card wrapper so the cluster (positioned
  outside the anchor) participates in the same hover scope. PlaylistCard
  refresh kebab gets the same treatment.
- AlbumCard drops the year line. Title plus artist is enough on Home
  tiles; the album detail page still shows the year.
- HorizontalScrollRow h2 picks up a flex-baseline layout with a trailing
  12-wide accent rule (after:bg-accent/60), so section headers read as
  chapter breaks instead of identical plain text.
- AlbumCard art-wrap is shadow-sm by default and lifts to shadow-lg
  plus ring-accent/40 on hover. Pairs with the existing
  group-hover:scale-1.03 for a clean lift-on-mouseover feel.
2026-06-01 19:54:16 -04:00
bvandeusen 9fc21d217a feat(web): keyboard reorder on playlist tracks (Tier C10)
test-web / test (push) Successful in 31s
Drag was already wired via @neodrag/svelte; this adds the missing
keyboard path so reorder is accessible without a pointer device.

- The drag handle becomes a real <button> (focusable). Disabled
  when canDrag is false so non-owners + unavailable tracks can't
  attempt a move.
- ArrowUp / ArrowDown on the focused handle call
  onMove(row.position, row.position +/- 1). Bounds clamping
  already lives in the page-level handler (playlists/[id]/+page.svelte:39),
  so out-of-range targets resolve as no-ops.
- Matches QueueTrackRow's pattern: aria-label "Reorder track …",
  aria-keyshortcuts, swallow Space/Enter to avoid scrolling.

Test updates: drag-handle assertions retargeted to /reorder track/
+ two new ArrowUp/ArrowDown handler tests.
2026-06-01 14:16:27 -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 bbc1036f77 fix(web): update PlayerBar test for /now-playing cover link
test-web / test (push) Successful in 30s
CI on a2466b7d caught it: the cover/title area now links to
/now-playing per the NowPlaying landing, but PlayerBar.test.ts
still asserted /open album/i + href /albums/xyz. Rewritten test
asserts /open now playing/i + href /now-playing, with a comment
explaining the Spotify/YouTube Music pattern (album reachable via
the album-name link inside NowPlaying).
2026-06-01 11:32:46 -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 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
bvandeusenandClaude Opus 4.7 a7bea43a13 feat(discover): on-demand Lidarr artist art for suggestions
Out-of-library suggestion artists (artist_similarity_unmatched rows)
have no local art row, so the Discover card always showed a
placeholder. Resolve art on-demand from Lidarr's artist lookup,
matched by MBID (foreignArtistId == candidate_mbid), and pass the
remote image URL straight through — no caching (a suggestion may
never be viewed; the browser fetches the URL directly).

- suggestionView gains image_url (omitempty); handler resolves it via
  bounded-concurrency Lidarr LookupArtist, best-effort, never fails
  the request.
- Lidarr is the sole source: disabled / unreachable / no-match →
  empty → existing placeholder. (No inline TheAudioDB fallback — it's
  externally rate-limited and there's no cache to amortize it.)
- web: ArtistSuggestion.image_url?; SuggestionFeed passes it to
  DiscoverResultCard (already supports imageUrl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:16:56 -04:00
bvandeusenandClaude Opus 4.7 ce36760819 feat(playlists): #417 — "Refreshed …" subtitle on system tiles
Closes the last buildable item of the #411 system-playlists-v2
umbrella. System playlists atomic-replace on rebuild, so created_at
(already on the wire — no server change) is the last-rotated time.
Surface it as a small tile subtitle so users see how fresh a mix
is: "Refreshed just now / today / yesterday / N days ago / Mon D".

- web PlaylistCard: refreshedLabel() + a muted footer line, shown
  only when system_variant != null. Unparseable/empty timestamp →
  suppressed (web test fixtures use created_at:'' so no test churn).
- flutter PlaylistCard: mirrored _refreshedLabel() + subtitle under
  the system badge for isSystem playlists.

Friendly wording deliberately distinct from HistoryRow's "m/h ago";
per-surface helper per the project's existing relative-time
convention. CI-pending; closes with the umbrella on device-check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:03:41 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 179519689b feat(web): #415 stage 3 (web) — rotation-aware system playlist play
Web half of Stage 3. System-playlist tile play now:
- calls the new GET /api/playlists/system/{variant}/shuffle endpoint
  (rotation-aware order from the server) instead of getPlaylist;
  plays the returned order AS-IS — no client Fisher-Yates, since
  the server already ordered it. Supersedes #413's client shuffle
  for system playlists specifically; user playlists keep getPlaylist
  + stored order.
- tags the queue with the system variant. The player store carries
  _queueSource; the events dispatcher includes `source` on
  play_started so the server advances that playlist's rotation.

User playlists are unchanged (getPlaylist, plain playQueue, no
source). Tests updated: For-You play hits systemShuffle (not
getPlaylist/refresh) and passes source:for_you; user play uses
getPlaylist + plain playQueue with no source.

Flutter half is blocked — the Flutter client has no play-event
reporting at all (no /api/events POST, no scrobble), so there's no
play_started to attach `source` to. Surfacing that as a separate
decision rather than silently scope-exploding #415.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:57:00 -04:00
bvandeusenandClaude Opus 4.7 d12afdad6e feat(playlists): system playlists default to shuffle on tile play
Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).

Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
  03:00 user-local snapshot is what plays now. Stops burning server
  compute on every press and stops swapping the playlist out from
  under the user.
- Generalize the kebab affordance to render for any system playlist
  (was Discover-only). Adds "Refresh For You" as an explicit
  replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
  the queue when shuffle:true. PlaylistCard passes shuffle:true for
  any non-null system_variant.

Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
  starting index and enables AudioServiceShuffleMode.all after
  setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.

User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:55:57 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 bb9e979876 feat(web,flutter): #401 — now-playing highlight on TrackRow + PlaylistTrackRow
Cross-platform consistency: when a track is currently playing, its row
on the album / liked / history / search / playlist detail surfaces
gets the same accent-border + bg-lift treatment that QueueTrackRow
applies on the queue panel. Closes the inconsistency caught during
the #375 DRY audit (the queue row had a "you are here" indicator;
other track-row surfaces did not).

Web — TrackRow.svelte + PlaylistTrackRow.svelte:
  isCurrent = player.current?.id === track.id
  → border-l-2 border-l-accent bg-surface-hover when true.
  PlaylistTrackRow additionally gates on !isUnavailable so deleted
  rows never match a phantom playing id.

Flutter — TrackRow + _PlaylistTrackRow:
  TrackRow becomes a ConsumerWidget, watches mediaItemProvider, and
  wraps its InkWell in a Container whose BoxDecoration carries the
  fs.iron background + 2px fs.accent left border when current. Title
  text also shifts to fs.accent and FontWeight.w500. Same pattern for
  _PlaylistTrackRow.

No "Now playing" pill on these surfaces — the player bar already
names the track, so the accent band alone reads as enough cue.

For #401 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:05:33 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 51afc992d4 fix(web): apply data-testid attrs to PlayerBar that were dropped from a8e6e1c
The previous commit message said I added data-testid="player-bar-compact"
+ "player-bar-desktop" to PlayerBar.svelte but the edits actually
dropped silently — only the test file changes landed. Without the
testids, the test file's within(getByTestId(...)) calls all fail.

This adds the actual attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:52 -04:00
bvandeusenandClaude Opus 4.7 a8e6e1c2f7 fix(web,flutter): PlayerBar test scoping + system-playlists revalidate
Two bundled fixes:

### Web: PlayerBar test failures
PlayerBar renders both compact (md:hidden) and desktop (hidden md:flex)
blocks; jsdom doesn't apply CSS media queries so both DOM trees are
present in tests. screen.getByRole/getByText found duplicates →
14 test failures.

Added data-testid="player-bar-compact" + "player-bar-desktop"; tests
scope queries via within(getByTestId(...)). Compact is the focus of
#358, so most tests scope there. The "Up next" subgroup explicitly
scopes to desktop since that copy was dropped from compact.

### Flutter: system playlists not loading
cacheFirst was too conservative — when drift had user-created
playlists from sync but no system playlists (For-You / Discover),
fetchAndPopulate never fired (drift wasn't empty). Result: home
tile row showed user playlists but never the system ones.

Added cacheFirst alwaysRefresh option = stale-while-revalidate.
playlistsListProvider opts in: yields cache immediately, then kicks
off REST refresh in background. Drift watch() picks up the new rows
and re-emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:22 -04:00
bvandeusenandClaude Opus 4.7 b2d1a9ea10 fix(web): compact PlayerBar — 3-column top row, full-width seek bottom (#358)
Replaces the kebab-with-popover PlayerOverflowMenu approach with all
player controls visible inline. Operator's stated intent was "two-row
layout with all controls visible," not "kebab hides shuffle/repeat/
queue/volume" — restructured to match.

### Compact view (below md)

  ┌─────────┬───────────┬───────────────────┐
  │ art     │ ♥   ⋮     │ 🔀 🔁 ☰            │  ← short sub-row
  │ title   │           │                   │
  │ artist  │ ⏮  ⏯  ⏭   │ volume slider     │  ← play row at full size
  ├─────────┴───────────┴───────────────────┤
  │ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
  └─────────────────────────────────────────┘

- Column 1: cover + title/artist
- Column 2: like+kebab on top sub-row, play controls (40/48/40) below
- Column 3: shuffle/repeat/queue on top, volume slider below
- Bottom: full-width seek slider with time labels

### Desktop view (md+)

Existing 3-column layout preserved; only change is the redundant
PlayerOverflowMenu kebab (which had been mounting at all widths)
is no longer there. Volume + shuffle + repeat + queue stay inline
in the right cluster as before.

### Cleanup

- Deleted PlayerOverflowMenu.svelte + its test (no callers remain)
- The TrackMenu kebab (per-track actions) stays — it's a varying
  list of actions that doesn't fit inline

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:52:42 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 e16bdd9546 fix(web): read snake_case keys in MobileAppDownload (#397)
Server's clientVersionResponse marshals to {version, apk_url,
size_bytes} but the component was reading body.apkUrl / body.sizeBytes
(camelCase). The !body.apkUrl guard was always true → early return →
download button never rendered even when /api/client/version returned
200 with valid data.

Verified directly: /api/client/version returns
  {"version":"v2026.05.10.1","apk_url":"/api/client/apk","size_bytes":65301242}
on the deployed v2026.05.10.1 image; the UI just wasn't reading those
keys.

Map wire (snake_case) → component (camelCase) explicitly via a
WireInfo type so the boundary is auditable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:10:22 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 308202a1df refactor(web): extract CardActionCluster (#375 item #8 — narrowed scope)
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>
2026-05-10 16:58:17 -04:00
bvandeusenandClaude Opus 4.7 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