diff --git a/api/server.go b/api/server.go index 5adb4c2d..d67f31e5 100644 --- a/api/server.go +++ b/api/server.go @@ -426,6 +426,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/albums", app.v1UserAlbums) g.Get("/users/:userId/playlists", app.v1UserPlaylists) g.Get("/users/:userId/feed", app.v1UsersFeed) + g.Get("/users/:userId/feed/for-you", app.v1UsersFeedForYou) g.Get("/users/:userId/connected_wallets", app.v1UsersConnectedWallets) g.Get("/users/:userId/transactions/audio", app.v1UsersTransactionsAudio) g.Get("/users/:userId/transactions/audio/count", app.v1UsersTransactionsAudioCount) @@ -511,6 +512,9 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/fan_club/feed", app.v1FanClubFeed) g.Get("/fan-club/feed", app.v1FanClubFeed) + // For You feed (Twitter-style ranked feed) + g.Get("/feed/for-you", app.v1FeedForYou) + g.Get("/events/:eventId/comments", app.v1EventComments) g.Get("/events/:eventId/followers", app.v1EventsFollowers) g.Get("/events/:eventId/follow_state", app.v1EventFollowState) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 6a72c5f0..5cadb4e9 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -705,6 +705,70 @@ paths: "500": description: Server error content: {} + /feed/for-you: + get: + tags: + - feed + summary: Get For You feed + description: + Returns a personalized For You feed using Twitter-style multi-source + ranking (in-network, trending, underground, similar-artist candidates + scored by recency decay × engagement × social affinity). + operationId: Get For You Feed + security: + - {} + - OAuth2: + - read + parameters: + - name: user_id + in: query + description: + The user ID of the caller. Required — "For You" is personalized, + so the handler 400s without it. + required: true + schema: + type: string + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + default: 25 + minimum: 1 + maximum: 100 + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + default: 0 + minimum: 0 + maximum: 200 + - name: max_per_artist + in: query + description: + Maximum number of tracks per artist on a single page. Used by the + diversity pass to cap consecutive same-artist results. + schema: + type: integer + default: 3 + minimum: 1 + maximum: 10 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/tracks" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /developer-apps: post: tags: @@ -9375,6 +9439,66 @@ paths: "500": description: Server error content: {} + /users/{id}/feed/for-you: + get: + tags: + - users + summary: Get For You feed for user + description: + Returns a personalized For You track feed for the user. Mirrors + the client-side blend of recommended, following originals, weekly + trending, and underground trending using a fixed 10-slot + interleave (60% recommended, 20% following, 10% trending, 10% + underground). Already-saved tracks are filtered out and results + are deduped by track ID. + operationId: Get User For You Feed + 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 items to fetch + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + default: 0 + minimum: 0 + maximum: 200 + - 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" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /users/{id}/library/albums: get: tags: diff --git a/api/v1_feed_for_you.go b/api/v1_feed_for_you.go new file mode 100644 index 00000000..699acf90 --- /dev/null +++ b/api/v1_feed_for_you.go @@ -0,0 +1,408 @@ +package api + +import ( + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetFeedForYouParams struct { + Limit int `query:"limit" default:"25" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0,max=200"` + MaxPerArtist int `query:"max_per_artist" default:"3" validate:"min=1,max=10"` +} + +// v1FeedForYou returns a personalized "For You" track feed, modeled on +// Twitter's open-sourced 2023 algorithm (the-algorithm-ml). The shape of +// the pipeline is candidate-retrieval → ranking → filtering+diversity, +// the same three-stage pattern Twitter uses on top of a learned "heavy +// ranker." Audius doesn't yet have a trained ranker, so the heavy ranker +// is approximated by a hand-tuned linear blend below; the candidate +// retrieval / diversity passes carry over directly so a learned model +// can drop in later. +// +// 1. CANDIDATE RETRIEVAL (UNION across four sources, each capped): +// - in_network — tracks uploaded in the last 14 days by users I follow. +// Strongest "this is for me" signal. +// - trending — top week-trending from track_trending_scores. +// Mirrors GET /tracks/trending. Capped at 100. +// - underground — week-trending tracks whose owner has < 1500 +// follower & following count (mirrors GET /tracks/trending/underground). +// Capped at 50. +// - similar — recent uploads by artists who are saved by other +// users that also save artists I save. 1-hop collaborative filter on +// the saves graph; capped at 50 artists × recent uploads. +// +// 2. RANKING — three light signals combined linearly: +// +// recency_score = exp(-ln(2) * age_hours / 48) +// // 48h half-life: 48h-old → 0.5, 96h → 0.25 +// engagement_score = ln(1 + 3*saves + 2*reposts + 1*plays) / 12 +// // saves > reposts > plays, log-compressed +// social_boost = 1.0 + min(ln(1 + my_engagement_with_artist) / 4, 1) +// // up to ~2x for artists I already engage with often +// source_weight = {in_network: 1.20, trending: 1.00, +// underground: 0.95, similar: 0.90} +// +// final_score = (0.55 * recency_score + 0.45 * engagement_score) +// * social_boost * source_weight +// +// 3. FILTERS — applied once after the union to keep the candidate set cheap: +// - is_delete / is_unlisted / is_available / stem_of (track liveness) +// - users.is_deactivated / is_available (owner liveness — same shape +// as v1_events_remix_contests.go) +// - access_authorities: caller's wallet must be on the list, else +// ungated only (matches the v1_users_feed authed-wallet pattern) +// - already-saved by caller (don't resurface) +// - the caller's own uploads +// +// 4. DIVERSITY — author-cap of N tracks per owner via row_number() +// (configurable via max_per_artist; default 3), then a Go-side greedy +// pass that, when the next track shares an owner with the previously +// emitted one, prefers the next non-same-owner candidate within a +// 5-position lookahead. This is a "consecutive same-artist penalty" +// without paying for a second ranker. +// +// PAGINATION is offset/limit applied on the diversity-ordered list, so +// pages are stable as long as the underlying scores haven't shifted. +// +// Query params: +// - user_id (required): the caller. The handler 400s without it +// because "For You" without a "you" degenerates into trending+underground. +// - limit (default 25, max 100) +// - offset (default 0, max 200) +// - max_per_artist (default 3, max 10) — author cap per page +func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { + var params = GetFeedForYouParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myId := app.getMyId(c) + if myId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "user_id is required") + } + + authedWallet := app.tryGetAuthedWallet(c) + + // Pull a candidate pool larger than the page size so the diversity + // cap and the consecutive-same-artist pass have headroom to reorder. + const candidatePoolSize = 200 + + sql := ` + WITH + 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 + ), + my_saved_tracks 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 + ), + my_saved_artists AS ( + SELECT DISTINCT t.owner_id AS artist_id + FROM my_saved_tracks mst + JOIN tracks t ON t.track_id = mst.track_id + ), + -- 1-hop collaborative filter on the saves graph: artists saved by + -- users who *also* save my saved-artists, but who I haven't saved myself. + -- Bounded so the planner can't get adventurous on power-users. + similar_artists AS ( + SELECT t2.owner_id AS artist_id, COUNT(DISTINCT s2.user_id) AS overlap + FROM saves s1 + JOIN tracks t1 ON s1.save_item_id = t1.track_id + JOIN saves s2 ON s2.user_id = s1.user_id + AND s2.save_type = 'track' + AND s2.is_current = true + AND s2.is_delete = false + JOIN tracks t2 ON s2.save_item_id = t2.track_id + WHERE s1.save_type = 'track' + AND s1.is_current = true + AND s1.is_delete = false + AND s1.user_id <> @userId + AND t1.owner_id IN (SELECT artist_id FROM my_saved_artists) + AND t2.owner_id NOT IN (SELECT artist_id FROM my_saved_artists) + GROUP BY t2.owner_id + ORDER BY overlap DESC + LIMIT 50 + ), + -- Per-artist engagement strength (saves + reposts + plays of any of + -- their tracks by me). Used for the social_boost multiplier. + my_artist_affinity AS ( + SELECT t.owner_id AS artist_id, + LN(1 + COUNT(*)) AS affinity + 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 + UNION ALL + 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 + UNION ALL + SELECT play_item_id AS track_id FROM plays + WHERE user_id = @userId + ) eng + JOIN tracks t ON t.track_id = eng.track_id + GROUP BY t.owner_id + ), + -- Source 1: in-network (followed-creator) recent uploads. + -- Bounded so a power-user with thousands of follows doesn't pull a + -- multi-thousand-row pool we'd just throw away after ranking. + cand_in_network AS ( + SELECT t.track_id, 'in_network'::text AS source + FROM tracks t + JOIN follow_set f ON f.user_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 + AND t.created_at >= NOW() - INTERVAL '14 days' + ORDER BY t.created_at DESC + LIMIT 200 + ), + -- Source 2: weekly trending. + cand_trending AS ( + SELECT tts.track_id, 'trending'::text AS source + FROM track_trending_scores tts + JOIN tracks t ON t.track_id = tts.track_id + AND t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + 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 100 + ), + -- Source 3: underground trending (sub-1500 follower & following artists). + 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 + AND t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + 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 50 + ), + -- Source 4: similar-artist recent uploads. + cand_similar AS ( + SELECT t.track_id, 'similar'::text AS source + FROM tracks t + JOIN similar_artists sa ON sa.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 + AND t.created_at >= NOW() - INTERVAL '60 days' + ORDER BY sa.overlap DESC, t.created_at DESC + LIMIT 100 + ), + -- One row per track_id. DISTINCT ON keeps the strongest (lowest-prio) + -- source so an in-network track that's also trending keeps the + -- in_network weight rather than the lower trending weight. + candidates AS ( + SELECT DISTINCT ON (track_id) track_id, source + FROM ( + SELECT track_id, source, 1 AS prio FROM cand_in_network + UNION ALL + SELECT track_id, source, 2 AS prio FROM cand_trending + UNION ALL + SELECT track_id, source, 3 AS prio FROM cand_underground + UNION ALL + SELECT track_id, source, 4 AS prio FROM cand_similar + ) u + ORDER BY track_id, prio + ), + filtered AS ( + SELECT + c.track_id, + c.source, + t.owner_id, + t.created_at, + COALESCE(at.save_count, 0) AS save_count, + COALESCE(at.repost_count, 0) AS repost_count, + COALESCE(ap.count, 0) AS play_count, + COALESCE(maa.affinity, 0) AS affinity + FROM candidates c + JOIN tracks t ON t.track_id = c.track_id + JOIN users u ON u.user_id = t.owner_id + LEFT JOIN aggregate_track at ON at.track_id = c.track_id + LEFT JOIN aggregate_plays ap ON ap.play_item_id = c.track_id + LEFT JOIN my_artist_affinity maa ON maa.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 + -- Owner liveness — pattern from v1_events_remix_contests.go + AND u.is_current = true + AND u.is_deactivated = false + AND u.is_available = true + -- Access-gating: ungated, or caller's wallet is on the list + AND (t.access_authorities IS NULL + OR (COALESCE(@authed_wallet, '') <> '' + AND EXISTS ( + SELECT 1 FROM unnest(t.access_authorities) aa + WHERE lower(aa) = lower(@authed_wallet) + ))) + -- Don't resurface tracks the caller already saved + AND NOT EXISTS ( + SELECT 1 FROM my_saved_tracks ms WHERE ms.track_id = c.track_id + ) + -- Don't recommend the caller's own uploads + AND t.owner_id <> @userId + ), + scored AS ( + SELECT + f.track_id, + f.owner_id, + -- 48h half-life on age in hours + EXP(-LN(2) * GREATEST(EXTRACT(EPOCH FROM (NOW() - f.created_at)) / 3600.0, 0) / 48.0) + AS recency_score, + -- saves > reposts > plays, log-compressed and normalized + LN(1 + 3 * f.save_count + 2 * f.repost_count + f.play_count) / 12.0 + AS engagement_score, + -- 1.0 baseline, up to ~2x for high-affinity artists + 1.0 + LEAST(f.affinity / 4.0, 1.0) AS social_boost, + CASE f.source + WHEN 'in_network' THEN 1.20 + WHEN 'trending' THEN 1.00 + WHEN 'underground' THEN 0.95 + WHEN 'similar' THEN 0.90 + ELSE 1.00 + END AS source_weight + FROM filtered f + ), + final_scored AS ( + SELECT + track_id, + owner_id, + (0.55 * recency_score + 0.45 * engagement_score) + * social_boost * source_weight AS score + FROM scored + ), + -- Hard cap: max 3 tracks per artist before paginating. + 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, owner_id + FROM capped + WHERE rn_artist <= @maxPerArtist + ORDER BY score DESC, track_id DESC + LIMIT @poolSize + ` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "userId": myId, + "poolSize": candidatePoolSize, + "maxPerArtist": params.MaxPerArtist, + "authed_wallet": authedWallet, + }) + if err != nil { + return err + } + + type ranked struct { + TrackID int32 + OwnerID int32 + } + pool, err := pgx.CollectRows(rows, pgx.RowToStructByPos[ranked]) + if err != nil { + return err + } + + // Greedy diversity pass: keep the global rank order, but if the next + // track shares an owner with the one just emitted, prefer the next + // non-same-owner candidate within a small lookahead. Soft penalty on + // consecutive-same-artist runs without computing a second ranker. + const lookahead = 5 + ordered := make([]ranked, 0, len(pool)) + used := make([]bool, len(pool)) + var lastOwner int32 = -1 + for n := 0; n < len(pool); n++ { + pickIdx := -1 + for i := 0; i < len(pool) && i < n+lookahead+1; i++ { + if used[i] { + continue + } + if pickIdx == -1 { + pickIdx = i + } + if pool[i].OwnerID != lastOwner { + pickIdx = i + break + } + } + if pickIdx == -1 { + break + } + used[pickIdx] = true + ordered = append(ordered, pool[pickIdx]) + lastOwner = pool[pickIdx].OwnerID + } + + // Apply pagination on the diversity-ordered list. + start := params.Offset + if start > len(ordered) { + start = len(ordered) + } + end := start + params.Limit + if end > len(ordered) { + end = len(ordered) + } + page := ordered[start:end] + + trackIds := make([]int32, len(page)) + for i, r := range page { + trackIds[i] = r.TrackID + } + + tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIds, + MyID: myId, + AuthedWallet: authedWallet, + }, + }) + if err != nil { + return err + } + + // Tracks() returns rows in id order; re-emit in our ranked order. + byId := make(map[int32]dbv1.Track, len(tracks)) + for _, t := range tracks { + byId[t.TrackID] = t + } + sorted := make([]dbv1.Track, 0, len(trackIds)) + for _, id := range trackIds { + if t, ok := byId[id]; ok { + sorted = append(sorted, t) + } + } + + return v1TracksResponse(c, sorted) +} diff --git a/api/v1_feed_for_you_test.go b/api/v1_feed_for_you_test.go new file mode 100644 index 00000000..abd3297a --- /dev/null +++ b/api/v1_feed_for_you_test.go @@ -0,0 +1,401 @@ +package api + +import ( + "fmt" + "testing" + "time" + + "api.audius.co/api/dbv1" + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// feedForYouFixtures builds a small graph that exercises every candidate +// source and every filter the For You feed cares about. +// +// user 1 = me (the viewer) +// user 2 = an artist I follow -> in-network candidates +// user 3 = an artist I do NOT follow -> trending candidate +// user 4 = an underground artist -> underground candidate +// user 5 = a "similar" artist -> CF similar candidate (1-hop) +// user 6 = a peer who shares a save -> CF bridge user +// user 7 = a deactivated artist -> filter test +func feedForYouFixtures() database.FixtureMap { + now := time.Now() + hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } + 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": "trending_artist", "handle_lc": "trending_artist", "wallet": "0x0000000000000000000000000000000000000003"}, + {"user_id": 4, "handle": "underground", "handle_lc": "underground", "wallet": "0x0000000000000000000000000000000000000004"}, + {"user_id": 5, "handle": "similar", "handle_lc": "similar", "wallet": "0x0000000000000000000000000000000000000005"}, + {"user_id": 6, "handle": "peer", "handle_lc": "peer", "wallet": "0x0000000000000000000000000000000000000006"}, + {"user_id": 7, "handle": "deactivated", "handle_lc": "deactivated", "wallet": "0x0000000000000000000000000000000000000007", "is_deactivated": true}, + {"user_id": 8, "handle": "saved_artist", "handle_lc": "saved_artist", "wallet": "0x0000000000000000000000000000000000000008"}, + } + + aggregateUser := []map[string]any{ + {"user_id": 1, "follower_count": 0, "following_count": 1}, + {"user_id": 2, "follower_count": 100, "following_count": 10}, + {"user_id": 3, "follower_count": 5000, "following_count": 200}, + // Underground artist: must be < 1500 on both counts. + {"user_id": 4, "follower_count": 100, "following_count": 50}, + {"user_id": 5, "follower_count": 200, "following_count": 100}, + {"user_id": 6, "follower_count": 50, "following_count": 50}, + {"user_id": 7, "follower_count": 0, "following_count": 0}, + {"user_id": 8, "follower_count": 200, "following_count": 50}, + } + + tracks := []map[string]any{ + // In-network: two recent tracks by user 2 (the artist I follow). + {"track_id": 101, "owner_id": 2, "title": "in-network 1", "created_at": hoursAgo(2)}, + {"track_id": 102, "owner_id": 2, "title": "in-network 2", "created_at": hoursAgo(10)}, + // Trending track by user 3 (in track_trending_scores). + {"track_id": 201, "owner_id": 3, "title": "trending", "created_at": hoursAgo(72)}, + // Underground track by user 4 (also has trending score). + {"track_id": 301, "owner_id": 4, "title": "underground", "created_at": hoursAgo(50)}, + // Similar-artist candidate: track by user 5. + {"track_id": 501, "owner_id": 5, "title": "similar artist", "created_at": hoursAgo(30)}, + // Saved artist's track that I already saved -> must be filtered out. + {"track_id": 801, "owner_id": 8, "title": "already saved", "created_at": hoursAgo(100)}, + // Track by deactivated user -> filtered. + {"track_id": 701, "owner_id": 7, "title": "deactivated artist track", "created_at": hoursAgo(2)}, + // Track that is unlisted -> filtered. + {"track_id": 901, "owner_id": 2, "title": "unlisted", "created_at": hoursAgo(2), "is_unlisted": true}, + // Track that is deleted -> filtered. + {"track_id": 902, "owner_id": 2, "title": "deleted", "created_at": hoursAgo(2), "is_delete": true}, + // User 6's track (referenced by saves to drive CF; not a candidate + // because user 6 is in my_saved_artists indirectly only via user 8). + } + + follows := []map[string]any{ + // I follow user 2. + {"follower_user_id": 1, "followee_user_id": 2}, + } + + saves := []map[string]any{ + // I have already saved track 801 (by user 8, my favorite artist). + {"user_id": 1, "save_item_id": 801, "save_type": "track"}, + // User 6 saved 801 too (overlap with me on user 8). + {"user_id": 6, "save_item_id": 801, "save_type": "track"}, + // User 6 also saved a track by user 5 -> drives "similar artist". + {"user_id": 6, "save_item_id": 501, "save_type": "track"}, + } + + trackTrendingScores := []map[string]any{ + // Trending leader. + {"track_id": 201, "score": 9_000_000_000, "time_range": "week"}, + // Underground (also trending but artist is sub-1500). + {"track_id": 301, "score": 5_000_000_000, "time_range": "week"}, + } + + aggregateTrack := []map[string]any{ + {"track_id": 101, "save_count": 5, "repost_count": 2}, + {"track_id": 102, "save_count": 1, "repost_count": 0}, + {"track_id": 201, "save_count": 100, "repost_count": 50}, + {"track_id": 301, "save_count": 30, "repost_count": 10}, + {"track_id": 501, "save_count": 10, "repost_count": 5}, + } + + return database.FixtureMap{ + "users": users, + "aggregate_user": aggregateUser, + "tracks": tracks, + "follows": follows, + "saves": saves, + "track_trending_scores": trackTrendingScores, + "aggregate_track": aggregateTrack, + } +} + +func TestV1FeedForYou_Basic(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + database.Seed(app.pool.Replicas[0], feedForYouFixtures()) + + var response struct { + Data []dbv1.Track + } + path := "/v1/feed/for-you?user_id=" + trashid.MustEncodeHashID(1) + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + // We should see candidates from at least the in-network and trending + // sources, and we should NOT see any of the filtered tracks. + gotIDs := map[int32]bool{} + for _, tr := range response.Data { + gotIDs[tr.TrackID] = true + } + + // Filtered: already saved, deactivated, unlisted, deleted. + for _, banned := range []int32{801, 701, 901, 902} { + assert.Falsef(t, gotIDs[banned], "track %d should be filtered out", banned) + } + + // At least one in-network track should appear. + assert.True(t, gotIDs[101] || gotIDs[102], "expected an in-network track in results, got %v", gotIDs) + + // Trending track should appear (it's a wide-net candidate). + assert.True(t, gotIDs[201], "expected trending track in results, got %v", gotIDs) +} + +func TestV1FeedForYou_RequiresUserId(t *testing.T) { + app := emptyTestApp(t) + status, _ := testGet(t, app, "/v1/feed/for-you") + assert.Equal(t, 400, status) +} + +func TestV1FeedForYou_ExcludesAlreadySavedTracks(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + database.Seed(app.pool.Replicas[0], feedForYouFixtures()) + + var response struct { + Data []dbv1.Track + } + path := "/v1/feed/for-you?user_id=" + trashid.MustEncodeHashID(1) + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + for _, tr := range response.Data { + assert.NotEqual(t, int32(801), tr.TrackID, "already-saved track should be excluded") + } +} + +func TestV1FeedForYou_MaxThreePerArtist(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + // Build a user (the artist) who has many recent in-network tracks. The + // feed should cap them at 3 per page. + now := time.Now() + hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } + users := []map[string]any{ + {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, + {"user_id": 2, "handle": "prolific", "handle_lc": "prolific", "wallet": "0x0000000000000000000000000000000000000002"}, + } + tracks := make([]map[string]any, 10) + for i := range tracks { + tracks[i] = map[string]any{ + "track_id": 1000 + i, + "owner_id": 2, + "title": fmt.Sprintf("prolific %d", i), + "created_at": hoursAgo(i + 1), + } + } + follows := []map[string]any{{"follower_user_id": 1, "followee_user_id": 2}} + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": users, + "tracks": tracks, + "follows": follows, + }) + + var response struct { + Data []dbv1.Track + } + path := "/v1/feed/for-you?user_id=" + trashid.MustEncodeHashID(1) + "&limit=20" + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + count := 0 + for _, tr := range response.Data { + if tr.UserID == trashid.HashId(2) { + count++ + } + } + assert.LessOrEqual(t, count, 3, "expected at most 3 tracks per artist, got %d", count) +} + +func TestV1FeedForYou_DiversityPassNoConsecutiveSameArtist(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + now := time.Now() + hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } + + // Two artists I follow, each with several recent tracks. The diversity + // pass should interleave them so no two consecutive tracks share an + // artist (when an alternative is available within the lookahead window). + users := []map[string]any{ + {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000001"}, + {"user_id": 2, "handle": "a1", "handle_lc": "a1", "wallet": "0x0000000000000000000000000000000000000002"}, + {"user_id": 3, "handle": "a2", "handle_lc": "a2", "wallet": "0x0000000000000000000000000000000000000003"}, + } + tracks := []map[string]any{ + {"track_id": 100, "owner_id": 2, "title": "a1-1", "created_at": hoursAgo(1)}, + {"track_id": 101, "owner_id": 2, "title": "a1-2", "created_at": hoursAgo(2)}, + {"track_id": 102, "owner_id": 2, "title": "a1-3", "created_at": hoursAgo(3)}, + {"track_id": 200, "owner_id": 3, "title": "a2-1", "created_at": hoursAgo(4)}, + {"track_id": 201, "owner_id": 3, "title": "a2-2", "created_at": hoursAgo(5)}, + {"track_id": 202, "owner_id": 3, "title": "a2-3", "created_at": hoursAgo(6)}, + } + follows := []map[string]any{ + {"follower_user_id": 1, "followee_user_id": 2}, + {"follower_user_id": 1, "followee_user_id": 3}, + } + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": users, + "tracks": tracks, + "follows": follows, + }) + + var response struct { + Data []dbv1.Track + } + path := "/v1/feed/for-you?user_id=" + trashid.MustEncodeHashID(1) + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + require.GreaterOrEqual(t, len(response.Data), 4, "want at least 4 tracks to assert interleave") + + // Walk the response: for each pair, if there's any other artist in the + // rest of the list, we should not have two same-artist tracks adjacent. + for i := 1; i < len(response.Data); i++ { + prev := response.Data[i-1].UserID + cur := response.Data[i].UserID + if prev == cur { + // Only fail if a different-artist track existed elsewhere in the + // page that could have been swapped in. + otherExists := false + for j := i; j < len(response.Data); j++ { + if response.Data[j].UserID != prev { + otherExists = true + break + } + } + if otherExists { + t.Fatalf("adjacent same-artist tracks at pos %d/%d (artist %v); other artist available later in page", i-1, i, prev) + } + } + } +} + +func TestV1FeedForYou_PaginationDoesNotRepeat(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + database.Seed(app.pool.Replicas[0], feedForYouFixtures()) + + page := func(limit, offset int) []int32 { + var resp struct { + Data []dbv1.Track + } + path := fmt.Sprintf("/v1/feed/for-you?user_id=%s&limit=%d&offset=%d", + trashid.MustEncodeHashID(1), limit, offset) + status, body := testGet(t, app, path, &resp) + require.Equal(t, 200, status, string(body)) + ids := make([]int32, len(resp.Data)) + for i, tr := range resp.Data { + ids[i] = tr.TrackID + } + return ids + } + + first := page(2, 0) + second := page(2, 2) + + seen := map[int32]bool{} + for _, id := range first { + seen[id] = true + } + for _, id := range second { + assert.Falsef(t, seen[id], "track %d appeared on both pages", id) + } +} + +func TestV1FeedForYou_InvalidParams(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + for _, val := range []string{"-1", "101", "invalid"} { + status, _ := testGet(t, app, "/v1/feed/for-you?user_id="+trashid.MustEncodeHashID(1)+"&limit="+val) + assert.Equal(t, 400, status, "limit=%s", val) + } + for _, val := range []string{"-1", "invalid"} { + status, _ := testGet(t, app, "/v1/feed/for-you?user_id="+trashid.MustEncodeHashID(1)+"&offset="+val) + assert.Equal(t, 400, status, "offset=%s", val) + } +} + +// TestV1FeedForYou_RecencyAndEngagementRanking isolates the ranking +// signals: all three artists are in-network (same source weight) and the +// caller has no prior engagement with any of them (uniform social_boost), +// so the only thing differentiating ranks is recency × engagement. +// +// track A — fresh, no engagement → mid-rank +// track B — old, high engagement → low-rank (recency decay) +// track C — fresh + high engagement → top-rank +func TestV1FeedForYou_RecencyAndEngagementRanking(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + const ( + me = 200 + artistA = 201 + artistB = 202 + artistC = 203 + ) + now := time.Now() + + users := []map[string]any{ + {"user_id": me, "handle": "ranker_me", "handle_lc": "ranker_me", + "wallet": "0x0000000000000000000000000000000000000200"}, + {"user_id": artistA, "handle": "artist_a", "handle_lc": "artist_a", + "wallet": "0x0000000000000000000000000000000000000201"}, + {"user_id": artistB, "handle": "artist_b", "handle_lc": "artist_b", + "wallet": "0x0000000000000000000000000000000000000202"}, + {"user_id": artistC, "handle": "artist_c", "handle_lc": "artist_c", + "wallet": "0x0000000000000000000000000000000000000203"}, + } + aggregateUser := []map[string]any{ + {"user_id": artistA, "follower_count": 100, "following_count": 50}, + {"user_id": artistB, "follower_count": 100, "following_count": 50}, + {"user_id": artistC, "follower_count": 100, "following_count": 50}, + } + tracks := []map[string]any{ + {"track_id": 2001, "owner_id": artistA, "title": "fresh, low engagement", + "created_at": now.Add(-1 * time.Hour)}, + {"track_id": 2002, "owner_id": artistB, "title": "old, high engagement", + "created_at": now.Add(-13 * 24 * time.Hour)}, // ~13d, still inside the 14d in-network window + {"track_id": 2003, "owner_id": artistC, "title": "fresh, high engagement", + "created_at": now.Add(-2 * time.Hour)}, + } + aggregateTrack := []map[string]any{ + {"track_id": 2001, "save_count": 0, "repost_count": 0}, + {"track_id": 2002, "save_count": 5_000, "repost_count": 1_000}, + {"track_id": 2003, "save_count": 5_000, "repost_count": 1_000}, + } + follows := []map[string]any{ + {"follower_user_id": me, "followee_user_id": artistA}, + {"follower_user_id": me, "followee_user_id": artistB}, + {"follower_user_id": me, "followee_user_id": artistC}, + } + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": users, + "aggregate_user": aggregateUser, + "tracks": tracks, + "aggregate_track": aggregateTrack, + "follows": follows, + }) + + var response struct { + Data []dbv1.Track + } + path := "/v1/feed/for-you?user_id=" + trashid.MustEncodeHashID(me) + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + require.GreaterOrEqual(t, len(response.Data), 3, "expected all three candidates in response") + + idx := func(id int32) int { + for i, tr := range response.Data { + if tr.TrackID == id { + return i + } + } + return -1 + } + assert.Less(t, idx(2003), idx(2001), + "fresh+engaged 2003 must outrank low-engagement 2001") + assert.Less(t, idx(2003), idx(2002), + "fresh+engaged 2003 must outrank old 2002 (recency decay)") +} diff --git a/api/v1_users_feed_for_you.go b/api/v1_users_feed_for_you.go new file mode 100644 index 00000000..af9786ca --- /dev/null +++ b/api/v1_users_feed_for_you.go @@ -0,0 +1,327 @@ +package api + +import ( + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetUsersFeedForYouParams struct { + Limit int `query:"limit" default:"10" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0,max=200"` +} + +// v1UsersFeedForYou returns a personalized "For You" track feed for the +// user identified in the path. It replaces the client-side blend in +// apps/packages/common/src/api/tan-query/lineups/useForYouFeed.ts: four +// candidate streams (recommended, following originals, weekly trending, +// underground trending) interleaved with a fixed 10-slot pattern, +// +// pos: [R R R F R T R F U R] +// +// where R=recommended, F=following, T=trending, U=underground. That gives +// 60% recommended (50% baseline + 10% filler when other sources thin +// out), 20% following, 10% trending, 10% underground. When a slot's +// preferred source is exhausted we fall through to the next source in +// priority order recommended → following → trending → underground. +// +// Each source is independently capped at 200 candidates. The union is +// deduped by track_id and the caller's already-saved tracks are filtered +// out at the SQL level (the client filters them on the way out; +// filtering early avoids burning candidates on already-saved tracks). +// Pagination (limit/offset) is applied to the composed list, so pages +// are stable as long as the underlying source ranks haven't shifted. +// +// Source SQL mirrors the underlying single-source endpoints: +// - recommended: v1_users_recommended_tracks.go (top tracks from the +// user's top-played genres, excluding tracks they've played). Uses +// score-DESC ordering instead of random() so pagination is stable. +// - following: v1_users_feed.go w/ filter=original (track originals +// from artists the user follows, last year) +// - trending: v1_tracks_trending.go (week) +// - underground: v1_tracks_trending_underground.go (week, sub-1500 +// follower & following artists) +// +// Liveness, gating, and unlisted/deleted filters mirror the patterns +// used in those files and in v1_events_remix_contests.go. +func (app *ApiServer) v1UsersFeedForYou(c *fiber.Ctx) error { + var params GetUsersFeedForYouParams + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + userId := app.getUserId(c) + myId := app.getMyId(c) + authedWallet := app.tryGetAuthedWallet(c) + + // Pull a generous pool from each source so the interleave has room + // to dedupe even at large offsets. + const perSourceLimit = 200 + + sql := ` + WITH + 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 + ), + my_saved_tracks 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 + ), + played_tracks AS ( + SELECT DISTINCT play_item_id + FROM plays + WHERE user_id = @userId + ), + top_genres AS ( + SELECT t.genre + FROM played_tracks pt + JOIN tracks t ON t.track_id = pt.play_item_id + WHERE t.genre IS NOT NULL + GROUP BY t.genre + ORDER BY COUNT(*) DESC + LIMIT 5 + ), + cand_recommended AS ( + SELECT + tts.track_id, + 'recommended'::text AS source, + ROW_NUMBER() OVER (ORDER BY tts.score DESC, tts.track_id DESC) AS rn + FROM track_trending_scores tts + JOIN top_genres tg ON tg.genre = tts.genre + JOIN tracks t ON t.track_id = tts.track_id + JOIN users u ON u.user_id = t.owner_id + WHERE tts.type = 'TRACKS' + AND tts.version = 'pnagD' + AND tts.time_range = 'week' + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + AND t.stem_of IS NULL + AND u.is_deactivated = false + AND (t.access_authorities IS NULL + OR (COALESCE(@authedWallet, '') <> '' + AND EXISTS (SELECT 1 FROM unnest(t.access_authorities) aa + WHERE lower(aa) = lower(@authedWallet)))) + AND NOT EXISTS (SELECT 1 FROM played_tracks pt WHERE pt.play_item_id = tts.track_id) + AND NOT EXISTS (SELECT 1 FROM my_saved_tracks ms WHERE ms.track_id = tts.track_id) + ORDER BY tts.score DESC, tts.track_id DESC + LIMIT @perSource + ), + cand_following AS ( + SELECT + t.track_id, + 'following'::text AS source, + ROW_NUMBER() OVER (ORDER BY t.created_at DESC, t.track_id DESC) AS rn + FROM tracks t + JOIN follow_set fs ON fs.user_id = t.owner_id + JOIN users u ON u.user_id = t.owner_id + WHERE t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + AND t.stem_of IS NULL + AND t.created_at >= NOW() - INTERVAL '1 YEAR' + AND u.is_deactivated = false + AND (t.access_authorities IS NULL + OR (COALESCE(@authedWallet, '') <> '' + AND EXISTS (SELECT 1 FROM unnest(t.access_authorities) aa + WHERE lower(aa) = lower(@authedWallet)))) + AND NOT EXISTS (SELECT 1 FROM my_saved_tracks ms WHERE ms.track_id = t.track_id) + ORDER BY t.created_at DESC, t.track_id DESC + LIMIT @perSource + ), + cand_trending AS ( + SELECT + tts.track_id, + 'trending'::text AS source, + ROW_NUMBER() OVER (ORDER BY tts.score DESC, tts.track_id DESC) AS rn + FROM track_trending_scores tts + JOIN tracks t ON t.track_id = tts.track_id + JOIN users u ON u.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 t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + AND u.is_deactivated = false + AND NOT EXISTS (SELECT 1 FROM my_saved_tracks ms WHERE ms.track_id = tts.track_id) + ORDER BY tts.score DESC, tts.track_id DESC + LIMIT @perSource + ), + cand_underground AS ( + SELECT + tts.track_id, + 'underground'::text AS source, + ROW_NUMBER() OVER (ORDER BY tts.score DESC, tts.track_id DESC) AS rn + FROM track_trending_scores tts + JOIN tracks t ON t.track_id = tts.track_id + JOIN users u ON u.user_id = t.owner_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 t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + AND u.is_deactivated = false + AND au.follower_count < 1500 + AND au.following_count < 1500 + AND NOT EXISTS (SELECT 1 FROM my_saved_tracks ms WHERE ms.track_id = tts.track_id) + ORDER BY tts.score DESC, tts.track_id DESC + LIMIT @perSource + ) + SELECT track_id, source, rn FROM cand_recommended + UNION ALL + SELECT track_id, source, rn FROM cand_following + UNION ALL + SELECT track_id, source, rn FROM cand_trending + UNION ALL + SELECT track_id, source, rn FROM cand_underground + ORDER BY source, rn + ` + + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "userId": userId, + "perSource": perSourceLimit, + "authedWallet": authedWallet, + }) + if err != nil { + return err + } + + type candRow struct { + TrackID int32 `db:"track_id"` + Source string `db:"source"` + Rn int32 `db:"rn"` + } + cands, err := pgx.CollectRows(rows, pgx.RowToStructByName[candRow]) + if err != nil { + return err + } + + bySrc := map[string][]int32{ + "recommended": {}, + "following": {}, + "trending": {}, + "underground": {}, + } + for _, r := range cands { + bySrc[r.Source] = append(bySrc[r.Source], r.TrackID) + } + + // Build the composed list up to the page boundary so offset/limit are + // deterministic. + target := params.Offset + params.Limit + composed := blendForYouSources(bySrc, target) + + start := params.Offset + if start > len(composed) { + start = len(composed) + } + end := start + params.Limit + if end > len(composed) { + end = len(composed) + } + pageIds := composed[start:end] + + tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: pageIds, + MyID: myId, + AuthedWallet: authedWallet, + }, + }) + if err != nil { + return err + } + + byId := make(map[int32]dbv1.Track, len(tracks)) + for _, t := range tracks { + byId[t.TrackID] = t + } + sorted := make([]dbv1.Track, 0, len(pageIds)) + for _, id := range pageIds { + if t, ok := byId[id]; ok { + sorted = append(sorted, t) + } + } + + return v1TracksResponse(c, sorted) +} + +// forYouSlotPattern is the 10-slot interleave used by useForYouFeed.ts in +// apps. Six recommended, two following, one trending, one underground. +var forYouSlotPattern = []string{ + "recommended", "recommended", "recommended", + "following", "recommended", "trending", + "recommended", "following", "underground", + "recommended", +} + +// forYouFallbackOrder is the priority order to fall through to when a +// slot's preferred source has run out of candidates. +var forYouFallbackOrder = []string{"recommended", "following", "trending", "underground"} + +// blendForYouSources interleaves four ranked candidate lists using the +// slot pattern above. Dedupes globally by track_id; falls through to the +// next source in priority order when the slot's preferred source is +// exhausted. Returns up to `target` track ids. +func blendForYouSources(bySrc map[string][]int32, target int) []int32 { + cursors := map[string]int{ + "recommended": 0, "following": 0, "trending": 0, "underground": 0, + } + seen := map[int32]bool{} + composed := make([]int32, 0, target) + + tryTake := func(src string) (int32, bool) { + list := bySrc[src] + for cursors[src] < len(list) { + id := list[cursors[src]] + cursors[src]++ + if seen[id] { + continue + } + seen[id] = true + return id, true + } + return 0, false + } + + slot := 0 + for len(composed) < target { + preferred := forYouSlotPattern[slot%len(forYouSlotPattern)] + // Build the per-slot probe order: preferred first, then the rest + // of the fallback in declared order, skipping the duplicate. + order := make([]string, 0, len(forYouFallbackOrder)) + order = append(order, preferred) + for _, k := range forYouFallbackOrder { + if k != preferred { + order = append(order, k) + } + } + + picked := int32(-1) + for _, src := range order { + if id, ok := tryTake(src); ok { + picked = id + break + } + } + if picked == -1 { + break + } + composed = append(composed, picked) + slot++ + } + return composed +} diff --git a/api/v1_users_feed_for_you_test.go b/api/v1_users_feed_for_you_test.go new file mode 100644 index 00000000..1673049d --- /dev/null +++ b/api/v1_users_feed_for_you_test.go @@ -0,0 +1,409 @@ +package api + +import ( + "fmt" + "testing" + "time" + + "api.audius.co/api/dbv1" + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// usersFeedForYouFixtures builds a graph that exercises every candidate +// source for the user-scoped For You feed: +// +// user 1 = me (the viewer) +// user 2 = an artist I follow -> following candidates +// user 3 = a popular trending artist -> trending candidate +// user 4 = an underground artist (sub-1500 fols) -> underground candidate +// user 5 = a recommended-genre artist -> recommended candidate +// user 6 = a deactivated artist -> filter test +// user 7 = an artist whose track I've saved -> dedupe-as-favorite test +func usersFeedForYouFixtures() database.FixtureMap { + now := time.Now() + hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } + + 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": "trending_artist", "handle_lc": "trending_artist", "wallet": "0x0000000000000000000000000000000000000003"}, + {"user_id": 4, "handle": "underground", "handle_lc": "underground", "wallet": "0x0000000000000000000000000000000000000004"}, + {"user_id": 5, "handle": "rec_artist", "handle_lc": "rec_artist", "wallet": "0x0000000000000000000000000000000000000005"}, + {"user_id": 6, "handle": "deactivated", "handle_lc": "deactivated", "wallet": "0x0000000000000000000000000000000000000006", "is_deactivated": true}, + {"user_id": 7, "handle": "saved_artist", "handle_lc": "saved_artist", "wallet": "0x0000000000000000000000000000000000000007"}, + } + + aggregateUser := []map[string]any{ + {"user_id": 1, "follower_count": 0, "following_count": 1}, + {"user_id": 2, "follower_count": 100, "following_count": 10}, + {"user_id": 3, "follower_count": 5000, "following_count": 200}, // not underground + {"user_id": 4, "follower_count": 100, "following_count": 50}, // underground + {"user_id": 5, "follower_count": 200, "following_count": 100}, + {"user_id": 6, "follower_count": 0, "following_count": 0}, + {"user_id": 7, "follower_count": 100, "following_count": 50}, + } + + tracks := []map[string]any{ + // Following candidates: recent uploads by user 2. + {"track_id": 101, "owner_id": 2, "title": "following 1", "created_at": hoursAgo(2), "genre": "Rock"}, + {"track_id": 102, "owner_id": 2, "title": "following 2", "created_at": hoursAgo(10), "genre": "Rock"}, + // Trending candidate by user 3 (>=1500 followers). + {"track_id": 201, "owner_id": 3, "title": "trending", "created_at": hoursAgo(48), "genre": "Pop"}, + // Underground candidate by user 4 (sub-1500). + {"track_id": 301, "owner_id": 4, "title": "underground", "created_at": hoursAgo(50), "genre": "Pop"}, + // Recommended candidate (top-genre Rock by user 5). + {"track_id": 501, "owner_id": 5, "title": "rock recommendation", "created_at": hoursAgo(30), "genre": "Rock"}, + // Already-saved track (favorite filter): user 7. + {"track_id": 701, "owner_id": 7, "title": "already saved", "created_at": hoursAgo(80), "genre": "Pop"}, + // Track by deactivated user (must be filtered). + {"track_id": 601, "owner_id": 6, "title": "deactivated", "created_at": hoursAgo(2), "genre": "Pop"}, + // Played track by user 1 (drives top_genres = Rock; played -> excluded + // from recommended). + {"track_id": 401, "owner_id": 5, "title": "i played this", "created_at": hoursAgo(100), "genre": "Rock"}, + // Unlisted by user 2 (must be filtered). + {"track_id": 901, "owner_id": 2, "title": "unlisted", "created_at": hoursAgo(2), "is_unlisted": true}, + // Deleted by user 2 (must be filtered). + {"track_id": 902, "owner_id": 2, "title": "deleted", "created_at": hoursAgo(2), "is_delete": true}, + } + + follows := []map[string]any{ + // I follow user 2. + {"follower_user_id": 1, "followee_user_id": 2}, + } + + // I have one play (track 401, Rock) -> top_genres = Rock. + plays := []map[string]any{ + {"id": 1, "user_id": 1, "play_item_id": 401, "created_at": hoursAgo(100)}, + } + + // I have already saved track 701 (favorite filter input). + saves := []map[string]any{ + {"user_id": 1, "save_item_id": 701, "save_type": "track"}, + } + + trackTrendingScores := []map[string]any{ + // Trending leaders, ungenred (matches trending/underground sources). + {"track_id": 201, "score": 9_000_000_000, "time_range": "week", "genre": ""}, + {"track_id": 301, "score": 5_000_000_000, "time_range": "week", "genre": ""}, + {"track_id": 701, "score": 4_000_000_000, "time_range": "week", "genre": ""}, + // Genre-tagged Rock scores drive `recommended` (top-genre source). + {"track_id": 501, "score": 8_000_000_000, "time_range": "week", "genre": "Rock"}, + {"track_id": 401, "score": 7_500_000_000, "time_range": "week", "genre": "Rock"}, + } + + aggregateTrack := []map[string]any{ + {"track_id": 101, "save_count": 5, "repost_count": 2}, + {"track_id": 102, "save_count": 1, "repost_count": 0}, + {"track_id": 201, "save_count": 100, "repost_count": 50}, + {"track_id": 301, "save_count": 30, "repost_count": 10}, + {"track_id": 501, "save_count": 10, "repost_count": 5}, + } + + return database.FixtureMap{ + "users": users, + "aggregate_user": aggregateUser, + "tracks": tracks, + "follows": follows, + "plays": plays, + "saves": saves, + "track_trending_scores": trackTrendingScores, + "aggregate_track": aggregateTrack, + } +} + +func TestV1UsersFeedForYou_Basic(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + database.Seed(app.pool.Replicas[0], usersFeedForYouFixtures()) + + var response struct { + Data []dbv1.Track + } + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?limit=20" + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + gotIDs := map[int32]bool{} + for _, tr := range response.Data { + gotIDs[tr.TrackID] = true + } + + // Filtered tracks must not appear: deactivated owner, unlisted, + // deleted, already-saved. + for _, banned := range []int32{601, 901, 902, 701} { + assert.Falsef(t, gotIDs[banned], "track %d should be filtered out, got %v", banned, gotIDs) + } + + // At least one following track should appear. + assert.True(t, gotIDs[101] || gotIDs[102], "expected a following track in results, got %v", gotIDs) + + // The trending and underground tracks should both appear. + assert.True(t, gotIDs[201], "expected trending track 201 in results, got %v", gotIDs) + assert.True(t, gotIDs[301], "expected underground track 301 in results, got %v", gotIDs) + + // Recommended candidate (501) should appear (top-genre Rock, + // excluded play is 401). + assert.True(t, gotIDs[501], "expected recommended track 501 in results, got %v", gotIDs) + // Played track (401) should NOT appear in recommended. + assert.False(t, gotIDs[401], "played track 401 should be excluded from recommended") +} + +func TestV1UsersFeedForYou_RequiresValidUserId(t *testing.T) { + app := emptyTestApp(t) + status, _ := testGet(t, app, "/v1/users/not-a-real-id/feed/for-you") + assert.Equal(t, 400, status) +} + +func TestV1UsersFeedForYou_ExcludesAlreadySavedTracks(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + database.Seed(app.pool.Replicas[0], usersFeedForYouFixtures()) + + var response struct { + Data []dbv1.Track + } + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?limit=50" + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + for _, tr := range response.Data { + assert.NotEqual(t, int32(701), tr.TrackID, "already-saved track 701 should be filtered") + } +} + +func TestV1UsersFeedForYou_DedupeAcrossSources(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + // Track 100 is reachable from BOTH the following source (user 2 is + // followed) AND the trending source (it has an ungenred trending + // score). It must appear exactly once in the response. + now := time.Now() + users := []map[string]any{ + {"user_id": 1, "handle": "me", "handle_lc": "me", "wallet": "0x0000000000000000000000000000000000000010"}, + {"user_id": 2, "handle": "dual", "handle_lc": "dual", "wallet": "0x0000000000000000000000000000000000000020"}, + } + aggregateUser := []map[string]any{ + {"user_id": 2, "follower_count": 5000, "following_count": 100}, + } + tracks := []map[string]any{ + {"track_id": 100, "owner_id": 2, "title": "dual-source", "created_at": now.Add(-2 * time.Hour)}, + } + follows := []map[string]any{ + {"follower_user_id": 1, "followee_user_id": 2}, + } + trackTrendingScores := []map[string]any{ + {"track_id": 100, "score": 9_000_000_000, "time_range": "week", "genre": ""}, + } + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": users, + "aggregate_user": aggregateUser, + "tracks": tracks, + "follows": follows, + "track_trending_scores": trackTrendingScores, + }) + + var response struct { + Data []dbv1.Track + } + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?limit=20" + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + count := 0 + for _, tr := range response.Data { + if tr.TrackID == 100 { + count++ + } + } + assert.Equal(t, 1, count, "track 100 should appear exactly once after dedupe; saw %d", count) +} + +func TestV1UsersFeedForYou_PaginationDoesNotRepeat(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + database.Seed(app.pool.Replicas[0], usersFeedForYouFixtures()) + + page := func(limit, offset int) []int32 { + var resp struct { + Data []dbv1.Track + } + path := fmt.Sprintf("/v1/users/%s/feed/for-you?limit=%d&offset=%d", + trashid.MustEncodeHashID(1), limit, offset) + status, body := testGet(t, app, path, &resp) + require.Equal(t, 200, status, string(body)) + ids := make([]int32, len(resp.Data)) + for i, tr := range resp.Data { + ids[i] = tr.TrackID + } + return ids + } + + first := page(2, 0) + second := page(2, 2) + + seen := map[int32]bool{} + for _, id := range first { + seen[id] = true + } + for _, id := range second { + assert.Falsef(t, seen[id], "track %d appeared on both pages", id) + } +} + +func TestV1UsersFeedForYou_InvalidParams(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + for _, val := range []string{"-1", "101", "invalid"} { + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?limit=" + val + status, _ := testGet(t, app, path) + assert.Equal(t, 400, status, "limit=%s", val) + } + for _, val := range []string{"-1", "201", "invalid"} { + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?offset=" + val + status, _ := testGet(t, app, path) + assert.Equal(t, 400, status, "offset=%s", val) + } +} + +// TestV1UsersFeedForYou_InterleaveBlend exercises the slot pattern. Seed +// each source with an unambiguous, named track, then assert that all four +// sources contributed at least one track within a single 10-slot page. +// +// track 1100, 1101 -> following (user 2) +// track 1200 -> trending (user 3, no genre on trending score) +// track 1300 -> underground (user 4, sub-1500 follower & following) +// track 1500 -> recommended (top-genre Rock; user 5) +// track 1600 (Rock) -> a played track that drives top_genres = Rock +func TestV1UsersFeedForYou_InterleaveBlend(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + now := time.Now() + hoursAgo := func(h int) time.Time { return now.Add(-time.Duration(h) * time.Hour) } + + users := []map[string]any{ + {"user_id": 1, "handle": "blender_me", "handle_lc": "blender_me", + "wallet": "0x0000000000000000000000000000000000000100"}, + {"user_id": 2, "handle": "blender_following", "handle_lc": "blender_following", + "wallet": "0x0000000000000000000000000000000000000200"}, + {"user_id": 3, "handle": "blender_trending", "handle_lc": "blender_trending", + "wallet": "0x0000000000000000000000000000000000000300"}, + {"user_id": 4, "handle": "blender_underground", "handle_lc": "blender_underground", + "wallet": "0x0000000000000000000000000000000000000400"}, + {"user_id": 5, "handle": "blender_rec", "handle_lc": "blender_rec", + "wallet": "0x0000000000000000000000000000000000000500"}, + } + aggregateUser := []map[string]any{ + {"user_id": 2, "follower_count": 100, "following_count": 100}, + {"user_id": 3, "follower_count": 5000, "following_count": 200}, + {"user_id": 4, "follower_count": 100, "following_count": 50}, + {"user_id": 5, "follower_count": 100, "following_count": 100}, + } + tracks := []map[string]any{ + {"track_id": 1100, "owner_id": 2, "title": "follow1", "created_at": hoursAgo(1)}, + {"track_id": 1101, "owner_id": 2, "title": "follow2", "created_at": hoursAgo(2)}, + {"track_id": 1200, "owner_id": 3, "title": "trending1", "created_at": hoursAgo(20)}, + {"track_id": 1300, "owner_id": 4, "title": "underground1", "created_at": hoursAgo(20)}, + {"track_id": 1500, "owner_id": 5, "title": "rec1", "created_at": hoursAgo(20), "genre": "Rock"}, + // played track to seed top_genres = Rock + {"track_id": 1600, "owner_id": 5, "title": "ihaveplayed", "created_at": hoursAgo(50), "genre": "Rock"}, + } + follows := []map[string]any{ + {"follower_user_id": 1, "followee_user_id": 2}, + } + plays := []map[string]any{ + {"id": 9001, "user_id": 1, "play_item_id": 1600, "created_at": hoursAgo(50)}, + } + trackTrendingScores := []map[string]any{ + // Trending and underground are ungenred. + {"track_id": 1200, "score": 9_000_000_000, "time_range": "week", "genre": ""}, + {"track_id": 1300, "score": 5_000_000_000, "time_range": "week", "genre": ""}, + // Recommended is genre-tagged Rock. + {"track_id": 1500, "score": 8_000_000_000, "time_range": "week", "genre": "Rock"}, + {"track_id": 1600, "score": 7_000_000_000, "time_range": "week", "genre": "Rock"}, + } + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": users, + "aggregate_user": aggregateUser, + "tracks": tracks, + "follows": follows, + "plays": plays, + "track_trending_scores": trackTrendingScores, + }) + + var response struct { + Data []dbv1.Track + } + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you?limit=10" + status, body := testGet(t, app, path, &response) + require.Equal(t, 200, status, string(body)) + + got := map[int32]bool{} + for _, tr := range response.Data { + got[tr.TrackID] = true + } + + // All four sources should have contributed at least one track to + // the first 10-slot page. + assert.True(t, got[1100] || got[1101], "expected a following track in the first page, got %v", got) + assert.True(t, got[1200], "expected the trending track in the first page, got %v", got) + assert.True(t, got[1300], "expected the underground track in the first page, got %v", got) + assert.True(t, got[1500], "expected the recommended track in the first page, got %v", got) +} + +// TestBlendForYouSources_Pattern unit-tests the interleave with synthetic +// ranked lists and asserts the canonical 10-slot ordering when every +// source is fully populated. +func TestBlendForYouSources_Pattern(t *testing.T) { + bySrc := map[string][]int32{ + "recommended": {10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, + "following": {20, 21, 22, 23, 24, 25, 26, 27, 28, 29}, + "trending": {30, 31, 32, 33, 34, 35, 36, 37, 38, 39}, + "underground": {40, 41, 42, 43, 44, 45, 46, 47, 48, 49}, + } + got := blendForYouSources(bySrc, 10) + want := []int32{10, 11, 12, 20, 13, 30, 14, 21, 40, 15} + assert.Equal(t, want, got, "first 10 slots should follow [R R R F R T R F U R]") +} + +// TestBlendForYouSources_FallthroughWhenSourceEmpty verifies that an +// empty source delegates to the next source in the fallback order. +func TestBlendForYouSources_FallthroughWhenSourceEmpty(t *testing.T) { + // No `following` candidates at all; following slots should be + // filled by the next priority source (recommended). + bySrc := map[string][]int32{ + "recommended": {10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, + "following": {}, + "trending": {30}, + "underground": {40}, + } + got := blendForYouSources(bySrc, 10) + // At slot 3 (following), recommended fills in (13). At slot 7 + // (following again), recommended fills in (15). + want := []int32{10, 11, 12, 13, 14, 30, 15, 16, 40, 17} + assert.Equal(t, want, got) +} + +// TestBlendForYouSources_GlobalDedupe verifies that an id appearing in +// multiple sources is emitted only once. +func TestBlendForYouSources_GlobalDedupe(t *testing.T) { + bySrc := map[string][]int32{ + "recommended": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + "following": {2, 11, 12}, // 2 collides with recommended + "trending": {3, 13}, // 3 collides + "underground": {1, 14}, // 1 collides + } + got := blendForYouSources(bySrc, 10) + seen := map[int32]int{} + for _, id := range got { + seen[id]++ + } + for id, n := range seen { + assert.Equalf(t, 1, n, "track id %d emitted %d times", id, n) + } +}