Three carry-over fixes from the M3 milestone gate verification, bundled into one focused PR.
What this ships
1. Genre splitting in BuildSessionVector
The library has many tracks whose tracks.genre is a denormalized multi-genre string like "Indie Pop; Pop; Alternative Pop" or "Boom Bap; Downtempo; Hip Hop; Lo-Fi". The previous code treated the whole string as a single opaque tag, so:
Single-genre "Pop" track and multi-genre "Indie Pop; Pop; Alternative Pop" track failed to share the Pop tag
Tags axis in similarity scoring (weight 0.7!) returned 0 for nearly all comparisons
Result: contextual radio recommendations were indistinguishable from weighted random shuffle
internal/recommendation/sessionvector.go now exports splitGenres() that splits on ; and ,, trims whitespace, and drops empty fragments. Single-genre strings still return as a one-element slice, so existing single-genre tracks ("Pop", "Rock", etc.) work unchanged. Concatenated-without-separator strings ("ElectronicComplextroGlitch Hop") stay as one opaque tag — needs a genre dictionary, deferred.
3 new tests: TestSplitGenres (table-driven), TestBuildSessionVector_MultiGenreSplitsOnSemicolon, TestBuildSessionVector_NoSeparatorStaysOpaque. All existing tests pass unchanged.
2. Play-radio button on TrackRow
playRadio() was only wired to track-click in /search and /search/tracks. /library/liked, album pages, and any search-results inner-click had no way to start a radio.
web/src/lib/components/TrackRow.svelte gains a small 📻 button between the LikeButton and the +-queue button. Click handler calls playRadio(track.id) with stopPropagation so the row's row-click activator doesn't also fire.
1 new test: radio button calls playRadio with track id and does NOT trigger row play.
3. Search field focus loss (already on dev as b7a59a9)
SvelteKit's goto() resets focus by default on every navigation. Debounced typeahead-style URL updates blurred the search input every 250ms. Fix: pass keepFocus: true to both goto() calls in SearchInput.svelte::navigate(). Tests updated to assert the new arg.
Test plan
go test -short -race ./... — all 13 packages pass
golangci-lint run ./... — clean
cd web && npm run check — 0 errors / 0 warnings
cd web && npm test — 175 tests across 29 files (was 174)
cd web && npm run build — succeeds
Manual: search field stays focused while typing across debounce; radio button on /library/liked and album pages triggers radio; radio recommendations now visibly differ from shuffle on libraries with semicolon-separated multi-genre tracks
Notes
Out of scope for this PR: scanner-side genre normalization (write-time fix), AlbumCard radio button, the "no-separator" parsing case ("ElectronicComplextroGlitch Hop" and friends — needs a genre dictionary, deferred to M4).
Existing data compatibility: contextual_likes captured before this change have single-string tags; once the new code is deployed, freshly-built session vectors will have multi-tag entries. Old likes won't match new sessions where the genre string differs. Acceptable: the manual-testing corpus is tiny and gets rebuilt naturally by continued listening.
Three carry-over fixes from the M3 milestone gate verification, bundled into one focused PR.
## What this ships
### 1. Genre splitting in `BuildSessionVector`
The library has many tracks whose `tracks.genre` is a denormalized multi-genre string like `"Indie Pop; Pop; Alternative Pop"` or `"Boom Bap; Downtempo; Hip Hop; Lo-Fi"`. The previous code treated the whole string as a single opaque tag, so:
- Single-genre `"Pop"` track and multi-genre `"Indie Pop; Pop; Alternative Pop"` track failed to share the `Pop` tag
- Tags axis in similarity scoring (weight **0.7**!) returned `0` for nearly all comparisons
- Result: contextual `radio` recommendations were indistinguishable from weighted random shuffle
`internal/recommendation/sessionvector.go` now exports `splitGenres()` that splits on `;` and `,`, trims whitespace, and drops empty fragments. Single-genre strings still return as a one-element slice, so existing single-genre tracks (`"Pop"`, `"Rock"`, etc.) work unchanged. Concatenated-without-separator strings (`"ElectronicComplextroGlitch Hop"`) stay as one opaque tag — needs a genre dictionary, deferred.
3 new tests: `TestSplitGenres` (table-driven), `TestBuildSessionVector_MultiGenreSplitsOnSemicolon`, `TestBuildSessionVector_NoSeparatorStaysOpaque`. All existing tests pass unchanged.
### 2. Play-radio button on `TrackRow`
`playRadio()` was only wired to track-click in `/search` and `/search/tracks`. `/library/liked`, album pages, and any search-results inner-click had no way to start a radio.
`web/src/lib/components/TrackRow.svelte` gains a small 📻 button between the LikeButton and the `+`-queue button. Click handler calls `playRadio(track.id)` with `stopPropagation` so the row's row-click activator doesn't also fire.
1 new test: `radio button calls playRadio with track id and does NOT trigger row play`.
### 3. Search field focus loss (already on dev as `b7a59a9`)
SvelteKit's `goto()` resets focus by default on every navigation. Debounced typeahead-style URL updates blurred the search input every 250ms. Fix: pass `keepFocus: true` to both `goto()` calls in `SearchInput.svelte::navigate()`. Tests updated to assert the new arg.
## Test plan
- [x] `go test -short -race ./...` — all 13 packages pass
- [x] `golangci-lint run ./...` — clean
- [x] `cd web && npm run check` — 0 errors / 0 warnings
- [x] `cd web && npm test` — **175 tests** across 29 files (was 174)
- [x] `cd web && npm run build` — succeeds
- [ ] Manual: search field stays focused while typing across debounce; radio button on `/library/liked` and album pages triggers radio; radio recommendations now visibly differ from shuffle on libraries with semicolon-separated multi-genre tracks
## Notes
- **Out of scope for this PR**: scanner-side genre normalization (write-time fix), `AlbumCard` radio button, the "no-separator" parsing case (`"ElectronicComplextroGlitch Hop"` and friends — needs a genre dictionary, deferred to M4).
- **Existing data compatibility**: contextual_likes captured before this change have single-string tags; once the new code is deployed, freshly-built session vectors will have multi-tag entries. Old likes won't match new sessions where the genre string differs. Acceptable: the manual-testing corpus is tiny and gets rebuilt naturally by continued listening.
- **No schema changes**, no new migrations.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Three carry-over fixes from the M3 milestone gate verification, bundled into one focused PR.
What this ships
1. Genre splitting in
BuildSessionVectorThe library has many tracks whose
tracks.genreis a denormalized multi-genre string like"Indie Pop; Pop; Alternative Pop"or"Boom Bap; Downtempo; Hip Hop; Lo-Fi". The previous code treated the whole string as a single opaque tag, so:"Pop"track and multi-genre"Indie Pop; Pop; Alternative Pop"track failed to share thePoptag0for nearly all comparisonsradiorecommendations were indistinguishable from weighted random shuffleinternal/recommendation/sessionvector.gonow exportssplitGenres()that splits on;and,, trims whitespace, and drops empty fragments. Single-genre strings still return as a one-element slice, so existing single-genre tracks ("Pop","Rock", etc.) work unchanged. Concatenated-without-separator strings ("ElectronicComplextroGlitch Hop") stay as one opaque tag — needs a genre dictionary, deferred.3 new tests:
TestSplitGenres(table-driven),TestBuildSessionVector_MultiGenreSplitsOnSemicolon,TestBuildSessionVector_NoSeparatorStaysOpaque. All existing tests pass unchanged.2. Play-radio button on
TrackRowplayRadio()was only wired to track-click in/searchand/search/tracks./library/liked, album pages, and any search-results inner-click had no way to start a radio.web/src/lib/components/TrackRow.sveltegains a small 📻 button between the LikeButton and the+-queue button. Click handler callsplayRadio(track.id)withstopPropagationso the row's row-click activator doesn't also fire.1 new test:
radio button calls playRadio with track id and does NOT trigger row play.3. Search field focus loss (already on dev as
b7a59a9)SvelteKit's
goto()resets focus by default on every navigation. Debounced typeahead-style URL updates blurred the search input every 250ms. Fix: passkeepFocus: trueto bothgoto()calls inSearchInput.svelte::navigate(). Tests updated to assert the new arg.Test plan
go test -short -race ./...— all 13 packages passgolangci-lint run ./...— cleancd web && npm run check— 0 errors / 0 warningscd web && npm test— 175 tests across 29 files (was 174)cd web && npm run build— succeeds/library/likedand album pages triggers radio; radio recommendations now visibly differ from shuffle on libraries with semicolon-separated multi-genre tracksNotes
AlbumCardradio button, the "no-separator" parsing case ("ElectronicComplextroGlitch Hop"and friends — needs a genre dictionary, deferred to M4).🤖 Generated with Claude Code