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:
@@ -254,18 +254,41 @@ func redistributeSlots(buckets []bucketRequest) []int {
|
|||||||
|
|
||||||
// interleaveBuckets round-robins items from the buckets in order. The
|
// interleaveBuckets round-robins items from the buckets in order. The
|
||||||
// first item of each bucket appears before the second of any bucket.
|
// 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 {
|
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
|
||||||
total := 0
|
total := 0
|
||||||
for _, b := range buckets {
|
for _, b := range buckets {
|
||||||
total += len(b)
|
total += len(b)
|
||||||
}
|
}
|
||||||
out := make([]discoverTrack, 0, total)
|
out := make([]discoverTrack, 0, total)
|
||||||
|
seen := make(map[pgtype.UUID]struct{}, total)
|
||||||
indices := make([]int, len(buckets))
|
indices := make([]int, len(buckets))
|
||||||
for {
|
for {
|
||||||
anyAppended := false
|
anyAppended := false
|
||||||
for bi, b := range buckets {
|
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) {
|
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]++
|
indices[bi]++
|
||||||
anyAppended = true
|
anyAppended = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user