feat: genre mixes — a daily playlist per genre you listen to, on a "Your genres" Home row (#6904)
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
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>
This commit is contained in:
@@ -88,6 +88,8 @@ func bucketRecSource(src string) recFamily {
|
||||
return recFamily{"for_you", "For You", intentGoTo}
|
||||
case src == "songs_like_artist":
|
||||
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||
case src == "genre_mix":
|
||||
return recFamily{"genre_mix", "Genre mixes", intentGoTo}
|
||||
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||
return recFamily{"radio", "Radio", intentGoTo}
|
||||
case src == "discover":
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: genre_mixes.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const listGenreMixCandidates = `-- name: ListGenreMixCandidates :many
|
||||
|
||||
WITH library_genres AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key,
|
||||
min(trim(g.g))::text AS label,
|
||||
COUNT(DISTINCT t.id) AS track_count,
|
||||
array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids
|
||||
FROM tracks t
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE t.missing_since IS NULL
|
||||
AND trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
HAVING COUNT(DISTINCT t.id) >= 25
|
||||
AND COUNT(DISTINCT t.artist_id) >= 3
|
||||
),
|
||||
taste AS (
|
||||
SELECT lower(trim(tpt.tag)) AS genre_key, SUM(tpt.weight) AS weight
|
||||
FROM taste_profile_tags tpt
|
||||
WHERE tpt.user_id = $1
|
||||
GROUP BY lower(trim(tpt.tag))
|
||||
HAVING SUM(tpt.weight) > 0
|
||||
),
|
||||
listened AS (
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
UNION ALL
|
||||
SELECT gl.track_id
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
),
|
||||
history AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key, COUNT(*) AS plays
|
||||
FROM listened l
|
||||
JOIN tracks t ON t.id = l.id
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
)
|
||||
SELECT lg.genre_key::text AS genre_key,
|
||||
lg.label AS label,
|
||||
(CASE WHEN ta.genre_key IS NOT NULL THEN 1
|
||||
WHEN h.genre_key IS NOT NULL THEN 2
|
||||
ELSE 3 END)::int AS tier,
|
||||
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
|
||||
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
||||
ELSE lg.track_count::float8 END)::float8 AS score,
|
||||
lg.artist_ids
|
||||
FROM library_genres lg
|
||||
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
|
||||
LEFT JOIN history h ON h.genre_key = lg.genre_key
|
||||
ORDER BY tier, score DESC, lg.genre_key
|
||||
LIMIT 60
|
||||
`
|
||||
|
||||
type ListGenreMixCandidatesRow struct {
|
||||
GenreKey string
|
||||
Label string
|
||||
Tier int32
|
||||
Score float64
|
||||
ArtistIds []pgtype.UUID
|
||||
}
|
||||
|
||||
// Candidate queries for the genre mixes: one daily system playlist per genre
|
||||
// the user listens to. Genres live inline on tracks.genre as a delimited
|
||||
// string and are split on [;,] the same way the Discover and radio tag arms
|
||||
// split them. Matching is case-insensitive, so the ID3 "Post-Punk" and the
|
||||
// folksonomy "post-punk" in the taste profile count as one genre.
|
||||
// Library genres big enough to carry a mix (>= 25 playable tracks across
|
||||
// >= 3 artists), each tagged with how close it is to the user's listening:
|
||||
//
|
||||
// tier 1: a positive taste-profile genre, scored by its taste weight
|
||||
// tier 2: a genre of tracks the user has played through or liked, scored
|
||||
// by how many — catches a profile that has not been built yet or
|
||||
// has decayed past its window
|
||||
// tier 3: any other library genre, scored by its size
|
||||
//
|
||||
// artist_ids lets the caller skip a genre whose artists mostly repeat one
|
||||
// already picked (Indie Rock after Alternative Rock).
|
||||
// $1 user_id.
|
||||
func (q *Queries) ListGenreMixCandidates(ctx context.Context, userID pgtype.UUID) ([]ListGenreMixCandidatesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGenreMixCandidates, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGenreMixCandidatesRow
|
||||
for rows.Next() {
|
||||
var i ListGenreMixCandidatesRow
|
||||
if err := rows.Scan(
|
||||
&i.GenreKey,
|
||||
&i.Label,
|
||||
&i.Tier,
|
||||
&i.Score,
|
||||
&i.ArtistIds,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listGenreMixTracks = `-- name: ListGenreMixTracks :many
|
||||
WITH play_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
skip_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = true
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
eligible AS (
|
||||
SELECT t.id, t.album_id, t.artist_id,
|
||||
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
||||
md5(t.id::text || $2::text) AS tiebreak
|
||||
FROM tracks t
|
||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||
LEFT JOIN general_likes gl ON gl.user_id = $1 AND gl.track_id = t.id
|
||||
WHERE t.missing_since IS NULL -- never offer a file that is gone
|
||||
AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE lower(trim(g.g)) = $3::text
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT e.id, e.album_id, e.artist_id, e.familiar, e.tiebreak, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn
|
||||
FROM eligible e
|
||||
)
|
||||
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
||||
FROM ranked r
|
||||
WHERE r.rn <= 200
|
||||
ORDER BY r.familiar DESC, r.tiebreak
|
||||
`
|
||||
|
||||
type ListGenreMixTracksParams struct {
|
||||
UserID pgtype.UUID
|
||||
DateSeed string
|
||||
GenreKey string
|
||||
}
|
||||
|
||||
type ListGenreMixTracksRow struct {
|
||||
ID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Familiar bool
|
||||
}
|
||||
|
||||
// Playable tracks carrying one genre, split into the two halves a genre mix
|
||||
// blends: familiar (the user has played it through or liked it) and unheard.
|
||||
// Up to 200 of each, each half in a daily-deterministic order so the mix
|
||||
// changes day to day and holds still within a day.
|
||||
//
|
||||
// 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
|
||||
// with a couple of stray skips in.
|
||||
func (q *Queries) ListGenreMixTracks(ctx context.Context, arg ListGenreMixTracksParams) ([]ListGenreMixTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listGenreMixTracks, arg.UserID, arg.DateSeed, arg.GenreKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListGenreMixTracksRow
|
||||
for rows.Next() {
|
||||
var i ListGenreMixTracksRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AlbumID,
|
||||
&i.ArtistID,
|
||||
&i.Familiar,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Restores the 0028 constraint set. Genre-mix rows would violate it, so they
|
||||
-- go first.
|
||||
DELETE FROM playlists WHERE system_variant = 'genre_mix';
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_kind_variant_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_kind_variant_consistent CHECK (
|
||||
(kind = 'user' AND system_variant IS NULL)
|
||||
OR
|
||||
(kind = 'system' AND system_variant IN (
|
||||
'for_you', 'songs_like_artist', 'discover',
|
||||
'deep_cuts', 'rediscover', 'new_for_you',
|
||||
'on_this_day', 'first_listens'
|
||||
))
|
||||
);
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_seed_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_seed_consistent CHECK (
|
||||
(system_variant IS NULL AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'discover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'deep_cuts' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'rediscover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'new_for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'on_this_day' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'first_listens' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'songs_like_artist' AND seed_artist_id IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Genre mixes: one daily system playlist per genre the user listens to
|
||||
-- ("Post-Punk mix", "Jazz mix", ...). Several per user, like
|
||||
-- songs_like_artist, but seedless: the genre lives in the playlist name and
|
||||
-- seed_artist_id is NULL.
|
||||
--
|
||||
-- Postgres has no ALTER CONSTRAINT for CHECK, so both constraints are
|
||||
-- dropped and re-added with 'genre_mix' in the accepted set.
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_kind_variant_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_kind_variant_consistent CHECK (
|
||||
(kind = 'user' AND system_variant IS NULL)
|
||||
OR
|
||||
(kind = 'system' AND system_variant IN (
|
||||
'for_you', 'songs_like_artist', 'discover',
|
||||
'deep_cuts', 'rediscover', 'new_for_you',
|
||||
'on_this_day', 'first_listens', 'genre_mix'
|
||||
))
|
||||
);
|
||||
|
||||
ALTER TABLE playlists DROP CONSTRAINT IF EXISTS playlists_seed_consistent;
|
||||
ALTER TABLE playlists
|
||||
ADD CONSTRAINT playlists_seed_consistent CHECK (
|
||||
(system_variant IS NULL AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'discover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'deep_cuts' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'rediscover' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'new_for_you' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'on_this_day' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'first_listens' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'genre_mix' AND seed_artist_id IS NULL)
|
||||
OR
|
||||
(system_variant = 'songs_like_artist' AND seed_artist_id IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
-- Candidate queries for the genre mixes: one daily system playlist per genre
|
||||
-- the user listens to. Genres live inline on tracks.genre as a delimited
|
||||
-- string and are split on [;,] the same way the Discover and radio tag arms
|
||||
-- split them. Matching is case-insensitive, so the ID3 "Post-Punk" and the
|
||||
-- folksonomy "post-punk" in the taste profile count as one genre.
|
||||
|
||||
-- name: ListGenreMixCandidates :many
|
||||
-- Library genres big enough to carry a mix (>= 25 playable tracks across
|
||||
-- >= 3 artists), each tagged with how close it is to the user's listening:
|
||||
--
|
||||
-- tier 1: a positive taste-profile genre, scored by its taste weight
|
||||
-- tier 2: a genre of tracks the user has played through or liked, scored
|
||||
-- by how many — catches a profile that has not been built yet or
|
||||
-- has decayed past its window
|
||||
-- tier 3: any other library genre, scored by its size
|
||||
--
|
||||
-- artist_ids lets the caller skip a genre whose artists mostly repeat one
|
||||
-- already picked (Indie Rock after Alternative Rock).
|
||||
-- $1 user_id.
|
||||
WITH library_genres AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key,
|
||||
min(trim(g.g))::text AS label,
|
||||
COUNT(DISTINCT t.id) AS track_count,
|
||||
array_agg(DISTINCT t.artist_id)::uuid[] AS artist_ids
|
||||
FROM tracks t
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE t.missing_since IS NULL
|
||||
AND trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
HAVING COUNT(DISTINCT t.id) >= 25
|
||||
AND COUNT(DISTINCT t.artist_id) >= 3
|
||||
),
|
||||
taste AS (
|
||||
SELECT lower(trim(tpt.tag)) AS genre_key, SUM(tpt.weight) AS weight
|
||||
FROM taste_profile_tags tpt
|
||||
WHERE tpt.user_id = $1
|
||||
GROUP BY lower(trim(tpt.tag))
|
||||
HAVING SUM(tpt.weight) > 0
|
||||
),
|
||||
listened AS (
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
UNION ALL
|
||||
SELECT gl.track_id
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
),
|
||||
history AS (
|
||||
SELECT lower(trim(g.g)) AS genre_key, COUNT(*) AS plays
|
||||
FROM listened l
|
||||
JOIN tracks t ON t.id = l.id
|
||||
CROSS JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE trim(g.g) <> ''
|
||||
GROUP BY lower(trim(g.g))
|
||||
)
|
||||
SELECT lg.genre_key::text AS genre_key,
|
||||
lg.label AS label,
|
||||
(CASE WHEN ta.genre_key IS NOT NULL THEN 1
|
||||
WHEN h.genre_key IS NOT NULL THEN 2
|
||||
ELSE 3 END)::int AS tier,
|
||||
(CASE WHEN ta.genre_key IS NOT NULL THEN ta.weight
|
||||
WHEN h.genre_key IS NOT NULL THEN h.plays::float8
|
||||
ELSE lg.track_count::float8 END)::float8 AS score,
|
||||
lg.artist_ids
|
||||
FROM library_genres lg
|
||||
LEFT JOIN taste ta ON ta.genre_key = lg.genre_key
|
||||
LEFT JOIN history h ON h.genre_key = lg.genre_key
|
||||
ORDER BY tier, score DESC, lg.genre_key
|
||||
LIMIT 60;
|
||||
|
||||
-- name: ListGenreMixTracks :many
|
||||
-- Playable tracks carrying one genre, split into the two halves a genre mix
|
||||
-- blends: familiar (the user has played it through or liked it) and unheard.
|
||||
-- Up to 200 of each, each half in a daily-deterministic order so the mix
|
||||
-- changes day to day and holds still within a day.
|
||||
--
|
||||
-- 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
|
||||
-- with a couple of stray skips in.
|
||||
WITH play_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = false
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
skip_counts AS (
|
||||
SELECT pe.track_id, COUNT(*) AS c
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = true
|
||||
GROUP BY pe.track_id
|
||||
),
|
||||
eligible AS (
|
||||
SELECT t.id, t.album_id, t.artist_id,
|
||||
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
||||
md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak
|
||||
FROM tracks t
|
||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||
LEFT JOIN general_likes gl ON gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
|
||||
WHERE t.missing_since IS NULL -- never offer a file that is gone
|
||||
AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(g)
|
||||
WHERE lower(trim(g.g)) = sqlc.arg(genre_key)::text
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM quarantine q
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT e.*, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn
|
||||
FROM eligible e
|
||||
)
|
||||
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
||||
FROM ranked r
|
||||
WHERE r.rn <= 200
|
||||
ORDER BY r.familiar DESC, r.tiebreak;
|
||||
@@ -93,6 +93,7 @@ var systemPlaylistSources = map[string]bool{
|
||||
"on_this_day": true,
|
||||
"first_listens": true,
|
||||
"songs_like_artist": true,
|
||||
"genre_mix": true,
|
||||
}
|
||||
|
||||
// lookupSystemPickKind resolves a system-playlist play's pick_kind
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// seedGenre creates artists × perArtist tracks tagged genre, each artist on
|
||||
// its own album, and returns the tracks.
|
||||
func seedGenre(t *testing.T, pool *pgxpool.Pool, genre string, artists, perArtist int) []dbq.Track {
|
||||
t.Helper()
|
||||
var out []dbq.Track
|
||||
for a := 0; a < artists; a++ {
|
||||
for k := 0; k < perArtist; k++ {
|
||||
tr := seedTrack(t, pool, fmt.Sprintf("%s %d-%d", genre, a, k), fmt.Sprintf("%s Artist %d", genre, a))
|
||||
setGenre(t, pool, tr.ID, genre)
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestListGenreMixCandidates_TiersAndSizeFloor: a taste-profile genre is
|
||||
// tier 1 (matched case-insensitively), a genre the user has only played is
|
||||
// tier 2, an untouched one is tier 3, and a genre too small for a mix is not
|
||||
// offered at all.
|
||||
func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, pool, "gmixcand")
|
||||
now := time.Now().UTC()
|
||||
|
||||
seedGenre(t, pool, "Shoegaze", 6, 5)
|
||||
kraut := seedGenre(t, pool, "Krautrock", 6, 5)
|
||||
seedGenre(t, pool, "Dub", 6, 5)
|
||||
seedGenre(t, pool, "Zydeco", 2, 5) // 10 tracks, 2 artists: below the floor
|
||||
|
||||
seedTasteTag(t, pool, u.ID, "shoegaze", 4)
|
||||
seedPlayEvent(t, pool, u.ID, kraut[0].ID, now.Add(-time.Hour), false)
|
||||
|
||||
rows, err := dbq.New(pool).ListGenreMixCandidates(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list candidates: %v", err)
|
||||
}
|
||||
want := map[string]int32{"Shoegaze": 1, "Krautrock": 2, "Dub": 3}
|
||||
tier := map[string]int32{}
|
||||
order := []string{}
|
||||
for _, r := range rows {
|
||||
tier[r.Label] = r.Tier
|
||||
if _, mine := want[r.Label]; mine {
|
||||
order = append(order, r.Label)
|
||||
}
|
||||
if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 {
|
||||
t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds))
|
||||
}
|
||||
}
|
||||
for label, w := range want {
|
||||
if got, ok := tier[label]; !ok || got != w {
|
||||
t.Errorf("%s tier = %d (present %v), want %d", label, got, ok, w)
|
||||
}
|
||||
}
|
||||
if _, ok := tier["Zydeco"]; ok {
|
||||
t.Error("Zydeco (10 tracks, 2 artists) offered as a genre mix")
|
||||
}
|
||||
if strings.Join(order, ",") != "Shoegaze,Krautrock,Dub" {
|
||||
t.Errorf("order = %v, want tier order Shoegaze, Krautrock, Dub", order)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and
|
||||
// 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
|
||||
// stays; tracks of other genres never appear.
|
||||
func TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, pool, "gmixtracks")
|
||||
now := time.Now().UTC()
|
||||
|
||||
shoegaze := seedGenre(t, pool, "Shoegaze", 6, 5)
|
||||
other := seedGenre(t, pool, "Dub", 3, 2)
|
||||
played, liked, rejected, favourite := shoegaze[0], shoegaze[1], shoegaze[2], shoegaze[3]
|
||||
|
||||
seedPlayEvent(t, pool, u.ID, played.ID, now.Add(-time.Hour), false)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, liked.ID); err != nil {
|
||||
t.Fatalf("like: %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-time.Duration(i+2)*time.Hour), true)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+10)*time.Hour), false)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+20)*time.Hour), true)
|
||||
}
|
||||
|
||||
rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{
|
||||
UserID: u.ID, DateSeed: "2026-11-05", GenreKey: "shoegaze",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list tracks: %v", err)
|
||||
}
|
||||
familiar := map[pgtype.UUID]bool{}
|
||||
for _, r := range rows {
|
||||
familiar[r.ID] = r.Familiar
|
||||
}
|
||||
if len(rows) != len(shoegaze)-1 {
|
||||
t.Errorf("rows = %d, want %d (every Shoegaze track but the rejected one)", len(rows), len(shoegaze)-1)
|
||||
}
|
||||
for _, tr := range other {
|
||||
if _, in := familiar[tr.ID]; in {
|
||||
t.Fatalf("a Dub track appeared in the Shoegaze pool")
|
||||
}
|
||||
}
|
||||
if _, in := familiar[rejected.ID]; in {
|
||||
t.Error("a track skipped twice and never played through was offered")
|
||||
}
|
||||
for name, tr := range map[string]dbq.Track{"played": played, "liked": liked, "favourite": favourite} {
|
||||
if f, in := familiar[tr.ID]; !in || !f {
|
||||
t.Errorf("%s track: present %v familiar %v, want present and familiar", name, in, f)
|
||||
}
|
||||
}
|
||||
if f := familiar[shoegaze[4].ID]; f {
|
||||
t.Error("an untouched track came back familiar")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSystemPlaylists_GenreMixes: a full build gives the user a mix for
|
||||
// the genre they listen to, holding only that genre's tracks, with both
|
||||
// halves of the blend stamped.
|
||||
func TestBuildSystemPlaylists_GenreMixes(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, pool, "gmixbuild")
|
||||
now := time.Now().UTC()
|
||||
|
||||
shoegaze := seedGenre(t, pool, "Shoegaze", 8, 5)
|
||||
seedGenre(t, pool, "Dub", 6, 5)
|
||||
seedTasteTag(t, pool, u.ID, "Shoegaze", 5)
|
||||
for i, tr := range shoegaze[:12] {
|
||||
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i+1)*time.Hour), false)
|
||||
}
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(ctx, pool, discardLogger(), u.ID, now, t.TempDir()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
var plID pgtype.UUID
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT id FROM playlists
|
||||
WHERE user_id = $1 AND system_variant = 'genre_mix' AND name = 'Shoegaze mix'`,
|
||||
u.ID).Scan(&plID); err != nil {
|
||||
t.Fatalf("no Shoegaze mix built: %v", err)
|
||||
}
|
||||
|
||||
inGenre := map[pgtype.UUID]bool{}
|
||||
for _, tr := range shoegaze {
|
||||
inGenre[tr.ID] = true
|
||||
}
|
||||
rows, err := pool.Query(ctx, `SELECT track_id, pick_kind FROM playlist_tracks WHERE playlist_id = $1`, plID)
|
||||
if err != nil {
|
||||
t.Fatalf("list mix tracks: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
kinds := map[string]int{}
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var id pgtype.UUID
|
||||
var kind *string
|
||||
if err := rows.Scan(&id, &kind); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
n++
|
||||
if !inGenre[id] {
|
||||
t.Errorf("track %s in the Shoegaze mix is not a Shoegaze track", uuidString(id))
|
||||
}
|
||||
if kind != nil {
|
||||
kinds[*kind]++
|
||||
}
|
||||
}
|
||||
if n < 15 {
|
||||
t.Fatalf("Shoegaze mix holds %d tracks, want at least 15", n)
|
||||
}
|
||||
if kinds["taste"] == 0 || kinds["taste_unheard"] == 0 {
|
||||
t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// genreTracks builds n tracks, each on its own album and artist (so the
|
||||
// caps never bind), numbered from start.
|
||||
func genreTracks(start, n int, familiar bool) []genreTrack {
|
||||
out := make([]genreTrack, n)
|
||||
for i := range out {
|
||||
id := uuidN(start + i)
|
||||
out[i] = genreTrack{
|
||||
discoverTrack: discoverTrack{ID: id, AlbumID: id, ArtistID: id},
|
||||
Familiar: familiar,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kindsOf(tracks []rankedCandidate) string {
|
||||
s := make([]byte, len(tracks))
|
||||
for i, t := range tracks {
|
||||
switch t.PickKind {
|
||||
case pickKindTaste:
|
||||
s[i] = 'F'
|
||||
case pickKindTasteUnheard:
|
||||
s[i] = 'U'
|
||||
default:
|
||||
s[i] = '?'
|
||||
}
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func TestComposeGenreMix_InterleavesThreeFamiliarToTwoUnheard(t *testing.T) {
|
||||
tracks := append(genreTracks(1, 20, true), genreTracks(100, 20, false)...)
|
||||
got := composeGenreMix(tracks, 10)
|
||||
if want := "FFUFUFFUFU"; kindsOf(got) != want {
|
||||
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeGenreMix_ShortSideIsFilledFromTheOther(t *testing.T) {
|
||||
// Two familiar tracks only: the rest of the mix is unheard, not short.
|
||||
tracks := append(genreTracks(1, 2, true), genreTracks(100, 20, false)...)
|
||||
got := composeGenreMix(tracks, 10)
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("len = %d, want 10", len(got))
|
||||
}
|
||||
if want := "FFUUUUUUUU"; kindsOf(got) != want {
|
||||
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
|
||||
}
|
||||
|
||||
// No unheard tracks at all: an all-familiar mix.
|
||||
got = composeGenreMix(genreTracks(1, 12, true), 10)
|
||||
if want := "FFFFFFFFFF"; kindsOf(got) != want {
|
||||
t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) {
|
||||
// One artist, one album, 8 tracks, plus 4 single-track artists. The cap
|
||||
// keeps 2 of the big album; the top-up refills from it so the mix still
|
||||
// reaches 8.
|
||||
big := genreTracks(1, 8, false)
|
||||
for i := range big {
|
||||
big[i].AlbumID = uuidN(500)
|
||||
big[i].ArtistID = uuidN(500)
|
||||
}
|
||||
tracks := append(big, genreTracks(100, 4, false)...)
|
||||
got := composeGenreMix(tracks, 8)
|
||||
if len(got) != 8 {
|
||||
t.Fatalf("len = %d, want 8", len(got))
|
||||
}
|
||||
// The first six are the capped pool: 2 from the big album, then the 4
|
||||
// singles — the big album does not lead the mix wholesale.
|
||||
bigID := map[pgtype.UUID]bool{}
|
||||
for _, b := range big {
|
||||
bigID[b.ID] = true
|
||||
}
|
||||
inHead := 0
|
||||
for _, c := range got[:6] {
|
||||
if bigID[c.TrackID] {
|
||||
inHead++
|
||||
}
|
||||
}
|
||||
if inHead != 2 {
|
||||
t.Fatalf("big-album tracks in the capped head = %d, want 2", inHead)
|
||||
}
|
||||
}
|
||||
|
||||
func artists(ns ...int) []pgtype.UUID {
|
||||
out := make([]pgtype.UUID, len(ns))
|
||||
for i, n := range ns {
|
||||
out[i] = uuidN(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestOverlapsKept_NearDuplicatesCollapseButSubgenresOfABroadGenreStay(t *testing.T) {
|
||||
rock := genreCandidate{Label: "Rock", ArtistIDs: artists(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)}
|
||||
altRock := genreCandidate{Label: "Alternative Rock", ArtistIDs: artists(1, 2, 3)}
|
||||
lofi := genreCandidate{Label: "Lo-Fi", ArtistIDs: artists(20, 21, 22, 23)}
|
||||
lofiHipHop := genreCandidate{Label: "Lo-Fi Hip Hop", ArtistIDs: artists(20, 21, 22, 24)}
|
||||
|
||||
if overlapsKept(altRock, []genreCandidate{rock}) {
|
||||
t.Error("Alternative Rock (3 of Rock's 10 artists) collapsed into Rock; a subgenre of a broad genre should stay")
|
||||
}
|
||||
if !overlapsKept(lofiHipHop, []genreCandidate{rock, lofi}) {
|
||||
t.Error("Lo-Fi Hip Hop (3 of 5 artists shared with Lo-Fi) was kept; near duplicates should collapse")
|
||||
}
|
||||
if overlapsKept(lofi, nil) {
|
||||
t.Error("nothing kept yet, but overlap reported")
|
||||
}
|
||||
}
|
||||
|
||||
// buildN returns a builder that gives every genre n tracks, except those
|
||||
// listed in short, which get too few to make a mix.
|
||||
func buildN(n int, short ...string) func(genreCandidate) []rankedCandidate {
|
||||
thin := map[string]bool{}
|
||||
for _, s := range short {
|
||||
thin[s] = true
|
||||
}
|
||||
return func(c genreCandidate) []rankedCandidate {
|
||||
if thin[c.Label] {
|
||||
return make([]rankedCandidate, genreMixMinLen-1)
|
||||
}
|
||||
return make([]rankedCandidate, n)
|
||||
}
|
||||
}
|
||||
|
||||
func labels(built []builtPlaylist) []string {
|
||||
out := make([]string, len(built))
|
||||
for i, b := range built {
|
||||
out[i] = b.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func distinctGenre(label string, tier int32, n int) genreCandidate {
|
||||
return genreCandidate{Key: label, Label: label, Tier: tier, ArtistIDs: artists(n)}
|
||||
}
|
||||
|
||||
func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) {
|
||||
var cands []genreCandidate
|
||||
for i := 0; i < 14; i++ {
|
||||
cands = append(cands, distinctGenre(string(rune('A'+i)), 1, i+1))
|
||||
}
|
||||
got := selectGenreMixes(cands, buildN(30))
|
||||
if len(got) != genreMixCount {
|
||||
t.Fatalf("built %d mixes, want %d", len(got), genreMixCount)
|
||||
}
|
||||
if got[0].Name != "A mix" || got[0].Variant != "genre_mix" {
|
||||
t.Fatalf("first mix = %q (%s), want \"A mix\" (genre_mix)", got[0].Name, got[0].Variant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGenreMixes_ThinGenreGivesWayToTheNext(t *testing.T) {
|
||||
cands := []genreCandidate{
|
||||
distinctGenre("Jazz", 1, 1),
|
||||
distinctGenre("Dub", 1, 2),
|
||||
distinctGenre("Soul", 2, 3),
|
||||
}
|
||||
got := labels(selectGenreMixes(cands, buildN(30, "Dub")))
|
||||
if len(got) != 2 || got[0] != "Jazz mix" || got[1] != "Soul mix" {
|
||||
t.Fatalf("mixes = %v, want [Jazz mix Soul mix]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectGenreMixes_LibraryGenresOnlyFillAShortShelf(t *testing.T) {
|
||||
// Two listened genres: tier 3 tops the shelf up to genreMixMinShelf.
|
||||
cands := []genreCandidate{
|
||||
distinctGenre("Jazz", 1, 1),
|
||||
distinctGenre("Soul", 2, 2),
|
||||
distinctGenre("Polka", 3, 3),
|
||||
distinctGenre("Opera", 3, 4),
|
||||
distinctGenre("Ska", 3, 5),
|
||||
}
|
||||
got := labels(selectGenreMixes(cands, buildN(30)))
|
||||
if len(got) != genreMixMinShelf {
|
||||
t.Fatalf("mixes = %v, want %d", got, genreMixMinShelf)
|
||||
}
|
||||
|
||||
// Enough listened genres: no tier-3 genre at all.
|
||||
var full []genreCandidate
|
||||
for i := 0; i < genreMixMinShelf; i++ {
|
||||
full = append(full, distinctGenre(string(rune('A'+i)), 1, i+1))
|
||||
}
|
||||
full = append(full, distinctGenre("Polka", 3, 99))
|
||||
for _, name := range labels(selectGenreMixes(full, buildN(30))) {
|
||||
if name == "Polka mix" {
|
||||
t.Fatal("a library-only genre was added to a shelf the user's own genres already fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,8 +531,8 @@ type systemPlaylistKind struct {
|
||||
// Discover, the discovery mixes) and so can be addressed by kind:
|
||||
// the generic /system/{kind}/{refresh,shuffle} endpoints + the
|
||||
// per-tile refresh affordance only apply to these. Non-singleton
|
||||
// kinds (songs_like_artist → up to 3 per user) are played by
|
||||
// playlist id and have no by-kind endpoint.
|
||||
// kinds (songs_like_artist, genre_mix — several per user) are played
|
||||
// by playlist id and have no by-kind endpoint.
|
||||
Singleton bool
|
||||
Produce systemPlaylistProducer
|
||||
}
|
||||
@@ -563,6 +563,7 @@ var systemPlaylistRegistry = func() []systemPlaylistKind {
|
||||
{Key: "for_you", Singleton: true, Produce: produceForYou},
|
||||
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
|
||||
{Key: "discover", Singleton: true, Produce: produceDiscover},
|
||||
{Key: "genre_mix", Singleton: false, Produce: produceGenreMixes},
|
||||
}
|
||||
// The five discovery mixes share one Produce closure factory keyed
|
||||
// by a per-mix spec; spec list + factory live in system_mixes.go.
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
|
||||
if !r.SeedArtistID.Valid {
|
||||
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
|
||||
}
|
||||
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
|
||||
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens", "genre_mix":
|
||||
// Discover and the discovery mixes are all seedless —
|
||||
// no seed_artist_id required.
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user