diff --git a/api/server.go b/api/server.go index 70496829..37add5b0 100644 --- a/api/server.go +++ b/api/server.go @@ -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() @@ -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, @@ -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) @@ -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 diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 80b523d7..cd786894 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -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: diff --git a/api/v1_users_discover_weekly.go b/api/v1_users_weekly_rotation.go similarity index 93% rename from api/v1_users_discover_weekly.go rename to api/v1_users_weekly_rotation.go index 359b9a79..7e9da475 100644 --- a/api/v1_users_discover_weekly.go +++ b/api/v1_users_weekly_rotation.go @@ -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"` } @@ -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: @@ -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 @@ -96,8 +96,8 @@ 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, ¶ms); err != nil { return err } @@ -105,9 +105,9 @@ func (app *ApiServer) v1UsersDiscoverWeekly(c *fiber.Ctx) error { 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, @@ -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 } @@ -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 @@ -395,6 +395,6 @@ func (app *ApiServer) getDiscoverWeeklyTrackIds( return nil, err } - app.discoverWeeklyCache.Set(cacheKey, ids) + app.weeklyRotationCache.Set(cacheKey, ids) return ids, nil } diff --git a/api/v1_users_discover_weekly_test.go b/api/v1_users_weekly_rotation_test.go similarity index 92% rename from api/v1_users_discover_weekly_test.go rename to api/v1_users_weekly_rotation_test.go index e2dc4bdf..58f76e70 100644 --- a/api/v1_users_discover_weekly_test.go +++ b/api/v1_users_weekly_rotation_test.go @@ -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. @@ -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) } @@ -123,7 +123,7 @@ 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 @@ -131,18 +131,18 @@ func discoverWeeklyTitles(tracks []dbv1.Track) []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. @@ -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{ @@ -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) @@ -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{ @@ -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) @@ -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{ @@ -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") @@ -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) } @@ -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{ @@ -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")