diff --git a/api/server.go b/api/server.go index 7a384df9..915bf393 100644 --- a/api/server.go +++ b/api/server.go @@ -158,6 +158,18 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } + // Caches the candidate user-id list returned by the /v1/users/:userId/ + // suggested-follows query. Shorter TTL than relatedUsersCache: this list is + // per-viewer and shrinks as the user acts on it, so a follow should drop out + // of their suggestions promptly. + suggestedFollowsCache, err := otter.MustBuilder[string, []int32](20_000). + WithTTL(5 * time.Minute). + CollectStats(). + Build() + if err != nil { + panic(err) + } + // Caches the normalized popular-genre slice returned by /v1/genres/popular, // which otherwise runs a GROUP BY genre scan over the tracks table on every // request. Keyed by (limit, offset, startTime bucket); the result is an @@ -294,6 +306,7 @@ func NewApiServer(config config.Config) *ApiServer { oauthTokenCache: &oauthTokenCache, qualifiedPlaylistsCache: &qualifiedPlaylistsCache, relatedUsersCache: &relatedUsersCache, + suggestedFollowsCache: &suggestedFollowsCache, genresPopularCache: &genresPopularCache, sitemapXMLCache: &sitemapXMLCache, requestValidator: requestValidator, @@ -472,6 +485,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/mutuals", app.v1UsersMutuals) g.Get("/users/:userId/reposts", app.v1UsersReposts) g.Get("/users/:userId/related", app.v1UsersRelated) + g.Get("/users/:userId/suggested-follows", app.v1UsersSuggestedFollows) g.Get("/users/:userId/supporting", app.v1UsersSupporting) g.Get("/users/:userId/supporting/:supportedUserId", app.v1UsersSupporting) g.Get("/users/:userId/supporters", app.v1UsersSupporters) @@ -852,6 +866,7 @@ type ApiServer struct { oauthTokenCache *otter.Cache[string, oauthTokenCacheEntry] qualifiedPlaylistsCache *otter.Cache[string, []int32] relatedUsersCache *otter.Cache[string, []int32] + suggestedFollowsCache *otter.Cache[string, []int32] genresPopularCache *otter.Cache[string, []PopularGenre] sitemapXMLCache *otter.Cache[string, sitemapXMLCacheEntry] requestValidator *RequestValidator diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 6eed67e7..8e462193 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -7107,6 +7107,57 @@ paths: "500": description: Server error content: {} + /users/{id}/suggested-follows: + get: + tags: + - users + description: + Gets artists to suggest the user follow, based on the tracks and + albums they have favorited or reposted but whose artist they do not + already follow. Returns an empty list for users with no favorites or + reposts. + operationId: Get Suggested Follows + security: + - {} + - OAuth2: + - read + parameters: + - name: id + in: path + description: A User ID + required: true + schema: + type: string + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/related_artist_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /users/{id}/supporters: get: tags: diff --git a/api/v1_users_suggested_follows.go b/api/v1_users_suggested_follows.go new file mode 100644 index 00000000..57668aa3 --- /dev/null +++ b/api/v1_users_suggested_follows.go @@ -0,0 +1,195 @@ +package api + +import ( + "context" + "fmt" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetUsersSuggestedFollowsParams struct { + Limit int `query:"limit" default:"10" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0"` +} + +const ( + // Only the most recent N favorites and N reposts are considered. A user's + // engagement history is unbounded, and everything downstream of it joins + // per-row, so this caps the worst case for heavy users. Recent engagement + // is also the better signal, so the cap costs little. + suggestedFollowsEngagementCap = 2000 + + // Engagement weight decays with e^(-age/tau). At tau = 180 days a favorite + // from six months ago counts ~37% of one from today, so long-dormant taste + // still contributes but does not outrank what the user likes now. + suggestedFollowsDecaySeconds = 180 * 24 * 60 * 60 + + // A repost is a public endorsement, a favorite is private. Weight the + // stronger signal higher. + suggestedFollowsFavoriteWeight = 1.0 + suggestedFollowsRepostWeight = 1.5 +) + +/* +Suggests artists to follow based on the user's own favorites and reposts. + +This is the "direct owner" pass: artists whose tracks or albums the user has +already favorited or reposted but has not followed. It is deliberately not a +collaborative filter — those candidates are already engaged with, so they need +no graph traversal to justify, and "you saved three of their tracks and never +followed them" is both the cheapest and the most legible suggestion available. + +Distinct from /users/:userId/related, which is artist-anchored ("followers of X +also follow Y"). This one is viewer-anchored. + +Suggestions exclude anyone the seed user already follows, so the result depends +only on the path userId — not on the caller — and is cached on that alone. +*/ +func (app *ApiServer) v1UsersSuggestedFollows(c *fiber.Ctx) error { + params := GetUsersSuggestedFollowsParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myId := app.getMyId(c) + userId := app.getUserId(c) + + candidateIds, err := app.getSuggestedFollowIds( + c.Context(), + userId, + params.Limit, + params.Offset, + ) + if err != nil { + return err + } + + users, err := app.queries.Users(c.Context(), dbv1.GetUsersParams{ + MyID: myId, + Ids: candidateIds, + }) + if err != nil { + return err + } + + return v1UsersResponse(c, users) +} + +func (app *ApiServer) getSuggestedFollowIds( + ctx context.Context, + userId int32, + limit int, + offset int, +) ([]int32, error) { + cacheKey := fmt.Sprintf("suggested_follows:%d:%d:%d", userId, limit, offset) + if hit, ok := app.suggestedFollowsCache.Get(cacheKey); ok { + return hit, nil + } + + sql := ` + WITH recent_favorites AS ( + SELECT + save_item_id AS item_id, + save_type::text AS item_type, + @favoriteWeight::float8 AS weight, + created_at + FROM saves + WHERE user_id = @userId + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT @engagementCap + ), + recent_reposts AS ( + SELECT + repost_item_id AS item_id, + repost_type::text AS item_type, + @repostWeight::float8 AS weight, + created_at + FROM reposts + WHERE user_id = @userId + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT @engagementCap + ), + my_engagement AS ( + SELECT * FROM recent_favorites + UNION ALL + SELECT * FROM recent_reposts + ), + -- Attribute each favorite/repost to the artist who owns the item. + seeds AS ( + SELECT t.owner_id AS user_id, e.weight, e.created_at + FROM my_engagement e + JOIN tracks t ON t.track_id = e.item_id + WHERE e.item_type = 'track' + AND t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + AND t.stem_of IS NULL + UNION ALL + SELECT p.playlist_owner_id AS user_id, e.weight, e.created_at + FROM my_engagement e + JOIN playlists p ON p.playlist_id = e.item_id + WHERE e.item_type IN ('album', 'playlist') + AND p.is_current = true + AND p.is_delete = false + AND p.is_private = false + ), + scored AS ( + SELECT + s.user_id, + SUM( + s.weight * exp( + -EXTRACT(epoch FROM ((now() AT TIME ZONE 'utc') - s.created_at))::float8 + / @decaySeconds::float8 + ) + ) AS score + FROM seeds s + GROUP BY s.user_id + ) + SELECT sc.user_id + FROM scored sc + JOIN users u ON u.user_id = sc.user_id + WHERE sc.user_id != @userId + AND u.is_current = true + AND u.is_deactivated = false + AND u.is_available = true + AND NOT EXISTS ( + SELECT 1 + FROM follows f + WHERE f.follower_user_id = @userId + AND f.followee_user_id = sc.user_id + AND f.is_current = true + AND f.is_delete = false + ) + -- user_id breaks score ties so pagination is stable across pages. + ORDER BY sc.score DESC, sc.user_id ASC + LIMIT @limit + OFFSET @offset + ` + + rows, err := app.pool.Query(ctx, sql, pgx.NamedArgs{ + "userId": userId, + "limit": limit, + "offset": offset, + "engagementCap": suggestedFollowsEngagementCap, + "decaySeconds": suggestedFollowsDecaySeconds, + "favoriteWeight": suggestedFollowsFavoriteWeight, + "repostWeight": suggestedFollowsRepostWeight, + }) + if err != nil { + return nil, err + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[int32]) + if err != nil { + return nil, err + } + + app.suggestedFollowsCache.Set(cacheKey, ids) + return ids, nil +} diff --git a/api/v1_users_suggested_follows_test.go b/api/v1_users_suggested_follows_test.go new file mode 100644 index 00000000..bbc717a0 --- /dev/null +++ b/api/v1_users_suggested_follows_test.go @@ -0,0 +1,155 @@ +package api + +import ( + "testing" + "time" + + "api.audius.co/api/dbv1" + "api.audius.co/database" + "github.com/stretchr/testify/assert" +) + +func TestV1UsersSuggestedFollows(t *testing.T) { + app := emptyTestApp(t) + + // user 1 is the seed user. Every other user owns content user 1 has + // engaged with, or is a control for one of the exclusion rules. + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "rayjacobson", "handle_lc": "rayjacobson", "name": "Ray Jacobson", "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060"}, + {"user_id": 2, "handle": "twofaves", "handle_lc": "twofaves", "wallet": "0x0000000000000000000000000000000000000002"}, + {"user_id": 3, "handle": "onerepost", "handle_lc": "onerepost", "wallet": "0x0000000000000000000000000000000000000003"}, + {"user_id": 4, "handle": "alreadyfollowed", "handle_lc": "alreadyfollowed", "wallet": "0x0000000000000000000000000000000000000004"}, + {"user_id": 5, "handle": "deactivated", "handle_lc": "deactivated", "is_deactivated": true, "wallet": "0x0000000000000000000000000000000000000005"}, + {"user_id": 6, "handle": "onefave", "handle_lc": "onefave", "wallet": "0x0000000000000000000000000000000000000006"}, + {"user_id": 7, "handle": "albumowner", "handle_lc": "albumowner", "wallet": "0x0000000000000000000000000000000000000007"}, + {"user_id": 8, "handle": "unlistedonly", "handle_lc": "unlistedonly", "wallet": "0x0000000000000000000000000000000000000008"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "follower_count": 10}, + {"user_id": 2, "follower_count": 500}, + {"user_id": 3, "follower_count": 400}, + {"user_id": 4, "follower_count": 300}, + {"user_id": 5, "follower_count": 200}, + {"user_id": 6, "follower_count": 100}, + {"user_id": 7, "follower_count": 50}, + {"user_id": 8, "follower_count": 25}, + }, + "tracks": []map[string]any{ + {"track_id": 100, "owner_id": 1, "title": "my own track"}, + {"track_id": 200, "owner_id": 2, "title": "faved a"}, + {"track_id": 201, "owner_id": 2, "title": "faved b"}, + {"track_id": 300, "owner_id": 3, "title": "reposted"}, + {"track_id": 400, "owner_id": 4, "title": "faved but followed"}, + {"track_id": 500, "owner_id": 5, "title": "faved but deactivated"}, + {"track_id": 600, "owner_id": 6, "title": "faved once"}, + {"track_id": 800, "owner_id": 8, "title": "unlisted", "is_unlisted": true}, + }, + "playlists": []map[string]any{ + {"playlist_id": 700, "playlist_owner_id": 7, "playlist_name": "an album", "is_album": true}, + }, + "follows": []map[string]any{ + {"follower_user_id": 1, "followee_user_id": 4}, + }, + "saves": []map[string]any{ + {"user_id": 1, "save_item_id": 100, "save_type": "track"}, // self, excluded + {"user_id": 1, "save_item_id": 200, "save_type": "track"}, + {"user_id": 1, "save_item_id": 201, "save_type": "track"}, + {"user_id": 1, "save_item_id": 400, "save_type": "track"}, // already followed, excluded + {"user_id": 1, "save_item_id": 500, "save_type": "track"}, // deactivated, excluded + {"user_id": 1, "save_item_id": 600, "save_type": "track"}, + {"user_id": 1, "save_item_id": 700, "save_type": "album"}, + {"user_id": 1, "save_item_id": 800, "save_type": "track"}, // unlisted track, excluded + }, + "reposts": []map[string]any{ + {"user_id": 1, "repost_item_id": 300, "repost_type": "track"}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + var resp struct { + Data []dbv1.User + } + + // Ranking: user 2 has two favorites (2.0) > user 3's single repost (1.5) > + // the two single favorites (1.0 each), which tie and fall back to user_id. + { + status, _ := testGet(t, app, "/v1/users/7eP5n/suggested-follows", &resp) + assert.Equal(t, 200, status) + handles := make([]string, len(resp.Data)) + for i, u := range resp.Data { + handles[i] = u.Handle.String + } + assert.Equal(t, []string{"twofaves", "onerepost", "onefave", "albumowner"}, handles) + } + + // A repost outweighs a single favorite even though both users were engaged + // with exactly once. + { + status, _ := testGet(t, app, "/v1/users/7eP5n/suggested-follows?limit=2", &resp) + assert.Equal(t, 200, status) + assert.Len(t, resp.Data, 2) + assert.Equal(t, "twofaves", resp.Data[0].Handle.String) + assert.Equal(t, "onerepost", resp.Data[1].Handle.String) + } + + // Offset walks the same ordering rather than reshuffling it. + { + status, _ := testGet(t, app, "/v1/users/7eP5n/suggested-follows?limit=2&offset=2", &resp) + assert.Equal(t, 200, status) + assert.Len(t, resp.Data, 2) + assert.Equal(t, "onefave", resp.Data[0].Handle.String) + assert.Equal(t, "albumowner", resp.Data[1].Handle.String) + } + + // A user with no favorites or reposts gets nothing rather than an error -- + // the caller is expected to fall back to a non-personalized surface. + { + status, _ := testGet(t, app, "/v1/users/ML51L/suggested-follows", &resp) + assert.Equal(t, 200, status) + assert.Len(t, resp.Data, 0) + } +} + +// The decay term is the only reason a single favorite can outrank another +// single favorite, so it needs a case of its own -- the fixtures above all +// share a created_at and would pass with the decay removed entirely. +func TestV1UsersSuggestedFollowsRecencyDecay(t *testing.T) { + app := emptyTestApp(t) + + longAgo := time.Now().AddDate(-2, 0, 0) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "rayjacobson", "handle_lc": "rayjacobson", "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060"}, + {"user_id": 2, "handle": "stale", "handle_lc": "stale", "wallet": "0x0000000000000000000000000000000000000002"}, + {"user_id": 3, "handle": "fresh", "handle_lc": "fresh", "wallet": "0x0000000000000000000000000000000000000003"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "follower_count": 10}, + {"user_id": 2, "follower_count": 10}, + {"user_id": 3, "follower_count": 10}, + }, + "tracks": []map[string]any{ + {"track_id": 200, "owner_id": 2, "title": "old favorite"}, + {"track_id": 300, "owner_id": 3, "title": "new favorite"}, + }, + "saves": []map[string]any{ + {"user_id": 1, "save_item_id": 200, "save_type": "track", "created_at": longAgo}, + {"user_id": 1, "save_item_id": 300, "save_type": "track", "created_at": time.Now()}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + var resp struct { + Data []dbv1.User + } + + // Equal raw engagement (one favorite each), so recency alone decides. User 2 + // sorts first on user_id, which makes this fail loudly if decay stops working. + status, _ := testGet(t, app, "/v1/users/7eP5n/suggested-follows", &resp) + assert.Equal(t, 200, status) + assert.Len(t, resp.Data, 2) + assert.Equal(t, "fresh", resp.Data[0].Handle.String) + assert.Equal(t, "stale", resp.Data[1].Handle.String) +}