feat(discover): seed request suggestions from the taste profile — #2372
The Discover request surface was the one recommendation surface still on its M5c implementation from early May. #796's taste profile, #1488's taste_unheard bucket and #1490's folksonomy enrichment all modernized in-library surfaces; this one was never in scope for any of them, so it still projected raw likes + plays through artist_similarity_unmatched. Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed over every play of the artist. It is unbounded, and contribution is signal × similarity — so a handful of heavily-played artists monopolize all twelve slots, and their share GROWS the more the user listens. The surface entrenched harder the better it knew you, which is exactly backwards and matches the reported "goes stale once it has a strong signal of your taste". It also counted every play_event with no was_skipped filter, so skipping an artist repeatedly INCREASED its signal and pushed more of its neighbours at the user. ListMostPlayedTracksForUser and the taste engine both filter skips; this query was the odd one out. Seeds now come from taste_profile_artists.weight, which the taste engine has already engagement-graded, time-decayed and signed — an artist the user drifted away from stops contributing instead of accumulating forever, and can even contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is the profile, tier 2 is likes + completed plays for a user who has no profile rows yet (new account, or before the first daily recompute), so the surface never empties. The old unfiltered-play signal is gone, not kept behind a toggle. The signal is also log-damped, so one artist cannot take every slot even when its weight dwarfs the rest. $2 stays wired to the tier-2 decay: it is genuinely still used there, and dropping the parameter would have changed the generated signature. sqlc's image is not on this workstation and the change preserves the query signature exactly — same three params, same seven columns — so only the embedded SQL const moves. Both copies are edited and verified byte-identical rather than pulling a container onto the operator's machine; a malformed query fails the integration lane loudly, which is the real check either way. Four integration tests cover what changed: a taste weight alone seeds with no like or play; tier 2 does not run alongside tier 1; a non-positive weight never seeds (guarded by a second positive row, so an empty tier 1 can't make it pass for the wrong reason); and skip-only history seeds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1022,20 +1022,66 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||||
WITH seeds AS (
|
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
|
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
|
--
|
||||||
|
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
|
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
|
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
|
-- from stops contributing instead of accumulating forever.
|
||||||
|
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
-- (new account, or before the first daily recompute).
|
||||||
|
--
|
||||||
|
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
--
|
||||||
|
-- Candidates already in the library, or already requested and not terminal,
|
||||||
|
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||||
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
|
|||||||
@@ -258,27 +258,66 @@ ORDER BY started_at DESC
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: SuggestArtistsForUser :many
|
-- name: SuggestArtistsForUser :many
|
||||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
--
|
||||||
-- artist_similarity_unmatched and filters out candidates already in
|
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
-- from stops contributing instead of accumulating forever.
|
||||||
WITH seeds AS (
|
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
-- (new account, or before the first daily recompute).
|
||||||
|
--
|
||||||
|
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
--
|
||||||
|
-- Candidates already in the library, or already requested and not terminal,
|
||||||
|
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||||
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
// suggestions.go is the per-user artist-suggestion service behind the
|
||||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
// Discover request surface. Seeds from the taste profile (falling back to
|
||||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
// likes + completed plays for a user who has none yet), projects those seeds
|
||||||
// resolved to artist names.
|
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||||
|
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||||
|
//
|
||||||
|
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||||
|
// without bound and counted skips as engagement, so a few heavily-played
|
||||||
|
// artists monopolized every slot and the surface entrenched harder the more
|
||||||
|
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||||
|
// see internal/db/queries/recommendation.sql for the tiering.
|
||||||
package recommendation
|
package recommendation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -32,8 +39,12 @@ type SeedContribution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
// capped at 50 (default 12 when out of range).
|
||||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
//
|
||||||
|
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
|
||||||
|
// seed path only — the likes + completed-plays fallback used while the user
|
||||||
|
// has no taste-profile rows yet. Once the profile is populated it seeds
|
||||||
|
// instead, carrying its own decay, so this knob stops applying.
|
||||||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 12
|
limit = 12
|
||||||
|
|||||||
@@ -330,3 +330,145 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
|||||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||||
|
|
||||||
|
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, artistID, weight,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("set taste weight: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
|
||||||
|
userID, startedAt,
|
||||||
|
).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, $4, true)`,
|
||||||
|
userID, trackID, sessionID, startedAt,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert skipped play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||||
|
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||||
|
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seed := seedArtist(t, pool, "Profile Seed", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "out-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
||||||
|
}
|
||||||
|
if out[0].Score <= 0 {
|
||||||
|
t.Errorf("score = %v, want > 0", out[0].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
||||||
|
// the user played but that the taste engine did not keep does NOT seed. That is
|
||||||
|
// the point of the rewrite: the taste engine decides what counts as affinity.
|
||||||
|
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
kept := seedArtist(t, pool, "Kept By Taste", "")
|
||||||
|
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
||||||
|
|
||||||
|
// `dropped` has real completed plays but no profile row.
|
||||||
|
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
||||||
|
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "kept-cand" {
|
||||||
|
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
||||||
|
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
||||||
|
// tier 2 and the assertion would pass for the wrong reason.
|
||||||
|
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
positive := seedArtist(t, pool, "Still Liked", "")
|
||||||
|
abandoned := seedArtist(t, pool, "Abandoned", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
||||||
|
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == "neg-cand" {
|
||||||
|
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
||||||
|
t.Errorf("out = %+v, want only pos-cand", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||||
|
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||||
|
// neighbours at the user (#2367 mechanism 3).
|
||||||
|
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
skipped := seedArtist(t, pool, "Only Skipped", "")
|
||||||
|
|
||||||
|
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||||
|
}
|
||||||
|
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user