release / go (push) Successful in 2m0s
release / web (push) Successful in 1m33s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 4m14s
release / Build signed APK (push) Successful in 4m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
The shelf had four mixes built largely from the same lo-fi beats: about 1,250 tracks carry Hip Hop, Lo-Fi, Instrumental, Downtempo and four more tags at once, and the near-duplicate guard compared artists, not songs, so each tag passed as its own channel. Genre selection now works on songs: - ListGenreMixCandidates returns each genre's track ids instead of its artist ids. - A broad genre, one whose songs mostly also carry clearly narrower candidates (Rock over Pop Punk and Ska Punk), is considered after the specific genres of its tier. - A genre is kept only when at least half its songs are not already in a kept genre. - Each mix leaves out songs tagged with a genre kept before it (ListGenreMixTracks exclude_genres), so a song is in one mix at most. The tiers, the minimum shelf and the minimum mix length are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
8.9 KiB
Go
281 lines
8.9 KiB
Go
package playlists
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Genre mixes: one daily playlist per genre the user listens to, shown on
|
|
// their own Home row. Each mix is a blend of the user's familiar tracks in
|
|
// that genre and tracks in it they have not heard yet.
|
|
//
|
|
// Which genres: ListGenreMixCandidates ranks library genres in three tiers
|
|
// (taste profile → listening history → the rest of the library). Tier 3
|
|
// only fills a shelf the user's own history cannot: a user with a handful of
|
|
// listened genres gets those, topped up to genreMixMinShelf, rather than
|
|
// ten channels of music they never play.
|
|
//
|
|
// The shelf is a set of channels that sound different from one another. Tags
|
|
// overlap heavily (a lo-fi beat is often tagged Lo-Fi, Hip Hop, Chillwave and
|
|
// Instrumental at once), so the pick is made on songs, not names: a genre
|
|
// whose songs mostly sit in a genre already picked adds no new channel, and a
|
|
// broad genre whose songs mostly carry narrower ones gives way to them, so
|
|
// the shelf holds Pop Punk and Ska Punk rather than one Rock mix. Each song
|
|
// belongs to at most one mix.
|
|
//
|
|
// The set of genres is stable from day to day, like a list of channels; the
|
|
// tracks inside each mix change daily. ListGenreMixTracks draws them by a
|
|
// date-seeded weighted lottery: familiar tracks lean toward the ones played
|
|
// most and liked, unheard ones toward the user's artists and artists similar
|
|
// to them, and every track keeps some chance.
|
|
|
|
const (
|
|
// genreMixCount is the most genre mixes a user gets.
|
|
genreMixCount = 10
|
|
// genreMixMinShelf is how many mixes tier-3 genres may fill up to when
|
|
// the user's history yields fewer.
|
|
genreMixMinShelf = 4
|
|
// genreMixLen is each mix's length.
|
|
genreMixLen = 50
|
|
// genreMixMinLen withholds a genre whose eligible tracks can't make a
|
|
// real mix; the next candidate genre takes its place.
|
|
genreMixMinLen = 15
|
|
// A mix is genreMixFamiliarParts in every genreMixParts tracks the user
|
|
// already knows (3 in 5); the rest is unheard tracks in the genre.
|
|
// Either side running short is filled from the other.
|
|
genreMixFamiliarParts = 3
|
|
genreMixParts = 5
|
|
// genreMixMinNewShare is the share of a genre's songs that must not be
|
|
// in a genre already picked for it to get a mix of its own.
|
|
genreMixMinNewShare = 0.5
|
|
// A genre is broad when at least genreMixBroadShare of its songs also
|
|
// carry a clearly narrower candidate genre: one at most
|
|
// genreMixNarrowerSize times its size with at least half of its songs
|
|
// inside it. Broad genres are considered after the specific ones of
|
|
// their tier.
|
|
genreMixBroadShare = 0.5
|
|
genreMixNarrowerSize = 0.5
|
|
// genreMixTier3 is the tier of genres the user has no history with.
|
|
genreMixTier3 = 3
|
|
)
|
|
|
|
// genreCandidate is one row of ListGenreMixCandidates.
|
|
type genreCandidate struct {
|
|
Key string
|
|
Label string
|
|
Tier int32
|
|
TrackIDs []pgtype.UUID
|
|
}
|
|
|
|
// genreTrack is one row of ListGenreMixTracks.
|
|
type genreTrack struct {
|
|
discoverTrack
|
|
Familiar bool
|
|
}
|
|
|
|
// produceGenreMixes builds up to genreMixCount "{Genre} mix" playlists.
|
|
// The candidate query failing is fatal to the build, like the Songs-like
|
|
// seed query; a single genre's track query failing skips that genre.
|
|
func produceGenreMixes(
|
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
|
userID pgtype.UUID, dateStr string, _ time.Time,
|
|
) ([]builtPlaylist, error) {
|
|
rows, err := q.ListGenreMixCandidates(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list genre mix candidates: %w", err)
|
|
}
|
|
cands := make([]genreCandidate, len(rows))
|
|
for i, r := range rows {
|
|
cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, TrackIDs: r.TrackIds}
|
|
}
|
|
build := func(c genreCandidate, exclude []string) []rankedCandidate {
|
|
trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
|
|
UserID: userID,
|
|
GenreKey: c.Key,
|
|
DateSeed: dateStr,
|
|
ExcludeGenres: exclude,
|
|
})
|
|
if terr != nil {
|
|
logger.Warn("system playlist: genre mix tracks query failed; skipping genre",
|
|
"user_id", uuidStringPL(userID), "genre", c.Label, "err", terr)
|
|
return nil
|
|
}
|
|
tracks := make([]genreTrack, len(trackRows))
|
|
for i, r := range trackRows {
|
|
tracks[i] = genreTrack{
|
|
discoverTrack: discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID},
|
|
Familiar: r.Familiar,
|
|
}
|
|
}
|
|
return composeGenreMix(tracks, genreMixLen)
|
|
}
|
|
return selectGenreMixes(cands, build), nil
|
|
}
|
|
|
|
// selectGenreMixes walks the candidates, specific genres before broad ones
|
|
// within each tier, and keeps the ones that make a real mix of music the
|
|
// shelf does not have yet. It takes tier-3 genres only while the shelf is
|
|
// below genreMixMinShelf. build is handed the genres kept so far, whose songs
|
|
// the new mix leaves out.
|
|
func selectGenreMixes(
|
|
cands []genreCandidate, build func(genreCandidate, []string) []rankedCandidate,
|
|
) []builtPlaylist {
|
|
sets := make([]map[pgtype.UUID]struct{}, len(cands))
|
|
for i, c := range cands {
|
|
sets[i] = make(map[pgtype.UUID]struct{}, len(c.TrackIDs))
|
|
for _, id := range c.TrackIDs {
|
|
sets[i][id] = struct{}{}
|
|
}
|
|
}
|
|
owned := map[pgtype.UUID]struct{}{}
|
|
kept := []string{}
|
|
var out []builtPlaylist
|
|
for _, i := range specificFirst(cands, sets) {
|
|
c := cands[i]
|
|
if len(out) >= genreMixCount {
|
|
break
|
|
}
|
|
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
|
|
break
|
|
}
|
|
if !mostlyNew(sets[i], owned) {
|
|
continue
|
|
}
|
|
tracks := build(c, kept)
|
|
if len(tracks) < genreMixMinLen {
|
|
continue
|
|
}
|
|
kept = append(kept, c.Key)
|
|
for id := range sets[i] {
|
|
owned[id] = struct{}{}
|
|
}
|
|
out = append(out, builtPlaylist{
|
|
Name: c.Label + " mix",
|
|
Variant: "genre_mix",
|
|
Tracks: tracks,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// specificFirst returns the candidates' indexes in rank order, except that
|
|
// within each tier the broad genres come after the specific ones.
|
|
func specificFirst(cands []genreCandidate, sets []map[pgtype.UUID]struct{}) []int {
|
|
broad := make([]bool, len(cands))
|
|
for i := range cands {
|
|
broad[i] = isBroad(i, sets)
|
|
}
|
|
order := make([]int, 0, len(cands))
|
|
for start := 0; start < len(cands); {
|
|
end := start
|
|
for end < len(cands) && cands[end].Tier == cands[start].Tier {
|
|
end++
|
|
}
|
|
for _, wantBroad := range []bool{false, true} {
|
|
for i := start; i < end; i++ {
|
|
if broad[i] == wantBroad {
|
|
order = append(order, i)
|
|
}
|
|
}
|
|
}
|
|
start = end
|
|
}
|
|
return order
|
|
}
|
|
|
|
// isBroad reports whether at least genreMixBroadShare of genre g's songs
|
|
// also carry a clearly narrower candidate genre.
|
|
func isBroad(g int, sets []map[pgtype.UUID]struct{}) bool {
|
|
if len(sets[g]) == 0 {
|
|
return false
|
|
}
|
|
var narrower []map[pgtype.UUID]struct{}
|
|
for h, set := range sets {
|
|
if h == g || float64(len(set)) > genreMixNarrowerSize*float64(len(sets[g])) {
|
|
continue
|
|
}
|
|
if 2*sharedCount(set, sets[g]) >= len(set) {
|
|
narrower = append(narrower, set)
|
|
}
|
|
}
|
|
covered := 0
|
|
for id := range sets[g] {
|
|
for _, set := range narrower {
|
|
if _, ok := set[id]; ok {
|
|
covered++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return float64(covered) >= genreMixBroadShare*float64(len(sets[g]))
|
|
}
|
|
|
|
// mostlyNew reports whether at least genreMixMinNewShare of set's songs are
|
|
// not yet owned by a kept genre.
|
|
func mostlyNew(set, owned map[pgtype.UUID]struct{}) bool {
|
|
return float64(len(set)-sharedCount(set, owned)) >= genreMixMinNewShare*float64(len(set))
|
|
}
|
|
|
|
// sharedCount counts the songs in both a and b.
|
|
func sharedCount(a, b map[pgtype.UUID]struct{}) int {
|
|
if len(b) < len(a) {
|
|
a, b = b, a
|
|
}
|
|
n := 0
|
|
for id := range a {
|
|
if _, ok := b[id]; ok {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// composeGenreMix interleaves familiar and unheard tracks 3 to 5
|
|
// (familiar, familiar, unheard, familiar, unheard, …),
|
|
// continuing with whichever side remains once the other runs out, then
|
|
// applies the per-album / per-artist caps and truncates to n. A genre too
|
|
// narrow for the caps is topped up from the uncapped pool, as the discovery
|
|
// mixes are. Familiar tracks are stamped pick_kind 'taste' and unheard ones
|
|
// 'taste_unheard', so skips can be attributed to each half.
|
|
func composeGenreMix(tracks []genreTrack, n int) []rankedCandidate {
|
|
var familiar, unheard []discoverTrack
|
|
for _, t := range tracks {
|
|
d := t.discoverTrack
|
|
if t.Familiar {
|
|
d.PickKind = pickKindTaste
|
|
familiar = append(familiar, d)
|
|
} else {
|
|
d.PickKind = pickKindTasteUnheard
|
|
unheard = append(unheard, d)
|
|
}
|
|
}
|
|
pool := make([]discoverTrack, 0, len(familiar)+len(unheard))
|
|
fi, ui := 0, 0
|
|
for fi < len(familiar) || ui < len(unheard) {
|
|
takeFamiliar := fi < len(familiar) &&
|
|
(ui >= len(unheard) || fi*genreMixParts < genreMixFamiliarParts*(fi+ui+1))
|
|
if takeFamiliar {
|
|
pool = append(pool, familiar[fi])
|
|
fi++
|
|
} else {
|
|
pool = append(pool, unheard[ui])
|
|
ui++
|
|
}
|
|
}
|
|
capped := topUpFromRaw(capByAlbumAndArtist(pool), pool, n)
|
|
if len(capped) > n {
|
|
capped = capped[:n]
|
|
}
|
|
out := make([]rankedCandidate, len(capped))
|
|
for i, t := range capped {
|
|
out[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
|
|
}
|
|
return out
|
|
}
|