release / web (push) Successful in 1m24s
release / go (push) Successful in 1m47s
release / govulncheck (push) Successful in 25s
release / integration (push) Failing after 4m38s
release / android (push) Successful in 4m3s
release / Build signed APK (push) Successful in 4m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A new system playlist kind, genre_mix: up to 10 "{Genre} mix" playlists per
user, rebuilt daily with the other system mixes.
Which genres: library genres with at least 25 playable tracks across 3
artists, ranked in tiers — taste-profile genres first (matched
case-insensitively, so ID3 "Post-Punk" and folksonomy "post-punk" are one),
then genres the user has played or liked, then the rest of the library, which
only tops a short shelf up to 4. A genre whose artist set is a near
duplicate of one already picked (Jaccard >= 0.5) is skipped, so "Lo-Fi" and
"Lo-Fi Hip Hop" don't both appear but "Alternative Rock" survives "Rock". A
genre that can't fill 15 tracks gives way to the next.
Each mix is 50 tracks: 3 in 5 the user already knows (played through or
liked), the rest unheard in that genre, interleaved, with the usual
per-album/per-artist caps. Tracks skipped twice and more often than played
are left out. The order changes daily and holds within a day. Picks are
stamped taste / taste_unheard, and genre_mix plays count toward rotation and
the recommendation metrics.
Migration 0102 adds genre_mix to the playlists variant constraints.
Web and Android each get a "Your genres" row in name order, hidden until a
mix exists. Kinds with several playlists per user now play by playlist id
wherever the client used to test seed_artist_id (web card, detail page
self-heal), keyed on the server's refreshable flag; on Android genre_mix
joins songs_like_artist as not refreshable. The web detail page's
"mix was rebuilt" reload now finds a Songs-like or genre mix by name instead
of calling the by-kind shuffle, which those kinds don't have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
6.4 KiB
Go
203 lines
6.4 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 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
|
|
}
|