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
15 changes: 15 additions & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -294,6 +306,7 @@ func NewApiServer(config config.Config) *ApiServer {
oauthTokenCache: &oauthTokenCache,
qualifiedPlaylistsCache: &qualifiedPlaylistsCache,
relatedUsersCache: &relatedUsersCache,
suggestedFollowsCache: &suggestedFollowsCache,
genresPopularCache: &genresPopularCache,
sitemapXMLCache: &sitemapXMLCache,
requestValidator: requestValidator,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
195 changes: 195 additions & 0 deletions api/v1_users_suggested_follows.go
Original file line number Diff line number Diff line change
@@ -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, &params); 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
}
Loading
Loading