Commit Graph
186 Commits
Author SHA1 Message Date
bvandeusen a9ca49dc4e feat(library): genre + year quick-jumps on album and artist detail — #367
test-web / test (push) Successful in 44s
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 4m59s
Last bullet of #367. From an album you like, one click to everything else from
that year or in that genre.

Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is
AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So
both detail responses gained a derived `genres` array, computed from the
entity's tracks rather than stored, since an album's tracks can legitimately
disagree about genre.

Split and trimmed identically to the browse index. That's the invariant this
whole task turned on: if the chip's matching diverged from the index's
splitting, a chip would lead to a page that doesn't contain the album you
clicked from.

No year link on artist detail. An artist spans many years, so a single one
would be a lie about the discography — genres only there.

Genre lookup failure is logged and degrades to no chips rather than failing the
request; a navigation nicety must not 404 a detail page that otherwise loaded.
`genres` is always an array at JSON, never null, matching how every other list
field in this package is emitted.

## Type widening, and the TypeScript version of a lesson from earlier today

Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture
that constructs one. Six of them across three test files. That's the same shape
as the Go signature changes that cost three CI rounds in #2453 — change a type,
then go find everything that builds it — so I searched for the constructions
before pushing instead of after. All six updated.

Tests: the encoded href for a slash-bearing genre ("Rock/Pop" →
?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all.
gofmt verified clean via docker rather than guessed.
2026-08-05 13:50:29 -04:00
bvandeusen feb1c2eca8 feat(web): genre and year browse pages — #367
test-web / test (push) Successful in 33s
Client half of #367. Two new Library tabs, each an index plus a drill-down.

Genres are ordered by track count rather than alphabetically. Raw ID3 carries a
long tail of one-off tags, so alphabetical would bury the handful of genres you
actually have a library's worth of. Years are grouped into decades — a flat
list of every year in a decades-deep library is a wall of numbers, and the
decade is how people actually think about it.

## Selection travels in the query string, not the path

`?g=Rock%2FPop`, not `/library/genres/Rock%2FPop`. A slash-bearing genre cannot
survive a path segment — the server sees two segments, and a hard reload
wouldn't reconstruct it through the SPA fallback either. There's a test pinning
the encoded href and another pinning that the DECODED value reaches the API.

## Why these two pages don't use svelte-query for their lists

The indexes do — fetched once per mount, so static options suffice and the
cache survives bouncing in and out of a drill-down.

The drill-down lists deliberately don't. Their selection comes from the URL and
changes WITHOUT remounting the page, and this codebase has no
reactive-query-options pattern anywhere; inventing one here would be a larger
change than the feature justifies, and one I can't exercise locally. So they
use $effect keyed on the derived selection with an explicit Load more.

The stale-response guard is a plain `let`, not $state, and that's load-bearing:
as reactive state, reading the token inside the fetch path would make the
effect depend on its own writes. Its job is to discard a late response for a
previously selected genre instead of painting it over the current one.

## Also

Added the year filter to /library/albums' contract but NOT to that page's UI —
its infinite scroll is a svelte-query infinite query, and making it react to a
filter is the same reactive-options problem. The dedicated pages cover the
capability, which is the shape the task offered as its alternative.

Library tab bar's comment claims it mirrors Android's LibraryScreen. These two
tabs have no Android equivalent, so I noted that inline rather than leaving the
claim quietly false. Parity remains an open call.

Not yet done from #367's bullet list: genre/year quick-jump links on album and
artist detail. Year is free (AlbumRef already carries it) but genre is exposed
nowhere client-side — AlbumDetail is AlbumRef + tracks, and neither carries
genre — so it needs a small API addition. Following as its own commit.
2026-08-05 13:41:51 -04:00
bvandeusen 381e9cedb7 feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
test-go / test (push) Failing after 50s
test-web / test (push) Failing after 50s
test-go / integration (push) Failing after 2m19s
Fixes the defect the operator spotted in #370 immediately after it shipped:
auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a
proxy on a public address — a separate host, or a CDN, i.e. anyone running
this publicly, since public means TLS means a proxy — recorded the PROXY for
every session. created_ip and last_ip were then always equal and the
"Address changed" signal could never fire. The feature looked like it worked
and reported nothing.

Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx).
XFF grows left-to-right as each proxy appends the peer it received from, so
for client -> CDN -> own-proxy -> app the app sees [client, CDN] with
RemoteAddr = own-proxy, and the client sits at XFF[len - hops]:

  0  RemoteAddr, XFF ignored — no proxy
  1  the address your own proxy observed
  2  through a CDN in front of your proxy

Default 1, per the operator: publicly reachable means a TLS terminator in
front.

The cost is real and stated rather than hidden. hops >= 1 DECLARES that a
proxy exists; set it with no proxy, or deeper than the actual chain, and the
index reaches attacker-supplied entries, letting a visitor choose which
address their own session shows — defeating exactly the detection #370 is
for. That's inherent to the model, which is why 0 is a first-class value and
the admin card says "count your proxies, don't guess high" instead of just
exposing a number. Both mis-set shapes are pinned by tests so they stay known
consequences rather than surprises.

Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an
optimisation: ClientIP runs in RequireUser for every authenticated request, so
a per-request query would put the database on the critical path of the whole
API. New() always returns a usable service so a boot-time DB hiccup degrades
to the default instead of breaking that path (rule #131), and Hops() is
nil-safe because test routers construct middleware without it.

RequireUser now takes a func() int rather than an int — the value is
operator-editable at runtime while the middleware is built once at boot, and
reading it per request is what makes a save take effect with no restart
(rule #25).

The admin card is verifiable, not just configurable: it reports the address
the CURRENT setting resolves THIS request to, the raw forwarded chain, and the
socket peer — so you set the number, save, and confirm the address matches the
machine you're on. It also counts the arriving chain and says how many proxies
that implies. GET/PUT both return that payload, PUT recomputed under the new
value, so the effect is visible without a reload.

Also fixes styling in the #370 card that CI could not catch: text-destructive
and bg-destructive don't exist in this Tailwind config — the palette is
colors.action.destructive — so the "Address changed" warning and the
sign-out-others button were rendering unstyled. Both now use
text-action-destructive / bg-action-destructive / text-action-fg.

Not done here: requestlog.go still logs raw RemoteAddr and will disagree with
the sessions UI about who connected. Left for its own change.
2026-08-05 10:07:43 -04:00
bvandeusen bf649f3beb feat(web): active sessions card in Settings — #370
test-web / test (push) Successful in 32s
Client half of #370. Lists every device signed in to your account, with a
per-row sign-out and a "sign out all other devices" action.

The card does one thing the API alone doesn't: it says "Address changed" when
created_ip and last_ip differ, rather than printing two addresses and leaving
you to compare them. That mismatch — same device string, different origin —
is the shape of a stolen token, and it's the reason IP capture was worth a
migration. Making the operator spot it by eye would have wasted the data.

Placed with Password and API Token rather than at the bottom of the page:
those three are the account-security group, and this is the one that tells
you the other two need attention.

Details worth naming:

- The current session gets a "This device" badge and NO sign-out button —
  offering one would log you out of the page you're standing on. The server
  already excludes it from logout-others; this makes that visible.
- Sign-out-all-others is a two-step confirm and states the count, so the
  button can't be a surprise.
- A 404 on revoke reloads instead of erroring. It means the session is
  already gone — revoked elsewhere, or expired — so the list was simply
  stale and showing the truth is the right response. The code is
  `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it.
- Empty and error states both handled (rule #24); the empty case is
  practically unreachable since listing requires an authenticated request,
  and is handled rather than assumed.
- User-agent parsing is deliberately coarse. A real UA parser is a
  dependency and a maintenance burden for a string whose only job is "do you
  recognise this?" — the addresses carry the actual signal.

Tests cover the parts that would be quiet if broken: the current-session
badge suppressing its own sign-out button, the address-changed warning
appearing and NOT appearing, the two-step confirm not firing on first click,
and the load-failure retry.

Android parity is a separate decision, not assumed.
2026-08-05 09:25:20 -04:00
bvandeusenandClaude Opus 5 ca4832e620 feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
test-web / test (push) Successful in 35s
Rule #25/#27: the two knobs slice 6 added server-side are now touchable —
taste-tag weight and snooze length, with deviation dots, save, and reset,
matching the existing profile/taste cards.

Copy states what each knob does AND what it doesn't: the tag-weight hint
says 0 turns the term off and that an untagged candidate is never
penalised, and the snooze hint says it records no opinion about the artist
and never feeds the taste profile. Those are the two properties most likely
to be assumed backwards by whoever turns these next.

Also fixed a latent fragility the new card exposed rather than caused: all
three reset buttons had the accessible name "Reset to defaults", so the
existing test picked the LAST one and assumed that meant taste. Adding a
card below it would have silently retargeted that assertion at the wrong
scope. Each reset button now names its scope — better for screen readers
too, since three identical buttons on one page is a real a11y defect — and
the test selects by name instead of position.

The page's test fixture needed the new `discover` key in both `snapshot`
and `shipped`: the `as TuningSnapshot` cast means a missing field is not a
compile error, it's every test on the page throwing inside fillForm. Noted
that in the fixture so the next scope doesn't rediscover it.

Includes a test that a weight of 0 is actually SENT rather than dropped as
falsy — the off switch is the one value a truthiness bug would eat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:49:17 -04:00
bvandeusenandClaude Opus 5 18a61f1065 fix(discover): complete the page-test mock + drop a return from returnsIn — #2375
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Failing after 6m20s
Two CI failures from 6e39471a, both mechanical.

web: src/routes/discover/discover.test.ts mocks $lib/api/suggestions with
a factory, and SuggestionFeed now imports createSnoozesQuery from it. A
factory-shaped module mock must export everything the component tree
imports or rendering throws before any assertion runs — so all 12 of that
suite's tests failed on a surface they don't even exercise. Stubbed the
three new exports and defaulted the snooze query to empty, which keeps
the feed's empty-state copy on the "no signal yet" branch those tests
assert. (Same shape as Scribe #2109: when a shared component grows a
dependency, the break is in unrelated fixtures, not assertions.)

android: detekt ReturnCount — returnsIn had 3 returns against a limit of
2. Folded the two "nothing to state" guards into one by computing the
remaining duration as a nullable up front.

The Android compile and unit tests never ran on the last push: detekt
gates them, so Lucide.Clock is still unproven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:09:24 -04:00
bvandeusenandClaude Opus 4.8 f2dcf2596d fix(player): render QueueDrawer inside QueryClientProvider so queue LikeButtons resolve — #1928
test-web / test (push) Successful in 40s
The queue drawer's <aside> is always mounted, so QueueTrackRow's LikeButton
(added in #1596) instantiates the moment the queue populates on first play.
LikeButton calls useQueryClient() at init; with the drawer outside the
provider it threw 'No QueryClient was found in Svelte context', aborting the
reactive flush that starts playback — so play appeared to do nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:47:21 -04:00
bvandeusenandClaude Opus 4.8 f0c08e7326 feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).

- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
  canonical mood buckets (synonyms collapse). Imported by both the taste
  builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
  (DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
  EraScale); accumulate folds each play/like's mood buckets at
  base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
  (own tanh scale + additive 0.12 share, so it never weakens the existing
  signal when a track has no mood tags). Match now takes the candidate's
  mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
  the primary similarity loader only — the near-whole-library fallback
  pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
  card ("Mood weight" row) + Go/web tests.

Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:32:41 -04:00
bvandeusenandClaude Opus 4.8 65dd132b3d feat(taste): time-of-day / weekday context conditioning — #1531
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 42s
test-go / integration (push) Successful in 4m46s
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.

- Migration 0046: recommendation_weight_profiles.context_time_weight
  (per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
  counts split by the current cell (daypart night[22,5)/morning[5,12)/
  afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
  started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
  artist's shrunk cell-share minus the user's baseline share, clamped to
  [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
  artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
  candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
  + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
  profiles (uniform start, re-bakeable).

Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:43 -04:00
bvandeusenandClaude Opus 4.8 40384cc05e feat(taste): era/decade taste facet — #1530
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.

- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
  + taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
  EnrichedTagScale), accumulate folds each play/like's decade at
  base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
  tanh scale + additive 0.15 share so it never weakens the existing
  artist/tag signal when a track is undated); candidate queries return
  album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
  card (auto-renders the new row) + Go/web tests.

Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:01:00 -04:00
bvandeusenandClaude Opus 4.8 2b3be8311a feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m45s
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).

- Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills
  the existing row).
- recsettings: TasteTuning gains the field; seeded/read/updated through
  reconcile + persistTaste; applyTastePatch validates it to [0,1]
  (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps
  it into the profile build.
- API: tasteTuningResp exposes enriched_tag_scale.
- Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre
  only). Tests: recsettings persist+range, web fixture field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:51:36 -04:00
bvandeusenandClaude Opus 4.8 797ed1f5ad feat(web): tag enrichment sources admin card (#1490 Step 4 web)
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m45s
Add a "Tag enrichment sources" card to the admin integrations page,
mirroring the cover-art providers card: per-provider enable toggle +
API-key field + Save + Test connection, over /api/admin/tag-sources.
Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled
tracks for re-enrichment via the version bump.

- admin.ts: TagProvider types + get/update/test functions +
  createTagProvidersQuery; qk.tagProviders key.
- integrations/+page.svelte: the card + local edit state, with
  MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes.
- Tests: admin.tag-sources API test + integrations component tests
  (render / enable+key save / test connection), plus the mock plumbing
  the existing suite needs for the new query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:33:21 -04:00
bvandeusenandClaude Opus 4.8 c1d143cf4a feat(home): Songs-like → dedicated row + wider spread (#1491)
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 43s
android / Build + lint + test (push) Failing after 1m29s
test-go / integration (push) Successful in 4m42s
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.

Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
  songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
  and daily rotation preserved.

Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
  it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
  / orderedRealPlaylists no longer reserve the 3 songs-like slots.
  Offline shows cached mixes (available-first), hides the row when none.

Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
  and removed songs-like from the Playlists carousel.

Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:41:08 -04:00
bvandeusenandClaude Fable 5 29e1e7c64c test(web/tuning): marker summary appears in tooltips too — use getAllByText
test-web / test (push) Successful in 32s
The knob-turn summary renders both as the sparkline tick tooltips (one
per series) and in the list under the chart; the single-element query
tripped on the duplicates in CI run 1906.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:35:51 -04:00
bvandeusenandClaude Fable 5 9ad4343c76 feat(tuning): weekly trend view — per-surface series + knob-turn markers
test-go / integration (push) Successful in 4m44s
test-go / test (push) Successful in 33s
test-web / test (push) Failing after 38s
The verify half of the tune→verify loop (#1251), on the same admin
Tuning page as the knobs:

- RecommendationWeeklyTrends: weekly per-source outcomes aggregated
  across all users (the knobs are global, so judging a turn needs
  global outcomes — rows carry rates only, no track/user identity),
  with a taste-hit count per bucket: plays whose track's artist has a
  positive weight in the player's current taste profile. That's the
  "cheap recompute" reading — retroactive over the whole window, at
  the cost of profile drift.
- GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52):
  per-family weekly series (skip rate, sample-weighted completion,
  taste-hit rate) plus the tuning-audit markers inside the window.
- Web: sparkline table under the tuning cards — skip rate per week on
  a shared axis with dashed ticks at knob turns, latest-week columns,
  window taste-hit rate, low-volume rows dimmed as anecdote, and a
  plain-text list of the window's tuning changes.

Also fixes the revive unused-parameter lint on the tuning GET handler
that failed CI run 1903 on the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:29:33 -04:00
bvandeusenandClaude Fable 5 0d0a8f46b1 feat(tuning): scoring weights → DB-backed admin tuning lab
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00
bvandeusenandClaude Fable 5 5faa57634b feat(metrics): provenance as standard — pick_kind for all system mixes
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 38s
test-go / integration (push) Successful in 4m36s
The #1249 mechanism (stamp WHY a track is in the snapshot at build
time, freeze it onto the play at ingestion, break it down in metrics)
generalizes from a For You one-off to the standard for every system
mix (#1270):

- Migration 0039 widens both pick_kind CHECKs (drop + re-add in the
  same change) to taste/fresh + Discover's dormant/cross_user/random
  + tier1-3 for the rule-#131 eligibility ladders.
- GetForYouPickKindForTrack becomes GetSystemPickKindForTrack
  (user, variant, track); ingestion stamps any systemPlaylistSources
  play from its own variant's live snapshot, live + offline paths.
- Discover stamps its candidate bucket on discoverTrack before the
  interleave, making the 40/30/30 allocation measurable; dedup keeps
  the taking bucket's stamp.
- Metrics replace the for_you special-case with one pick-kind
  vocabulary — any family with attributed plays gets a breakdown,
  future stamping mixes need no metrics change.
- Web: breakdown sub-rows are now toggled per surface (collapsed by
  default) so eight stamping mixes don't swamp the card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 23:26:52 -04:00
bvandeusenandClaude Fable 5 fb4431207d feat(recommendation): For You exploration attribution — taste vs fresh picks
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s
Milestone #127 step 2 (#1249). For You deliberately blends two
populations — a head of top-scored taste picks and a tail sampled from
deeper ranking (the freshness injection) — but the metrics judged it as
one blob, so its skip rate couldn't distinguish "the taste engine is
missing" from "the freshness tax is too high". That number decides the
exploration share before we tune it.

- Migration 0038: nullable pick_kind ('taste'|'fresh') on both
  playlist_tracks (stamped at snapshot build) and play_events (frozen at
  play-ingestion — the snapshot rebuilds daily, so attribution cannot be
  reconstructed at read time).
- Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool
  fallback is all taste (top-N-by-score IS the taste mechanism). Other
  variants persist NULL.
- Ingestion: for_you plays (live + offline replay) look the track up in
  the user's current snapshot; not found → unattributed, never guessed.
- Metrics: For You's row gains a breakdown (taste / fresh / earlier
  unattributed plays), parent row stays the sum; web card renders the
  sub-rows indented with the same baseline deltas + low-data dimming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 20:39:41 -04:00
bvandeusenandClaude Fable 5 60533073ad feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m30s
The recommendation metrics table was observable but not actionable: raw
source strings (album:<uuid> one-offs) drowned the stable surfaces, and
manual plays were excluded so skip rates had no control group.

- SQL: include NULL-source rows (the baseline) and carry completion_n
  so family merges can weight avg_completion correctly.
- Handler buckets raw sources into stable families (radio:<uuid> →
  Radio, album:/artist: → direct plays, etc.) grouped by surface
  intent: go-to / discovery / direct — each band judged against its
  job, since discovery mixes are expected to skip hotter. Families
  under 20 plays are flagged low-confidence, not hidden.
- Settings card renders the baseline row and per-surface deltas in
  percentage points vs baseline (worse-than-baseline deltas in danger
  color), intent hint copy per group, low-data rows dimmed.
- Pure-unit test for the bucketing/merge; DB test updated to the new
  contract (baseline included, radio:<uuid> collapse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 18:00:40 -04:00
bvandeusenandClaude Opus 4.8 79f2d79a2e feat(diagnostics): 'playback' kind, newest-first sort, fix active-route subtitle
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 41s
android / Build + lint + test (push) Successful in 3m40s
test-go / integration (push) Successful in 4m30s
Relabel (#1204): route + player_state events fire for every output route,
not just UPnP — split them into a new 'playback' kind; 'upnp_sync' now
means genuinely UPnP/Sonos signal (drops, resync). Migration 0037 adds
'playback' to the kind CHECK; server whitelist, Android reporter labels,
and the web kind filter updated.

Web sort: the diagnostics list gains a Newest/Oldest-first sort (default
newest at top); export follows the displayed order.

Fix (#1205): OutputRoute.isConnected was derived from RouteInfo.connectionState,
which stays DISCONNECTED for local SYSTEM routes even when active — so a
connected Bluetooth device showed "Available" and reported connected:false.
The picker subtitle now uses isSelected (route == selected route); the dead
isConnected field is removed and the misleading `connected` field dropped
from the diagnostics route event (it only ever logs the active route).

Refs Scribe M9 (#119), tasks #1204 #1205.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 16:33:01 -04:00
bvandeusenandClaude Opus 4.8 96b15b75e6 feat(web/diagnostics): default to recent 500, move time window to Advanced
test-web / test (push) Successful in 39s
The diagnostics view already defaulted to the most recent 500 events (no
window); make that the obvious path. Device/Kind stay primary; the
start/end window + row cap move into a collapsed "Advanced filters"
disclosure (auto-opens when a window is active) with a "Reset to recent
500" action. Caption now states whether you're seeing the recent default
or a windowed slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-30 12:42:25 -04:00
bvandeusenandClaude Opus 4.8 bffa5b28bd fix(diagnostics): StateFlow distinctUntilChanged build error + AdminUser test fixtures
test-web / test (push) Failing after 33s
android / Build + lint + test (push) Successful in 3m29s
- DiagnosticsReporter.collectServerHealth: drop distinctUntilChanged() on
  networkStatus.state (StateFlow is already distinct; the deprecation
  warning is a hard error under allWarningsAsErrors).
- web users.test.ts: add debug_mode_enabled to the alice/bob AdminUser
  fixtures now that the field is required on the type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
2026-06-29 19:05:50 -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 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
bvandeusenandClaude Opus 4.8 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
bvandeusenandClaude Opus 4.8 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
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 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 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 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
bvandeusenandClaude Opus 4.8 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)
android / Build + lint + test (push) Successful in 3m37s
test-web / test (push) Successful in 33s
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
bvandeusenandClaude Opus 4.8 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-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 / test (pull_request) Successful in 34s
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