2e6d2ffd03
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).
1392 lines
52 KiB
Markdown
1392 lines
52 KiB
Markdown
# 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`).
|