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
10 changes: 5 additions & 5 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,13 @@ func NewApiServer(config config.Config) *ApiServer {
panic(err)
}

// Caches the track-id list returned by the /v1/users/:userId/discover-weekly
// Caches the track-id list returned by the /v1/users/:userId/weekly-rotation
// query. The mix is deterministic for the whole ISO week and the cache key
// carries the year/week, so entries are immutable for their lifetime and a
// long TTL is safe — a stale entry is the correct answer, not a stale one.
// Sized larger than the other recommendation caches because this is the
// most expensive query of the three and the least likely to be re-derived.
discoverWeeklyCache, err := otter.MustBuilder[string, []int32](50_000).
weeklyRotationCache, err := otter.MustBuilder[string, []int32](50_000).
WithTTL(6 * time.Hour).
CollectStats().
Build()
Expand Down Expand Up @@ -321,7 +321,7 @@ func NewApiServer(config config.Config) *ApiServer {
qualifiedPlaylistsCache: &qualifiedPlaylistsCache,
relatedUsersCache: &relatedUsersCache,
suggestedFollowsCache: &suggestedFollowsCache,
discoverWeeklyCache: &discoverWeeklyCache,
weeklyRotationCache: &weeklyRotationCache,
genresPopularCache: &genresPopularCache,
sitemapXMLCache: &sitemapXMLCache,
requestValidator: requestValidator,
Expand Down Expand Up @@ -501,7 +501,7 @@ func NewApiServer(config config.Config) *ApiServer {
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/discover-weekly", app.v1UsersDiscoverWeekly)
g.Get("/users/:userId/weekly-rotation", app.v1UsersWeeklyRotation)
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 @@ -883,7 +883,7 @@ type ApiServer struct {
qualifiedPlaylistsCache *otter.Cache[string, []int32]
relatedUsersCache *otter.Cache[string, []int32]
suggestedFollowsCache *otter.Cache[string, []int32]
discoverWeeklyCache *otter.Cache[string, []int32]
weeklyRotationCache *otter.Cache[string, []int32]
genresPopularCache *otter.Cache[string, []PopularGenre]
sitemapXMLCache *otter.Cache[string, sitemapXMLCacheEntry]
requestValidator *RequestValidator
Expand Down
6 changes: 3 additions & 3 deletions api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7107,17 +7107,17 @@ paths:
"500":
description: Server error
content: {}
/users/{id}/discover-weekly:
/users/{id}/weekly-rotation:
get:
tags:
- users
description:
Gets the user's Discover Weekly mix - a personalized set of tracks
Gets the user's Weekly Rotation mix - a personalized set of tracks
they have not heard, weighted toward artists they do not already
follow. The mix is fixed for the calendar week (ISO week, UTC) and
rotates when the week rolls over. Unlike suggested-follows, this
returns results for users with no listening history.
operationId: Get Discover Weekly
operationId: Get Weekly Rotation
security:
- {}
- OAuth2:
Expand Down
38 changes: 19 additions & 19 deletions api/v1_users_discover_weekly.go → api/v1_users_weekly_rotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/jackc/pgx/v5"
)

type GetUsersDiscoverWeeklyParams struct {
type GetUsersWeeklyRotationParams struct {
Limit int `query:"limit" default:"30" validate:"min=1,max=50"`
}

Expand All @@ -19,20 +19,20 @@ const (
// allowed to reach much further back than the For You feed (48h
// half-life), but a track from 2019 that never found an audience is
// usually not a hidden gem — it's an abandoned upload.
discoverWeeklyMaxAgeDays = 365
weeklyRotationMaxAgeDays = 365

// Week-seeded jitter band. Scores across the candidate pool are tightly
// clustered, so without a deterministic per-week perturbation the same
// user would get a near-identical mix every week. +/-15% is enough to
// rotate the ordering among comparable candidates without letting a weak
// track outrank a genuinely better one.
discoverWeeklyJitterFloor = 0.85
discoverWeeklyJitterRange = 0.30
weeklyRotationJitterFloor = 0.85
weeklyRotationJitterRange = 0.30
)

/*
Returns a fixed-size, taste-matched track mix that is stable for the
calendar week — the "Discover Weekly" surface.
calendar week — the "Weekly Rotation" surface.

Distinct from GET /v1/users/{id}/feed/for-you in three ways that matter:

Expand Down Expand Up @@ -81,7 +81,7 @@ FILTERS. Track liveness (is_delete / is_unlisted / is_available /
stem_of), owner liveness (is_deactivated / is_available), gated tracks
excluded entirely (a mix the listener can't play through is worse than a
shorter mix), own uploads, anything played, anything saved, and anything
older than discoverWeeklyMaxAgeDays.
older than weeklyRotationMaxAgeDays.

DIVERSITY. One track per artist, hard. For You allows 3 because a feed is
expected to show you more from someone you follow; a 30-track mix with two
Expand All @@ -96,18 +96,18 @@ Query params:
- user_id (optional): the caller, for viewer-relative fields on the
returned tracks. Independent of the path id, same as elsewhere.
*/
func (app *ApiServer) v1UsersDiscoverWeekly(c *fiber.Ctx) error {
params := GetUsersDiscoverWeeklyParams{}
func (app *ApiServer) v1UsersWeeklyRotation(c *fiber.Ctx) error {
params := GetUsersWeeklyRotationParams{}
if err := app.ParseAndValidateQueryParams(c, &params); err != nil {
return err
}

userId := app.getUserId(c)
myId := app.getMyId(c)

year, week := discoverWeeklyPeriod(time.Now().UTC())
year, week := weeklyRotationPeriod(time.Now().UTC())

trackIds, err := app.getDiscoverWeeklyTrackIds(
trackIds, err := app.getWeeklyRotationTrackIds(
c.Context(),
userId,
year,
Expand All @@ -133,22 +133,22 @@ func (app *ApiServer) v1UsersDiscoverWeekly(c *fiber.Ctx) error {
return v1TracksResponse(c, tracks)
}

// discoverWeeklyPeriod returns the ISO year and ISO week that `t` falls in.
// weeklyRotationPeriod returns the ISO year and ISO week that `t` falls in.
// The mix is keyed on this pair, so it changes exactly once a week at the
// ISO week boundary (Monday 00:00 UTC).
func discoverWeeklyPeriod(t time.Time) (int, int) {
func weeklyRotationPeriod(t time.Time) (int, int) {
return t.ISOWeek()
}

func (app *ApiServer) getDiscoverWeeklyTrackIds(
func (app *ApiServer) getWeeklyRotationTrackIds(
ctx context.Context,
userId int32,
year int,
week int,
limit int,
) ([]int32, error) {
cacheKey := fmt.Sprintf("discover_weekly:%d:%d:%d:%d", userId, year, week, limit)
if hit, ok := app.discoverWeeklyCache.Get(cacheKey); ok {
cacheKey := fmt.Sprintf("weekly_rotation:%d:%d:%d:%d", userId, year, week, limit)
if hit, ok := app.weeklyRotationCache.Get(cacheKey); ok {
return hit, nil
}

Expand Down Expand Up @@ -383,9 +383,9 @@ func (app *ApiServer) getDiscoverWeeklyTrackIds(
"userId": userId,
"seedKey": fmt.Sprintf("%d:%d:%d", userId, year, week),
"limit": limit,
"maxAgeDays": discoverWeeklyMaxAgeDays,
"jitterFloor": discoverWeeklyJitterFloor,
"jitterRange": discoverWeeklyJitterRange,
"maxAgeDays": weeklyRotationMaxAgeDays,
"jitterFloor": weeklyRotationJitterFloor,
"jitterRange": weeklyRotationJitterRange,
})
if err != nil {
return nil, err
Expand All @@ -395,6 +395,6 @@ func (app *ApiServer) getDiscoverWeeklyTrackIds(
return nil, err
}

app.discoverWeeklyCache.Set(cacheKey, ids)
app.weeklyRotationCache.Set(cacheKey, ids)
return ids, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/require"
)

// discoverWeeklyFixtures builds a graph covering every filter and both
// weeklyRotationFixtures builds a graph covering every filter and both
// candidate sources.
//
// user 1 = me (the viewer). Plays rock, so rock is my affinity genre.
Expand All @@ -24,7 +24,7 @@ import (
// user 8 = artist with a gated track -> filtered
// user 9 = artist with an ancient track-> filtered (age)
// user 10 = artist with two good tracks -> one-per-artist cap
func discoverWeeklyFixtures() database.FixtureMap {
func weeklyRotationFixtures() database.FixtureMap {
now := time.Now()
daysAgo := func(d int) time.Time { return now.AddDate(0, 0, -d) }

Expand Down Expand Up @@ -123,26 +123,26 @@ func discoverWeeklyFixtures() database.FixtureMap {
}

// titles pulls the track titles out of a response, in order.
func discoverWeeklyTitles(tracks []dbv1.Track) []string {
func weeklyRotationTitles(tracks []dbv1.Track) []string {
out := make([]string, len(tracks))
for i, t := range tracks {
out[i] = t.Title.String
}
return out
}

func TestV1UsersDiscoverWeekly(t *testing.T) {
func TestV1UsersWeeklyRotation(t *testing.T) {
app := emptyTestApp(t)
database.Seed(app.pool.Replicas[0], discoverWeeklyFixtures())
database.Seed(app.pool.Replicas[0], weeklyRotationFixtures())

var resp struct {
Data []dbv1.Track
}

status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &resp)
status, _ := testGet(t, app, "/v1/users/7eP5n/weekly-rotation", &resp)
assert.Equal(t, 200, status)

titles := discoverWeeklyTitles(resp.Data)
titles := weeklyRotationTitles(resp.Data)

// Every filter, asserted as absence rather than ordering so the week
// jitter can't make this flaky.
Expand Down Expand Up @@ -176,7 +176,7 @@ func TestV1UsersDiscoverWeekly(t *testing.T) {
// (0.70 vs 1.25, a 1.79x ratio) is wider than the jitter band can close
// (1.35x at the extremes), so this ordering is guaranteed rather than
// merely likely.
func TestV1UsersDiscoverWeeklyDemotesFollowedArtists(t *testing.T) {
func TestV1UsersWeeklyRotationDemotesFollowedArtists(t *testing.T) {
app := emptyTestApp(t)

fixtures := database.FixtureMap{
Expand Down Expand Up @@ -213,7 +213,7 @@ func TestV1UsersDiscoverWeeklyDemotesFollowedArtists(t *testing.T) {
var resp struct {
Data []dbv1.Track
}
status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &resp)
status, _ := testGet(t, app, "/v1/users/7eP5n/weekly-rotation", &resp)
assert.Equal(t, 200, status)
require.Len(t, resp.Data, 2)

Expand All @@ -227,7 +227,7 @@ func TestV1UsersDiscoverWeeklyDemotesFollowedArtists(t *testing.T) {
// This is the case that separates the surface from suggested-follows, which
// correctly returns nothing for a cold account: a mix that is empty on
// first open has no reason to exist.
func TestV1UsersDiscoverWeeklyColdStart(t *testing.T) {
func TestV1UsersWeeklyRotationColdStart(t *testing.T) {
app := emptyTestApp(t)

fixtures := database.FixtureMap{
Expand All @@ -254,7 +254,7 @@ func TestV1UsersDiscoverWeeklyColdStart(t *testing.T) {
var resp struct {
Data []dbv1.Track
}
status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &resp)
status, _ := testGet(t, app, "/v1/users/7eP5n/weekly-rotation", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 1, "no listening history still yields a mix")
assert.Equal(t, "a track", resp.Data[0].Title.String)
Expand All @@ -264,10 +264,10 @@ func TestV1UsersDiscoverWeeklyColdStart(t *testing.T) {
// halves matter: the first is the product promise, the second is the only
// thing keeping the mix from being the same 30 tracks forever.
//
// Goes through getDiscoverWeeklyTrackIds rather than the HTTP handler
// Goes through getWeeklyRotationTrackIds rather than the HTTP handler
// because the handler derives the period from the wall clock, and the point
// here is to vary it.
func TestV1UsersDiscoverWeeklyStableWithinWeek(t *testing.T) {
func TestV1UsersWeeklyRotationStableWithinWeek(t *testing.T) {
app := emptyTestApp(t)

fixtures := database.FixtureMap{
Expand Down Expand Up @@ -311,26 +311,26 @@ func TestV1UsersDiscoverWeeklyStableWithinWeek(t *testing.T) {

ctx := context.Background()

weekA1, err := app.getDiscoverWeeklyTrackIds(ctx, 1, 2026, 10, 20)
weekA1, err := app.getWeeklyRotationTrackIds(ctx, 1, 2026, 10, 20)
require.NoError(t, err)
require.NotEmpty(t, weekA1)

// Same period, recomputed: byte-identical.
app.discoverWeeklyCache.Clear()
weekA2, err := app.getDiscoverWeeklyTrackIds(ctx, 1, 2026, 10, 20)
app.weeklyRotationCache.Clear()
weekA2, err := app.getWeeklyRotationTrackIds(ctx, 1, 2026, 10, 20)
require.NoError(t, err)
assert.Equal(t, weekA1, weekA2,
"the mix is deterministic for a given (user, year, week)")

// Next week: same candidates, different mix.
weekB, err := app.getDiscoverWeeklyTrackIds(ctx, 1, 2026, 11, 20)
weekB, err := app.getWeeklyRotationTrackIds(ctx, 1, 2026, 11, 20)
require.NoError(t, err)
require.NotEmpty(t, weekB)
assert.NotEqual(t, weekA1, weekB,
"the week seed rotates the mix when the week rolls over")

// And a different listener gets a different mix in the same week.
weekAOther, err := app.getDiscoverWeeklyTrackIds(ctx, 2, 2026, 10, 20)
weekAOther, err := app.getWeeklyRotationTrackIds(ctx, 2, 2026, 10, 20)
require.NoError(t, err)
assert.NotEqual(t, weekA1, weekAOther,
"the seed is per-listener, not global")
Expand All @@ -352,12 +352,12 @@ func padWallet(n int) string {

// The path :userId goes through requireUserIdMiddleware, so a junk hash id
// is a 400 rather than a silent fallback to user 0.
func TestV1UsersDiscoverWeeklyRequiresValidUserId(t *testing.T) {
func TestV1UsersWeeklyRotationRequiresValidUserId(t *testing.T) {
app := emptyTestApp(t)
var resp struct {
Data []dbv1.Track
}
status, _ := testGet(t, app, "/v1/users/not-a-real-id/discover-weekly", &resp)
status, _ := testGet(t, app, "/v1/users/not-a-real-id/weekly-rotation", &resp)
assert.Equal(t, 400, status)
}

Expand All @@ -373,7 +373,7 @@ func TestV1UsersDiscoverWeeklyRequiresValidUserId(t *testing.T) {
// Every other test here seeds score rows without a genre, so none of them
// could catch it. This one seeds a genre-carrying row specifically -- the
// shape that actually reaches the query in production.
func TestV1UsersDiscoverWeeklyReadsGenreCarryingTrendingRows(t *testing.T) {
func TestV1UsersWeeklyRotationReadsGenreCarryingTrendingRows(t *testing.T) {
app := emptyTestApp(t)

fixtures := database.FixtureMap{
Expand All @@ -396,7 +396,7 @@ func TestV1UsersDiscoverWeeklyReadsGenreCarryingTrendingRows(t *testing.T) {
var resp struct {
Data []dbv1.Track
}
status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &resp)
status, _ := testGet(t, app, "/v1/users/7eP5n/weekly-rotation", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 1,
"a score row carrying a genre must still be a candidate")
Expand Down
Loading