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
46 changes: 40 additions & 6 deletions api/v1_users_feed_for_you.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,15 @@ type GetUsersFeedForYouParams struct {
// // collections stay in the feed but rank below
// // comparably-scored tracks, targeting roughly one
// // collection per 5-6 tracks.
// repeat_penalty = {played in last 7d: 0.25, played in last 14d: 0.50,
// unplayed: 1.00}
// // 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).
//
// final_score = (0.55 * recency_score + 0.45 * engagement_score)
// * social_boost * source_weight * content_type_weight
// * repeat_penalty
//
// 3. FILTERS — applied once after the union to keep the candidate set
// cheap:
Expand Down Expand Up @@ -162,6 +168,25 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error {
AND is_current = true
AND is_delete = false
),
-- Tracks I've played in the last 14 days, with the most recent play
-- time. Used for the repeat_penalty multiplier so already-heard
-- tracks rank below fresh content without being filtered out.
--
-- Bounded the same way as the affinity play sub-select: a heavy
-- listener can have hundreds of thousands of play rows, so scan only
-- the most recent slice before applying the 14-day window.
my_recent_plays AS (
SELECT track_id, MAX(created_at) AS last_played_at
FROM (
SELECT play_item_id AS track_id, created_at
FROM plays
WHERE user_id = @userId
ORDER BY created_at DESC
LIMIT 2000
) p
WHERE created_at >= NOW() - INTERVAL '14 days'
GROUP BY track_id
),
-- 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 @@ -376,13 +401,15 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error {
COALESCE(at.save_count, 0) AS save_count,
COALESCE(at.repost_count, 0) AS repost_count,
COALESCE(ap.count, 0) AS play_count,
COALESCE(maa.affinity, 0) AS affinity
COALESCE(maa.affinity, 0) AS affinity,
mrp.last_played_at AS last_played_at
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
WHERE c.entity_type = 'track'
AND t.is_current = true
AND t.is_delete = false
Expand Down Expand Up @@ -413,7 +440,8 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error {
COALESCE(ap.save_count, 0) AS save_count,
COALESCE(ap.repost_count, 0) AS repost_count,
0 AS play_count,
COALESCE(maa.affinity, 0) AS affinity
COALESCE(maa.affinity, 0) AS affinity,
NULL::timestamp AS last_played_at
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 @@ -433,11 +461,11 @@ 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
save_count, repost_count, play_count, affinity, last_played_at
FROM filtered_tracks
UNION ALL
SELECT entity_type, entity_id, owner_id, created_at, source,
save_count, repost_count, play_count, affinity
save_count, repost_count, play_count, affinity, last_played_at
FROM filtered_playlists
),
scored AS (
Expand All @@ -463,7 +491,12 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error {
CASE f.entity_type
WHEN 'track' THEN 1.00
ELSE 0.60
END AS content_type_weight
END AS content_type_weight,
CASE
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
FROM filtered f
),
final_scored AS (
Expand All @@ -473,7 +506,8 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error {
owner_id,
created_at,
(0.55 * recency_score + 0.45 * engagement_score)
* social_boost * source_weight * content_type_weight AS score
* social_boost * source_weight * content_type_weight
* repeat_penalty AS score
FROM scored
),
capped AS (
Expand Down
77 changes: 77 additions & 0 deletions api/v1_users_feed_for_you_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,83 @@ func TestV1FeedForYou_IncludesCollections(t *testing.T) {
"deactivated-owner track 701 must not appear")
}

// feedForYouTrackPositions fetches a wide page of the for-you feed for
// user 1 and returns the index of each track by raw track id (-1 if the
// track isn't on the page).
func feedForYouTrackPositions(t *testing.T, app *ApiServer, trackIds ...int32) map[int32]int {
t.Helper()
var resp struct {
Data []feedForYouItem
}
path := fmt.Sprintf("/v1/users/%s/feed/for-you?limit=20", trashid.MustEncodeHashID(1))
status, body := testGet(t, app, path, &resp)
require.Equal(t, 200, status, string(body))

positions := map[int32]int{}
for _, id := range trackIds {
positions[id] = -1
}
for i, it := range resp.Data {
if it.Type != "track" {
continue
}
var idHolder struct {
ID string `json:"id"`
}
_ = json.Unmarshal(it.Item, &idHolder)
for _, id := range trackIds {
if idHolder.ID == trashid.MustEncodeHashID(int(id)) {
positions[id] = i
}
}
}
return positions
}

// TestV1FeedForYou_RepeatSuppressionDownranksPlayedTracks asserts that a
// track the viewer played in the last 7 days gets the 0.25x repeat
// penalty: track 101 normally outranks its sibling 102 (same owner,
// fresher, more engagement), but a recent play by user 1 should push it
// below 102 — while still keeping it in the feed.
func TestV1FeedForYou_RepeatSuppressionDownranksPlayedTracks(t *testing.T) {
app := emptyTestApp(t)
app.skipAuthCheck = true

fixtures := feedForYouFixtures()
fixtures["plays"] = []map[string]any{
{"id": 1, "user_id": 1, "play_item_id": 101,
"created_at": time.Now().Add(-2 * 24 * time.Hour)},
}
database.Seed(app.pool.Replicas[0], fixtures)

pos := feedForYouTrackPositions(t, app, 101, 102)
require.NotEqual(t, -1, pos[102], "unplayed track 102 should be in the feed")
require.NotEqual(t, -1, pos[101], "played track 101 should stay in the feed, just ranked lower")
assert.Greater(t, pos[101], pos[102],
"recently played track 101 should rank below unplayed sibling 102")
}

// TestV1FeedForYou_OldPlaysDoNotSuppress asserts the repeat penalty only
// looks back 14 days: a play from 20 days ago should leave track 101 in
// its natural position above 102.
func TestV1FeedForYou_OldPlaysDoNotSuppress(t *testing.T) {
app := emptyTestApp(t)
app.skipAuthCheck = true

fixtures := feedForYouFixtures()
fixtures["plays"] = []map[string]any{
{"id": 1, "user_id": 1, "play_item_id": 101,
"created_at": time.Now().Add(-20 * 24 * time.Hour)},
}
database.Seed(app.pool.Replicas[0], fixtures)

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],
"a 20-day-old play is outside the window and should not downrank track 101")
}

// 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