diff --git a/internal/playlists/discover.go b/internal/playlists/discover.go index f86be276..65f41d48 100644 --- a/internal/playlists/discover.go +++ b/internal/playlists/discover.go @@ -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 } diff --git a/internal/playlists/discover_test.go b/internal/playlists/discover_test.go index e58d93a7..e299cb11 100644 --- a/internal/playlists/discover_test.go +++ b/internal/playlists/discover_test.go @@ -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) + } + } +}