From 6cbaf33ab4fa573ecd70f48971716012514680fb Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 12 Jun 2026 01:14:55 -0700 Subject: [PATCH] feat(feed): boost tracks from genres the listener plays most Add a genre_affinity multiplier to the for-you feed final_score, built from the genre mix of the viewer's most recent plays (bounded scan, last 1000): a genre at >=30% of recent listening gets the full 1.30x, unfamiliar genres get 0.85x, and playlists / genre-less tracks / cold-start users stay neutral at 1.00x. --- api/v1_users_feed_for_you.go | 60 +++++++++++++++++++++++--- api/v1_users_feed_for_you_test.go | 72 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/api/v1_users_feed_for_you.go b/api/v1_users_feed_for_you.go index e03c15a3..c283a228 100644 --- a/api/v1_users_feed_for_you.go +++ b/api/v1_users_feed_for_you.go @@ -72,10 +72,17 @@ type GetUsersFeedForYouParams struct { // // tracks the user already heard recently stay in // // the feed but rank below fresh content. Playlists // // always get 1.00 (no per-user playlist play data). +// genre_affinity = 0.85 + 0.45 * min(genre_share / 0.30, 1) +// // genre_share is the fraction of my recent plays +// // in the track's genre: a genre at >= 30% of my +// // listening gets the full 1.30x, genres I never +// // play get 0.85x. Neutral 1.00 for playlists, +// // genre-less tracks, and cold-start users with +// // no play history. // // final_score = (0.55 * recency_score + 0.45 * engagement_score) // * social_boost * source_weight * content_type_weight -// * repeat_penalty +// * repeat_penalty * genre_affinity // // 3. FILTERS — applied once after the union to keep the candidate set // cheap: @@ -187,6 +194,28 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { WHERE created_at >= NOW() - INTERVAL '14 days' GROUP BY track_id ), + -- Genre mix of my recent listening: share = fraction of my recent + -- plays that landed in each genre. Feeds the genre_affinity + -- multiplier so tracks in genres I play often rank higher and + -- genres I never touch rank slightly lower. + -- + -- Bounded like the other play sub-selects: only the most recent + -- slice of plays is scanned, so a heavy listener can't pull this + -- CTE wide. + my_genre_affinity AS ( + SELECT t.genre, + COUNT(*)::double precision / SUM(COUNT(*)) OVER () AS share + FROM ( + SELECT play_item_id AS track_id + FROM plays + WHERE user_id = @userId + ORDER BY created_at DESC + LIMIT 1000 + ) p + JOIN tracks t ON t.track_id = p.track_id + WHERE t.genre IS NOT NULL AND t.genre <> '' + GROUP BY t.genre + ), -- Per-owner engagement strength: saves+reposts of any of their -- tracks/playlists by me, plus plays of their tracks. Used for the -- social_boost multiplier. @@ -402,7 +431,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { COALESCE(at.repost_count, 0) AS repost_count, COALESCE(ap.count, 0) AS play_count, COALESCE(maa.affinity, 0) AS affinity, - mrp.last_played_at AS last_played_at + mrp.last_played_at AS last_played_at, + t.genre AS genre, + mga.share AS genre_share FROM candidates c JOIN tracks t ON t.track_id = c.entity_id JOIN users u ON u.user_id = t.owner_id @@ -410,6 +441,7 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { LEFT JOIN aggregate_plays ap ON ap.play_item_id = c.entity_id LEFT JOIN my_artist_affinity maa ON maa.artist_id = t.owner_id LEFT JOIN my_recent_plays mrp ON mrp.track_id = c.entity_id + LEFT JOIN my_genre_affinity mga ON mga.genre = t.genre WHERE c.entity_type = 'track' AND t.is_current = true AND t.is_delete = false @@ -441,7 +473,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { COALESCE(ap.repost_count, 0) AS repost_count, 0 AS play_count, COALESCE(maa.affinity, 0) AS affinity, - NULL::timestamp AS last_played_at + NULL::timestamp AS last_played_at, + NULL::text AS genre, + NULL::double precision AS genre_share FROM candidates c JOIN playlists p ON p.playlist_id = c.entity_id JOIN users u ON u.user_id = p.playlist_owner_id @@ -461,11 +495,13 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { ), filtered AS ( SELECT entity_type, entity_id, owner_id, created_at, source, - save_count, repost_count, play_count, affinity, last_played_at + save_count, repost_count, play_count, affinity, last_played_at, + genre, genre_share FROM filtered_tracks UNION ALL SELECT entity_type, entity_id, owner_id, created_at, source, - save_count, repost_count, play_count, affinity, last_played_at + save_count, repost_count, play_count, affinity, last_played_at, + genre, genre_share FROM filtered_playlists ), scored AS ( @@ -496,7 +532,17 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { WHEN f.last_played_at >= NOW() - INTERVAL '7 days' THEN 0.25 WHEN f.last_played_at >= NOW() - INTERVAL '14 days' THEN 0.50 ELSE 1.00 - END AS repeat_penalty + END AS repeat_penalty, + CASE + -- Playlists carry no single genre; genre-less tracks + -- and cold-start users (no genre history at all) are + -- neutral rather than penalized. + WHEN f.entity_type <> 'track' THEN 1.00 + WHEN f.genre IS NULL OR f.genre = '' THEN 1.00 + WHEN NOT EXISTS (SELECT 1 FROM my_genre_affinity) THEN 1.00 + WHEN f.genre_share IS NULL THEN 0.85 + ELSE 0.85 + 0.45 * LEAST(f.genre_share / 0.30, 1.0) + END AS genre_affinity FROM filtered f ), final_scored AS ( @@ -507,7 +553,7 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { created_at, (0.55 * recency_score + 0.45 * engagement_score) * social_boost * source_weight * content_type_weight - * repeat_penalty AS score + * repeat_penalty * genre_affinity AS score FROM scored ), capped AS ( diff --git a/api/v1_users_feed_for_you_test.go b/api/v1_users_feed_for_you_test.go index 242b6cf5..027144b8 100644 --- a/api/v1_users_feed_for_you_test.go +++ b/api/v1_users_feed_for_you_test.go @@ -370,6 +370,78 @@ func TestV1FeedForYou_OldPlaysDoNotSuppress(t *testing.T) { "a 20-day-old play is outside the window and should not downrank track 101") } +// feedForYouGenreFixtures returns the standard fixture graph with +// genres on the two in-network sibling tracks: 101 (the naturally +// stronger one) is Rock, 102 is Electronic. Track 666 is an old upload +// (outside every candidate window) that exists only to receive plays +// that shape the viewer's genre mix. +func feedForYouGenreFixtures() database.FixtureMap { + fixtures := feedForYouFixtures() + for _, tr := range fixtures["tracks"] { + switch tr["track_id"] { + case 101: + tr["genre"] = "Rock" + case 102: + tr["genre"] = "Electronic" + } + } + fixtures["tracks"] = append(fixtures["tracks"], map[string]any{ + "track_id": 666, "owner_id": 3, "title": "old electronic upload", + "genre": "Electronic", + "created_at": time.Now().Add(-100 * 24 * time.Hour), + }) + return fixtures +} + +// TestV1FeedForYou_GenreAffinityBoostsFavoredGenre asserts the +// genre_affinity multiplier: the viewer's recent plays are 100% +// Electronic, so Electronic track 102 (1.30x) should outrank its +// naturally-stronger Rock sibling 101 (0.85x, unfamiliar genre) — +// while 101 stays in the feed rather than being filtered. +func TestV1FeedForYou_GenreAffinityBoostsFavoredGenre(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + fixtures := feedForYouGenreFixtures() + // Plays of old track 666 build an all-Electronic genre profile. + // 666 is 100 days old: outside the artist-affinity 90-day window + // and outside every candidate source, so the only signal these + // plays add is genre. The plays are 30+ days old so the repeat + // penalty (14-day window) is not in play either. + plays := make([]map[string]any, 0, 5) + for i := 0; i < 5; i++ { + plays = append(plays, map[string]any{ + "id": i + 1, "user_id": 1, "play_item_id": 666, + "created_at": time.Now().Add(-time.Duration(30+i) * 24 * time.Hour), + }) + } + fixtures["plays"] = plays + database.Seed(app.pool.Replicas[0], fixtures) + + pos := feedForYouTrackPositions(t, app, 101, 102) + require.NotEqual(t, -1, pos[102], "favored-genre track 102 should be in the feed") + require.NotEqual(t, -1, pos[101], + "unfamiliar-genre track 101 should stay in the feed, just ranked lower") + assert.Greater(t, pos[101], pos[102], + "Electronic track 102 should outrank Rock sibling 101 for an all-Electronic listener") +} + +// TestV1FeedForYou_GenreAffinityNeutralOnColdStart asserts that a user +// with no play history gets no genre boost or penalty: 101 keeps its +// natural position above 102 even though genres differ. +func TestV1FeedForYou_GenreAffinityNeutralOnColdStart(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + database.Seed(app.pool.Replicas[0], feedForYouGenreFixtures()) + + pos := feedForYouTrackPositions(t, app, 101, 102) + require.NotEqual(t, -1, pos[101]) + require.NotEqual(t, -1, pos[102]) + assert.Less(t, pos[101], pos[102], + "with no play history genre_affinity must be neutral; 101 keeps its natural rank") +} + // TestV1FeedForYou_PerArtistCapIsShared asserts that max_per_artist // applies across both tracks and playlists for the same owner — the // diversity cap is shared, not per-type. User 2 (followed) has 2 tracks