Files
bvandeusen 95d68e3d3d feat: M3.5 polish — genre splitting + radio button surface
Two carry-overs from M3 verification, bundled with the search-input
fix already on dev (b7a59a9).

1. Genre splitting in BuildSessionVector
   The library has many tracks whose genre tag is a denormalized
   multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them
   as one opaque tag means a single-genre "Pop" track and a multi-genre
   track listing Pop both fail to share the Pop key, so the tags axis
   in similarity scoring (weight 0.7!) returned 0 in nearly all cases
   on real libraries. Result: contextual scoring couldn't differentiate.

   Add splitGenres() that splits on ; and , and trims whitespace;
   strings without a delimiter come back as a single-element slice
   (so the existing single-genre cases keep working unchanged).
   Concatenated-without-separator output ("ElectronicComplextroGlitch
   Hop") stays opaque — that needs a genre dictionary, out of scope.

2. Play-radio button on TrackRow
   playRadio() was only wired to the click handler on /search and
   /search/tracks, leaving /library/liked, album pages, and search
   results' inner clicks with no way to start a radio. Adds a small
   radio button to TrackRow (between LikeButton and the +queue button).
   Click → playRadio(track.id), with stopPropagation so the row's own
   activate handler doesn't also fire.

Tests:
- 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon
  split, no-separator stays opaque. Existing single-genre tests pass
  unchanged.
- 1 new TrackRow test: radio button calls playRadio with track id and
  does NOT trigger row play.

Verified locally: go -short -race ./... clean, golangci-lint clean,
svelte-check 0/0, 175 vitest tests (was 174), web build succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:50:00 -04:00

67 lines
1.7 KiB
Go

package recommendation
import (
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
type SessionVector struct {
Seed bool `json:"seed"`
Artists []string `json:"artists"`
Tags map[string]int `json:"tags"`
RecentTrackIDs []string `json:"recent_track_ids"`
}
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
v := SessionVector{
Seed: len(priorTracks) < 3,
Artists: []string{},
Tags: map[string]int{},
RecentTrackIDs: []string{},
}
seen := map[string]bool{}
for _, t := range priorTracks {
artistID := uuidString(t.ArtistID)
if !seen[artistID] {
seen[artistID] = true
v.Artists = append(v.Artists, artistID)
}
if t.Genre != nil && *t.Genre != "" {
for _, g := range splitGenres(*t.Genre) {
v.Tags[g]++
}
}
v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
}
return v
}
func uuidString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
return u.String()
}
// splitGenres splits a track's denormalized genre string on the common
// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims
// whitespace; drops empty fragments. Strings with no delimiter come back
// as a single-element slice. Concatenated-without-separator inputs (e.g.
// "ElectronicComplextroGlitch Hop" from broken tag-editor output) cannot
// be split without a genre dictionary and stay as one opaque tag.
func splitGenres(s string) []string {
parts := strings.FieldsFunc(s, func(r rune) bool {
return r == ';' || r == ','
})
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}