feat: genre mixes pick specific genres that don't share songs (#6933)
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
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>
This commit is contained in:
@@ -17,7 +17,7 @@ WITH library_genres AS (
|
|||||||
SELECT lower(trim(g.g)) AS genre_key,
|
SELECT lower(trim(g.g)) AS genre_key,
|
||||||
min(trim(g.g))::text AS label,
|
min(trim(g.g))::text AS label,
|
||||||
COUNT(DISTINCT t.id) AS track_count,
|
COUNT(DISTINCT t.id) AS track_count,
|
||||||
array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids
|
array_agg(DISTINCT t.id)::uuid[] AS track_ids
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||||
WHERE t.missing_since IS NULL
|
WHERE t.missing_since IS NULL
|
||||||
@@ -59,7 +59,7 @@ SELECT lg.genre_key::text AS genre_key,
|
|||||||
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
|
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
|
||||||
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
||||||
ELSE lg.track_count::float8 END)::float8 AS score,
|
ELSE lg.track_count::float8 END)::float8 AS score,
|
||||||
lg.artist_ids
|
lg.track_ids
|
||||||
FROM library_genres lg
|
FROM library_genres lg
|
||||||
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
|
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
|
||||||
LEFT JOIN history h ON h.genre_key = lg.genre_key
|
LEFT JOIN history h ON h.genre_key = lg.genre_key
|
||||||
@@ -68,11 +68,11 @@ SELECT lg.genre_key::text AS genre_key,
|
|||||||
`
|
`
|
||||||
|
|
||||||
type ListGenreMixCandidatesRow struct {
|
type ListGenreMixCandidatesRow struct {
|
||||||
GenreKey string
|
GenreKey string
|
||||||
Label string
|
Label string
|
||||||
Tier int32
|
Tier int32
|
||||||
Score float64
|
Score float64
|
||||||
ArtistIds []pgtype.UUID
|
TrackIds []pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate queries for the genre mixes: one daily system playlist per genre
|
// Candidate queries for the genre mixes: one daily system playlist per genre
|
||||||
@@ -89,8 +89,10 @@ type ListGenreMixCandidatesRow struct {
|
|||||||
// has decayed past its window
|
// has decayed past its window
|
||||||
// tier 3: any other library genre, scored by its size
|
// tier 3: any other library genre, scored by its size
|
||||||
//
|
//
|
||||||
// artist_ids lets the caller skip a genre whose artists mostly repeat one
|
// track_ids lets the caller tell how much music two genres share: a genre
|
||||||
// already picked (Indie Rock after Alternative Rock).
|
// whose songs mostly sit in genres already picked adds no new channel, and a
|
||||||
|
// broad genre ("Rock") whose songs mostly carry narrower ones ("Pop Punk",
|
||||||
|
// "Ska Punk") gives way to them.
|
||||||
// $1 user_id.
|
// $1 user_id.
|
||||||
func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) {
|
func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listGenreMixCandidates, userID)
|
rows, err := q.db.Query(ctx, listGenreMixCandidates, userID)
|
||||||
@@ -106,7 +108,7 @@ func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID
|
|||||||
&i.Label,
|
&i.Label,
|
||||||
&i.Tier,
|
&i.Tier,
|
||||||
&i.Score,
|
&i.Score,
|
||||||
&i.ArtistIds,
|
&i.TrackIds,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,11 @@ eligible AS (
|
|||||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||||
WHERE lower(trim(g.g)) = $3::text
|
WHERE lower(trim(g.g)) = $3::text
|
||||||
)
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||||
|
WHERE lower(trim(g.g)) = ANY($4::text[])
|
||||||
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM quarantine q
|
SELECT 1 FROM quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
@@ -207,9 +214,10 @@ SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
|||||||
`
|
`
|
||||||
|
|
||||||
type ListGenreMixTracksParams struct {
|
type ListGenreMixTracksParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
DateSeed string
|
DateSeed string
|
||||||
GenreKey string
|
GenreKey string
|
||||||
|
ExcludeGenres []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListGenreMixTracksRow struct {
|
type ListGenreMixTracksRow struct {
|
||||||
@@ -244,8 +252,16 @@ type ListGenreMixTracksRow struct {
|
|||||||
// A track the user keeps skipping stays out: twice-skipped and skipped more
|
// A track the user keeps skipping stays out: twice-skipped and skipped more
|
||||||
// often than played through. The second half of that test keeps a favourite
|
// often than played through. The second half of that test keeps a favourite
|
||||||
// with a couple of stray skips in.
|
// with a couple of stray skips in.
|
||||||
|
//
|
||||||
|
// exclude_genres keeps each song in one mix: a track carrying a genre that
|
||||||
|
// already has a mix today is left to that mix.
|
||||||
func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) {
|
func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listGenreMixTracks, arg.UserID, arg.DateSeed, arg.GenreKey)
|
rows, err := q.db.Query(ctx, listGenreMixTracks,
|
||||||
|
arg.UserID,
|
||||||
|
arg.DateSeed,
|
||||||
|
arg.GenreKey,
|
||||||
|
arg.ExcludeGenres,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,16 @@
|
|||||||
-- has decayed past its window
|
-- has decayed past its window
|
||||||
-- tier 3: any other library genre, scored by its size
|
-- tier 3: any other library genre, scored by its size
|
||||||
--
|
--
|
||||||
-- artist_ids lets the caller skip a genre whose artists mostly repeat one
|
-- track_ids lets the caller tell how much music two genres share: a genre
|
||||||
-- already picked (Indie Rock after Alternative Rock).
|
-- whose songs mostly sit in genres already picked adds no new channel, and a
|
||||||
|
-- broad genre ("Rock") whose songs mostly carry narrower ones ("Pop Punk",
|
||||||
|
-- "Ska Punk") gives way to them.
|
||||||
-- $1 user_id.
|
-- $1 user_id.
|
||||||
WITH library_genres AS (
|
WITH library_genres AS (
|
||||||
SELECT lower(trim(g.g)) AS genre_key,
|
SELECT lower(trim(g.g)) AS genre_key,
|
||||||
min(trim(g.g))::text AS label,
|
min(trim(g.g))::text AS label,
|
||||||
COUNT(DISTINCT t.id) AS track_count,
|
COUNT(DISTINCT t.id) AS track_count,
|
||||||
array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids
|
array_agg(DISTINCT t.id)::uuid[] AS track_ids
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||||
WHERE t.missing_since IS NULL
|
WHERE t.missing_since IS NULL
|
||||||
@@ -63,7 +65,7 @@ SELECT lg.genre_key::text AS genre_key,
|
|||||||
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
|
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
|
||||||
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
||||||
ELSE lg.track_count::float8 END)::float8 AS score,
|
ELSE lg.track_count::float8 END)::float8 AS score,
|
||||||
lg.artist_ids
|
lg.track_ids
|
||||||
FROM library_genres lg
|
FROM library_genres lg
|
||||||
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
|
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
|
||||||
LEFT JOIN history h ON h.genre_key = lg.genre_key
|
LEFT JOIN history h ON h.genre_key = lg.genre_key
|
||||||
@@ -96,6 +98,9 @@ SELECT lg.genre_key::text AS genre_key,
|
|||||||
-- A track the user keeps skipping stays out: twice-skipped and skipped more
|
-- A track the user keeps skipping stays out: twice-skipped and skipped more
|
||||||
-- often than played through. The second half of that test keeps a favourite
|
-- often than played through. The second half of that test keeps a favourite
|
||||||
-- with a couple of stray skips in.
|
-- with a couple of stray skips in.
|
||||||
|
--
|
||||||
|
-- exclude_genres keeps each song in one mix: a track carrying a genre that
|
||||||
|
-- already has a mix today is left to that mix.
|
||||||
WITH play_counts AS (
|
WITH play_counts AS (
|
||||||
SELECT pe.track_id, COUNT(*) AS c
|
SELECT pe.track_id, COUNT(*) AS c
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -166,6 +171,11 @@ eligible AS (
|
|||||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||||
WHERE lower(trim(g.g)) = sqlc.arg(genre_key)::text
|
WHERE lower(trim(g.g)) = sqlc.arg(genre_key)::text
|
||||||
)
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||||
|
WHERE lower(trim(g.g)) = ANY(sqlc.arg(exclude_genres)::text[])
|
||||||
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM quarantine q
|
SELECT 1 FROM quarantine q
|
||||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ import (
|
|||||||
// listened genres gets those, topped up to genreMixMinShelf, rather than
|
// listened genres gets those, topped up to genreMixMinShelf, rather than
|
||||||
// ten channels of music they never play.
|
// 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
|
// 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
|
// tracks inside each mix change daily. ListGenreMixTracks draws them by a
|
||||||
// date-seeded weighted lottery: familiar tracks lean toward the ones played
|
// date-seeded weighted lottery: familiar tracks lean toward the ones played
|
||||||
@@ -43,22 +51,26 @@ const (
|
|||||||
// Either side running short is filled from the other.
|
// Either side running short is filled from the other.
|
||||||
genreMixFamiliarParts = 3
|
genreMixFamiliarParts = 3
|
||||||
genreMixParts = 5
|
genreMixParts = 5
|
||||||
// genreMixMaxOverlap skips a candidate genre whose artist set is a near
|
// genreMixMinNewShare is the share of a genre's songs that must not be
|
||||||
// duplicate of one already picked: shared artists / all artists of the
|
// in a genre already picked for it to get a mix of its own.
|
||||||
// pair (Jaccard) at or above this. Symmetric on purpose — a broad
|
genreMixMinNewShare = 0.5
|
||||||
// "Rock" does not knock out every rock subgenre, but "Lo-Fi" and
|
// A genre is broad when at least genreMixBroadShare of its songs also
|
||||||
// "Lo-Fi Hip Hop" with the same artists collapse to one mix.
|
// carry a clearly narrower candidate genre: one at most
|
||||||
genreMixMaxOverlap = 0.5
|
// 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 is the tier of genres the user has no history with.
|
||||||
genreMixTier3 = 3
|
genreMixTier3 = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// genreCandidate is one row of ListGenreMixCandidates.
|
// genreCandidate is one row of ListGenreMixCandidates.
|
||||||
type genreCandidate struct {
|
type genreCandidate struct {
|
||||||
Key string
|
Key string
|
||||||
Label string
|
Label string
|
||||||
Tier int32
|
Tier int32
|
||||||
ArtistIDs []pgtype.UUID
|
TrackIDs []pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// genreTrack is one row of ListGenreMixTracks.
|
// genreTrack is one row of ListGenreMixTracks.
|
||||||
@@ -80,13 +92,14 @@ func produceGenreMixes(
|
|||||||
}
|
}
|
||||||
cands := make([]genreCandidate, len(rows))
|
cands := make([]genreCandidate, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, ArtistIDs: r.ArtistIds}
|
cands[i] = genreCandidate{Key: r.GenreKey, Label: r.Label, Tier: r.Tier, TrackIDs: r.TrackIds}
|
||||||
}
|
}
|
||||||
build := func(c genreCandidate) []rankedCandidate {
|
build := func(c genreCandidate, exclude []string) []rankedCandidate {
|
||||||
trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
|
trackRows, terr := q.ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
GenreKey: c.Key,
|
GenreKey: c.Key,
|
||||||
DateSeed: dateStr,
|
DateSeed: dateStr,
|
||||||
|
ExcludeGenres: exclude,
|
||||||
})
|
})
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
logger.Warn("system playlist: genre mix tracks query failed; skipping genre",
|
logger.Warn("system playlist: genre mix tracks query failed; skipping genre",
|
||||||
@@ -105,28 +118,43 @@ func produceGenreMixes(
|
|||||||
return selectGenreMixes(cands, build), nil
|
return selectGenreMixes(cands, build), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectGenreMixes walks the candidates in rank order and keeps the ones
|
// selectGenreMixes walks the candidates, specific genres before broad ones
|
||||||
// that make a real mix, skipping a genre that mostly repeats the artists of
|
// within each tier, and keeps the ones that make a real mix of music the
|
||||||
// one already kept, and taking tier-3 genres only while the shelf is below
|
// shelf does not have yet. It takes tier-3 genres only while the shelf is
|
||||||
// genreMixMinShelf.
|
// below genreMixMinShelf. build is handed the genres kept so far, whose songs
|
||||||
func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []rankedCandidate) []builtPlaylist {
|
// the new mix leaves out.
|
||||||
var kept []genreCandidate
|
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
|
var out []builtPlaylist
|
||||||
for _, c := range cands {
|
for _, i := range specificFirst(cands, sets) {
|
||||||
|
c := cands[i]
|
||||||
if len(out) >= genreMixCount {
|
if len(out) >= genreMixCount {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
|
if c.Tier >= genreMixTier3 && len(out) >= genreMixMinShelf {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if overlapsKept(c, kept) {
|
if !mostlyNew(sets[i], owned) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tracks := build(c)
|
tracks := build(c, kept)
|
||||||
if len(tracks) < genreMixMinLen {
|
if len(tracks) < genreMixMinLen {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, c)
|
kept = append(kept, c.Key)
|
||||||
|
for id := range sets[i] {
|
||||||
|
owned[id] = struct{}{}
|
||||||
|
}
|
||||||
out = append(out, builtPlaylist{
|
out = append(out, builtPlaylist{
|
||||||
Name: c.Label + " mix",
|
Name: c.Label + " mix",
|
||||||
Variant: "genre_mix",
|
Variant: "genre_mix",
|
||||||
@@ -136,29 +164,76 @@ func selectGenreMixes(cands []genreCandidate, build func(genreCandidate) []ranke
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// overlapsKept reports whether c's artists are a near duplicate (Jaccard at
|
// specificFirst returns the candidates' indexes in rank order, except that
|
||||||
// or above genreMixMaxOverlap) of any single kept genre's.
|
// within each tier the broad genres come after the specific ones.
|
||||||
func overlapsKept(c genreCandidate, kept []genreCandidate) bool {
|
func specificFirst(cands []genreCandidate, sets []map[pgtype.UUID]struct{}) []int {
|
||||||
if len(c.ArtistIDs) == 0 {
|
broad := make([]bool, len(cands))
|
||||||
return false
|
for i := range cands {
|
||||||
|
broad[i] = isBroad(i, sets)
|
||||||
}
|
}
|
||||||
for _, k := range kept {
|
order := make([]int, 0, len(cands))
|
||||||
in := make(map[pgtype.UUID]struct{}, len(k.ArtistIDs))
|
for start := 0; start < len(cands); {
|
||||||
for _, a := range k.ArtistIDs {
|
end := start
|
||||||
in[a] = struct{}{}
|
for end < len(cands) && cands[end].Tier == cands[start].Tier {
|
||||||
|
end++
|
||||||
}
|
}
|
||||||
shared := 0
|
for _, wantBroad := range []bool{false, true} {
|
||||||
for _, a := range c.ArtistIDs {
|
for i := start; i < end; i++ {
|
||||||
if _, ok := in[a]; ok {
|
if broad[i] == wantBroad {
|
||||||
shared++
|
order = append(order, i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
union := len(in) + len(c.ArtistIDs) - shared
|
start = end
|
||||||
if float64(shared) >= genreMixMaxOverlap*float64(union) {
|
}
|
||||||
return true
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
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
|
// composeGenreMix interleaves familiar and unheard tracks 3 to 5
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
|
|||||||
if _, mine := want[r.Label]; mine {
|
if _, mine := want[r.Label]; mine {
|
||||||
order = append(order, r.Label)
|
order = append(order, r.Label)
|
||||||
}
|
}
|
||||||
if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 {
|
if r.Label == "Shoegaze" && len(r.TrackIds) != 30 {
|
||||||
t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds))
|
t.Errorf("Shoegaze track_ids = %d, want 30", len(r.TrackIds))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for label, w := range want {
|
for label, w := range want {
|
||||||
@@ -81,6 +81,40 @@ func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListGenreMixTracks_LeavesOutSongsOfGenresAlreadyPicked: a track that
|
||||||
|
// also carries an excluded genre (matched case-insensitively) belongs to that
|
||||||
|
// genre's mix and is left out of this one.
|
||||||
|
func TestListGenreMixTracks_LeavesOutSongsOfGenresAlreadyPicked(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
u := seedUser(t, pool, "gmixexcl")
|
||||||
|
|
||||||
|
punk := seedGenre(t, pool, "Ska Punk", 4, 5)
|
||||||
|
shared := punk[:5]
|
||||||
|
for _, tr := range shared {
|
||||||
|
setGenre(t, pool, tr.ID, "Ska Punk; Pop Punk")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
|
||||||
|
UserID: u.ID, DateSeed: "2026-11-07", GenreKey: "ska punk", ExcludeGenres: []string{"pop punk"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list tracks: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != len(punk)-len(shared) {
|
||||||
|
t.Fatalf("rows = %d, want %d (the Ska Punk tracks not also Pop Punk)", len(rows), len(punk)-len(shared))
|
||||||
|
}
|
||||||
|
got := map[pgtype.UUID]bool{}
|
||||||
|
for _, r := range rows {
|
||||||
|
got[r.ID] = true
|
||||||
|
}
|
||||||
|
for _, tr := range shared {
|
||||||
|
if got[tr.ID] {
|
||||||
|
t.Fatal("a track also tagged Pop Punk was offered after Pop Punk was excluded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and
|
// TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and
|
||||||
// liked tracks come back familiar, the rest unheard; a track skipped more
|
// liked tracks come back familiar, the rest unheard; a track skipped more
|
||||||
// often than played through is left out, while a favourite with stray skips
|
// often than played through is left out, while a favourite with stray skips
|
||||||
|
|||||||
@@ -92,39 +92,85 @@ func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func artists(ns ...int) []pgtype.UUID {
|
// songs returns the track ids from..to inclusive.
|
||||||
out := make([]pgtype.UUID, len(ns))
|
func songs(from, to int) []pgtype.UUID {
|
||||||
for i, n := range ns {
|
out := make([]pgtype.UUID, 0, to-from+1)
|
||||||
out[i] = uuidN(n)
|
for n := from; n <= to; n++ {
|
||||||
|
out = append(out, uuidN(n))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOverlapsKept_NearDuplicatesCollapseButSubgenresOfABroadGenreStay(t *testing.T) {
|
func genreOf(label string, tier int32, ids ...[]pgtype.UUID) genreCandidate {
|
||||||
rock := genreCandidate{Label: "Rock", ArtistIDs: artists(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)}
|
var all []pgtype.UUID
|
||||||
altRock := genreCandidate{Label: "Alternative Rock", ArtistIDs: artists(1, 2, 3)}
|
for _, part := range ids {
|
||||||
lofi := genreCandidate{Label: "Lo-Fi", ArtistIDs: artists(20, 21, 22, 23)}
|
all = append(all, part...)
|
||||||
lofiHipHop := genreCandidate{Label: "Lo-Fi Hip Hop", ArtistIDs: artists(20, 21, 22, 24)}
|
}
|
||||||
|
return genreCandidate{Key: label, Label: label, Tier: tier, TrackIDs: all}
|
||||||
|
}
|
||||||
|
|
||||||
if overlapsKept(altRock, []genreCandidate{rock}) {
|
// A genre whose songs mostly sit in one already picked is the same channel
|
||||||
t.Error("Alternative Rock (3 of Rock's 10 artists) collapsed into Rock; a subgenre of a broad genre should stay")
|
// under another name: Lo-Fi Hip Hop after Lo-Fi.
|
||||||
|
func TestSelectGenreMixes_AGenreOfMostlyPickedSongsGivesWay(t *testing.T) {
|
||||||
|
cands := []genreCandidate{
|
||||||
|
genreOf("Lo-Fi", 1, songs(1, 100)),
|
||||||
|
genreOf("Lo-Fi Hip Hop", 1, songs(1, 80), songs(500, 510)),
|
||||||
|
genreOf("Jazz", 1, songs(1000, 1100)),
|
||||||
}
|
}
|
||||||
if !overlapsKept(lofiHipHop, []genreCandidate{rock, lofi}) {
|
got := labels(selectGenreMixes(cands, buildN(30)))
|
||||||
t.Error("Lo-Fi Hip Hop (3 of 5 artists shared with Lo-Fi) was kept; near duplicates should collapse")
|
if len(got) != 2 || got[0] != "Lo-Fi mix" || got[1] != "Jazz mix" {
|
||||||
|
t.Fatalf("mixes = %v, want [Lo-Fi mix Jazz mix]", got)
|
||||||
}
|
}
|
||||||
if overlapsKept(lofi, nil) {
|
}
|
||||||
t.Error("nothing kept yet, but overlap reported")
|
|
||||||
|
// A broad genre whose songs mostly carry narrower genres gives way to them,
|
||||||
|
// even when it ranks higher, and each later mix leaves out the songs of the
|
||||||
|
// genres picked before it.
|
||||||
|
func TestSelectGenreMixes_ABroadGenreGivesWayToItsSpecificOnes(t *testing.T) {
|
||||||
|
cands := []genreCandidate{
|
||||||
|
genreOf("Rock", 1, songs(1, 1000)),
|
||||||
|
genreOf("Pop Punk", 1, songs(1, 300)),
|
||||||
|
genreOf("Ska Punk", 1, songs(250, 600)),
|
||||||
|
}
|
||||||
|
excluded := map[string][]string{}
|
||||||
|
build := func(c genreCandidate, exclude []string) []rankedCandidate {
|
||||||
|
excluded[c.Label] = append([]string(nil), exclude...)
|
||||||
|
return make([]rankedCandidate, 30)
|
||||||
|
}
|
||||||
|
got := labels(selectGenreMixes(cands, build))
|
||||||
|
if len(got) != 2 || got[0] != "Pop Punk mix" || got[1] != "Ska Punk mix" {
|
||||||
|
t.Fatalf("mixes = %v, want [Pop Punk mix Ska Punk mix]", got)
|
||||||
|
}
|
||||||
|
if ex := excluded["Ska Punk"]; len(ex) != 1 || ex[0] != "Pop Punk" {
|
||||||
|
t.Fatalf("Ska Punk mix excludes %v, want [Pop Punk]", ex)
|
||||||
|
}
|
||||||
|
if ex := excluded["Pop Punk"]; len(ex) != 0 {
|
||||||
|
t.Fatalf("first mix excludes %v, want nothing", ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genre only partly covered by narrower ones is a channel of its own, and
|
||||||
|
// keeps its rank.
|
||||||
|
func TestSelectGenreMixes_AGenreMostlyItsOwnKeepsItsRank(t *testing.T) {
|
||||||
|
cands := []genreCandidate{
|
||||||
|
genreOf("Rock", 1, songs(1, 2000)),
|
||||||
|
genreOf("Pop Punk", 1, songs(1, 300)),
|
||||||
|
genreOf("Jazz", 1, songs(3000, 3100)),
|
||||||
|
}
|
||||||
|
got := labels(selectGenreMixes(cands, buildN(30)))
|
||||||
|
if len(got) != 2 || got[0] != "Rock mix" || got[1] != "Jazz mix" {
|
||||||
|
t.Fatalf("mixes = %v, want [Rock mix Jazz mix]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildN returns a builder that gives every genre n tracks, except those
|
// buildN returns a builder that gives every genre n tracks, except those
|
||||||
// listed in short, which get too few to make a mix.
|
// listed in short, which get too few to make a mix.
|
||||||
func buildN(n int, short ...string) func(genreCandidate) []rankedCandidate {
|
func buildN(n int, short ...string) func(genreCandidate, []string) []rankedCandidate {
|
||||||
thin := map[string]bool{}
|
thin := map[string]bool{}
|
||||||
for _, s := range short {
|
for _, s := range short {
|
||||||
thin[s] = true
|
thin[s] = true
|
||||||
}
|
}
|
||||||
return func(c genreCandidate) []rankedCandidate {
|
return func(c genreCandidate, _ []string) []rankedCandidate {
|
||||||
if thin[c.Label] {
|
if thin[c.Label] {
|
||||||
return make([]rankedCandidate, genreMixMinLen-1)
|
return make([]rankedCandidate, genreMixMinLen-1)
|
||||||
}
|
}
|
||||||
@@ -140,8 +186,9 @@ func labels(built []builtPlaylist) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distinctGenre is a genre of 100 songs of its own, numbered from n*100.
|
||||||
func distinctGenre(label string, tier int32, n int) genreCandidate {
|
func distinctGenre(label string, tier int32, n int) genreCandidate {
|
||||||
return genreCandidate{Key: label, Label: label, Tier: tier, ArtistIDs: artists(n)}
|
return genreOf(label, tier, songs(n*100, n*100+99))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) {
|
func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user