6-task TDD-shaped plan covering: LoadRadioCandidatesV2 sqlc query (5-way UNION), Score() extension with SimilarityScore + SimilarityWeight, LoadCandidatesFromSimilarity Go function with CandidateSourceLimits + 10 integration tests covering all sources + dedup + exclude + recently- played + seed-excluded + empty library, radio handler with exclude param + parseExcludeParam helper + M3 fallback + 4 new HTTP tests, frontend queue refresh at 80% with radioSeedId state + clear-on-non- radio-enqueue + 5 vitest tests, final verification + branch finish (closes M4).
52 KiB
M4c — Radio Similarity-Driven Candidate Pool + Queue Refresh Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace M3's whole-library candidate pool with a 5-way SQL UNION of similarity sources (LB-similar tracks, similar-artist tracks, MB-tag overlap, likes-overlap, random fill), add a SimilarityScore × SimilarityWeight term to M3's Score(), and have the SPA auto-refresh the radio queue at 80% consumed via a new ?exclude= query param. Closes M4.
Architecture: New sqlc query LoadRadioCandidatesV2 performs the 5-way UNION + per-source scoring + final dedup-by-max in SQL. New LoadCandidatesFromSimilarity Go function projects rows to []Candidate (same return type as M3's LoadCandidates, so Shuffle() is unchanged). Radio handler uses the new function as primary, falls back to M3's LoadCandidates on any error. Web client tracks radioSeedId and fires a refresh fetch when 80% consumed; clears on non-radio enqueues.
Tech Stack: Go 1.23 + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TanStack Query. No schema migrations — relies on M4b's track_similarity / artist_similarity and existing M2 general_likes / M0-M1 tracks.genre.
Reference: design spec at docs/superpowers/specs/2026-04-28-m4c-radio-design.md.
File Structure
New server files:
| File | Responsibility |
|---|---|
internal/db/queries/radio.sql (modify) |
Append LoadRadioCandidatesV2 :many — 5-way UNION + dedup-by-max. |
internal/db/dbq/radio.sql.go |
Generated bindings. |
Note: internal/db/queries/recommendation.sql currently holds LoadRadioCandidates (M3). I'll add the V2 query to the same file to keep radio-related queries co-located.
Modified server files:
| File | Change |
|---|---|
internal/recommendation/score.go |
ScoringInputs gains SimilarityScore float64, ScoringWeights gains SimilarityWeight float64. Score() adds + in.SimilarityScore * w.SimilarityWeight. |
internal/recommendation/score_test.go |
3 new tests for the new term. |
internal/recommendation/candidates.go |
Adds CandidateSourceLimits struct, DefaultCandidateSourceLimits(), LoadCandidatesFromSimilarity(). M3's LoadCandidates retained unchanged for fallback + tests. |
internal/recommendation/candidates_test.go (or new candidates_v2_test.go) |
10 new integration tests for the similarity-pool path. |
internal/config/config.go |
RecommendationConfig gains SimilarityWeight float64 (default 2.0, yaml similarity_weight). |
internal/api/radio.go |
Parse ?exclude=, build limits struct, call LoadCandidatesFromSimilarity first then fallback to LoadCandidates, thread SimilarityWeight into ScoringWeights. |
internal/api/radio_test.go |
4 new tests for similarity ranking, exclude param, malformed exclude, fallback path. |
internal/api/auth_test.go (or wherever recCfg test fixture lives) |
Add SimilarityWeight: 2.0 to the test recCfg literal. |
Frontend files:
| File | Responsibility |
|---|---|
web/src/lib/player/store.svelte.ts |
Add radioSeedId state, appendRadioTracks() helper, refresh effect, clear-on-non-radio-enqueue logic. |
web/src/lib/player/store.test.ts |
5 new tests covering refresh behavior. |
Task 1: sqlc query LoadRadioCandidatesV2
Files:
-
Modify:
internal/db/queries/recommendation.sql(append) -
Generated:
internal/db/dbq/recommendation.sql.go -
Step 1: Append the query
Append to internal/db/queries/recommendation.sql:
-- name: LoadRadioCandidatesV2 :many
-- M4c: similarity-driven candidate pool. 5-way UNION:
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
WITH
seed_artist AS (SELECT artist_id FROM tracks WHERE id = $2),
seed_tags AS (
SELECT trim(g) AS tag
FROM tracks t,
regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g
WHERE t.id = $2 AND trim(g) <> ''
),
exclude_set AS (
SELECT unnest($4::uuid[]) AS id
UNION ALL
SELECT track_id FROM play_events
WHERE user_id = $1 AND started_at > now() - $3 * interval '1 hour'
),
lb_similar AS (
SELECT ts.track_b_id AS id, ts.score AS sim_score
FROM track_similarity ts
WHERE ts.track_a_id = $2
AND ts.source = 'listenbrainz'
AND ts.track_b_id NOT IN (SELECT id FROM exclude_set)
ORDER BY ts.score DESC
LIMIT $5
),
similar_artists AS (
SELECT t.id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'listenbrainz'
AND t.id NOT IN (SELECT id FROM exclude_set)
ORDER BY asim.score DESC, random()
LIMIT $6
),
tag_overlap AS (
SELECT t.id,
(count(DISTINCT trim(g_raw))::float8
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
FROM tracks t,
regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_raw
WHERE trim(g_raw) IN (SELECT tag FROM seed_tags)
AND t.id NOT IN (SELECT id FROM exclude_set)
AND t.id <> $2
GROUP BY t.id
HAVING count(DISTINCT trim(g_raw)) > 0
ORDER BY sim_score DESC
LIMIT $7
),
likes_overlap AS (
SELECT gl.track_id AS id, 0.6::float8 AS sim_score
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
AND t.id NOT IN (SELECT id FROM exclude_set)
AND EXISTS (
SELECT 1 FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') g
WHERE trim(g) IN (SELECT tag FROM seed_tags)
)
ORDER BY random()
LIMIT $8
),
random_fill AS (
SELECT t.id, 0.0::float8 AS sim_score
FROM tracks t
WHERE t.id NOT IN (SELECT id FROM exclude_set)
AND t.id <> $2
AND t.id NOT IN (
SELECT id FROM lb_similar
UNION SELECT id FROM similar_artists
UNION SELECT id FROM tag_overlap
UNION SELECT id FROM likes_overlap
)
ORDER BY random()
LIMIT $9
)
SELECT
sqlc.embed(t),
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
max(u.sim_score) AS similarity_score
FROM (
SELECT * FROM lb_similar
UNION ALL SELECT * FROM similar_artists
UNION ALL SELECT * FROM tag_overlap
UNION ALL SELECT * FROM likes_overlap
UNION ALL SELECT * FROM random_fill
) u
JOIN tracks t ON t.id = u.id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
count(*) AS play_count,
count(*) FILTER (WHERE was_skipped) AS skip_count
FROM play_events
WHERE user_id = $1 AND track_id = t.id
) pe ON true
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.created_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
- Step 2: Run sqlc generate
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate
Expected: internal/db/dbq/recommendation.sql.go regenerated with LoadRadioCandidatesV2(ctx, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error). Inspect to confirm:
-
The
LoadRadioCandidatesV2Paramsstruct has 9 fields. sqlc names them after column hints when possible; otherwise positional likeColumn3,Column4. Note actual generated names — Task 3 will use them. -
The
LoadRadioCandidatesV2Rowstruct has the embeddedTrackplusIsLiked bool,LastPlayedAt pgtype.Timestamptz,PlayCount int64,SkipCount int64,SimilarityScore float64. -
Step 3: Verify compile
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...
Expected: clean.
- Step 4: Commit
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
git add internal/db/queries/recommendation.sql internal/db/dbq/
git commit -m "feat(db): add LoadRadioCandidatesV2 sqlc query (5-way UNION)"
Task 2: Score() + RecommendationConfig extension
Files:
-
Modify:
internal/recommendation/score.go -
Modify:
internal/recommendation/score_test.go -
Modify:
internal/config/config.go -
Step 1: Write the failing tests
Append to internal/recommendation/score_test.go:
func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) {
w := defaultWeights()
w.SimilarityWeight = 2.0
in := ScoringInputs{SimilarityScore: 1.0}
got := Score(in, w, time.Now(), fixedRNG(0.5))
// base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0
want := 4.0
if math.Abs(got-want) > 1e-9 {
t.Errorf("score = %v, want %v", got, want)
}
}
func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) {
w := defaultWeights()
w.SimilarityWeight = 2.0
in := ScoringInputs{SimilarityScore: 0.5}
got := Score(in, w, time.Now(), fixedRNG(0.5))
// base 1.0 + recency 1.0 + similarity 1.0 = 3.0
want := 3.0
if math.Abs(got-want) > 1e-9 {
t.Errorf("score = %v, want %v", got, want)
}
}
func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) {
wWithSim := defaultWeights()
wWithSim.SimilarityWeight = 2.0
withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5))
withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
if math.Abs(withSim-withoutSim) > 1e-9 {
t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim)
}
}
- Step 2: Run tests to verify they fail
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v
Expected: FAIL — ScoringInputs has no SimilarityScore, ScoringWeights has no SimilarityWeight.
- Step 3: Extend
score.go
Replace internal/recommendation/score.go:
// Package recommendation implements the weighted-shuffle scoring engine
// from spec §6. The Score function is pure and takes an injectable RNG so
// tests can pin jitter to deterministic values.
package recommendation
import (
"time"
)
// ScoringInputs are the per-track facts the score function consumes.
// ContextualMatchScore is in [0, 1] — max similarity between the user's
// current session vector and any non-seed contextual_like row for this
// track. SimilarityScore is in [0, 1] — max similarity-source score for
// this candidate (LB-similar / similar-artist × 0.5 / tag-overlap jaccard
// / likes-overlap constant; random fill = 0). Both set by candidate
// loaders before scoring.
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
ContextualMatchScore float64 // [0, 1]; 0 when no signal
SimilarityScore float64 // [0, 1]; 0 when no signal (M4c)
}
// ScoringWeights are the operator-tunable knobs. Defaults live in
// config.RecommendationConfig and are propagated here per request.
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64 // M4c
}
// Score computes the weighted-shuffle score per spec §6:
//
// score = base
// + (is_general_liked ? LikeBoost : 0)
// + recency_decay * RecencyWeight
// - skip_ratio * SkipPenalty
// + contextual_match_score * ContextWeight
// + similarity_score * SimilarityWeight
// + small_random_jitter
//
// Higher score = more likely to surface. rng is a function returning a
// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed
// value in tests.
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
s := w.BaseWeight
if in.IsGeneralLiked {
s += w.LikeBoost
}
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += in.ContextualMatchScore * w.ContextWeight
s += in.SimilarityScore * w.SimilarityWeight
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
// recencyDecay returns a value in [0, 1]:
// - never played → 1.0 (cold-start tracks compete favorably with stale ones).
// - age < 30 days → linear ramp age_days / 30.
// - age ≥ 30 days → 1.0 (capped).
//
// Negative ages (clock skew) clamp to 0 to avoid math weirdness.
func recencyDecay(lastPlayed *time.Time, now time.Time) float64 {
if lastPlayed == nil {
return 1.0
}
age := now.Sub(*lastPlayed)
days := age.Hours() / 24
if days < 0 {
return 0.0
}
if days >= 30 {
return 1.0
}
return days / 30.0
}
// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0
// rather than dividing by zero, so they aren't penalized.
func skipRatio(plays, skips int) float64 {
if plays == 0 {
return 0.0
}
return float64(skips) / float64(plays)
}
- Step 4: Extend
RecommendationConfig
In internal/config/config.go, modify RecommendationConfig:
type RecommendationConfig struct {
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
SimilarityWeight float64 `yaml:"similarity_weight"` // M4c
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
In the same file, modify Default()'s Recommendation block to include the new field:
Recommendation: RecommendationConfig{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
RecentlyPlayedHours: 1,
RadioSize: 50,
RadioSizeMax: 200,
},
- Step 5: Run tests to verify they pass
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v
Expected: PASS for all existing Score tests + 3 new similarity tests.
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...
Expected: clean.
- Step 6: Verify lint clean
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...
Expected: clean.
- Step 7: Commit
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
git add internal/recommendation/score.go internal/recommendation/score_test.go internal/config/config.go
git commit -m "feat(recommendation): extend Score with SimilarityScore + SimilarityWeight"
Task 3: LoadCandidatesFromSimilarity Go function + integration tests
Files:
-
Modify:
internal/recommendation/candidates.go(append) -
Create:
internal/recommendation/candidates_v2_test.go -
Step 1: Write the failing integration tests
Create internal/recommendation/candidates_v2_test.go:
package recommendation
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// helperLBSimilarity inserts a track_similarity row.
func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) {
t.Helper()
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`,
a, b, score); err != nil {
t.Fatalf("insert track_similarity: %v", err)
}
}
// helperArtistSimilarity inserts an artist_similarity row.
func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) {
t.Helper()
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`,
a, b, score); err != nil {
t.Fatalf("insert artist_similarity: %v", err)
}
}
// helperSeedTrackWithGenre creates a track with a specified genre + mbid.
func helperSeedTrackWithGenre(t *testing.T, f fixture, title, genre, mbid string) dbq.Track {
t.Helper()
tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title,
AlbumID: f.tracks[0].AlbumID,
ArtistID: f.tracks[0].ArtistID,
FilePath: "/tmp/" + title + ".flac",
DurationMs: 180_000,
Genre: &genre,
Mbid: &mbid,
})
if err != nil {
t.Fatalf("track: %v", err)
}
return tr
}
func defaultLimits() CandidateSourceLimits {
return DefaultCandidateSourceLimits()
}
func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
f := newFixture(t, 5)
seed := f.tracks[0]
target := f.tracks[1]
helperLBSimilarity(t, f, seed.ID, target.ID, 0.85)
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
var found *Candidate
for i := range got {
if got[i].Track.ID == target.ID {
found = &got[i]
break
}
}
if found == nil {
t.Fatal("LB-similar target missing from candidates")
}
if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 {
t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore)
}
}
func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) {
f := newFixture(t, 1) // seed only
seed := f.tracks[0]
// Add a second artist + track in that artist; relate the artists via artist_similarity.
otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"})
otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID})
otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID,
FilePath: "/tmp/other.flac", DurationMs: 180_000,
})
helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8)
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == otherTrack.ID {
// 0.8 × 0.5 = 0.4
if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 {
t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore)
}
return
}
}
t.Error("similar-artist track missing from candidates")
}
func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
f := newFixture(t, 0)
seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock; Pop", "11111111-1111-1111-1111-111111111111")
target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222")
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == target.ID {
// Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5.
if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 {
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore)
}
return
}
}
t.Error("tag-overlap target missing from candidates")
}
func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
f := newFixture(t, 0)
seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111")
liked := helperSeedTrackWithGenre(t, f, "Liked", "Rock", "22222222-2222-2222-2222-222222222222")
if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil {
t.Fatalf("like: %v", err)
}
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == liked.ID {
// Could come from tag-overlap (1.0 jaccard) OR likes-overlap (0.6) — max wins.
// Since both tracks are tagged "Rock" identically, jaccard = 1/1 = 1.0, which beats 0.6.
if c.Inputs.SimilarityScore < 0.59 {
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore)
}
return
}
}
t.Error("liked track with shared tag missing from candidates")
}
func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) {
f := newFixture(t, 10) // 10 tracks; no similarity data
seed := f.tracks[0]
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
// Random fill should provide at least some of the 9 non-seed tracks.
if len(got) == 0 {
t.Error("random fill returned 0 candidates; expected at least some")
}
// All should have SimilarityScore = 0 (no similarity sources).
for _, c := range got {
if c.Inputs.SimilarityScore != 0 {
t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore)
}
}
}
func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) {
f := newFixture(t, 5)
seed := f.tracks[0]
excluded := f.tracks[1].ID
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true},
[]pgtype.UUID{excluded}, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == excluded {
t.Error("excluded track appeared in candidates")
}
}
}
func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) {
f := newFixture(t, 5)
seed := f.tracks[0]
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == seed.ID {
t.Error("seed track appeared in candidates")
}
}
}
func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) {
f := newFixture(t, 5)
seed := f.tracks[0]
recent := f.tracks[1].ID
// Insert a play_event within the last hour.
var sessionID pgtype.UUID
if err := f.pool.QueryRow(context.Background(),
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
f.user).Scan(&sessionID); err != nil {
t.Fatalf("session: %v", err)
}
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`,
f.user, recent, sessionID); err != nil {
t.Fatalf("play_event: %v", err)
}
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == recent {
t.Error("recently-played track appeared in candidates")
}
}
}
func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
f := newFixture(t, 0)
seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111")
// Target shares the LB-similar source (high score) AND tag-overlap (low score).
target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222")
helperLBSimilarity(t, f, seed.ID, target.ID, 0.95)
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
count := 0
for _, c := range got {
if c.Track.ID == target.ID {
count++
// LB 0.95 should win over tag-overlap (jaccard 1/1 = 1.0). Wait — 1.0 > 0.95. Adjust:
// seed and target both have only "Rock" tag → jaccard = 1.0 from tag-overlap.
// LB sim = 0.95. Max = 1.0. So this test verifies max() picks tag-overlap here.
if c.Inputs.SimilarityScore < 0.99 {
t.Errorf("dedup max SimilarityScore = %v, want 1.0 (tag-overlap wins)", c.Inputs.SimilarityScore)
}
}
}
if count != 1 {
t.Errorf("target appeared %d times, want 1 (dedup failed)", count)
}
}
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
f := newFixture(t, 0)
// Seed an isolated track to query against.
seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111")
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(got) != 0 {
t.Errorf("got %d candidates from empty library, want 0", len(got))
}
_ = time.Now() // silence unused-import warning if loop never runs
}
The fixture and newFixture helpers come from candidates_test.go (existing M3 fixture). If newFixture(t, 0) doesn't exist (i.e. it requires ≥1 track), peek at the existing helper's signature and adjust the test to use the smallest legal value.
- Step 2: Run tests to verify they fail
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v
Expected: FAIL — undefined: LoadCandidatesFromSimilarity, undefined: CandidateSourceLimits, undefined: DefaultCandidateSourceLimits.
- Step 3: Implement
LoadCandidatesFromSimilarity
Append to internal/recommendation/candidates.go:
// CandidateSourceLimits controls per-source K values for the M4c
// similarity-driven pool. Defaults via DefaultCandidateSourceLimits().
type CandidateSourceLimits struct {
LBSimilar int
SimilarArtist int
TagOverlap int
LikesOverlap int
RandomFill int
}
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
func DefaultCandidateSourceLimits() CandidateSourceLimits {
return CandidateSourceLimits{
LBSimilar: 30,
SimilarArtist: 30,
TagOverlap: 20,
LikesOverlap: 20,
RandomFill: 30,
}
}
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
//
// Caller (radio handler) falls back to LoadCandidates on error.
func LoadCandidatesFromSimilarity(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector,
exclude []pgtype.UUID,
limits CandidateSourceLimits,
) ([]Candidate, error) {
if exclude == nil {
exclude = []pgtype.UUID{}
}
rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{
UserID: userID,
ID: seedID,
Column3: float64(recentlyPlayedHours),
Column4: exclude,
Column5: int32(limits.LBSimilar),
Column6: int32(limits.SimilarArtist),
Column7: int32(limits.TagOverlap),
Column8: int32(limits.LikesOverlap),
Column9: int32(limits.RandomFill),
})
if err != nil {
return nil, err
}
likes, err := loadContextualLikesByTrack(ctx, q, userID)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
if r.LastPlayedAt.Valid {
t := r.LastPlayedAt.Time
lpt = &t
}
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
out = append(out, Candidate{
Track: r.Track,
Inputs: ScoringInputs{
IsGeneralLiked: r.IsLiked,
LastPlayedAt: lpt,
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
SimilarityScore: r.SimilarityScore,
},
})
}
return out, nil
}
Note on sqlc-generated param field names: the LoadRadioCandidatesV2Params field names depend on sqlc's auto-generated names from the SQL parameter positions. After Task 1's make generate, inspect internal/db/dbq/recommendation.sql.go and adjust the field references (UserID, ID, Column3, etc.) to match what sqlc actually emitted. The actual names may be Column3 for $3 (recently_played_hours), Column4 for $4 (exclude), etc., or sqlc may pick smarter names from context. Rename to match.
- Step 4: Run tests to verify they pass
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v
Expected: PASS for all 10 tests.
- Step 5: Verify lint clean
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/recommendation/...
Expected: clean.
- Step 6: Commit
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
git add internal/recommendation/candidates.go internal/recommendation/candidates_v2_test.go
git commit -m "feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool)"
Task 4: Radio handler — exclude param + similarity pool + fallback
Files:
-
Modify:
internal/api/radio.go -
Modify:
internal/api/radio_test.go -
Modify:
internal/api/auth_test.go(testHandlers'recCfgliteral) -
Step 1: Write the failing tests
Append to internal/api/radio_test.go:
func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
// Two non-seed tracks: one LB-similar (high score), one not.
target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000)
control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000)
if _, err := pool.Exec(context.Background(),
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`,
seed.ID, target.ID); err != nil {
t.Fatalf("insert sim: %v", err)
}
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
// resp.Tracks[0] is seed; check whether target ranks above control.
var targetIdx, controlIdx int
targetIdx, controlIdx = -1, -1
for i, tr := range resp.Tracks {
if tr.ID == uuidToString(target.ID) {
targetIdx = i
}
if tr.ID == uuidToString(control.ID) {
controlIdx = i
}
}
if targetIdx < 0 || controlIdx < 0 {
t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx)
}
if targetIdx >= controlIdx {
t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx)
}
}
func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000)
for i := 3; i <= 6; i++ {
seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
}
q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID)
w := callRadio(h, user, q)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
for _, tr := range resp.Tracks {
if tr.ID == uuidToString(excluded.ID) {
t.Error("excluded track present in response")
}
}
}
func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)
q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID)
w := callRadio(h, user, q)
if w.Code != http.StatusOK {
t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
for _, tr := range resp.Tracks {
if tr.ID == uuidToString(other.ID) {
t.Error("other track should still be excluded after dropping malformed entry")
}
}
}
func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) {
// Defensive: even when the similarity pool returns no candidates,
// the seed track must still be the first track in the response.
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) {
t.Errorf("seed not at index 0: %v", resp.Tracks)
}
}
- Step 2: Update
auth_test.go::testHandlersrecCfg literal
In internal/api/auth_test.go, find the recCfg := config.RecommendationConfig{...} literal in testHandlers and add SimilarityWeight: 2.0:
recCfg := config.RecommendationConfig{
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
- Step 3: Update the radio handler
In internal/api/radio.go, replace the LoadCandidates call site. Before it, parse the exclude param + build the limits struct + add the seed to the exclude list. Keep the M3 LoadCandidates call as a fallback path.
Replace the section from currentVec := loadCurrentSessionVector(...) through weights := recommendation.ScoringWeights{...}:
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
limits := recommendation.DefaultCandidateSourceLimits()
candidates, err := recommendation.LoadCandidatesFromSimilarity(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
)
if err != nil {
h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err)
candidates, err = recommendation.LoadCandidates(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec,
)
if err != nil {
h.logger.Error("api: radio: load candidates fallback failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
return
}
}
weights := recommendation.ScoringWeights{
BaseWeight: h.recCfg.BaseWeight,
LikeBoost: h.recCfg.LikeBoost,
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight,
}
Append the helper at the bottom of the same file:
// parseExcludeParam parses a comma-separated list of UUIDs from the
// `exclude` query string, silently dropping malformed entries. Returns
// nil for empty or all-malformed input.
func parseExcludeParam(raw string) []pgtype.UUID {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]pgtype.UUID, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
id, ok := parseUUID(p)
if !ok {
continue
}
out = append(out, id)
}
return out
}
parseUUID already exists in internal/api/. Confirm that strings is in the import block (it should already be).
- Step 4: Run tests to verify they pass
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -v 2>&1 | tail -30
Expected: PASS for all existing API tests + 4 new radio tests.
- Step 5: Verify lint clean
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...
Expected: clean.
- Step 6: Commit
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
git add internal/api/radio.go internal/api/radio_test.go internal/api/auth_test.go
git commit -m "feat(api): radio uses similarity pool with exclude param + M3 fallback"
Task 5: Frontend — radio queue refresh at 80%
Files:
-
Modify:
web/src/lib/player/store.svelte.ts -
Modify:
web/src/lib/player/store.test.ts -
Step 1: Write the failing tests
Append to web/src/lib/player/store.test.ts:
describe('radio refresh at 80%', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('refresh fires at 80% queue consumption', async () => {
const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({
id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al',
artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1,
duration_sec: 100, stream_url: `/api/tracks/t${i}/stream`
}));
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
// Now queue is the 5 tracks. Reset mock for refresh call.
(apiGet as ReturnType<typeof vi.fn>).mockClear();
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
tracks: [tracks[0], { ...tracks[0], id: 'new1', title: 'New1' }]
});
// Advance to index 4 (5th of 5 = 100%, but cross 80% at index 3)
// Skip to index 3 (4th track played; 4/5 = 80%)
skipNext(); skipNext(); skipNext();
// Allow $effect to fire
await new Promise((r) => setTimeout(r, 10));
expect(apiGet).toHaveBeenCalled();
const callArg = (apiGet as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(callArg).toContain('seed_track=seed1');
expect(callArg).toContain('exclude=');
});
test('refresh below 80% does not fire', async () => {
const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({
id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al',
artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1,
duration_sec: 100, stream_url: `/api/tracks/t${i}/stream`
}));
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
(apiGet as ReturnType<typeof vi.fn>).mockClear();
skipNext(); skipNext(); // index = 2 (3 of 5 = 60%)
await new Promise((r) => setTimeout(r, 10));
expect(apiGet).not.toHaveBeenCalled();
});
test('refresh appends new tracks excluding seed at index 0', async () => {
const initial: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({
id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al',
artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1,
duration_sec: 100, stream_url: `/api/tracks/t${i}/stream`
}));
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ tracks: initial });
await playRadio('seed1');
// Refresh response: 5 tracks where the first is the seed (will be stripped).
const refreshTracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({
id: i === 0 ? initial[0].id : `n${i}`,
title: i === 0 ? initial[0].title : `N${i}`,
album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar',
track_number: i + 1, disc_number: 1, duration_sec: 100,
stream_url: '/api/tracks/x/stream'
}));
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ tracks: refreshTracks });
skipNext(); skipNext(); skipNext(); // index = 3 (80%)
await new Promise((r) => setTimeout(r, 10));
// Initial 5 + 4 new (5 in response - 1 seed stripped) = 9
expect(player.queue.length).toBe(9);
});
test('refresh does NOT double-fire while in-flight', async () => {
const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({
id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al',
artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1,
duration_sec: 100, stream_url: `/api/tracks/t${i}/stream`
}));
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
(apiGet as ReturnType<typeof vi.fn>).mockClear();
// Hang the refresh call so the in-flight flag stays set.
let resolveFn!: (v: unknown) => void;
const hanging = new Promise((r) => { resolveFn = r; });
(apiGet as ReturnType<typeof vi.fn>).mockReturnValueOnce(hanging);
skipNext(); skipNext(); skipNext(); // 80% — fires refresh
await new Promise((r) => setTimeout(r, 10));
// Now advance further while in-flight; should NOT fire a second call.
skipNext();
await new Promise((r) => setTimeout(r, 10));
expect(apiGet).toHaveBeenCalledTimes(1);
resolveFn({ tracks: [] });
});
test('refresh resets when user enqueues from non-radio source', async () => {
const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({
id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al',
artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1,
duration_sec: 100, stream_url: `/api/tracks/t${i}/stream`
}));
(apiGet as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
enqueueTrack({
id: 'manual1', title: 'Manual', album_id: 'al', album_title: 'Al',
artist_id: 'ar', artist_name: 'Ar', track_number: 99, disc_number: 1,
duration_sec: 100, stream_url: '/api/tracks/manual1/stream'
});
(apiGet as ReturnType<typeof vi.fn>).mockClear();
skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the now-6-track queue
await new Promise((r) => setTimeout(r, 10));
expect(apiGet).not.toHaveBeenCalled();
});
});
The existing test setup may already mock $lib/api/client (where apiGet lives) — match the existing pattern in the file. If apiGet isn't currently mocked, add a mock at the top of store.test.ts:
vi.mock('$lib/api/client', () => ({
api: { get: vi.fn(), put: vi.fn(), post: vi.fn(), del: vi.fn() }
}));
import { api } from '$lib/api/client';
const apiGet = api.get;
Match the existing import-style in this test file; if it already imports from $lib/api/client, reuse that.
- Step 2: Run tests to verify they fail
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10
Expected: most/all of the new tests fail because the refresh logic doesn't exist yet.
- Step 3: Modify the player store
In web/src/lib/player/store.svelte.ts, find the existing playRadio function and:
- Add a module-level
radioSeedIdstate variable - Add a
radioRefreshInFlightflag - Modify
playRadioto setradioSeedId - Modify
playQueue,enqueueTrack,enqueueTracksto clearradioSeedId(these are explicit non-radio enqueues) - Add an
$effectwatching consumption ratio; trigger refresh when conditions met
Replace the existing playRadio and the surrounding state declarations:
let _queue = $state<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume());
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(null);
// M4c: track when the queue was seeded by a radio call so we can
// auto-refresh at 80% consumption. Cleared when the user enqueues
// from a non-radio source.
let _radioSeedId = $state<string | null>(null);
let _radioRefreshInFlight = false;
Modify playQueue, enqueueTrack, enqueueTracks to clear _radioSeedId. Existing code:
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
_queue = tracks;
// ... existing logic ...
}
Add _radioSeedId = null; as the first line in playQueue, enqueueTrack, and enqueueTracks.
Replace playRadio:
export async function playRadio(seedTrackId: string): Promise<void> {
const resp = await api.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
);
if (resp.tracks.length === 0) return;
// playQueue clears _radioSeedId; set it back AFTER playQueue finishes.
playQueue(resp.tracks, 0);
_radioSeedId = seedTrackId;
}
Add the auto-refresh effect. Place after the state declarations (effects are top-level in .svelte.ts files):
$effect(() => {
if (_radioSeedId === null) return;
if (_radioRefreshInFlight) return;
if (_queue.length === 0) return;
const consumedRatio = (_index + 1) / _queue.length;
if (consumedRatio < 0.8) return;
_radioRefreshInFlight = true;
const seed = _radioSeedId;
const exclude = _queue.map((t) => t.id).join(',');
api
.get<RadioResponse>(
`/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}`
)
.then((resp) => {
// Strip the seed at index 0 (already in our queue) and append the rest.
const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed);
if (newTracks.length > 0) {
// Append without clearing radio state — this IS a radio refresh.
_queue = [..._queue, ...newTracks];
}
})
.catch(() => {
// Swallow; next track-advance can retry.
})
.finally(() => {
_radioRefreshInFlight = false;
});
});
Note: the existing code uses _queue = [..._queue, ...newTracks] style (immutable append) consistent with enqueueTrack/enqueueTracks. We don't call enqueueTracks here because it would clear _radioSeedId.
- Step 4: Run tests to verify they pass
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10
Expected: PASS for all existing tests + 5 new refresh tests.
- Step 5: Verify svelte-check + build
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3
Expected: 0 errors, 0 warnings.
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5
Expected: adapter-static emits web/build/.
- Step 6: Commit
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
git add web/
git commit -m "feat(web): radio queue auto-refreshes at 80% via ?exclude= param"
Task 6: Final verification + branch finish
Files: none (verification only)
- Step 1: Full Go test suite (-short -race)
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...
Expected: PASS across all 16 packages.
- Step 2: Full integration suite
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...
Expected: PASS apart from the pre-existing TestScanner_Integration flake (CI runs -short which skips it).
- Step 3: Lint
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...
Expected: clean.
- Step 4: Coverage check
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4c.out ./internal/recommendation/... ./internal/api/ && go tool cover -func=/tmp/cover-m4c.out | tail -5
Expected: internal/recommendation ≥ 80% (was 73% pre-M4c); internal/api/radio.go should be well-covered by the existing radio tests + 4 new ones.
- Step 5: Web verification
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build
Expected: svelte-check 0/0; vitest passes (180 + 5 = 185); build succeeds.
- Step 6: Docker smoke
Run: cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4c-smoke .
Expected: container builds.
- Step 7: Manual end-to-end gate (closes M4)
Set up:
docker compose up --build -d minstrel— pick up new code- Wait for M4b's similarity worker to fill
track_similarityfor ≥10 played tracks (≥1 hour of uptime + previously-played tracks, OR seed manually for testing) - In SPA: click radio from a played track. Queue should differ noticeably from M3's output.
- Listen through ~80% of the queue. Observe queue length increases as auto-refresh fires (browser DevTools network tab shows the
/api/radio?seed_track=…&exclude=…request). psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'"shows non-trivial similarity data.- Subjectively: radio quality should feel meaningfully better than M3's baseline.
This is the closing gate for M4.
- Step 8: Update Fable task #347
After PR opens, mark in_progress. After PR merges, mark done with the closing summary in the body. Note that #347 closes the M4 milestone.
- Step 9: Finishing the branch
REQUIRED SUB-SKILL: Use superpowers:finishing-a-development-branch to verify tests, present completion options, and execute the user's choice.
Per established cadence: this slice will land as a single-purpose PR. After merge, M4 milestone is fully closed; M5 (Lidarr quarantine + suggested-additions for tracks not in library) becomes the natural next milestone.
Self-Review
Spec coverage:
- §3 architecture overview → Tasks 1, 3, 4, 5 ✓
- §4 candidate pool SQL → Task 1 ✓
- §5 Score() formula extension → Task 2 ✓
- §6 Go-side wiring → Tasks 3, 4 ✓
- §7 frontend queue refresh → Task 5 ✓
- §8 test plan → embedded across Tasks 2-5; verified in Task 6 ✓
- §9 decisions ledger → all 7 baked into the plan defaults ✓
- §10 backwards compatibility → preserved by zero-default new fields + LoadCandidates retained ✓
No gaps.
Placeholder scan: No "TBD"/"TODO" content. The "use whatever sqlc emits at make generate time" guidance in Task 3 is a known sqlc-specific instruction, not a placeholder.
Type consistency:
ScoringInputs.SimilarityScoreandScoringWeights.SimilarityWeightdefined in Task 2; consumed in Tasks 3 and 4.CandidateSourceLimitsdefined in Task 3; consumed in Task 4.LoadCandidatesFromSimilaritysignature matches across Tasks 3 and 4.parseExcludeParamsignature consistent with[]pgtype.UUIDreturn type.- Frontend:
_radioSeedId,_radioRefreshInFlightdeclared at the top of Task 5 step 3; effect references them;playRadio/playQueue/etc. mutate them consistently. - sqlc-generated
LoadRadioCandidatesV2Paramsfield names: deferred to implementation time (sqlc's auto-naming is positional unless column hints help; the Task 3 code usesColumn3..Column9placeholders the implementer adjusts aftermake generate).