# 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`:

```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**

```bash
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`:

```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`:

```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`:

```go
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:

```go
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**

```bash
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`:

```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`:

```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**

```bash
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`:

```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`:

```go
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{...}`:

```go
	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:

```go
// 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**

```bash
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`:

```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`:

```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:

```ts
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:

```ts
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`:

```ts
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):

```ts
$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**

```bash
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`).
