diff --git a/api/server.go b/api/server.go index 5adb4c2d..616afc5b 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) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 6a72c5f0..6352d359 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -9375,6 +9375,76 @@ 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 feed for the user identified in the + path. Twitter-style multi-source pipeline — candidate retrieval + (in-network, trending, underground, similar-artist) → linear + ranking (recency decay × engagement × social affinity, weighted + by source) → diversity (per-artist cap + consecutive-same-artist + lookahead). + 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: 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 + - 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_users_feed_for_you.go b/api/v1_users_feed_for_you.go new file mode 100644 index 00000000..e378d811 --- /dev/null +++ b/api/v1_users_feed_for_you.go @@ -0,0 +1,417 @@ +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:"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"` +} + +// v1UsersFeedForYou returns a personalized "For You" track feed for the +// user identified in the path. 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 the path-param user (don't resurface) +// - the path-param user'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. +// +// Path: +// - id (required): the user being personalized for. Resolved by +// requireUserIdMiddleware; the handler returns the 400 from that +// middleware on an invalid hash id. +// +// Query params: +// - limit (default 25, max 100) +// - offset (default 0, max 200) +// - max_per_artist (default 3, max 10) — author cap per page +// - user_id (optional): the caller's id. Independent of the +// path id — used to populate has_current_user_reposted and similar +// viewer-relative fields on the returned tracks. Same role it plays +// on every other /v1/users/{id}/... endpoint. +func (app *ApiServer) v1UsersFeedForYou(c *fiber.Ctx) error { + var params = GetUsersFeedForYouParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + // Path id — the user we personalize for. Validated by middleware. + userId := app.getUserId(c) + // Optional caller id from ?user_id=, used only for viewer-relative + // track fields on the response shape. + myId := app.getMyId(c) + + 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": userId, + "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_users_feed_for_you_test.go b/api/v1_users_feed_for_you_test.go new file mode 100644 index 00000000..c1821c90 --- /dev/null +++ b/api/v1_users_feed_for_you_test.go @@ -0,0 +1,402 @@ +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/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you" + 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_RequiresValidUserId(t *testing.T) { + app := emptyTestApp(t) + // Invalid hash id in the path → 400 from requireUserIdMiddleware. + status, _ := testGet(t, app, "/v1/users/not-a-real-id/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/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you" + 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/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.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/users/" + trashid.MustEncodeHashID(1) + "/feed/for-you" + 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/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 TestV1FeedForYou_InvalidParams(t *testing.T) { + app := emptyTestApp(t) + app.skipAuthCheck = true + + for _, val := range []string{"-1", "101", "invalid"} { + status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/feed/for-you?limit="+val) + assert.Equal(t, 400, status, "limit=%s", val) + } + for _, val := range []string{"-1", "invalid"} { + status, _ := testGet(t, app, "/v1/users/"+trashid.MustEncodeHashID(1)+"/feed/for-you?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/users/" + trashid.MustEncodeHashID(me) + "/feed/for-you" + 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)") +}