95d68e3d3d
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>
67 lines
1.7 KiB
Go
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
|
|
}
|