Files
minstrel/internal/recommendation/shuffle.go
T
bvandeusenandClaude Opus 5.5 5b372f61d7
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m7s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m11s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m58s
release / Build signed APK (releases and dev) (push) Canceled after 5m59s
feat: the same song on several releases counts as one song (M498 #5438)
A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.

- tracks.song_id links copies; the generated song_key (song_id, else the
  track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
  with auto-resolve on) and, when a link is new, shares existing likes across
  the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
  logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
  take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
  Across releases tab dismisses the group and undoes the link.

Shared plays ("heard via another copy") are left for a later step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:07:55 -04:00

151 lines
4.7 KiB
Go

package recommendation
import (
"sort"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Candidate pairs a track with the inputs needed to score it.
type Candidate struct {
Track dbq.Track
Inputs ScoringInputs
}
// DiversityCaps bounds how much of one selection a single artist or album
// may occupy. A zero value means "no cap", which is what Shuffle did
// unconditionally before #3882.
type DiversityCaps struct {
MaxPerArtist int
MaxPerAlbum int
}
// radioCapArtistPer25 / radioCapAlbumPer25 hold the caps at the same
// PROPORTION the system mixes already use — 3 artist / 2 album tracks in a
// 25-track mix — so a 20-track radio and a 200-track one feel alike rather
// than one of them being effectively uncapped.
//
// A fixed count cannot do that. Three-per-artist is a reasonable 12% of a
// 25-track mix and an absurd 1.5% of a 200-track radio, where it would put
// the selection permanently in the relaxation path below and quietly undo
// the cap it was meant to enforce.
const (
radioCapArtistPer25 = 3
radioCapAlbumPer25 = 2
)
// RadioDiversityCaps scales the diversity caps to the requested radio
// length. Floors of 2 and 1 keep a very short radio from being capped into
// a single track per artist, which would be its own kind of wrong.
func RadioDiversityCaps(limit int) DiversityCaps {
artist := limit * radioCapArtistPer25 / 25
if artist < 2 {
artist = 2
}
album := limit * radioCapAlbumPer25 / 25
if album < 1 {
album = 1
}
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
}
// Shuffle scores each candidate, sorts descending by score, and returns the
// top `limit` candidates, preferring artist/album diversity. limit <= 0
// returns nil; nil input returns nil. Pure — no IO, no global state beyond
// the rng callback.
//
// THE COUNT IS NEVER REDUCED BY THE CAPS. Selection runs in two passes: the
// first takes candidates that fit under the caps, and the second fills any
// remaining slots from those the first pass skipped, still in score order.
// So the result holds min(limit, len(candidates)) either way — the caps
// change WHICH tracks are chosen, never HOW MANY.
//
// That two-pass shape is the whole design, and a hard cap would have been
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
// pool concentrated on a few artists would return six tracks and call it a
// radio. Rule 131's principle — degrade, never vanish — applies past the
// system mixes it was written for.
//
// Before #3882 there was no cap here at all, while discover.go,
// you_might_like.go and home.go all had one. That asymmetry is what let a
// radio session come back entirely from a single artist: nothing between
// the pool and the output bounded any artist's share, so a pool dominated
// by one artist produced an output dominated by it too.
func Shuffle(
candidates []Candidate,
weights ScoringWeights,
now time.Time,
rng func() float64,
limit int,
caps DiversityCaps,
) []Candidate {
if len(candidates) == 0 || limit <= 0 {
return nil
}
scored := make([]struct {
c Candidate
score float64
}, len(candidates))
for i, c := range candidates {
scored[i].c = c
scored[i].score = Score(c.Inputs, weights, now, rng)
}
sort.Slice(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
if limit > len(scored) {
limit = len(scored)
}
out := make([]Candidate, 0, limit)
deferred := make([]Candidate, 0, len(scored)-limit)
artistCount := map[pgtype.UUID]int{}
albumCount := map[pgtype.UUID]int{}
// One song on several releases is played once (M498 #5438): after its
// best-scoring copy, the others are dropped, not held back.
songs := map[pgtype.UUID]bool{}
repeat := func(c Candidate) bool { return c.Track.SongKey.Valid && songs[c.Track.SongKey] }
take := func(c Candidate) {
if c.Track.SongKey.Valid {
songs[c.Track.SongKey] = true
}
out = append(out, c)
}
for _, s := range scored {
if len(out) == limit {
break
}
if repeat(s.c) {
continue
}
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
if overArtist || overAlbum {
// Held back, not discarded — pass two may still need it.
deferred = append(deferred, s.c)
continue
}
artistCount[s.c.Track.ArtistID]++
albumCount[s.c.Track.AlbumID]++
take(s.c)
}
// Pass two: the caps could not fill the request, so relax them rather
// than hand back a short radio. Still score order, so the best of the
// held-back candidates go first.
for _, c := range deferred {
if len(out) == limit {
break
}
if repeat(c) {
continue
}
take(c)
}
return out
}