Files
minstrel/docs/superpowers/plans/2026-04-28-m4c-radio.md
T
bvandeusen 2e6d2ffd03 docs(plan): add M4c radio similarity-driven candidate pool plan
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).
2026-04-29 06:58:22 -04:00

52 KiB
Raw Blame History

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 LoadRadioCandidatesV2Params struct has 9 fields. sqlc names them after column hints when possible; otherwise positional like Column3, Column4. Note actual generated names — Task 3 will use them.

  • The LoadRadioCandidatesV2Row struct has the embedded Track plus IsLiked 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' recCfg literal)

  • 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::testHandlers recCfg 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:

  1. Add a module-level radioSeedId state variable
  2. Add a radioRefreshInFlight flag
  3. Modify playRadio to set radioSeedId
  4. Modify playQueue, enqueueTrack, enqueueTracks to clear radioSeedId (these are explicit non-radio enqueues)
  5. Add an $effect watching 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:

  1. docker compose up --build -d minstrel — pick up new code
  2. Wait for M4b's similarity worker to fill track_similarity for ≥10 played tracks (≥1 hour of uptime + previously-played tracks, OR seed manually for testing)
  3. In SPA: click radio from a played track. Queue should differ noticeably from M3's output.
  4. 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).
  5. psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'" shows non-trivial similarity data.
  6. 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.SimilarityScore and ScoringWeights.SimilarityWeight defined in Task 2; consumed in Tasks 3 and 4.
  • CandidateSourceLimits defined in Task 3; consumed in Task 4.
  • LoadCandidatesFromSimilarity signature matches across Tasks 3 and 4.
  • parseExcludeParam signature consistent with []pgtype.UUID return type.
  • Frontend: _radioSeedId, _radioRefreshInFlight declared at the top of Task 5 step 3; effect references them; playRadio/playQueue/etc. mutate them consistently.
  • sqlc-generated LoadRadioCandidatesV2Params field names: deferred to implementation time (sqlc's auto-naming is positional unless column hints help; the Task 3 code uses Column3..Column9 placeholders the implementer adjusts after make generate).