From 47a9411f97cc31aa0b3be4c3a6b189c3636b4028 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 24 Aug 2026 13:28:29 -0700 Subject: [PATCH] feat(api): add Discover Weekly mix endpoint GET /v1/users/:id/discover-weekly returns a fixed-size, taste-matched track mix: tracks the listener hasn't heard, weighted toward artists they don't already follow. Deliberately not a stored playlist. Audius playlists are on-chain entities, so minting one per user per week isn't viable; instead the query is deterministic given (user_id, iso_year, iso_week) and a hashtextextended seed on that tuple rotates the mix weekly without random(). Cached 6h -- entries are immutable for their key, so the TTL only bounds memory. Reuses the For You feed's genre-affinity and engagement terms but inverts the in-network weight (followed 0.70 vs unfollowed 1.25), hard-excludes played/saved rather than soft-penalizing, drops the recency half-life, and caps one track per artist. The product difference from /feed/for-you is artifact-vs-feed, not ranking quality. Known limitation: the played-exclusion has no upper time bound, so the mix can shrink mid-week as the listener plays through it once the cache expires. Fixing that properly needs a stored track list per (user, year, week), which also gives the mix a URL. Tracked as follow-up. Not yet profiled against production-scale data. Co-Authored-By: Claude Opus 5 --- api/server.go | 17 ++ api/swagger/swagger-v1.yaml | 45 +++ api/v1_users_discover_weekly.go | 393 +++++++++++++++++++++++++++ api/v1_users_discover_weekly_test.go | 362 ++++++++++++++++++++++++ 4 files changed, 817 insertions(+) create mode 100644 api/v1_users_discover_weekly.go create mode 100644 api/v1_users_discover_weekly_test.go diff --git a/api/server.go b/api/server.go index 915bf393..70496829 100644 --- a/api/server.go +++ b/api/server.go @@ -170,6 +170,20 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } + // Caches the track-id list returned by the /v1/users/:userId/discover-weekly + // 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). + WithTTL(6 * time.Hour). + 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 @@ -307,6 +321,7 @@ func NewApiServer(config config.Config) *ApiServer { qualifiedPlaylistsCache: &qualifiedPlaylistsCache, relatedUsersCache: &relatedUsersCache, suggestedFollowsCache: &suggestedFollowsCache, + discoverWeeklyCache: &discoverWeeklyCache, genresPopularCache: &genresPopularCache, sitemapXMLCache: &sitemapXMLCache, requestValidator: requestValidator, @@ -486,6 +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/supporting", app.v1UsersSupporting) g.Get("/users/:userId/supporting/:supportedUserId", app.v1UsersSupporting) g.Get("/users/:userId/supporters", app.v1UsersSupporters) @@ -867,6 +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] 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 8e462193..80b523d7 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -7107,6 +7107,51 @@ paths: "500": description: Server error content: {} + /users/{id}/discover-weekly: + get: + tags: + - users + description: + Gets the user's Discover Weekly 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 + security: + - {} + - OAuth2: + - read + parameters: + - name: id + in: path + description: A User ID + required: true + schema: + type: string + - name: limit + in: query + description: The number of tracks to fetch (default 30, max 50) + 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/tracks_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /users/{id}/suggested-follows: get: tags: diff --git a/api/v1_users_discover_weekly.go b/api/v1_users_discover_weekly.go new file mode 100644 index 00000000..c8763957 --- /dev/null +++ b/api/v1_users_discover_weekly.go @@ -0,0 +1,393 @@ +package api + +import ( + "context" + "fmt" + "time" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetUsersDiscoverWeeklyParams struct { + Limit int `query:"limit" default:"30" validate:"min=1,max=50"` +} + +const ( + // Tracks older than this are excluded outright. A discovery mix is + // 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 + + // 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 +) + +/* +Returns a fixed-size, taste-matched track mix that is stable for the +calendar week — the "Discover Weekly" surface. + +Distinct from GET /v1/users/{id}/feed/for-you in three ways that matter: + + - For You is a *feed*: freshness-weighted (48h half-life), re-ranked on + every load, infinite. This is an *artifact*: a fixed 30 tracks that do + not change until the week rolls over, so it can be linked, revisited, + and talked about. + - For You boosts in-network (followed) creators. This demotes them. The + point of the mix is artists the listener hasn't found yet, so a + followed artist has to clear a higher bar to appear. + - For You soft-penalizes tracks you've already heard. This excludes them + outright, along with anything you've saved. A mix with a track you + already know in it reads as broken. + +STABILITY. There is no precompute job and no stored playlist. Audius +playlists are on-chain entities, so minting one per user per week is not +on the table; instead the query is fully deterministic given +(user_id, iso_year, iso_week) and the result is cached until the week +rolls. Nothing here uses random() — the week-to-week variation comes from +`week_seed` below, which is a hash of (track_id, user_id, year, week). +Same inputs, same mix, all week. + +SCORING. + + quality_score = ln(1 + 3*saves + 2*reposts + 1*plays) / 12 + // same log-compressed engagement blend as For You: + // saves > reposts > plays. + genre_affinity = 0.85 + 0.45 * min(genre_share / 0.30, 1) + // genre_share is the fraction of my recent plays in + // the track's genre. Carried over from For You + // unchanged — this is the taste signal. + discovery_wt = {not followed, low affinity: 1.25, + not followed, some affinity: 1.00, + followed: 0.70} + // inverted relative to For You's in-network boost. + source_weight = {underground: 1.15, trending: 1.00} + // underground is upweighted here; the whole surface + // exists to promote things the listener wouldn't have + // stumbled into on the trending page. + week_seed = 0.85 + 0.30 * hash01(track_id, user_id, year, week) + + final_score = quality_score * genre_affinity * discovery_wt + * source_weight * week_seed + +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. + +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 +tracks from the same artist has wasted a slot. + +Path: + - id (required): the user being personalized for. Resolved by + requireUserIdMiddleware. + +Query params: + - limit (default 30, max 50) + - 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{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + userId := app.getUserId(c) + myId := app.getMyId(c) + + year, week := discoverWeeklyPeriod(time.Now().UTC()) + + trackIds, err := app.getDiscoverWeeklyTrackIds( + c.Context(), + userId, + year, + week, + params.Limit, + ) + if err != nil { + return err + } + + // Tracks returns in the order of the id list, which is the product here. + tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIds, + MyID: myId, + AuthedWallet: app.tryGetAuthedWallet(c), + }, + }) + if err != nil { + return err + } + + return v1TracksResponse(c, tracks) +} + +// discoverWeeklyPeriod 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) { + return t.ISOWeek() +} + +func (app *ApiServer) getDiscoverWeeklyTrackIds( + 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 { + return hit, nil + } + + sql := ` + WITH + -- Everything the listener has already heard. Unlike the For You feed, + -- which soft-penalizes repeats, these are excluded outright, so the + -- window is wider than that endpoint's 14 days. Still bounded: a heavy + -- listener has hundreds of thousands of play rows and an unbounded scan + -- is what put the older recommendation endpoints over the upstream + -- timeout (see PRs #805, #806). + my_played AS ( + SELECT DISTINCT play_item_id AS track_id + FROM ( + SELECT play_item_id + FROM plays + WHERE user_id = @userId + ORDER BY created_at DESC + LIMIT 10000 + ) p + ), + my_saved AS ( + SELECT save_item_id AS track_id + FROM saves + WHERE user_id = @userId + AND save_type = 'track' + AND is_current = true + AND is_delete = false + ), + -- Capped the same way as the For You feed's follow_set, and for the + -- same reason: a power user with thousands of follows otherwise pulls a + -- hash table wide enough to stall the planner on the join below. + follow_set AS ( + SELECT followee_user_id AS user_id + FROM follows + WHERE follower_user_id = @userId + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT 500 + ), + -- Genre mix of recent listening. Identical to the For You feed's + -- my_genre_affinity — this is the part of the taste model the two + -- surfaces genuinely share. + my_genre_affinity AS ( + SELECT t.genre, + COUNT(*)::double precision / SUM(COUNT(*)) OVER () AS share + FROM ( + SELECT play_item_id AS track_id + FROM plays + WHERE user_id = @userId + ORDER BY created_at DESC + LIMIT 1000 + ) p + JOIN tracks t ON t.track_id = p.track_id + WHERE t.genre IS NOT NULL AND t.genre <> '' + GROUP BY t.genre + ), + -- Owners the listener already engages with. Used to demote, not boost: + -- an artist whose tracks they already save is by definition not a + -- discovery. Bounded by recency like the For You affinity CTE. + my_artist_affinity AS ( + SELECT owner_id AS artist_id + FROM ( + SELECT t.owner_id + FROM ( + SELECT save_item_id AS track_id FROM saves + WHERE user_id = @userId AND save_type = 'track' + AND is_current = true AND is_delete = false + ORDER BY created_at DESC + LIMIT 200 + ) s + JOIN tracks t ON t.track_id = s.track_id + + UNION ALL + + SELECT t.owner_id + FROM ( + SELECT repost_item_id AS track_id FROM reposts + WHERE user_id = @userId AND repost_type = 'track' + AND is_current = true AND is_delete = false + ORDER BY created_at DESC + LIMIT 200 + ) r + JOIN tracks t ON t.track_id = r.track_id + ) eng + GROUP BY owner_id + ), + -- Source 1: weekly trending tracks. + cand_trending AS ( + SELECT tts.track_id, 'trending'::text AS source + FROM track_trending_scores tts + WHERE tts.type = 'TRACKS' + AND tts.version = 'pnagD' + AND tts.time_range = 'week' + AND (tts.genre IS NULL OR tts.genre = '') + ORDER BY tts.score DESC, tts.track_id DESC + LIMIT 400 + ), + -- Source 2: the same trending slice restricted to small creators. The + -- mirror of GET /tracks/trending/underground, and upweighted below. + cand_underground AS ( + SELECT tts.track_id, 'underground'::text AS source + FROM track_trending_scores tts + JOIN tracks t ON t.track_id = tts.track_id + JOIN aggregate_user au ON au.user_id = t.owner_id + WHERE tts.type = 'TRACKS' + AND tts.version = 'pnagD' + AND tts.time_range = 'week' + AND (tts.genre IS NULL OR tts.genre = '') + AND au.follower_count < 1500 + AND au.following_count < 1500 + ORDER BY tts.score DESC, tts.track_id DESC + LIMIT 300 + ), + candidates AS ( + SELECT track_id, source FROM cand_underground + UNION ALL + SELECT track_id, source FROM cand_trending + ), + -- One row per track. Underground sorts before trending so a track that + -- qualifies for both keeps the upweighted source. + deduped AS ( + SELECT DISTINCT ON (track_id) track_id, source + FROM candidates + ORDER BY track_id, source ASC + ), + filtered AS ( + SELECT + t.track_id, + t.owner_id, + t.genre, + d.source, + ga.share AS genre_share, + (fs.user_id IS NOT NULL) AS is_followed, + (aa.artist_id IS NOT NULL) AS has_affinity, + COALESCE(at.save_count, 0) AS save_count, + COALESCE(at.repost_count, 0) AS repost_count, + COALESCE(ap.count, 0) AS play_count + FROM deduped d + JOIN tracks t ON t.track_id = d.track_id + JOIN users u ON u.user_id = t.owner_id + LEFT JOIN aggregate_track at ON at.track_id = t.track_id + LEFT JOIN aggregate_plays ap ON ap.play_item_id = t.track_id + LEFT JOIN my_genre_affinity ga ON ga.genre = t.genre + LEFT JOIN follow_set fs ON fs.user_id = t.owner_id + LEFT JOIN my_artist_affinity aa ON aa.artist_id = t.owner_id + WHERE 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 + -- Gated tracks are dropped rather than surfaced-and-locked: a mix + -- the listener can't play straight through is worse than a short one. + AND t.is_stream_gated = false + AND t.created_at >= NOW() - MAKE_INTERVAL(days => @maxAgeDays::int) + AND t.owner_id <> @userId + AND u.is_current = true + AND u.is_deactivated = false + AND u.is_available = true + AND NOT EXISTS (SELECT 1 FROM my_played mp WHERE mp.track_id = t.track_id) + AND NOT EXISTS (SELECT 1 FROM my_saved ms WHERE ms.track_id = t.track_id) + ), + scored AS ( + SELECT + track_id, + owner_id, + LN(1 + 3 * save_count + 2 * repost_count + 1 * play_count) / 12.0 + AS quality_score, + CASE + WHEN genre IS NULL OR genre = '' THEN 1.00 + WHEN NOT EXISTS (SELECT 1 FROM my_genre_affinity) THEN 1.00 + WHEN genre_share IS NULL THEN 0.85 + ELSE 0.85 + 0.45 * LEAST(genre_share / 0.30, 1.0) + END AS genre_affinity, + CASE + WHEN is_followed THEN 0.70 + WHEN has_affinity THEN 1.00 + ELSE 1.25 + END AS discovery_weight, + CASE WHEN source = 'underground' THEN 1.15 ELSE 1.00 END + AS source_weight, + -- hashtextextended is stable across sessions and servers, unlike + -- hashtext's platform-dependent variants, so every API node + -- computes the same mix for the same week. abs() then a mod into + -- [0,1): a plain (x % n) can be negative for negative x. + -- + -- The listener/period half of the seed arrives pre-formatted as + -- @seedKey rather than as separate int params: pgx infers one type + -- per named arg, and @userId is already pinned to int by the + -- equality predicates above, so casting it to text here would + -- conflict. + @jitterFloor::float8 + @jitterRange::float8 * ( + (ABS(HASHTEXTEXTENDED( + track_id::text || ':' || @seedKey::text, 0 + )) % 1000000)::float8 / 1000000.0 + ) AS week_seed + FROM filtered + ), + final_scored AS ( + SELECT + track_id, + owner_id, + quality_score * genre_affinity * discovery_weight + * source_weight * week_seed AS score + FROM scored + ), + -- One track per artist, hard. + capped AS ( + SELECT track_id, owner_id, score, + ROW_NUMBER() OVER ( + PARTITION BY owner_id ORDER BY score DESC, track_id DESC + ) AS rn_artist + FROM final_scored + ) + SELECT track_id + FROM capped + WHERE rn_artist = 1 + -- track_id breaks score ties so the mix is byte-stable for the week. + ORDER BY score DESC, track_id DESC + LIMIT @limit + ` + + rows, err := app.pool.Query(ctx, sql, pgx.NamedArgs{ + "userId": userId, + "seedKey": fmt.Sprintf("%d:%d:%d", userId, year, week), + "limit": limit, + "maxAgeDays": discoverWeeklyMaxAgeDays, + "jitterFloor": discoverWeeklyJitterFloor, + "jitterRange": discoverWeeklyJitterRange, + }) + if err != nil { + return nil, err + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[int32]) + if err != nil { + return nil, err + } + + app.discoverWeeklyCache.Set(cacheKey, ids) + return ids, nil +} diff --git a/api/v1_users_discover_weekly_test.go b/api/v1_users_discover_weekly_test.go new file mode 100644 index 00000000..45ef52ef --- /dev/null +++ b/api/v1_users_discover_weekly_test.go @@ -0,0 +1,362 @@ +package api + +import ( + "context" + "testing" + "time" + + "api.audius.co/api/dbv1" + "api.audius.co/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// discoverWeeklyFixtures builds a graph covering every filter and both +// candidate sources. +// +// user 1 = me (the viewer). Plays rock, so rock is my affinity genre. +// user 2 = big unfollowed artist -> the expected top pick +// user 3 = underground artist -> underground source +// user 4 = an artist I follow -> demoted, not excluded +// user 5 = deactivated artist -> filtered +// user 6 = artist whose track I played -> filtered +// user 7 = artist whose track I saved -> filtered +// 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 { + now := time.Now() + daysAgo := func(d int) time.Time { return now.AddDate(0, 0, -d) } + + users := []map[string]any{ + {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, + {"user_id": 2, "handle": "bigartist", "handle_lc": "bigartist", "wallet": "0x0000000000000000000000000000000000000002"}, + {"user_id": 3, "handle": "underground", "handle_lc": "underground", "wallet": "0x0000000000000000000000000000000000000003"}, + {"user_id": 4, "handle": "followed", "handle_lc": "followed", "wallet": "0x0000000000000000000000000000000000000004"}, + {"user_id": 5, "handle": "deactivated", "handle_lc": "deactivated", "is_deactivated": true, "wallet": "0x0000000000000000000000000000000000000005"}, + {"user_id": 6, "handle": "alreadyplayed", "handle_lc": "alreadyplayed", "wallet": "0x0000000000000000000000000000000000000006"}, + {"user_id": 7, "handle": "alreadysaved", "handle_lc": "alreadysaved", "wallet": "0x0000000000000000000000000000000000000007"}, + {"user_id": 8, "handle": "gated", "handle_lc": "gated", "wallet": "0x0000000000000000000000000000000000000008"}, + {"user_id": 9, "handle": "ancient", "handle_lc": "ancient", "wallet": "0x0000000000000000000000000000000000000009"}, + {"user_id": 10, "handle": "twotracks", "handle_lc": "twotracks", "wallet": "0x000000000000000000000000000000000000000a"}, + } + + // Everyone is over the underground threshold except user 3. + aggregateUser := []map[string]any{ + {"user_id": 1, "follower_count": 0, "following_count": 1}, + {"user_id": 2, "follower_count": 5000, "following_count": 10}, + {"user_id": 3, "follower_count": 100, "following_count": 50}, + {"user_id": 4, "follower_count": 5000, "following_count": 10}, + {"user_id": 5, "follower_count": 5000, "following_count": 10}, + {"user_id": 6, "follower_count": 5000, "following_count": 10}, + {"user_id": 7, "follower_count": 5000, "following_count": 10}, + {"user_id": 8, "follower_count": 5000, "following_count": 10}, + {"user_id": 9, "follower_count": 5000, "following_count": 10}, + {"user_id": 10, "follower_count": 5000, "following_count": 10}, + } + + tracks := []map[string]any{ + // track 100 is mine: excluded as an own upload. + {"track_id": 100, "owner_id": 1, "title": "my own track", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 200, "owner_id": 2, "title": "big hit", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 300, "owner_id": 3, "title": "underground gem", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 400, "owner_id": 4, "title": "followed artist track", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 500, "owner_id": 5, "title": "deactivated owner", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 600, "owner_id": 6, "title": "already played", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 700, "owner_id": 7, "title": "already saved", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 800, "owner_id": 8, "title": "gated", "genre": "Rock", "created_at": daysAgo(5), "is_stream_gated": true}, + {"track_id": 900, "owner_id": 9, "title": "ancient", "genre": "Rock", "created_at": daysAgo(400)}, + {"track_id": 1000, "owner_id": 10, "title": "two tracks a", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 1001, "owner_id": 10, "title": "two tracks b", "genre": "Rock", "created_at": daysAgo(5)}, + {"track_id": 1100, "owner_id": 2, "title": "unlisted", "genre": "Rock", "created_at": daysAgo(5), "is_unlisted": true}, + {"track_id": 1200, "owner_id": 2, "title": "deleted", "genre": "Rock", "created_at": daysAgo(5), "is_delete": true}, + } + + // My listening history: a rock track by user 6, which both establishes + // Rock as my affinity genre and makes track 600 an already-played + // exclusion. + plays := []map[string]any{ + {"id": 1, "user_id": 1, "play_item_id": 600, "created_at": daysAgo(1)}, + } + + saves := []map[string]any{ + {"user_id": 1, "save_item_id": 700, "save_type": "track"}, + } + + follows := []map[string]any{ + {"follower_user_id": 1, "followee_user_id": 4}, + } + + // Every candidate needs a trending row to be retrieved at all. + trackTrendingScores := []map[string]any{} + for _, id := range []int{100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1001, 1100, 1200} { + trackTrendingScores = append(trackTrendingScores, map[string]any{ + "track_id": id, "score": 1_000_000_000, "time_range": "week", + }) + } + + // Engagement is what drives quality_score. The gaps here are wide enough + // to survive the +/-15% week jitter where the tests assert on ordering. + aggregateTrack := []map[string]any{ + {"track_id": 200, "save_count": 500, "repost_count": 200}, + {"track_id": 300, "save_count": 30, "repost_count": 10}, + {"track_id": 400, "save_count": 30, "repost_count": 10}, + {"track_id": 500, "save_count": 400, "repost_count": 100}, + {"track_id": 600, "save_count": 400, "repost_count": 100}, + {"track_id": 700, "save_count": 400, "repost_count": 100}, + {"track_id": 800, "save_count": 400, "repost_count": 100}, + {"track_id": 900, "save_count": 400, "repost_count": 100}, + {"track_id": 1000, "save_count": 20, "repost_count": 5}, + {"track_id": 1001, "save_count": 20, "repost_count": 5}, + } + + return database.FixtureMap{ + "users": users, + "aggregate_user": aggregateUser, + "tracks": tracks, + "plays": plays, + "saves": saves, + "follows": follows, + "track_trending_scores": trackTrendingScores, + "aggregate_track": aggregateTrack, + } +} + +// titles pulls the track titles out of a response, in order. +func discoverWeeklyTitles(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) { + app := emptyTestApp(t) + database.Seed(app.pool.Replicas[0], discoverWeeklyFixtures()) + + var resp struct { + Data []dbv1.Track + } + + status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &resp) + assert.Equal(t, 200, status) + + titles := discoverWeeklyTitles(resp.Data) + + // Every filter, asserted as absence rather than ordering so the week + // jitter can't make this flaky. + assert.NotContains(t, titles, "my own track", "own uploads are excluded") + assert.NotContains(t, titles, "deactivated owner", "deactivated owners are excluded") + assert.NotContains(t, titles, "already played", "played tracks are excluded") + assert.NotContains(t, titles, "already saved", "saved tracks are excluded") + assert.NotContains(t, titles, "gated", "stream-gated tracks are excluded") + assert.NotContains(t, titles, "ancient", "tracks past the age cutoff are excluded") + assert.NotContains(t, titles, "unlisted", "unlisted tracks are excluded") + assert.NotContains(t, titles, "deleted", "deleted tracks are excluded") + + // The survivors: users 2, 3, 4, and exactly one of user 10's two tracks. + assert.Contains(t, titles, "big hit") + assert.Contains(t, titles, "underground gem") + assert.Contains(t, titles, "followed artist track") + assert.Len(t, resp.Data, 4, "one track per artist across users 2, 3, 4, 10") + + // One-per-artist: user 10 uploaded two equally-scored tracks and gets + // exactly one slot. + twoTrackCount := 0 + for _, title := range titles { + if title == "two tracks a" || title == "two tracks b" { + twoTrackCount++ + } + } + assert.Equal(t, 1, twoTrackCount, "an artist never occupies two slots") +} + +// A followed artist is demoted, not removed. The discovery weight gap +// (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) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, + {"user_id": 2, "handle": "followed", "handle_lc": "followed", "wallet": "0x0000000000000000000000000000000000000002"}, + {"user_id": 3, "handle": "stranger", "handle_lc": "stranger", "wallet": "0x0000000000000000000000000000000000000003"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "follower_count": 0, "following_count": 1}, + {"user_id": 2, "follower_count": 5000, "following_count": 10}, + {"user_id": 3, "follower_count": 5000, "following_count": 10}, + }, + // Identical engagement and genre, so discovery_weight is the only + // thing separating them. + "tracks": []map[string]any{ + {"track_id": 200, "owner_id": 2, "title": "followed track", "genre": "Rock"}, + {"track_id": 300, "owner_id": 3, "title": "stranger track", "genre": "Rock"}, + }, + "aggregate_track": []map[string]any{ + {"track_id": 200, "save_count": 100, "repost_count": 50}, + {"track_id": 300, "save_count": 100, "repost_count": 50}, + }, + "follows": []map[string]any{ + {"follower_user_id": 1, "followee_user_id": 2}, + }, + "track_trending_scores": []map[string]any{ + {"track_id": 200, "score": 1_000_000_000, "time_range": "week"}, + {"track_id": 300, "score": 1_000_000_000, "time_range": "week"}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + var resp struct { + Data []dbv1.Track + } + status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &resp) + assert.Equal(t, 200, status) + require.Len(t, resp.Data, 2) + + assert.Equal(t, "stranger track", resp.Data[0].Title.String, + "an artist I don't follow outranks one I do, all else equal") + assert.Equal(t, "followed track", resp.Data[1].Title.String, + "but the followed artist is demoted, not filtered out") +} + +// A listener with no plays, saves, follows, or reposts still gets a mix. +// 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) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, + {"user_id": 2, "handle": "artist", "handle_lc": "artist", "wallet": "0x0000000000000000000000000000000000000002"}, + }, + "aggregate_user": []map[string]any{ + {"user_id": 1, "follower_count": 0, "following_count": 0}, + {"user_id": 2, "follower_count": 5000, "following_count": 10}, + }, + "tracks": []map[string]any{ + {"track_id": 200, "owner_id": 2, "title": "a track", "genre": "Rock"}, + }, + "aggregate_track": []map[string]any{ + {"track_id": 200, "save_count": 100, "repost_count": 50}, + }, + "track_trending_scores": []map[string]any{ + {"track_id": 200, "score": 1_000_000_000, "time_range": "week"}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + var resp struct { + Data []dbv1.Track + } + status, _ := testGet(t, app, "/v1/users/7eP5n/discover-weekly", &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) +} + +// The mix must not move within a week and must move between weeks. Both +// 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 +// because the handler derives the period from the wall clock, and the point +// here is to vary it. +func TestV1UsersDiscoverWeeklyStableWithinWeek(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": []map[string]any{{"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}}, + "aggregate_user": []map[string]any{{"user_id": 1, "follower_count": 0, "following_count": 0}}, + "tracks": []map[string]any{}, + "aggregate_track": []map[string]any{}, + "track_trending_scores": []map[string]any{}, + } + // A pool of equally-strong candidates by distinct artists. Equal scores + // mean the week seed is the only thing deciding the order, which is + // exactly what this test is about. + for i := 0; i < 40; i++ { + userId := 100 + i + trackId := 1000 + i + fixtures["users"] = append(fixtures["users"], map[string]any{ + "user_id": userId, + "handle": string(rune('a'+i%26)) + string(rune('a'+i/26)) + "artist", + "wallet": "0x" + padWallet(userId), + }) + fixtures["aggregate_user"] = append(fixtures["aggregate_user"], map[string]any{ + "user_id": userId, "follower_count": 5000, "following_count": 10, + }) + fixtures["tracks"] = append(fixtures["tracks"], map[string]any{ + "track_id": trackId, "owner_id": userId, "title": "track", "genre": "Rock", + }) + fixtures["aggregate_track"] = append(fixtures["aggregate_track"], map[string]any{ + "track_id": trackId, "save_count": 100, "repost_count": 50, + }) + fixtures["track_trending_scores"] = append(fixtures["track_trending_scores"], map[string]any{ + "track_id": trackId, "score": 1_000_000_000, "time_range": "week", + }) + } + // handle_lc is required alongside handle. + for _, u := range fixtures["users"] { + if h, ok := u["handle"].(string); ok { + u["handle_lc"] = h + } + } + database.Seed(app.pool.Replicas[0], fixtures) + + ctx := context.Background() + + weekA1, err := app.getDiscoverWeeklyTrackIds(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) + 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) + 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) + require.NoError(t, err) + assert.NotEqual(t, weekA1, weekAOther, + "the seed is per-listener, not global") +} + +// padWallet builds a distinct 40-hex-char wallet suffix from an int. +func padWallet(n int) string { + const hexDigits = "0123456789abcdef" + out := make([]byte, 40) + for i := range out { + out[i] = '0' + } + for i := len(out) - 1; i >= 0 && n > 0; i-- { + out[i] = hexDigits[n%16] + n /= 16 + } + return string(out) +} + +// 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) { + app := emptyTestApp(t) + var resp struct { + Data []dbv1.Track + } + status, _ := testGet(t, app, "/v1/users/not-a-real-id/discover-weekly", &resp) + assert.Equal(t, 400, status) +}