From 21694c357f05328308e5b015a878ccd3f81c93ab Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 16 Jun 2026 20:23:37 -0700 Subject: [PATCH] feat: add popular genres endpoint --- api/server.go | 3 + api/swagger/swagger-v1.yaml | 78 ++++++++++++++++++++++++++ api/v1_genres_popular.go | 54 ++++++++++++++++++ api/v1_genres_popular_test.go | 101 ++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 api/v1_genres_popular.go create mode 100644 api/v1_genres_popular_test.go diff --git a/api/server.go b/api/server.go index 49bd7315..3daf7df4 100644 --- a/api/server.go +++ b/api/server.go @@ -628,6 +628,9 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/prizes/claim", app.v1PrizesClaim) g.Get("/wallet/:wallet/prizes", app.v1WalletPrizes) + // Genres + g.Get("/genres/popular", app.v1GenresPopular) + // Resolve g.Get("/resolve", app.v1Resolve) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index ed669e99..c8d11d1b 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -59,6 +59,8 @@ tags: description: Events related operations - name: explore description: Explore related operations + - name: genres + description: Genre discovery operations - name: rewards description: Rewards related operations - name: prizes @@ -1591,6 +1593,57 @@ paths: "500": description: Server error content: {} + /genres/popular: + get: + tags: + - genres + description: Get popular genres from recently created tracks. + operationId: Get Popular Genres + parameters: + - name: start_time + in: query + description: Unix timestamp. Only tracks created after this time are counted. + schema: + type: integer + default: 0 + - name: limit + in: query + description: The number of genres to fetch + schema: + type: integer + minimum: 1 + maximum: 100 + default: 100 + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + minimum: 0 + default: 0 + - name: min_count + in: query + description: Minimum number of tracks required for a genre to be returned + schema: + type: integer + minimum: 1 + maximum: 1000000 + default: 1 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/popular_genres_response" + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} /playlists: get: tags: @@ -10594,6 +10647,28 @@ paths: content: {} components: schemas: + popular_genres_response: + required: + - data + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/popular_genre" + popular_genre: + required: + - name + - count + type: object + properties: + name: + type: string + description: Genre name + count: + type: integer + format: int64 + description: Number of tracks in this genre for the requested time window create_access_key_response: type: object required: @@ -13564,6 +13639,9 @@ components: event_data: type: object properties: {} + permalink: + type: string + description: Canonical contest permalink derived from event_routes. create_user_request_body: type: object required: diff --git a/api/v1_genres_popular.go b/api/v1_genres_popular.go new file mode 100644 index 00000000..8acbd7f0 --- /dev/null +++ b/api/v1_genres_popular.go @@ -0,0 +1,54 @@ +package api + +import ( + "time" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" +) + +type GetPopularGenresParams struct { + StartTime int `query:"start_time" default:"0"` + Limit int `query:"limit" default:"100" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0"` + MinCount int `query:"min_count" default:"1" validate:"min=1,max=1000000"` +} + +type PopularGenre struct { + Name string `json:"name"` + Count int64 `json:"count"` +} + +func (app *ApiServer) v1GenresPopular(c *fiber.Ctx) error { + params := GetPopularGenresParams{} + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + startTime := time.Unix(int64(params.StartTime), 0) + + genres, err := app.queries.GetGenres(c.Context(), dbv1.GetGenresParams{ + LimitVal: int32(params.Limit), + OffsetVal: int32(params.Offset), + StartTime: startTime, + }) + if err != nil { + return err + } + + result := make([]PopularGenre, 0, len(genres)) + for _, genre := range genres { + if genre.Count < int64(params.MinCount) { + continue + } + + result = append(result, PopularGenre{ + Name: genre.Genre.String, + Count: genre.Count, + }) + } + + return c.JSON(fiber.Map{ + "data": result, + }) +} diff --git a/api/v1_genres_popular_test.go b/api/v1_genres_popular_test.go new file mode 100644 index 00000000..cd596c39 --- /dev/null +++ b/api/v1_genres_popular_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenresPopular(t *testing.T) { + app := testAppWithFixtures(t) + + var response struct { + Data []struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + } + + status, _ := testGet(t, app, "/v1/genres/popular?start_time=0", &response) + require.Equal(t, 200, status) + require.NotEmpty(t, response.Data) + + var foundElectronic bool + for i, genre := range response.Data { + if genre.Name == "Electronic" { + foundElectronic = true + } + if i > 0 { + assert.GreaterOrEqual(t, response.Data[i-1].Count, genre.Count) + } + } + assert.True(t, foundElectronic, "expected fixture genres in response") +} + +func TestGenresPopularMinCount(t *testing.T) { + app := testAppWithFixtures(t) + + var response struct { + Data []struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + } + + status, _ := testGet(t, app, "/v1/genres/popular?start_time=0&min_count=2", &response) + require.Equal(t, 200, status) + require.NotEmpty(t, response.Data) + + for _, genre := range response.Data { + assert.GreaterOrEqual(t, genre.Count, int64(2)) + } +} + +func TestGenresPopularExcludesAccessAuthoritiesTracks(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + require.NotNil(t, app.writePool, "test requires write pool") + + url := fmt.Sprintf("/v1/genres/popular?start_time=%d", 0) + var before struct { + Data []struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + } + status, _ := testGet(t, app, url, &before) + require.Equal(t, 200, status) + + var electronicCountBefore int64 + for _, g := range before.Data { + if g.Name == "Electronic" { + electronicCountBefore = g.Count + break + } + } + require.Greater(t, electronicCountBefore, int64(0), "fixtures should have Electronic tracks") + + _, err := app.writePool.Exec(ctx, `UPDATE tracks SET access_authorities = ARRAY['0xgate']::text[] WHERE track_id = 100 AND is_current = true`) + require.NoError(t, err) + + var after struct { + Data []struct { + Name string `json:"name"` + Count int64 `json:"count"` + } + } + status, _ = testGet(t, app, url, &after) + require.Equal(t, 200, status) + + var electronicCountAfter int64 + for _, g := range after.Data { + if g.Name == "Electronic" { + electronicCountAfter = g.Count + break + } + } + assert.Equal(t, electronicCountBefore-1, electronicCountAfter, "genre count must exclude access_authorities tracks") +}