Skip to content

feat(api): suggest follows from a user's favorites and reposts - #1020

Merged
dylanjeffers merged 1 commit into
mainfrom
feat/suggested-follows
Aug 18, 2026
Merged

feat(api): suggest follows from a user's favorites and reposts#1020
dylanjeffers merged 1 commit into
mainfrom
feat/suggested-follows

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Adds GET /v1/users/:userId/suggested-follows — artists whose tracks or albums the user has favorited or reposted but whom they don't already follow.

This is the viewer-anchored counterpart to /users/:userId/related, which is artist-anchored ("followers of X also follow Y"). It needs no graph traversal: the candidates are already engaged with, so they justify themselves. "You saved three of their tracks and never followed them" is both the cheapest suggestion available and the most legible one.

Today "suggested follows" on the clients is a static JSON list of handles from SUGGESTED_FOLLOW_HANDLES. This is the first personalized source for that surface.

Scoring

Engagement is summed per artist, weighting a repost (1.5) above a favorite (1.0) — a repost is a public endorsement, a favorite is private — and decaying each on a 180-day exponential so dormant taste doesn't outrank current taste. Ties break on user_id so pagination is stable across pages.

Excluded: self, already-followed, deactivated, unavailable, unlisted tracks, stems, and private playlists.

Two details worth a look in review

  • The engagement scan is capped at the 2000 most recent favorites and reposts. Everything downstream joins per-row, so uncapped this scales with a heavy user's entire library. Recent engagement is also the better signal, so the cap costs little.
  • The decay clock is now() AT TIME ZONE 'utc', not bare now(). created_at is timestamp without time zone, so the plain form makes the computed age depend on the server's timezone.

Cached separately from relatedUsersCache at a shorter 5 min TTL: this list is per-viewer and shrinks as the user acts on it, so a follow should drop out of their suggestions promptly.

Testing

Two tests, both against real Postgres. The first covers ranking, per-artist aggregation, album favorites, pagination, the empty-engagement case, and each exclusion rule. The second isolates recency decay — every fixture in the first shares a created_at and would pass with the decay deleted, so it needs its own case. I verified it fails when the decay term is stubbed to 1.0.

Full ./api/... suite passes locally (with db and elasticsearch-test up).

Follow-ups, not in this PR

  • The client side is a separate PR against audius-protocolmerge and deploy this one first, or sdk.users.getSuggestedFollows 404s there.
  • Phase 2 is the collaborative-filter pass (co-favoriters of my items → who they follow), which needs hard fan-out caps or a precompute job. Deliberately excluded here.
  • The response is a plain user array, so there's no way to say why an artist was suggested. "Because you favorited X" would need a shape change and is probably most of the legibility win — worth deciding before this is widely surfaced.

🤖 Generated with Claude Code

Adds GET /v1/users/:userId/suggested-follows: artists whose tracks or
albums the user has favorited or reposted but whom they don't already
follow.

Distinct from /users/:userId/related, which is artist-anchored
("followers of X also follow Y"). This one is viewer-anchored, and needs
no graph traversal -- the candidates are already engaged with, so they
justify themselves.

Scoring sums engagement per artist, weighting a repost (1.5) above a
favorite (1.0) since a repost is a public endorsement, and decaying each
on a 180-day exponential so dormant taste doesn't outrank current taste.
Ties break on user_id to keep pagination stable.

Two details worth knowing:

- The engagement scan is capped at the 2000 most recent favorites and
  reposts. Everything downstream joins per-row, so uncapped this would
  scale with a heavy user's entire library.
- The decay clock is `now() AT TIME ZONE 'utc'`, not bare `now()`.
  created_at is `timestamp without time zone`, so the plain form makes
  the computed age depend on the server's timezone.

Cached separately from relatedUsersCache at a shorter 5min TTL: this
list is per-viewer and shrinks as the user acts on it, so a follow
should drop out of their suggestions promptly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dylanjeffers
dylanjeffers merged commit bde1a8b into main Aug 18, 2026
2 checks passed
@dylanjeffers
dylanjeffers deleted the feat/suggested-follows branch August 18, 2026 22:03
dylanjeffers added a commit to AudiusProject/apps that referenced this pull request Aug 18, 2026
Wires GET /users/:id/suggested-follows into web's empty feed and mobile's SuggestedFollows via a shared useFollowSuggestions hook: personalized when the user has favorites/reposts, existing static SUGGESTED_FOLLOW_HANDLES list when they don't.

sdk.users.getSuggestedFollows is hand-written pending the next SDK regen, since npm run gen pulls the spec from a running node.

Depends on AudiusProject/api#1020 (merged).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant