From c29d25d1cbec2e081a3bf2762a43135b977bbb33 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 07:50:35 -0400 Subject: [PATCH] fix(playlists): dedup tracks across discover buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/playlists/discover.go | 25 ++++++++++++++++++++++++- internal/playlists/discover_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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) + } + } +}