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 }