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 set of genres is stable from day to day, like a list of channels; the // tracks inside each mix change daily. 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 // genreMixMaxOverlap skips a candidate genre whose artist set is a near // duplicate of one already picked: shared artists / all artists of the // pair (Jaccard) at or above this. Symmetric on purpose — a broad // "Rock" does not knock out every rock subgenre, but "Lo-Fi" and // "Lo-Fi Hip Hop" with the same artists collapse to one mix. genreMixMaxOverlap = 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 ArtistIDs []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, ArtistIDs: r.ArtistIds} } build := func(c genreCandidate) []rankedCandidate { trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{ UserID: userID, GenreKey: c.Key, DateSeed: dateStr, }) 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 in rank order and keeps the ones // that make a real mix, skipping a genre that mostly repeats the artists of // one already kept, and taking tier-3 genres only while the shelf is below // genreMixMinShelf. func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []rankedCandidate) []builtPlaylist { var kept []genreCandidate var out []builtPlaylist for _, c := range cands { if len(out) >= genreMixCount { break } if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf { break } if overlapsKept(c, kept) { continue } tracks := build(c) if len(tracks) < genreMixMinLen { continue } kept = append(kept, c) out = append(out, builtPlaylist{ Name: c.Label + " mix", Variant: "genre_mix", Tracks: tracks, }) } return out } // overlapsKept reports whether c's artists are a near duplicate (Jaccard at // or above genreMixMaxOverlap) of any single kept genre's. func overlapsKept(c genreCandidate, kept []genreCandidate) bool { if len(c.ArtistIDs) == 0 { return false } for _, k := range kept { in := make(map[pgtype.UUID]struct{}, len(k.ArtistIDs)) for _, a := range k.ArtistIDs { in[a] = struct{}{} } shared := 0 for _, a := range c.ArtistIDs { if _, ok := in[a]; ok { shared++ } } union := len(in) + len(c.ArtistIDs) - shared if float64(shared) >= genreMixMaxOverlap*float64(union) { return true } } return false } // 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 }