diff --git a/api/server.go b/api/server.go index d76054e3..b2e9f2cb 100644 --- a/api/server.go +++ b/api/server.go @@ -456,7 +456,9 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/tracks/trending", app.v1TracksTrending) g.Get("/tracks/trending/ids", app.v1TracksTrendingIds) + g.Get("/tracks/trending/winners", app.v1TracksTrendingWinners) g.Get("/tracks/trending/underground", app.v1TracksTrendingUnderground) + g.Get("/tracks/trending/underground/winners", app.v1TracksTrendingUndergroundWinners) g.Get("/tracks/recommended", app.v1TracksTrending) g.Get("/tracks/recent-premium", app.v1TracksRecentPremium) g.Get("/tracks/usdc-purchase", app.v1TracksUsdcPurchase) diff --git a/api/server_test.go b/api/server_test.go index 33e9cf8e..7b713573 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -99,6 +99,7 @@ func testAppWithFixtures(t *testing.T) *ApiServer { database.SeedTable(app.pool.Replicas[0], "saves", testdata.SaveFixtures) database.SeedTable(app.pool.Replicas[0], "tracks", testdata.TrackFixtures) database.SeedTable(app.pool.Replicas[0], "track_trending_scores", testdata.TrackTrendingScoresFixtures) + database.SeedTable(app.pool.Replicas[0], "trending_results", testdata.TrendingResultsFixtures) database.SeedTable(app.pool.Replicas[0], "track_routes", testdata.TrackRoutesFixtures) database.SeedTable(app.pool.Replicas[0], "usdc_purchases", testdata.UsdcPurchasesFixtures) database.SeedTable(app.pool.Replicas[0], "usdc_transactions_history", testdata.UsdcTransactionsHistoryFixtures) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 76dd9149..72a0fbd8 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -2420,6 +2420,37 @@ paths: "500": description: Server error content: {} + /tracks/trending/winners: + get: + tags: + - tracks + description: Gets weekly trending winners from the trending_results table. Returns full track objects for the specified week. Defaults to the most recent week with data when no week is provided. + operationId: Get Trending Winners + parameters: + - name: week + in: query + description: Target week in YYYY-MM-DD format. Defaults to the most recent week with data. + schema: + type: string + format: date + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/tracks_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /tracks/trending/{version}: get: tags: @@ -2579,6 +2610,37 @@ paths: application/json: schema: $ref: "#/components/schemas/tracks_response" + /tracks/trending/underground/winners: + get: + tags: + - tracks + description: Gets weekly trending underground winners from the trending_results table. Returns full track objects for the specified week. Defaults to the most recent week with data when no week is provided. + operationId: Get Trending Underground Winners + parameters: + - name: week + in: query + description: Target week in YYYY-MM-DD format. Defaults to the most recent week with data. + schema: + type: string + format: date + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/tracks_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /tracks/trending/underground/{version}: get: tags: diff --git a/api/testdata/trending_results_fixtures.go b/api/testdata/trending_results_fixtures.go new file mode 100644 index 00000000..e0ed60d5 --- /dev/null +++ b/api/testdata/trending_results_fixtures.go @@ -0,0 +1,12 @@ +package testdata + +var TrendingResultsFixtures = []map[string]any{ + // Week 2022-01-21 - tracks 300, 202, 200 (rank 1, 2, 3) - matches trending order + {"user_id": 3, "id": "300", "rank": 1, "type": "TrendingType.TRACKS", "version": "TrendingVersion.ML51L", "week": "2022-01-21"}, + {"user_id": 2, "id": "202", "rank": 2, "type": "TrendingType.TRACKS", "version": "TrendingVersion.ML51L", "week": "2022-01-21"}, + {"user_id": 2, "id": "200", "rank": 3, "type": "TrendingType.TRACKS", "version": "TrendingVersion.ML51L", "week": "2022-01-21"}, + // Week 2022-01-21 - underground tracks 519, 520, 521 (rank 1, 2, 3) + {"user_id": 8, "id": "519", "rank": 1, "type": "TrendingType.UNDERGROUND_TRACKS", "version": "TrendingVersion.ML51L", "week": "2022-01-21"}, + {"user_id": 11, "id": "520", "rank": 2, "type": "TrendingType.UNDERGROUND_TRACKS", "version": "TrendingVersion.ML51L", "week": "2022-01-21"}, + {"user_id": 1, "id": "521", "rank": 3, "type": "TrendingType.UNDERGROUND_TRACKS", "version": "TrendingVersion.ML51L", "week": "2022-01-21"}, +} diff --git a/api/v1_tracks_trending_underground_winners.go b/api/v1_tracks_trending_underground_winners.go new file mode 100644 index 00000000..4cbf579e --- /dev/null +++ b/api/v1_tracks_trending_underground_winners.go @@ -0,0 +1,93 @@ +package api + +import ( + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetTrendingUndergroundWinnersTracksParams struct { + Week string `query:"week" default:""` +} + +func (app *ApiServer) v1TracksTrendingUndergroundWinners(c *fiber.Ctx) error { + var params = GetTrendingUndergroundWinnersTracksParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myId := app.getMyId(c) + + trackIds, err := app.getTrendingUndergroundWinnersIds(c, params.Week) + if err != nil { + return err + } + + if len(trackIds) == 0 { + return v1TracksResponse(c, []dbv1.Track{}) + } + + tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIds, + MyID: myId, + }, + }) + if err != nil { + return err + } + + return v1TracksResponse(c, tracks) +} + +func (app *ApiServer) getTrendingUndergroundWinnersIds(c *fiber.Ctx, weekParam string) ([]int32, error) { + args := pgx.NamedArgs{ + "type": "TrendingType.UNDERGROUND_TRACKS", + } + + var weekFilter string + if weekParam != "" { + args["week"] = weekParam + // Nearest row on or after the requested week; if none (e.g. future date), use most recent + weekFilter = `AND tr.week = COALESCE( + (SELECT MIN(tr2.week) FROM trending_results tr2 + WHERE tr2.type = @type AND tr2.week >= @week::date), + (SELECT MAX(tr2.week) FROM trending_results tr2 + WHERE tr2.type = @type) + )` + } else { + // Default: use the most recent week in the table that is before today + weekFilter = `AND tr.week = ( + SELECT MAX(tr2.week) + FROM trending_results tr2 + WHERE tr2.type = @type + AND tr2.week < CURRENT_DATE + )` + } + + sql := ` + SELECT tr.id::int + FROM trending_results tr + JOIN tracks t ON t.track_id = tr.id::int + AND t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + WHERE tr.type = @type + ` + weekFilter + ` + ORDER BY tr.rank ASC + ` + + rows, err := app.pool.Query(c.Context(), sql, args) + if err != nil { + return nil, err + } + defer rows.Close() + + trackIds, err := pgx.CollectRows(rows, pgx.RowTo[int32]) + if err != nil { + return nil, err + } + + return trackIds, nil +} diff --git a/api/v1_tracks_trending_underground_winners_test.go b/api/v1_tracks_trending_underground_winners_test.go new file mode 100644 index 00000000..a3bb7aab --- /dev/null +++ b/api/v1_tracks_trending_underground_winners_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "testing" + + "api.audius.co/api/dbv1" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestGetTrendingUndergroundWinnersTracks(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Test with explicit week - uses trending_results for 2022-01-21 + status, body := testGet(t, app, "/v1/tracks/trending/underground/winners?week=2022-01-21", &resp) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 3, + }) + + // Verify order: 519 (rank 1), 520 (rank 2), 521 (rank 3) + assert.Equal(t, trashid.MustEncodeHashID(519), resp.Data[0].ID) + assert.Equal(t, "Jazz", resp.Data[0].Genre.String) + + assert.Equal(t, trashid.MustEncodeHashID(520), resp.Data[1].ID) + assert.Equal(t, "Classical", resp.Data[1].Genre.String) + + assert.Equal(t, trashid.MustEncodeHashID(521), resp.Data[2].ID) + assert.Equal(t, "Electronic", resp.Data[2].Genre.String) +} + +func TestGetTrendingUndergroundWinnersTracksDefaultWeek(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Without week param - defaults to most recent week in table before today (2022-01-21) + status, body := testGet(t, app, "/v1/tracks/trending/underground/winners", &resp) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 3, + }) + + assert.Equal(t, trashid.MustEncodeHashID(519), resp.Data[0].ID) + assert.Equal(t, trashid.MustEncodeHashID(520), resp.Data[1].ID) + assert.Equal(t, trashid.MustEncodeHashID(521), resp.Data[2].ID) +} + +func TestGetTrendingUndergroundWinnersTracksOldDateUsesEarliestWeek(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Query before any data (2020-01-01): nearest row on or after is 2022-01-21 (earliest we have) + status, _ := testGet(t, app, "/v1/tracks/trending/underground/winners?week=2020-01-01", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) +} + +func TestGetTrendingUndergroundWinnersTracksNearestWeek(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Query 2022-01-20: nearest row on or after is 2022-01-21 + status, _ := testGet(t, app, "/v1/tracks/trending/underground/winners?week=2022-01-20", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) + assert.Equal(t, trashid.MustEncodeHashID(519), resp.Data[0].ID) + + // Query 2022-01-21: exact match + status, _ = testGet(t, app, "/v1/tracks/trending/underground/winners?week=2022-01-21", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) + + // Query 2022-01-25: no row after 2022-01-21, use most recent (2022-01-21) + status, _ = testGet(t, app, "/v1/tracks/trending/underground/winners?week=2022-01-25", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) + assert.Equal(t, trashid.MustEncodeHashID(519), resp.Data[0].ID) +} diff --git a/api/v1_tracks_trending_winners.go b/api/v1_tracks_trending_winners.go new file mode 100644 index 00000000..31a477ca --- /dev/null +++ b/api/v1_tracks_trending_winners.go @@ -0,0 +1,93 @@ +package api + +import ( + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetTrendingWinnersTracksParams struct { + Week string `query:"week" default:""` +} + +func (app *ApiServer) v1TracksTrendingWinners(c *fiber.Ctx) error { + var params = GetTrendingWinnersTracksParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + myId := app.getMyId(c) + + trackIds, err := app.getTrendingWinnersIds(c, params.Week) + if err != nil { + return err + } + + if len(trackIds) == 0 { + return v1TracksResponse(c, []dbv1.Track{}) + } + + tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIds, + MyID: myId, + }, + }) + if err != nil { + return err + } + + return v1TracksResponse(c, tracks) +} + +func (app *ApiServer) getTrendingWinnersIds(c *fiber.Ctx, weekParam string) ([]int32, error) { + args := pgx.NamedArgs{ + "type": "TrendingType.TRACKS", + } + + var weekFilter string + if weekParam != "" { + args["week"] = weekParam + // Nearest row on or after the requested week; if none (e.g. future date), use most recent + weekFilter = `AND tr.week = COALESCE( + (SELECT MIN(tr2.week) FROM trending_results tr2 + WHERE tr2.type = @type AND tr2.week >= @week::date), + (SELECT MAX(tr2.week) FROM trending_results tr2 + WHERE tr2.type = @type) + )` + } else { + // Default: use the most recent week in the table that is before today + weekFilter = `AND tr.week = ( + SELECT MAX(tr2.week) + FROM trending_results tr2 + WHERE tr2.type = @type + AND tr2.week < CURRENT_DATE + )` + } + + sql := ` + SELECT tr.id::int + FROM trending_results tr + JOIN tracks t ON t.track_id = tr.id::int + AND t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + WHERE tr.type = @type + ` + weekFilter + ` + ORDER BY tr.rank ASC + ` + + rows, err := app.pool.Query(c.Context(), sql, args) + if err != nil { + return nil, err + } + defer rows.Close() + + trackIds, err := pgx.CollectRows(rows, pgx.RowTo[int32]) + if err != nil { + return nil, err + } + + return trackIds, nil +} diff --git a/api/v1_tracks_trending_winners_test.go b/api/v1_tracks_trending_winners_test.go new file mode 100644 index 00000000..ed9cafe2 --- /dev/null +++ b/api/v1_tracks_trending_winners_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "testing" + + "api.audius.co/api/dbv1" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func TestGetTrendingWinnersTracks(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Test with explicit week - uses trending_results for 2022-01-21 + status, body := testGet(t, app, "/v1/tracks/trending/winners?week=2022-01-21", &resp) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 3, + }) + + // Verify order: 300 (rank 1), 202 (rank 2), 200 (rank 3) + assert.Equal(t, trashid.MustEncodeHashID(300), resp.Data[0].ID) + assert.Equal(t, "Electronic", resp.Data[0].Genre.String) + + assert.Equal(t, trashid.MustEncodeHashID(202), resp.Data[1].ID) + assert.Equal(t, "Alternative", resp.Data[1].Genre.String) + + assert.Equal(t, trashid.MustEncodeHashID(200), resp.Data[2].ID) + assert.Equal(t, "Electronic", resp.Data[2].Genre.String) +} + +func TestGetTrendingWinnersTracksDefaultWeek(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Without week param - defaults to most recent week in table before today (2022-01-21) + status, body := testGet(t, app, "/v1/tracks/trending/winners", &resp) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 3, + }) + + assert.Equal(t, trashid.MustEncodeHashID(300), resp.Data[0].ID) + assert.Equal(t, trashid.MustEncodeHashID(202), resp.Data[1].ID) + assert.Equal(t, trashid.MustEncodeHashID(200), resp.Data[2].ID) +} + +func TestGetTrendingWinnersTracksOldDateUsesEarliestWeek(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Query before any data (2020-01-01): nearest row on or after is 2022-01-21 (earliest we have) + status, _ := testGet(t, app, "/v1/tracks/trending/winners?week=2020-01-01", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) +} + +func TestGetTrendingWinnersTracksNearestWeek(t *testing.T) { + app := testAppWithFixtures(t) + + var resp struct { + Data []dbv1.Track + } + + // Query 2022-01-20: nearest row on or after is 2022-01-21 + status, _ := testGet(t, app, "/v1/tracks/trending/winners?week=2022-01-20", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) + assert.Equal(t, trashid.MustEncodeHashID(300), resp.Data[0].ID) + + // Query 2022-01-21: exact match + status, _ = testGet(t, app, "/v1/tracks/trending/winners?week=2022-01-21", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) + + // Query 2022-01-25: no row after 2022-01-21, use most recent (2022-01-21) + status, _ = testGet(t, app, "/v1/tracks/trending/winners?week=2022-01-25", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 3, len(resp.Data)) + assert.Equal(t, trashid.MustEncodeHashID(300), resp.Data[0].ID) +} diff --git a/database/seed.go b/database/seed.go index 6cc48054..1c7da467 100644 --- a/database/seed.go +++ b/database/seed.go @@ -153,6 +153,14 @@ var ( "score": nil, "created_at": time.Now(), }, + "trending_results": { + "user_id": nil, + "id": nil, + "rank": nil, + "type": "TrendingType.TRACKS", + "version": "TrendingVersion.ML51L", + "week": time.Now(), + }, "playlist_trending_scores": { "playlist_id": nil, "type": "PLAYLISTS",