fix(playlists): dedup tracks across discover buckets

Discover playlists could surface the same track twice with the
duplicates landing back-to-back — a "first song plays, then plays
again, skip works" symptom user reported on v2026.05.13.0. Root
cause: interleaveBuckets rotates one track per pass per bucket but
never tracks which IDs it has already emitted, so a track that's
both a dormant-artist pick AND a random-unheard pick comes out
once from each bucket.

On a single-user server the crossUser bucket is empty, so the
redistribute step rolls its slots into dormant + random. Their
output then interleaves d0, r0, d1, r1, … — and when d0 == r0
(common: a dormant-artist track is also valid for random-unheard)
the result is [X, X, …] with adjacent duplicates.

Fix: track seen track IDs across all buckets while interleaving;
skip already-taken IDs and advance to the next index in that
bucket. Dedup priority is bucket order, so a track in both
dormant and random comes from dormant.

Regression test covers the single-user case directly. The existing
round-robin test still passes — no shared IDs in that fixture.

Note: stale duplicates already written to drift / served as cached
playlists will clear naturally on the next playlist rebuild (the
03:00-local refresh, or any manual /api/me/playlists/refresh).
This commit is contained in:
2026-05-14 07:50:35 -04:00
parent 2ebe6229b7
commit c29d25d1cb
2 changed files with 48 additions and 1 deletions
+24 -1
View File
@@ -254,18 +254,41 @@ func redistributeSlots(buckets []bucketRequest) []int {
// interleaveBuckets round-robins items from the buckets in order. The
// first item of each bucket appears before the second of any bucket.
// interleaveBuckets walks each bucket round-robin, emitting one track
// per bucket per pass. Tracks already seen in an earlier bucket (or an
// earlier pass) are skipped — single-user servers hit this often
// because the empty crossUser bucket redistributes slots to dormant +
// random, and a dormant-artist track is also a valid random-unheard
// pick. Without dedup the same track lands at two interleaved
// positions, which on a 2-bucket round-robin (dormant+random)
// produces ADJACENT duplicates in the playlist — exactly the
// "every song has a duplicate right after it" report from v2026.05.13.0.
//
// Dedup priority is bucket order (caller-supplied), so a track in both
// dormant and random is taken from dormant.
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
total := 0
for _, b := range buckets {
total += len(b)
}
out := make([]discoverTrack, 0, total)
seen := make(map[pgtype.UUID]struct{}, total)
indices := make([]int, len(buckets))
for {
anyAppended := false
for bi, b := range buckets {
// Advance past tracks already taken from an earlier bucket
// or an earlier pass.
for indices[bi] < len(b) {
if _, dup := seen[b[indices[bi]].ID]; !dup {
break
}
indices[bi]++
}
if indices[bi] < len(b) {
out = append(out, b[indices[bi]])
t := b[indices[bi]]
out = append(out, t)
seen[t.ID] = struct{}{}
indices[bi]++
anyAppended = true
}
+24
View File
@@ -137,3 +137,27 @@ func TestInterleaveBuckets_RoundRobin(t *testing.T) {
}
}
}
func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
// Single-user server scenario: crossUser bucket empty, dormant +
// random share tracks. Without dedup, shared tracks land at
// adjacent interleaved positions — the duplication users reported
// on the Discover playlist in v2026.05.13.0.
dormant := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(3)}}
crossUser := []discoverTrack{} // empty bucket, single-user server
random := []discoverTrack{{ID: uuidN(1)}, {ID: uuidN(2)}, {ID: uuidN(4)}}
got := interleaveBuckets(dormant, crossUser, random)
// Tracks 1 and 2 appear in both dormant and random — must be
// emitted once each (from dormant, the earlier bucket). Track 3
// is dormant-only, track 4 is random-only.
if len(got) != 4 {
t.Fatalf("len = %d, want 4 (3 unique from dormant + 1 unique from random)", len(got))
}
wantOrder := []byte{1, 2, 3, 4}
for i, w := range wantOrder {
if got[i].ID.Bytes[15] != w {
t.Errorf("got[%d].ID = %d, want %d", i, got[i].ID.Bytes[15], w)
}
}
}