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