Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions api/v1_users_feed_for_you.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -402,14 +431,17 @@ 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
LEFT JOIN aggregate_track at ON at.track_id = c.entity_id
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
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down
72 changes: 72 additions & 0 deletions api/v1_users_feed_for_you_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading