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>
This commit is contained in:
2026-04-27 23:50:00 -04:00
parent b7a59a9473
commit 95d68e3d3d
4 changed files with 119 additions and 5 deletions
+24 -1
View File
@@ -1,6 +1,8 @@
package recommendation
import (
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -28,7 +30,9 @@ func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
v.Artists = append(v.Artists, artistID)
}
if t.Genre != nil && *t.Genre != "" {
v.Tags[*t.Genre]++
for _, g := range splitGenres(*t.Genre) {
v.Tags[g]++
}
}
v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
}
@@ -41,3 +45,22 @@ func uuidString(u pgtype.UUID) string {
}
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
}
@@ -123,6 +123,77 @@ func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) {
}
}
func TestBuildSessionVector_MultiGenreSplitsOnSemicolon(t *testing.T) {
a1 := uuid("11111111-1111-1111-1111-111111111111")
v := BuildSessionVector([]dbq.Track{
track(a1, "Indie Pop; Pop; Alternative Pop"),
track(a1, "Pop"),
track(a1, "Boom Bap; Hip Hop; Lo-Fi"),
})
// Pop appears once on track 1 and once on track 2 → count 2.
if v.Tags["Pop"] != 2 {
t.Errorf("Tags['Pop'] = %d, want 2 (one per matching track)", v.Tags["Pop"])
}
// Indie Pop, Alternative Pop only on track 1.
if v.Tags["Indie Pop"] != 1 || v.Tags["Alternative Pop"] != 1 {
t.Errorf("Indie/Alt Pop counts wrong: %+v", v.Tags)
}
// Track 3's genres.
if v.Tags["Boom Bap"] != 1 || v.Tags["Hip Hop"] != 1 || v.Tags["Lo-Fi"] != 1 {
t.Errorf("track-3 genre counts wrong: %+v", v.Tags)
}
}
func TestBuildSessionVector_NoSeparatorStaysOpaque(t *testing.T) {
// Concatenated strings without separators (broken tag-editor output)
// can't be parsed; they stay as one tag and only match identical strings.
a1 := uuid("11111111-1111-1111-1111-111111111111")
v := BuildSessionVector([]dbq.Track{
track(a1, "ElectronicComplextroGlitch Hop"),
track(a1, "ElectronicComplextroGlitch Hop"),
})
if v.Tags["ElectronicComplextroGlitch Hop"] != 2 {
t.Errorf("opaque tag count = %d, want 2", v.Tags["ElectronicComplextroGlitch Hop"])
}
if len(v.Tags) != 1 {
t.Errorf("len(Tags) = %d, want 1", len(v.Tags))
}
}
func TestSplitGenres(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{"Pop", []string{"Pop"}},
{"Pop; Rock", []string{"Pop", "Rock"}},
{"Pop, Rock", []string{"Pop", "Rock"}},
{"Pop; Rock, Jazz", []string{"Pop", "Rock", "Jazz"}},
{" Pop ; Rock ", []string{"Pop", "Rock"}},
{";; Pop ;;", []string{"Pop"}},
{"ElectronicComplextroGlitch Hop", []string{"ElectronicComplextroGlitch Hop"}},
}
for _, c := range cases {
got := splitGenres(c.in)
if c.want == nil {
if len(got) != 0 {
t.Errorf("splitGenres(%q) = %v, want empty", c.in, got)
}
continue
}
if len(got) != len(c.want) {
t.Errorf("splitGenres(%q) = %v, want %v", c.in, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("splitGenres(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
}
}
}
}
func TestBuildSessionVector_JSONRoundTrip(t *testing.T) {
a1 := uuid("11111111-1111-1111-1111-111111111111")
v := BuildSessionVector([]dbq.Track{
+14 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import LikeButton from './LikeButton.svelte';
type PlayHandler = (tracks: TrackRef[], index: number) => void;
@@ -34,6 +34,11 @@
e.stopPropagation();
enqueueTrack(track);
}
function onRadioClick(e: MouseEvent) {
e.stopPropagation();
playRadio(track.id);
}
</script>
<div
@@ -42,13 +47,20 @@
aria-label={track.title}
onclick={onRowClick}
onkeydown={onRowKey}
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<span class="text-right tabular-nums text-text-secondary">
{track.track_number ?? '—'}
</span>
<span class="truncate">{track.title}</span>
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
aria-label="Play radio from this track"
title="Play radio"
onclick={onRadioClick}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>📻</button>
<button
type="button"
aria-label="Add to queue"
+10 -2
View File
@@ -5,7 +5,8 @@ import { readable } from 'svelte/store';
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn()
enqueueTrack: vi.fn(),
playRadio: vi.fn()
}));
vi.mock('$lib/api/likes', () => ({
@@ -24,7 +25,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
});
import TrackRow from './TrackRow.svelte';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
const tracks: TrackRef[] = [
{
@@ -91,4 +92,11 @@ describe('TrackRow', () => {
expect(enqueueTrack).toHaveBeenCalledWith(tracks[0]);
expect(playQueue).not.toHaveBeenCalled();
});
test('radio button calls playRadio with track id and does NOT trigger row play', async () => {
render(TrackRow, { props: { tracks, index: 0 } });
await fireEvent.click(screen.getByRole('button', { name: /play radio/i }));
expect(playRadio).toHaveBeenCalledWith('t1');
expect(playQueue).not.toHaveBeenCalled();
});
});