Merge pull request 'feat: M4c radio similarity-driven candidate pool + 80% queue refresh (closes M4)' (#28) from dev into main
This commit was merged in pull request #28.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
|||||||
|
# M4c — Radio similarity-driven candidate pool + queue refresh at 80% (closes M4)
|
||||||
|
|
||||||
|
**Status:** Spec draft, 2026-04-28
|
||||||
|
**Tracking:** Fable #347
|
||||||
|
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
|
||||||
|
**Builds on:** M4a (PR #26 — outbound scrobble), M4b (PR #27 — inbound similarity ingest)
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Replace M3's "whole library minus seed minus recently-played" candidate pool
|
||||||
|
with a similarity-driven pool drawn from four sources (LB-similar tracks,
|
||||||
|
tracks by similar artists, MB-tag overlap, user's general likes overlapping
|
||||||
|
seed tags) plus a random-fill source that guarantees a minimum pool size.
|
||||||
|
Add a sixth scoring term (`SimilarityScore × SimilarityWeight`) so within-pool
|
||||||
|
ranking reflects per-track similarity strength. Web client auto-refreshes
|
||||||
|
the radio queue when 80% consumed.
|
||||||
|
|
||||||
|
When this slice merges, the M4 milestone closes: the engine has all three v1
|
||||||
|
components (scoring, session vectors, LB-derived similarity) wired through
|
||||||
|
both backend and frontend.
|
||||||
|
|
||||||
|
## 2. Non-goals (explicit)
|
||||||
|
|
||||||
|
- **Lazy LB fetch on radio click** — radio handler stays synchronous and
|
||||||
|
uses only data already in `track_similarity` / `artist_similarity`.
|
||||||
|
Sparse-data UX is handled by random-fill augmentation.
|
||||||
|
- **YAML-configurable per-source Ks** — hardcoded constants for v1.
|
||||||
|
- **Symmetric edge storage** — still one-way as M4b stored.
|
||||||
|
- **Per-source score weights as YAML** — tunable in code only.
|
||||||
|
- **Token-based queue-refresh continuation** — explicit `?exclude=...`
|
||||||
|
query param.
|
||||||
|
- **Pre-fetch similarity on track play** — M4b's hourly tick is the only
|
||||||
|
fetch trigger.
|
||||||
|
- **Suggested additions / Lidarr** — out-of-library LB matches discarded;
|
||||||
|
M5 territory.
|
||||||
|
- **Multi-seed / persistent radio stations** — every call is single-seed
|
||||||
|
and stateless.
|
||||||
|
- **Cross-user collaborative filtering source** — `user_cooccurrence`
|
||||||
|
schema slot reserved, not populated.
|
||||||
|
- **`SimilarityWeight` per-user override** — operator-only YAML for v1.
|
||||||
|
|
||||||
|
## 3. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/radio?seed_track=<uuid>&limit=N&exclude=t1,t2,...
|
||||||
|
↓
|
||||||
|
handleRadio
|
||||||
|
1. Auth + parse params (existing M3)
|
||||||
|
2. Get seed track + album + artist (existing)
|
||||||
|
3. q.GetCurrentSessionVectorForUser() (existing — M3)
|
||||||
|
4. recommendation.LoadCandidatesFromSimilarity(...) ← NEW
|
||||||
|
- 5-way SQL UNION: LB-similar / similar-artist tracks /
|
||||||
|
tag-overlap / likes-overlap / random-fill
|
||||||
|
- Excludes seed + ?exclude= list + recently-played
|
||||||
|
- Returns []Candidate with per-row SimilarityScore
|
||||||
|
- On error → fallback to M3's LoadCandidates (logged)
|
||||||
|
5. recommendation.Shuffle(candidates, weights, ...) ← extended
|
||||||
|
- M3's Score() gains SimilarityScore × SimilarityWeight term
|
||||||
|
- Otherwise unchanged
|
||||||
|
6. Resolve album/artist for picks (existing)
|
||||||
|
7. Return RadioResponse{Tracks: [seed, pick1, ...]}
|
||||||
|
↓
|
||||||
|
Web client
|
||||||
|
8. Player store watches currentIndex / queue.length ← NEW
|
||||||
|
- At ≥ 80% consumed, AND radioSeedId is set, AND no refresh
|
||||||
|
in-flight: GET /api/radio?seed_track=…&exclude=<queue>
|
||||||
|
- Append response.tracks (skipping index 0 = seed already in queue)
|
||||||
|
- radioSeedId cleared when user manually enqueues from elsewhere
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Candidate pool SQL (`LoadRadioCandidatesV2`)
|
||||||
|
|
||||||
|
5-way UNION; each branch produces `(track_id, similarity_score)`. After UNION,
|
||||||
|
the outer SELECT joins `tracks` + general_likes (for `is_liked`) + LATERAL
|
||||||
|
play_events aggregation (for `last_played_at` / `play_count` / `skip_count`),
|
||||||
|
GROUP BY track id, taking `max(similarity_score)` across sources.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- name: LoadRadioCandidatesV2 :many
|
||||||
|
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($5::uuid[]) AS id
|
||||||
|
UNION ALL
|
||||||
|
SELECT track_id FROM play_events
|
||||||
|
WHERE user_id = $1 AND started_at > now() - $4 * 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 $6
|
||||||
|
),
|
||||||
|
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 $7
|
||||||
|
),
|
||||||
|
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 $8
|
||||||
|
),
|
||||||
|
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 $9
|
||||||
|
),
|
||||||
|
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 $10
|
||||||
|
)
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Per-source K and score (defaults, hardcoded for v1)
|
||||||
|
|
||||||
|
| Source | K | sim_score per row |
|
||||||
|
|---|---|---|
|
||||||
|
| `lb_similar` (track_similarity) | 30 | LB raw score (0–1) |
|
||||||
|
| `similar_artists` | 30 | `artist_similarity.score × 0.5` |
|
||||||
|
| `tag_overlap` | 20 | jaccard: `shared_tags / seed_tag_count` |
|
||||||
|
| `likes_overlap` | 20 | constant `0.6` |
|
||||||
|
| `random_fill` | 30 | `0.0` |
|
||||||
|
|
||||||
|
Total ideal: 130 candidates pre-dedup; 60–100 after dedup. Random fill is
|
||||||
|
drawn AFTER the 4 similarity sources are exhausted (`NOT IN (lb_similar
|
||||||
|
UNION similar_artists UNION tag_overlap UNION likes_overlap)`), so it
|
||||||
|
strictly augments rather than overlaps. On very small libraries (<130
|
||||||
|
tracks total), the pool is naturally smaller — there is no hard floor;
|
||||||
|
the design assumes typical libraries have hundreds of tracks. M3's `Score()`
|
||||||
|
+ `Shuffle()` happily ranks small pools.
|
||||||
|
|
||||||
|
`max(sim_score)` on dedup so a track in multiple sources keeps its strongest
|
||||||
|
signal (LB's 0.85 beats tag's 0.4).
|
||||||
|
|
||||||
|
## 5. Score() formula extension
|
||||||
|
|
||||||
|
`internal/recommendation/score.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ScoringInputs struct {
|
||||||
|
IsGeneralLiked bool
|
||||||
|
LastPlayedAt *time.Time
|
||||||
|
PlayCount int
|
||||||
|
SkipCount int
|
||||||
|
ContextualMatchScore float64 // M3
|
||||||
|
SimilarityScore float64 // NEW — max across the 4 similarity sources, in [0,1]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoringWeights struct {
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64 // M3
|
||||||
|
SimilarityWeight float64 // NEW — default 2.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Updated formula:
|
||||||
|
|
||||||
|
```
|
||||||
|
score = base
|
||||||
|
+ (is_general_liked ? LikeBoost : 0)
|
||||||
|
+ recency_decay * RecencyWeight
|
||||||
|
- skip_ratio * SkipPenalty
|
||||||
|
+ contextual_match_score * ContextWeight
|
||||||
|
+ similarity_score * SimilarityWeight ← NEW
|
||||||
|
+ jitter
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.RecommendationConfig` gains `SimilarityWeight float64` (yaml
|
||||||
|
`similarity_weight`, default `2.0`). Same magnitude as `LikeBoost` and
|
||||||
|
`ContextWeight` — at perfect similarity (1.0), an LB-similar track gets a
|
||||||
|
+2.0 boost equivalent to an explicit general like.
|
||||||
|
|
||||||
|
**Backwards compatible:** zero-value `ScoringInputs{}` and `ScoringWeights{}`
|
||||||
|
produce M3 behavior because both new fields are zero-defaulted.
|
||||||
|
|
||||||
|
## 6. Go-side wiring
|
||||||
|
|
||||||
|
### 6.1 `LoadCandidatesFromSimilarity`
|
||||||
|
|
||||||
|
`internal/recommendation/candidates.go` gains a sibling to `LoadCandidates`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CandidateSourceLimits struct {
|
||||||
|
LBSimilar int // 30
|
||||||
|
SimilarArtist int // 30
|
||||||
|
TagOverlap int // 20
|
||||||
|
LikesOverlap int // 20
|
||||||
|
RandomFill int // 30 — drawn from tracks NOT already returned by the 4 similarity sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultCandidateSourceLimits() CandidateSourceLimits
|
||||||
|
|
||||||
|
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
|
||||||
|
// Returns []Candidate (same type as M3 LoadCandidates) so Shuffle() is
|
||||||
|
// unchanged. Caller 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
1. `q.LoadRadioCandidatesV2(...)` with the 10 params from §4
|
||||||
|
2. Existing `loadContextualLikesByTrack(...)` for the contextual scoring inputs
|
||||||
|
3. Project rows → `[]Candidate` with `Inputs.SimilarityScore = row.SimilarityScore`
|
||||||
|
|
||||||
|
`LoadCandidates` (M3 fallback) **stays in place**, still consumed by callers
|
||||||
|
that want whole-library scoring (unit tests, the radio handler's error path).
|
||||||
|
|
||||||
|
### 6.2 Radio handler change
|
||||||
|
|
||||||
|
`internal/api/radio.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude")) // []pgtype.UUID
|
||||||
|
exclude = append(exclude, seedID) // always exclude the seed
|
||||||
|
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",
|
||||||
|
"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", "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, // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`parseExcludeParam(s string) []pgtype.UUID` — splits on `,`, parses each
|
||||||
|
UUID, silently drops malformed entries. Returns nil for empty input.
|
||||||
|
|
||||||
|
## 7. Frontend (queue refresh at 80%)
|
||||||
|
|
||||||
|
`web/src/lib/player/store.svelte.ts`:
|
||||||
|
|
||||||
|
- New `radioSeedId` `$state<string | null>` set inside `playRadio()`.
|
||||||
|
- New `$effect` watching `(player.currentIndex + 1) / player.queue.length`:
|
||||||
|
- Fires when `radioSeedId` is set, queue is non-empty, ratio ≥ 0.8, and
|
||||||
|
no refresh in-flight.
|
||||||
|
- Calls `/api/radio?seed_track=<radioSeedId>&exclude=<queue.map(t=>t.id).join(',')>`.
|
||||||
|
- On success: appends `response.tracks.slice(1)` (drops the seed at index 0).
|
||||||
|
- On failure: logs + clears in-flight flag (next track-advance can retry).
|
||||||
|
- New `appendToQueue(tracks)` helper — pushes onto `player.queue` without
|
||||||
|
changing `currentIndex`.
|
||||||
|
- `radioSeedId = null` when user enqueues from non-radio paths (`playQueue`,
|
||||||
|
`enqueueTrack`, `enqueueTracks`) so the auto-refresh doesn't fire on
|
||||||
|
manually-built queues.
|
||||||
|
|
||||||
|
`RadioResponse` JSON shape unchanged. `TrackRef[]` array works for both
|
||||||
|
initial calls and refresh calls.
|
||||||
|
|
||||||
|
## 8. Test plan
|
||||||
|
|
||||||
|
### 8.1 Backend pure tests
|
||||||
|
|
||||||
|
`internal/recommendation/score_test.go` extensions:
|
||||||
|
- `TestScore_SimilarityScore_PerfectMatch_AddsWeightedTerm` — `1.0 × 2.0 = +2.0` over baseline
|
||||||
|
- `TestScore_SimilarityScore_HalfMatch` — `0.5 × 2.0 = +1.0`
|
||||||
|
- `TestScore_SimilarityScore_Zero_NoEffect` — random/serendipity tracks score same as M3 baseline
|
||||||
|
|
||||||
|
### 8.2 Backend integration tests
|
||||||
|
|
||||||
|
`internal/recommendation/candidates_v2_test.go` (new):
|
||||||
|
- `TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes`
|
||||||
|
- `TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute` (artist score × 0.5 verified)
|
||||||
|
- `TestLoadCandidatesFromSimilarity_TagOverlapContributes` (jaccard score)
|
||||||
|
- `TestLoadCandidatesFromSimilarity_LikesOverlapContributes` (0.6 constant)
|
||||||
|
- `TestLoadCandidatesFromSimilarity_RandomFillToTargetSize` (empty similarity tables → pool ≥ 60)
|
||||||
|
- `TestLoadCandidatesFromSimilarity_ExcludeListRespected`
|
||||||
|
- `TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded`
|
||||||
|
- `TestLoadCandidatesFromSimilarity_DedupTakesMaxScore` (LB 0.85 beats tag 0.4)
|
||||||
|
- `TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded`
|
||||||
|
- `TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError`
|
||||||
|
|
||||||
|
### 8.3 Backend HTTP tests
|
||||||
|
|
||||||
|
`internal/api/radio_test.go` extensions:
|
||||||
|
- `TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher` (deterministic via fixed RNG)
|
||||||
|
- `TestHandleRadio_ExcludeParam_FiltersOut`
|
||||||
|
- `TestHandleRadio_ExcludeParam_MalformedSkipped`
|
||||||
|
- `TestHandleRadio_FallbackToM3OnSimilarityError` (inject fault)
|
||||||
|
|
||||||
|
### 8.4 Frontend tests
|
||||||
|
|
||||||
|
`web/src/lib/player/store.test.ts` extensions:
|
||||||
|
- `radio refresh fires at 80% queue consumption` (5-track queue at index 3)
|
||||||
|
- `radio refresh appends new tracks (excluding seed)` (queue 5 → 9 after 5-track response)
|
||||||
|
- `radio refresh does NOT double-fire` (in-flight guard)
|
||||||
|
- `radio refresh resets when user enqueues from non-radio source`
|
||||||
|
- `radio refresh below threshold` (5-track queue at index 2 → no refresh)
|
||||||
|
|
||||||
|
### 8.5 Coverage targets
|
||||||
|
|
||||||
|
- `internal/recommendation` post-M4c: ≥ 80% (currently 73%)
|
||||||
|
- `internal/api/radio.go`: ≥ 75%
|
||||||
|
- Web `player/store`: ≥ 80% on the new refresh logic
|
||||||
|
|
||||||
|
### 8.6 Manual end-to-end gate (closes M4)
|
||||||
|
|
||||||
|
After deploy + M4b worker has filled `track_similarity` for ≥10 played tracks:
|
||||||
|
|
||||||
|
1. Click radio from a played track — queue should differ noticeably from M3
|
||||||
|
2. Listen through ~80% of the queue — observe queue length increase as
|
||||||
|
auto-refresh fires
|
||||||
|
3. `psql -c "SELECT count(*) FROM track_similarity"` shows non-trivial data
|
||||||
|
4. Subjectively: radio quality should feel meaningfully better than M3's
|
||||||
|
baseline. **This is the closing gate for M4.**
|
||||||
|
|
||||||
|
## 9. Decisions ledger
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 4-source pool composition + random fill | Cross-source diversity; sparse-fallback is automatic via random fill |
|
||||||
|
| 2 | Always augment with random to floor of 60 | Never returns empty radio; serendipity built in; degrades gracefully on sparse libraries |
|
||||||
|
| 3 | New `SimilarityScore × SimilarityWeight` term in M3 Score() | Wastes the LB scores otherwise; consistent with the M3 multi-input pattern |
|
||||||
|
| 4 | No lazy LB fetch | Keeps radio handler synchronous; M4b worker + augmentation cover the gap |
|
||||||
|
| 5 | `?exclude=...` query param for queue refresh | Stateless, simple, no new server state |
|
||||||
|
| 6 | Per-source K + score: hardcoded for v1 | YAGNI; expose later if telemetry warrants |
|
||||||
|
| 7 | Fallback to M3 LoadCandidates on similarity-pool error | Defense in depth for the central radio surface |
|
||||||
|
|
||||||
|
## 10. Backwards compatibility
|
||||||
|
|
||||||
|
- New SQL query alongside existing `LoadRadioCandidates`; M3 callers
|
||||||
|
unaffected.
|
||||||
|
- M3 `LoadCandidates` retained for the fallback path and direct test usage.
|
||||||
|
- `Score()` signature unchanged; new fields are zero-defaulted so existing
|
||||||
|
zero-value `ScoringInputs{}`/`ScoringWeights{}` constructions produce M3
|
||||||
|
scores.
|
||||||
|
- `/api/radio` request shape extended (adds optional `?exclude=`); existing
|
||||||
|
callers that don't pass it work identically.
|
||||||
|
- `RadioResponse` shape unchanged.
|
||||||
|
- No schema migration — relies on M4b's `track_similarity` /
|
||||||
|
`artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`.
|
||||||
|
|
||||||
|
## 11. M4 closure
|
||||||
|
|
||||||
|
After this slice merges, M4 is complete:
|
||||||
|
- M4a (PR #26) — outbound LB scrobble worker
|
||||||
|
- M4b (PR #27) — inbound LB similarity ingest
|
||||||
|
- M4c (this) — radio similarity-driven candidate pool + 80% queue refresh
|
||||||
|
|
||||||
|
Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in
|
||||||
|
library). Out-of-library LB-returned tracks (currently filtered out by the
|
||||||
|
in-library JOIN) become the natural input for M5's "would you like to add
|
||||||
|
this?" workflow.
|
||||||
@@ -52,7 +52,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
||||||
SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0,
|
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
|
||||||
|
|||||||
+47
-10
@@ -82,19 +82,33 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
||||||
|
|
||||||
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec)
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("api: radio: load candidates", "err", err)
|
h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
|
candidates, err = recommendation.LoadCandidates(
|
||||||
return
|
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{
|
weights := recommendation.ScoringWeights{
|
||||||
BaseWeight: h.recCfg.BaseWeight,
|
BaseWeight: h.recCfg.BaseWeight,
|
||||||
LikeBoost: h.recCfg.LikeBoost,
|
LikeBoost: h.recCfg.LikeBoost,
|
||||||
RecencyWeight: h.recCfg.RecencyWeight,
|
RecencyWeight: h.recCfg.RecencyWeight,
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
SkipPenalty: h.recCfg.SkipPenalty,
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
JitterMagnitude: h.recCfg.JitterMagnitude,
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
ContextWeight: h.recCfg.ContextWeight,
|
||||||
|
SimilarityWeight: h.recCfg.SimilarityWeight,
|
||||||
}
|
}
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
@@ -141,3 +155,26 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,110 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp RadioResponse
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
|
func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type RecommendationConfig struct {
|
|||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
SkipPenalty float64 `yaml:"skip_penalty"`
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
ContextWeight float64 `yaml:"context_weight"`
|
||||||
|
SimilarityWeight float64 `yaml:"similarity_weight"`
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
RadioSize int `yaml:"radio_size"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
@@ -95,6 +96,7 @@ func Default() Config {
|
|||||||
SkipPenalty: 1.0,
|
SkipPenalty: 1.0,
|
||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
ContextWeight: 2.0,
|
ContextWeight: 2.0,
|
||||||
|
SimilarityWeight: 2.0,
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
|
|||||||
@@ -96,3 +96,193 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadRadioCandidatesV2 = `-- name: LoadRadioCandidatesV2 :many
|
||||||
|
|
||||||
|
WITH
|
||||||
|
seed_artist AS (
|
||||||
|
SELECT artist_id
|
||||||
|
FROM tracks
|
||||||
|
WHERE tracks.id = $2
|
||||||
|
),
|
||||||
|
seed_tags AS (
|
||||||
|
SELECT trim(g) AS tag
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(tag) ON true
|
||||||
|
WHERE t.id = $2 AND trim(g) <> ''
|
||||||
|
),
|
||||||
|
excluded_ids AS (
|
||||||
|
SELECT unnest($4::uuid[]) AS id
|
||||||
|
UNION ALL
|
||||||
|
SELECT pe.track_id AS id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
|
||||||
|
),
|
||||||
|
lb_similar AS (
|
||||||
|
SELECT ts.track_b_id AS track_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 excluded_ids)
|
||||||
|
ORDER BY ts.score DESC
|
||||||
|
LIMIT $5
|
||||||
|
),
|
||||||
|
similar_artists AS (
|
||||||
|
SELECT t.id AS track_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 excluded_ids)
|
||||||
|
ORDER BY asim.score DESC, random()
|
||||||
|
LIMIT $6
|
||||||
|
),
|
||||||
|
tag_overlap AS (
|
||||||
|
SELECT t.id AS track_id,
|
||||||
|
(count(DISTINCT trim(g))::float8
|
||||||
|
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
GROUP BY t.id
|
||||||
|
HAVING count(DISTINCT trim(g_split.g)) > 0
|
||||||
|
ORDER BY sim_score DESC
|
||||||
|
LIMIT $7
|
||||||
|
),
|
||||||
|
likes_overlap AS (
|
||||||
|
SELECT gl.track_id, 0.6::float8 AS sim_score
|
||||||
|
FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1
|
||||||
|
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM tracks t
|
||||||
|
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_overlap(g) ON true
|
||||||
|
WHERE t.id = gl.track_id
|
||||||
|
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
|
||||||
|
)
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $8
|
||||||
|
),
|
||||||
|
random_fill AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM tracks t
|
||||||
|
WHERE t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
AND t.id NOT IN (
|
||||||
|
SELECT track_id FROM lb_similar
|
||||||
|
UNION SELECT track_id FROM similar_artists
|
||||||
|
UNION SELECT track_id FROM tag_overlap
|
||||||
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
)
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $9
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||||
|
(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,
|
||||||
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
|
FROM (
|
||||||
|
SELECT track_id, sim_score FROM lb_similar
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
|
) u
|
||||||
|
JOIN tracks t ON t.id = u.track_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.added_at, t.updated_at,
|
||||||
|
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
|
||||||
|
`
|
||||||
|
|
||||||
|
type LoadRadioCandidatesV2Params struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ID pgtype.UUID
|
||||||
|
Column3 interface{}
|
||||||
|
Column4 []pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
Limit_2 int32
|
||||||
|
Limit_3 int32
|
||||||
|
Limit_4 int32
|
||||||
|
Limit_5 int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadRadioCandidatesV2Row struct {
|
||||||
|
Track Track
|
||||||
|
IsLiked bool
|
||||||
|
LastPlayedAt pgtype.Timestamptz
|
||||||
|
PlayCount int64
|
||||||
|
SkipCount int64
|
||||||
|
SimilarityScore interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
|
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
|
||||||
|
arg.UserID,
|
||||||
|
arg.ID,
|
||||||
|
arg.Column3,
|
||||||
|
arg.Column4,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Limit_2,
|
||||||
|
arg.Limit_3,
|
||||||
|
arg.Limit_4,
|
||||||
|
arg.Limit_5,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []LoadRadioCandidatesV2Row
|
||||||
|
for rows.Next() {
|
||||||
|
var i LoadRadioCandidatesV2Row
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Track.ID,
|
||||||
|
&i.Track.Title,
|
||||||
|
&i.Track.AlbumID,
|
||||||
|
&i.Track.ArtistID,
|
||||||
|
&i.Track.TrackNumber,
|
||||||
|
&i.Track.DiscNumber,
|
||||||
|
&i.Track.DurationMs,
|
||||||
|
&i.Track.FilePath,
|
||||||
|
&i.Track.FileSize,
|
||||||
|
&i.Track.FileFormat,
|
||||||
|
&i.Track.Bitrate,
|
||||||
|
&i.Track.Mbid,
|
||||||
|
&i.Track.Genre,
|
||||||
|
&i.Track.AddedAt,
|
||||||
|
&i.Track.UpdatedAt,
|
||||||
|
&i.IsLiked,
|
||||||
|
&i.LastPlayedAt,
|
||||||
|
&i.PlayCount,
|
||||||
|
&i.SkipCount,
|
||||||
|
&i.SimilarityScore,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,118 @@ WHERE t.id <> $2
|
|||||||
WHERE user_id = $1 AND track_id = t.id
|
WHERE user_id = $1 AND track_id = t.id
|
||||||
AND started_at > now() - $3 * interval '1 hour'
|
AND started_at > now() - $3 * interval '1 hour'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 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 tracks.id = $2
|
||||||
|
),
|
||||||
|
seed_tags AS (
|
||||||
|
SELECT trim(g) AS tag
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(tag) ON true
|
||||||
|
WHERE t.id = $2 AND trim(g) <> ''
|
||||||
|
),
|
||||||
|
excluded_ids AS (
|
||||||
|
SELECT unnest($4::uuid[]) AS id
|
||||||
|
UNION ALL
|
||||||
|
SELECT pe.track_id AS id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
|
||||||
|
),
|
||||||
|
lb_similar AS (
|
||||||
|
SELECT ts.track_b_id AS track_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 excluded_ids)
|
||||||
|
ORDER BY ts.score DESC
|
||||||
|
LIMIT $5
|
||||||
|
),
|
||||||
|
similar_artists AS (
|
||||||
|
SELECT t.id AS track_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 excluded_ids)
|
||||||
|
ORDER BY asim.score DESC, random()
|
||||||
|
LIMIT $6
|
||||||
|
),
|
||||||
|
tag_overlap AS (
|
||||||
|
SELECT t.id AS track_id,
|
||||||
|
(count(DISTINCT trim(g))::float8
|
||||||
|
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
GROUP BY t.id
|
||||||
|
HAVING count(DISTINCT trim(g_split.g)) > 0
|
||||||
|
ORDER BY sim_score DESC
|
||||||
|
LIMIT $7
|
||||||
|
),
|
||||||
|
likes_overlap AS (
|
||||||
|
SELECT gl.track_id, 0.6::float8 AS sim_score
|
||||||
|
FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1
|
||||||
|
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM tracks t
|
||||||
|
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_overlap(g) ON true
|
||||||
|
WHERE t.id = gl.track_id
|
||||||
|
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
|
||||||
|
)
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $8
|
||||||
|
),
|
||||||
|
random_fill AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM tracks t
|
||||||
|
WHERE t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
AND t.id NOT IN (
|
||||||
|
SELECT track_id FROM lb_similar
|
||||||
|
UNION SELECT track_id FROM similar_artists
|
||||||
|
UNION SELECT track_id FROM tag_overlap
|
||||||
|
UNION SELECT track_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,
|
||||||
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
|
FROM (
|
||||||
|
SELECT track_id, sim_score FROM lb_similar
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
|
) u
|
||||||
|
JOIN tracks t ON t.id = u.track_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.added_at, t.updated_at,
|
||||||
|
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
|
||||||
|
|||||||
@@ -58,6 +58,95 @@ func LoadCandidates(
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: int64(recentlyPlayedHours),
|
||||||
|
Column4: exclude,
|
||||||
|
Limit: int32(limits.LBSimilar),
|
||||||
|
Limit_2: int32(limits.SimilarArtist),
|
||||||
|
Limit_3: int32(limits.TagOverlap),
|
||||||
|
Limit_4: int32(limits.LikesOverlap),
|
||||||
|
Limit_5: 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
|
||||||
|
}
|
||||||
|
// sqlc returns SimilarityScore as interface{} (couldn't infer the
|
||||||
|
// type through max(...) over a UNION). Type-assert; default to 0
|
||||||
|
// on the (impossible-but-defensive) case where it's nil/wrong type.
|
||||||
|
var simScore float64
|
||||||
|
if v, ok := r.SimilarityScore.(float64); ok {
|
||||||
|
simScore = v
|
||||||
|
}
|
||||||
|
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: simScore,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||||
// one query and groups them by track_id. Rows whose session_vector fails
|
// one query and groups them by track_id. Rows whose session_vector fails
|
||||||
// to unmarshal are skipped with no error (don't poison scoring over one
|
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helperSetTrackGenre updates a track's genre column. Used to retrofit
|
||||||
|
// genres onto the fixture's auto-created tracks (fixture creates tracks
|
||||||
|
// with NULL genre).
|
||||||
|
func helperSetTrackGenre(t *testing.T, f fixture, trackID pgtype.UUID, genre string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := f.pool.Exec(context.Background(),
|
||||||
|
`UPDATE tracks SET genre = $1 WHERE id = $2`, genre, trackID); err != nil {
|
||||||
|
t.Fatalf("set genre: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) // creates 1 artist + 1 album + 1 track (the seed)
|
||||||
|
seed := f.tracks[0]
|
||||||
|
// Add a SECOND artist + track in that artist; relate the two 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, 2)
|
||||||
|
seed := f.tracks[0]
|
||||||
|
target := f.tracks[1]
|
||||||
|
helperSetTrackGenre(t, f, seed.ID, "Rock; Pop")
|
||||||
|
helperSetTrackGenre(t, f, target.ID, "Rock")
|
||||||
|
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, 2)
|
||||||
|
seed := f.tracks[0]
|
||||||
|
liked := f.tracks[1]
|
||||||
|
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
||||||
|
helperSetTrackGenre(t, f, liked.ID, "Rock")
|
||||||
|
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 {
|
||||||
|
// Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap.
|
||||||
|
// likes-overlap = 0.6. Max wins = 1.0.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Error("random fill returned 0 candidates; expected at least some")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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, 2)
|
||||||
|
seed := f.tracks[0]
|
||||||
|
target := f.tracks[1]
|
||||||
|
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
||||||
|
helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap
|
||||||
|
helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
|
||||||
|
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++
|
||||||
|
// tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99
|
||||||
|
if c.Inputs.SimilarityScore < 0.99 {
|
||||||
|
t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", 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, 1) // just the seed
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Only the seed exists; it's excluded → 0 candidates.
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %d candidates from seed-only library, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,23 +11,26 @@ import (
|
|||||||
// ContextualMatchScore is in [0, 1] — max similarity between the user's
|
// ContextualMatchScore is in [0, 1] — max similarity between the user's
|
||||||
// current session vector and any non-seed contextual_like row for this
|
// current session vector and any non-seed contextual_like row for this
|
||||||
// track. Set by LoadCandidates after a bulk fetch.
|
// track. Set by LoadCandidates after a bulk fetch.
|
||||||
|
// SimilarityScore is in [0, 1]; 0 when no signal (random fill).
|
||||||
type ScoringInputs struct {
|
type ScoringInputs struct {
|
||||||
IsGeneralLiked bool
|
IsGeneralLiked bool
|
||||||
LastPlayedAt *time.Time // nil = never played
|
LastPlayedAt *time.Time // nil = never played
|
||||||
PlayCount int // total play_events
|
PlayCount int // total play_events
|
||||||
SkipCount int // play_events with was_skipped=true
|
SkipCount int // play_events with was_skipped=true
|
||||||
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
||||||
|
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||||
// config.RecommendationConfig and are propagated here per request.
|
// config.RecommendationConfig and are propagated here per request.
|
||||||
type ScoringWeights struct {
|
type ScoringWeights struct {
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score computes the weighted-shuffle score per spec §6:
|
// Score computes the weighted-shuffle score per spec §6:
|
||||||
@@ -37,6 +40,7 @@ type ScoringWeights struct {
|
|||||||
// + recency_decay * RecencyWeight
|
// + recency_decay * RecencyWeight
|
||||||
// - skip_ratio * SkipPenalty
|
// - skip_ratio * SkipPenalty
|
||||||
// + contextual_match_score * ContextWeight
|
// + contextual_match_score * ContextWeight
|
||||||
|
// + similarity_score * SimilarityWeight
|
||||||
// + small_random_jitter
|
// + small_random_jitter
|
||||||
//
|
//
|
||||||
// Higher score = more likely to surface. rng is a function returning a
|
// Higher score = more likely to surface. rng is a function returning a
|
||||||
@@ -50,6 +54,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
|||||||
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
||||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||||
s += in.ContextualMatchScore * w.ContextWeight
|
s += in.ContextualMatchScore * w.ContextWeight
|
||||||
|
s += in.SimilarityScore * w.SimilarityWeight
|
||||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,3 +182,37 @@ func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) {
|
|||||||
t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
|
t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ let _shuffle = $state(false);
|
|||||||
let _repeat = $state<RepeatMode>('off');
|
let _repeat = $state<RepeatMode>('off');
|
||||||
let _error = $state<string | null>(null);
|
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;
|
||||||
|
|
||||||
let _audioEl: HTMLAudioElement | null = null;
|
let _audioEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export const player = {
|
export const player = {
|
||||||
@@ -49,6 +55,7 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
|
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
|
||||||
|
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||||
_queue = tracks;
|
_queue = tracks;
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
_index = 0;
|
_index = 0;
|
||||||
@@ -199,11 +206,13 @@ export function reportStateFromAudio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueTrack(t: TrackRef): void {
|
export function enqueueTrack(t: TrackRef): void {
|
||||||
|
_radioSeedId = null; // M4c
|
||||||
_queue = [..._queue, t];
|
_queue = [..._queue, t];
|
||||||
if (_state === 'idle') _index = 0;
|
if (_state === 'idle') _index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueTracks(ts: TrackRef[]): void {
|
export function enqueueTracks(ts: TrackRef[]): void {
|
||||||
|
_radioSeedId = null; // M4c
|
||||||
if (ts.length === 0) return;
|
if (ts.length === 0) return;
|
||||||
_queue = [..._queue, ...ts];
|
_queue = [..._queue, ...ts];
|
||||||
if (_state === 'idle') _index = 0;
|
if (_state === 'idle') _index = 0;
|
||||||
@@ -213,5 +222,44 @@ export async function playRadio(seedTrackId: string): Promise<void> {
|
|||||||
const resp = await api.get<RadioResponse>(
|
const resp = await api.get<RadioResponse>(
|
||||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||||
);
|
);
|
||||||
if (resp.tracks.length > 0) playQueue(resp.tracks, 0);
|
if (resp.tracks.length === 0) return;
|
||||||
|
playQueue(resp.tracks, 0);
|
||||||
|
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M4c: auto-refresh radio queue when 80% consumed.
|
||||||
|
// Fires whenever the consumption ratio crosses 80% AND the queue was
|
||||||
|
// seeded by playRadio() AND no refresh is currently in-flight.
|
||||||
|
$effect.root(() => {
|
||||||
|
$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 any other
|
||||||
|
// tracks that happen to match the original seed id.
|
||||||
|
const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed);
|
||||||
|
if (newTracks.length > 0) {
|
||||||
|
// Append directly without calling enqueueTracks — that would
|
||||||
|
// clear _radioSeedId and prevent further refreshes.
|
||||||
|
_queue = [..._queue, ...newTracks];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Swallow; next track-advance can retry.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_radioRefreshInFlight = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -333,3 +333,81 @@ describe('player store — enqueue + playRadio', () => {
|
|||||||
apiGet.mockRestore();
|
apiGet.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('M4c radio auto-refresh at 80%', () => {
|
||||||
|
test('refresh fires at 80% queue consumption', async () => {
|
||||||
|
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||||
|
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||||
|
await playRadio('seed');
|
||||||
|
apiGet.mockClear();
|
||||||
|
apiGet.mockResolvedValueOnce({
|
||||||
|
tracks: [track('seed'), track('new1')]
|
||||||
|
});
|
||||||
|
// Advance to index 3 (4 of 5 = 80%)
|
||||||
|
skipNext(); skipNext(); skipNext();
|
||||||
|
// Allow $effect to flush
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(apiGet).toHaveBeenCalled();
|
||||||
|
const callArg = apiGet.mock.calls[0][0] as string;
|
||||||
|
expect(callArg).toContain('seed_track=seed');
|
||||||
|
expect(callArg).toContain('exclude=');
|
||||||
|
apiGet.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh below 80% does not fire', async () => {
|
||||||
|
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||||
|
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||||
|
await playRadio('seed');
|
||||||
|
apiGet.mockClear();
|
||||||
|
skipNext(); skipNext(); // index = 2 (3 of 5 = 60%)
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
apiGet.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh appends new tracks excluding seed at index 0', async () => {
|
||||||
|
const initial = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||||
|
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial });
|
||||||
|
await playRadio('seed');
|
||||||
|
apiGet.mockClear();
|
||||||
|
// Refresh response: 5 tracks where the first is the seed (will be stripped).
|
||||||
|
const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')];
|
||||||
|
apiGet.mockResolvedValueOnce({ tracks: refreshTracks });
|
||||||
|
skipNext(); skipNext(); skipNext(); // index = 3 (80%)
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
// Initial 5 + 4 new (5 in response - 1 seed stripped) = 9
|
||||||
|
expect(player.queue.length).toBe(9);
|
||||||
|
apiGet.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh does NOT double-fire while in-flight', async () => {
|
||||||
|
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||||
|
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||||
|
await playRadio('seed');
|
||||||
|
apiGet.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.mockReturnValueOnce(hanging as unknown as Promise<unknown>);
|
||||||
|
skipNext(); skipNext(); skipNext(); // 80% — fires refresh
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
// Now advance further while in-flight; should NOT fire a second call.
|
||||||
|
skipNext();
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(apiGet).toHaveBeenCalledTimes(1);
|
||||||
|
resolveFn({ tracks: [] });
|
||||||
|
apiGet.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh resets when user enqueues from non-radio source', async () => {
|
||||||
|
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||||
|
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||||
|
await playRadio('seed');
|
||||||
|
enqueueTrack(track('manual1'));
|
||||||
|
apiGet.mockClear();
|
||||||
|
skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the 6-track queue
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
apiGet.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user