From 97c11bd6499264e57b53e364472d2ab41052b8df Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 16 Mar 2026 17:08:12 -0700 Subject: [PATCH 1/5] Add download count to track --- api/dbv1/db.go | 2 +- api/dbv1/exists.sql.go | 2 +- api/dbv1/get_account_playlists.sql.go | 2 +- api/dbv1/get_connected_wallets.sql.go | 2 +- api/dbv1/get_developer_apps.sql.go | 2 +- api/dbv1/get_events.sql.go | 2 +- api/dbv1/get_extended_account_fields.sql.go | 2 +- api/dbv1/get_genres.sql.go | 2 +- api/dbv1/get_grants.sql.go | 2 +- api/dbv1/get_playlist_ids_by_permalink.sql.go | 2 +- api/dbv1/get_playlist_ids_by_upc.sql.go | 2 +- api/dbv1/get_playlists.sql.go | 2 +- api/dbv1/get_plays.sql.go | 2 +- api/dbv1/get_track_ids_by_isrc.sql.go | 2 +- api/dbv1/get_track_ids_by_permalink.sql.go | 2 +- api/dbv1/get_tracks.sql.go | 12 ++++++- api/dbv1/get_undisbursed_challenges.sql.go | 2 +- api/dbv1/get_user_for_handle.sql.go | 2 +- api/dbv1/get_user_for_wallet.sql.go | 2 +- api/dbv1/get_users.sql.go | 2 +- api/dbv1/models.go | 2 +- api/dbv1/queries/get_tracks.sql | 8 +++++ api/swagger/swagger-v1.yaml | 8 +++++ api/v1_track_test.go | 32 +++++++++++++++++-- api/v1_tracks_test.go | 10 +++--- 25 files changed, 82 insertions(+), 28 deletions(-) diff --git a/api/dbv1/db.go b/api/dbv1/db.go index 175e8ce2..2e65aa8d 100644 --- a/api/dbv1/db.go +++ b/api/dbv1/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 package dbv1 diff --git a/api/dbv1/exists.sql.go b/api/dbv1/exists.sql.go index 148531b7..a4a4f369 100644 --- a/api/dbv1/exists.sql.go +++ b/api/dbv1/exists.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: exists.sql package dbv1 diff --git a/api/dbv1/get_account_playlists.sql.go b/api/dbv1/get_account_playlists.sql.go index a1b78d02..e57c511a 100644 --- a/api/dbv1/get_account_playlists.sql.go +++ b/api/dbv1/get_account_playlists.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_account_playlists.sql package dbv1 diff --git a/api/dbv1/get_connected_wallets.sql.go b/api/dbv1/get_connected_wallets.sql.go index e1d96ab4..12617269 100644 --- a/api/dbv1/get_connected_wallets.sql.go +++ b/api/dbv1/get_connected_wallets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_connected_wallets.sql package dbv1 diff --git a/api/dbv1/get_developer_apps.sql.go b/api/dbv1/get_developer_apps.sql.go index 80c416e8..05d886c7 100644 --- a/api/dbv1/get_developer_apps.sql.go +++ b/api/dbv1/get_developer_apps.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_developer_apps.sql package dbv1 diff --git a/api/dbv1/get_events.sql.go b/api/dbv1/get_events.sql.go index 8f33c7ea..03d7dfde 100644 --- a/api/dbv1/get_events.sql.go +++ b/api/dbv1/get_events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_events.sql package dbv1 diff --git a/api/dbv1/get_extended_account_fields.sql.go b/api/dbv1/get_extended_account_fields.sql.go index fdd3f5a6..a3feb641 100644 --- a/api/dbv1/get_extended_account_fields.sql.go +++ b/api/dbv1/get_extended_account_fields.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_extended_account_fields.sql package dbv1 diff --git a/api/dbv1/get_genres.sql.go b/api/dbv1/get_genres.sql.go index eca10991..9edcf7be 100644 --- a/api/dbv1/get_genres.sql.go +++ b/api/dbv1/get_genres.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_genres.sql package dbv1 diff --git a/api/dbv1/get_grants.sql.go b/api/dbv1/get_grants.sql.go index 509aa285..ce3c5642 100644 --- a/api/dbv1/get_grants.sql.go +++ b/api/dbv1/get_grants.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_grants.sql package dbv1 diff --git a/api/dbv1/get_playlist_ids_by_permalink.sql.go b/api/dbv1/get_playlist_ids_by_permalink.sql.go index a890e7e2..a679af80 100644 --- a/api/dbv1/get_playlist_ids_by_permalink.sql.go +++ b/api/dbv1/get_playlist_ids_by_permalink.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_playlist_ids_by_permalink.sql package dbv1 diff --git a/api/dbv1/get_playlist_ids_by_upc.sql.go b/api/dbv1/get_playlist_ids_by_upc.sql.go index 791a38d4..aefb4eba 100644 --- a/api/dbv1/get_playlist_ids_by_upc.sql.go +++ b/api/dbv1/get_playlist_ids_by_upc.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_playlist_ids_by_upc.sql package dbv1 diff --git a/api/dbv1/get_playlists.sql.go b/api/dbv1/get_playlists.sql.go index c98c7fca..86ee6579 100644 --- a/api/dbv1/get_playlists.sql.go +++ b/api/dbv1/get_playlists.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_playlists.sql package dbv1 diff --git a/api/dbv1/get_plays.sql.go b/api/dbv1/get_plays.sql.go index b543be00..266523f6 100644 --- a/api/dbv1/get_plays.sql.go +++ b/api/dbv1/get_plays.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_plays.sql package dbv1 diff --git a/api/dbv1/get_track_ids_by_isrc.sql.go b/api/dbv1/get_track_ids_by_isrc.sql.go index 116a02e7..4e1b0227 100644 --- a/api/dbv1/get_track_ids_by_isrc.sql.go +++ b/api/dbv1/get_track_ids_by_isrc.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_track_ids_by_isrc.sql package dbv1 diff --git a/api/dbv1/get_track_ids_by_permalink.sql.go b/api/dbv1/get_track_ids_by_permalink.sql.go index 968d59b7..97fa9689 100644 --- a/api/dbv1/get_track_ids_by_permalink.sql.go +++ b/api/dbv1/get_track_ids_by_permalink.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_track_ids_by_permalink.sql package dbv1 diff --git a/api/dbv1/get_tracks.sql.go b/api/dbv1/get_tracks.sql.go index e4095d50..28fd4ebe 100644 --- a/api/dbv1/get_tracks.sql.go +++ b/api/dbv1/get_tracks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_tracks.sql package dbv1 @@ -48,6 +48,14 @@ SELECT duration, is_downloadable, COALESCE(aggregate_plays.count, 0) as play_count, + ( + SELECT count(*)::bigint + FROM track_downloads d + WHERE (t.stem_of IS NOT NULL + AND d.parent_track_id = (t.stem_of->>'parent_track_id')::int + AND d.track_id = t.track_id) + OR (t.stem_of IS NULL AND d.parent_track_id = t.track_id) + ) AS download_count, ddex_app, pinned_comment_id, playlists_containing_track, @@ -259,6 +267,7 @@ type GetTracksRow struct { Duration pgtype.Int4 `json:"duration"` IsDownloadable bool `json:"is_downloadable"` PlayCount int64 `json:"play_count"` + DownloadCount int64 `json:"download_count"` DdexApp pgtype.Text `json:"ddex_app"` PinnedCommentID pgtype.Int4 `json:"pinned_comment_id"` PlaylistsContainingTrack []int32 `json:"playlists_containing_track"` @@ -350,6 +359,7 @@ func (q *Queries) GetTracks(ctx context.Context, arg GetTracksParams) ([]GetTrac &i.Duration, &i.IsDownloadable, &i.PlayCount, + &i.DownloadCount, &i.DdexApp, &i.PinnedCommentID, &i.PlaylistsContainingTrack, diff --git a/api/dbv1/get_undisbursed_challenges.sql.go b/api/dbv1/get_undisbursed_challenges.sql.go index a1fb20a9..1652161f 100644 --- a/api/dbv1/get_undisbursed_challenges.sql.go +++ b/api/dbv1/get_undisbursed_challenges.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_undisbursed_challenges.sql package dbv1 diff --git a/api/dbv1/get_user_for_handle.sql.go b/api/dbv1/get_user_for_handle.sql.go index addb4359..97924f4e 100644 --- a/api/dbv1/get_user_for_handle.sql.go +++ b/api/dbv1/get_user_for_handle.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_user_for_handle.sql package dbv1 diff --git a/api/dbv1/get_user_for_wallet.sql.go b/api/dbv1/get_user_for_wallet.sql.go index 3dbe1d58..732a132c 100644 --- a/api/dbv1/get_user_for_wallet.sql.go +++ b/api/dbv1/get_user_for_wallet.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_user_for_wallet.sql package dbv1 diff --git a/api/dbv1/get_users.sql.go b/api/dbv1/get_users.sql.go index 2f9dc4e2..5fabdb4a 100644 --- a/api/dbv1/get_users.sql.go +++ b/api/dbv1/get_users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: get_users.sql package dbv1 diff --git a/api/dbv1/models.go b/api/dbv1/models.go index 19840c86..bb9fdfc1 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 package dbv1 diff --git a/api/dbv1/queries/get_tracks.sql b/api/dbv1/queries/get_tracks.sql index dd826b1a..3062e400 100644 --- a/api/dbv1/queries/get_tracks.sql +++ b/api/dbv1/queries/get_tracks.sql @@ -33,6 +33,14 @@ SELECT duration, is_downloadable, COALESCE(aggregate_plays.count, 0) as play_count, + ( + SELECT count(*)::bigint + FROM track_downloads d + WHERE (t.stem_of IS NOT NULL + AND d.parent_track_id = (t.stem_of->>'parent_track_id')::int + AND d.track_id = t.track_id) + OR (t.stem_of IS NULL AND d.parent_track_id = t.track_id) + ) AS download_count, ddex_app, pinned_comment_id, playlists_containing_track, diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 7791f0c0..70d76dd1 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -13992,6 +13992,7 @@ components: - is_unlisted - permalink - play_count + - download_count - preview - remix_of - repost_count @@ -14047,6 +14048,9 @@ components: type: boolean play_count: type: integer + download_count: + type: integer + description: Full track + all stems (parent) or stem-only (stem) permalink: type: string is_streamable: @@ -15458,6 +15462,7 @@ components: - is_unlisted - permalink - play_count + - download_count - preview - remix_of - repost_count @@ -15513,6 +15518,9 @@ components: type: boolean play_count: type: integer + download_count: + type: integer + description: Full track + all stems (parent) or stem-only (stem) permalink: type: string is_streamable: diff --git a/api/v1_track_test.go b/api/v1_track_test.go index 6d2803bf..14a0bf3e 100644 --- a/api/v1_track_test.go +++ b/api/v1_track_test.go @@ -1,11 +1,13 @@ package api import ( + "context" "testing" "api.audius.co/api/dbv1" "api.audius.co/trashid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetTrack(t *testing.T) { @@ -18,9 +20,33 @@ func TestGetTrack(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.id": "eYJyn", - "data.title": "Culca Canyon", - "data.play_count": 0, + "data.id": "eYJyn", + "data.title": "Culca Canyon", + "data.play_count": 0, + "data.download_count": 0, + }) +} + +func TestGetTrackDownloadCount(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + require.NotNil(t, app.writePool, "test requires write pool") + + // Track 200 is "Culca Canyon" (eYJyn). Insert two download rows so download_count is 2. + _, err := app.writePool.Exec(ctx, ` + INSERT INTO track_downloads (txhash, blocknumber, parent_track_id, track_id, user_id) + VALUES ('tx-dl-1', 101, 200, 200, 1), ('tx-dl-2', 101, 200, 200, 2) + `) + require.NoError(t, err) + + var trackResponse struct { + Data dbv1.Track + } + status, body := testGet(t, app, "/v1/full/tracks/eYJyn", &trackResponse) + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.id": "eYJyn", + "data.download_count": 2, }) } diff --git a/api/v1_tracks_test.go b/api/v1_tracks_test.go index 8506da73..8c0feb5a 100644 --- a/api/v1_tracks_test.go +++ b/api/v1_tracks_test.go @@ -19,8 +19,9 @@ func TestTracksEndpoint(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.0.id": "eYZmn", - "data.0.title": "T1", + "data.0.id": "eYZmn", + "data.0.title": "T1", + "data.0.download_count": 0, }) } @@ -31,8 +32,9 @@ func TestGetTracksByPermalink(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.0.id": "eYake", - "data.0.title": "track by permalink", + "data.0.id": "eYake", + "data.0.title": "track by permalink", + "data.0.download_count": 0, }) } From c21ca41aa5cf19a250a4cef7e818a2939449d73e Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 17 Mar 2026 14:52:07 -0700 Subject: [PATCH 2/5] Make separate endpoint --- api/dbv1/get_track_download_counts.sql.go | 52 ++++++++++ api/dbv1/get_tracks.sql.go | 10 -- .../queries/get_track_download_counts.sql | 15 +++ api/dbv1/queries/get_tracks.sql | 8 -- api/server.go | 2 + api/swagger/swagger-v1.yaml | 96 +++++++++++++++++-- api/v1_track_download_count.go | 57 +++++++++++ api/v1_track_test.go | 23 +++-- api/v1_tracks_test.go | 10 +- 9 files changed, 233 insertions(+), 40 deletions(-) create mode 100644 api/dbv1/get_track_download_counts.sql.go create mode 100644 api/dbv1/queries/get_track_download_counts.sql create mode 100644 api/v1_track_download_count.go diff --git a/api/dbv1/get_track_download_counts.sql.go b/api/dbv1/get_track_download_counts.sql.go new file mode 100644 index 00000000..cd2e7998 --- /dev/null +++ b/api/dbv1/get_track_download_counts.sql.go @@ -0,0 +1,52 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: get_track_download_counts.sql + +package dbv1 + +import ( + "context" +) + +const getTrackDownloadCounts = `-- name: GetTrackDownloadCounts :many +SELECT + t.track_id, + ( + SELECT count(*)::bigint + FROM track_downloads d + WHERE (t.stem_of IS NOT NULL + AND d.parent_track_id = (t.stem_of->>'parent_track_id')::int + AND d.track_id = t.track_id) + OR (t.stem_of IS NULL AND d.parent_track_id = t.track_id) + ) AS download_count +FROM tracks t +WHERE t.track_id = ANY($1::int[]) + AND t.is_current = true + AND t.is_delete = false +` + +type GetTrackDownloadCountsRow struct { + TrackID int32 `json:"track_id"` + DownloadCount int64 `json:"download_count"` +} + +func (q *Queries) GetTrackDownloadCounts(ctx context.Context, trackIds []int32) ([]GetTrackDownloadCountsRow, error) { + rows, err := q.db.Query(ctx, getTrackDownloadCounts, trackIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTrackDownloadCountsRow + for rows.Next() { + var i GetTrackDownloadCountsRow + if err := rows.Scan(&i.TrackID, &i.DownloadCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/api/dbv1/get_tracks.sql.go b/api/dbv1/get_tracks.sql.go index 28fd4ebe..6b394ca5 100644 --- a/api/dbv1/get_tracks.sql.go +++ b/api/dbv1/get_tracks.sql.go @@ -48,14 +48,6 @@ SELECT duration, is_downloadable, COALESCE(aggregate_plays.count, 0) as play_count, - ( - SELECT count(*)::bigint - FROM track_downloads d - WHERE (t.stem_of IS NOT NULL - AND d.parent_track_id = (t.stem_of->>'parent_track_id')::int - AND d.track_id = t.track_id) - OR (t.stem_of IS NULL AND d.parent_track_id = t.track_id) - ) AS download_count, ddex_app, pinned_comment_id, playlists_containing_track, @@ -267,7 +259,6 @@ type GetTracksRow struct { Duration pgtype.Int4 `json:"duration"` IsDownloadable bool `json:"is_downloadable"` PlayCount int64 `json:"play_count"` - DownloadCount int64 `json:"download_count"` DdexApp pgtype.Text `json:"ddex_app"` PinnedCommentID pgtype.Int4 `json:"pinned_comment_id"` PlaylistsContainingTrack []int32 `json:"playlists_containing_track"` @@ -359,7 +350,6 @@ func (q *Queries) GetTracks(ctx context.Context, arg GetTracksParams) ([]GetTrac &i.Duration, &i.IsDownloadable, &i.PlayCount, - &i.DownloadCount, &i.DdexApp, &i.PinnedCommentID, &i.PlaylistsContainingTrack, diff --git a/api/dbv1/queries/get_track_download_counts.sql b/api/dbv1/queries/get_track_download_counts.sql new file mode 100644 index 00000000..c1c5eb0a --- /dev/null +++ b/api/dbv1/queries/get_track_download_counts.sql @@ -0,0 +1,15 @@ +-- name: GetTrackDownloadCounts :many +SELECT + t.track_id, + ( + SELECT count(*)::bigint + FROM track_downloads d + WHERE (t.stem_of IS NOT NULL + AND d.parent_track_id = (t.stem_of->>'parent_track_id')::int + AND d.track_id = t.track_id) + OR (t.stem_of IS NULL AND d.parent_track_id = t.track_id) + ) AS download_count +FROM tracks t +WHERE t.track_id = ANY(@track_ids::int[]) + AND t.is_current = true + AND t.is_delete = false; diff --git a/api/dbv1/queries/get_tracks.sql b/api/dbv1/queries/get_tracks.sql index 3062e400..dd826b1a 100644 --- a/api/dbv1/queries/get_tracks.sql +++ b/api/dbv1/queries/get_tracks.sql @@ -33,14 +33,6 @@ SELECT duration, is_downloadable, COALESCE(aggregate_plays.count, 0) as play_count, - ( - SELECT count(*)::bigint - FROM track_downloads d - WHERE (t.stem_of IS NOT NULL - AND d.parent_track_id = (t.stem_of->>'parent_track_id')::int - AND d.track_id = t.track_id) - OR (t.stem_of IS NULL AND d.parent_track_id = t.track_id) - ) AS download_count, ddex_app, pinned_comment_id, playlists_containing_track, diff --git a/api/server.go b/api/server.go index 5029e2d4..267929a6 100644 --- a/api/server.go +++ b/api/server.go @@ -473,9 +473,11 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/tracks/feeling-lucky", app.v1TracksFeelingLucky) g.Get("/tracks/recent-comments", app.v1TracksRecentComments) g.Get("/tracks/most-shared", app.v1TracksMostShared) + g.Get("/tracks/download_counts", app.v1TracksDownloadCounts) g.Use("/tracks/:trackId", app.requireTrackIdMiddleware) g.Get("/tracks/:trackId", app.v1Track) + g.Get("/tracks/:trackId/download_count", app.v1TrackDownloadCount) g.Get("/tracks/:trackId/stream", app.v1TrackStream) g.Get("/tracks/:trackId/download", app.v1TrackDownload) g.Get("/tracks/:trackId/inspect", app.v1TrackInspect) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 70d76dd1..1b53431f 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -2088,6 +2088,34 @@ paths: "500": description: Server error content: {} + /tracks/download_counts: + get: + tags: + - tracks + description: Gets download counts for tracks by ID. Use this instead of loading full track objects when only download counts are needed. + operationId: Get Track Download Counts + security: + - {} + - OAuth2: + - read + parameters: + - name: id + in: query + description: Track ID(s) + required: true + style: form + explode: true + schema: + type: array + items: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/track_download_counts_response" /tracks/inspect: get: tags: @@ -3107,6 +3135,30 @@ paths: "500": description: Server error content: {} + /tracks/{track_id}/download_count: + get: + tags: + - tracks + description: Gets the download count for a single track. Full track + all stems (parent) or stem-only (stem). + operationId: Get Track Download Count + security: + - {} + - OAuth2: + - read + parameters: + - name: track_id + in: path + description: A Track ID + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/track_download_count_response" /tracks/{track_id}/access-info: get: tags: @@ -11045,6 +11097,42 @@ components: $ref: "#/components/schemas/version_metadata" data: $ref: "#/components/schemas/track" + track_download_count_response: + type: object + required: + - data + properties: + data: + type: object + required: + - id + - download_count + properties: + id: + type: string + description: Track ID (hash) + download_count: + type: integer + description: Full track + all stems (parent) or stem-only (stem) + track_download_counts_response: + type: object + required: + - data + properties: + data: + type: array + items: + type: object + required: + - id + - download_count + properties: + id: + type: string + description: Track ID (hash) + download_count: + type: integer + description: Full track + all stems (parent) or stem-only (stem) create_user_response: type: object properties: @@ -13992,7 +14080,6 @@ components: - is_unlisted - permalink - play_count - - download_count - preview - remix_of - repost_count @@ -14048,9 +14135,6 @@ components: type: boolean play_count: type: integer - download_count: - type: integer - description: Full track + all stems (parent) or stem-only (stem) permalink: type: string is_streamable: @@ -15462,7 +15546,6 @@ components: - is_unlisted - permalink - play_count - - download_count - preview - remix_of - repost_count @@ -15518,9 +15601,6 @@ components: type: boolean play_count: type: integer - download_count: - type: integer - description: Full track + all stems (parent) or stem-only (stem) permalink: type: string is_streamable: diff --git a/api/v1_track_download_count.go b/api/v1_track_download_count.go new file mode 100644 index 00000000..0dddc4c7 --- /dev/null +++ b/api/v1_track_download_count.go @@ -0,0 +1,57 @@ +package api + +import ( + "api.audius.co/trashid" + "github.com/gofiber/fiber/v2" +) + +// TrackDownloadCount is the response shape for a single track's download count. +type TrackDownloadCount struct { + ID string `json:"id"` + DownloadCount int64 `json:"download_count"` +} + +func (app *ApiServer) v1TracksDownloadCounts(c *fiber.Ctx) error { + ids := decodeIdList(c) + if len(ids) == 0 { + return c.JSON(fiber.Map{"data": []TrackDownloadCount{}}) + } + + rows, err := app.queries.GetTrackDownloadCounts(c.Context(), ids) + if err != nil { + return err + } + + result := make([]TrackDownloadCount, 0, len(rows)) + for _, row := range rows { + id, _ := trashid.EncodeHashId(int(row.TrackID)) + result = append(result, TrackDownloadCount{ + ID: id, + DownloadCount: row.DownloadCount, + }) + } + + return c.JSON(fiber.Map{"data": result}) +} + +func (app *ApiServer) v1TrackDownloadCount(c *fiber.Ctx) error { + trackId := c.Locals("trackId").(int) + + rows, err := app.queries.GetTrackDownloadCounts(c.Context(), []int32{int32(trackId)}) + if err != nil { + return err + } + + var count int64 + id, _ := trashid.EncodeHashId(trackId) + if len(rows) > 0 { + count = rows[0].DownloadCount + } + + return c.JSON(fiber.Map{ + "data": TrackDownloadCount{ + ID: id, + DownloadCount: count, + }, + }) +} diff --git a/api/v1_track_test.go b/api/v1_track_test.go index 14a0bf3e..010ce65b 100644 --- a/api/v1_track_test.go +++ b/api/v1_track_test.go @@ -20,10 +20,9 @@ func TestGetTrack(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.id": "eYJyn", - "data.title": "Culca Canyon", - "data.play_count": 0, - "data.download_count": 0, + "data.id": "eYJyn", + "data.title": "Culca Canyon", + "data.play_count": 0, }) } @@ -39,15 +38,23 @@ func TestGetTrackDownloadCount(t *testing.T) { `) require.NoError(t, err) - var trackResponse struct { - Data dbv1.Track - } - status, body := testGet(t, app, "/v1/full/tracks/eYJyn", &trackResponse) + // Single track download_count endpoint + status, body := testGet(t, app, "/v1/full/tracks/eYJyn/download_count", nil) assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ "data.id": "eYJyn", "data.download_count": 2, }) + + // Bulk download_counts endpoint + status2, body2 := testGet(t, app, "/v1/full/tracks/download_counts?id=eYJyn&id=eYZmn", nil) + assert.Equal(t, 200, status2) + jsonAssert(t, body2, map[string]any{ + "data.0.id": "eYJyn", + "data.0.download_count": 2, + "data.1.id": "eYZmn", + "data.1.download_count": 0, + }) } func TestGetTrackFollowDownloadAcess(t *testing.T) { diff --git a/api/v1_tracks_test.go b/api/v1_tracks_test.go index 8c0feb5a..8506da73 100644 --- a/api/v1_tracks_test.go +++ b/api/v1_tracks_test.go @@ -19,9 +19,8 @@ func TestTracksEndpoint(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.0.id": "eYZmn", - "data.0.title": "T1", - "data.0.download_count": 0, + "data.0.id": "eYZmn", + "data.0.title": "T1", }) } @@ -32,9 +31,8 @@ func TestGetTracksByPermalink(t *testing.T) { assert.Equal(t, 200, status) jsonAssert(t, body, map[string]any{ - "data.0.id": "eYake", - "data.0.title": "track by permalink", - "data.0.download_count": 0, + "data.0.id": "eYake", + "data.0.title": "track by permalink", }) } From 140cbb8c08ab614e65dab6eafc45d325910b3e03 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 17 Mar 2026 15:09:20 -0700 Subject: [PATCH 3/5] Update test logs --- api/server.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/server.go b/api/server.go index 267929a6..e2e12a27 100644 --- a/api/server.go +++ b/api/server.go @@ -44,6 +44,7 @@ import ( "github.com/mcuadros/go-defaults" "github.com/segmentio/encoding/json" "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) //go:embed swagger/swagger-v1.yaml @@ -301,8 +302,13 @@ func NewApiServer(config config.Config) *ApiServer { if app.rateLimitMiddleware != nil { app.Use(app.rateLimitMiddleware.Middleware(app)) } + // In test, only log request errors to avoid noisy success logs + requestLogger := logger + if config.Env == "test" { + requestLogger = logger.WithOptions(zap.IncreaseLevel(zapcore.ErrorLevel)) + } app.Use(fiberzap.New(fiberzap.Config{ - Logger: logger, + Logger: requestLogger, FieldsFunc: func(c *fiber.Ctx) []zap.Field { fields := []zap.Field{} From 67052f37a03d916c11f3d0753697b86b4e41882d Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 17 Mar 2026 15:21:51 -0700 Subject: [PATCH 4/5] Fix download tests --- api/v1_track_download_count_test.go | 57 +++++++++++++++++++++++++++++ api/v1_track_test.go | 33 ----------------- 2 files changed, 57 insertions(+), 33 deletions(-) create mode 100644 api/v1_track_download_count_test.go diff --git a/api/v1_track_download_count_test.go b/api/v1_track_download_count_test.go new file mode 100644 index 00000000..7d1e8627 --- /dev/null +++ b/api/v1_track_download_count_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestV1TrackDownloadCount(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + require.NotNil(t, app.writePool, "test requires write pool") + + // Track 200 is "Culca Canyon" (eYJyn). Insert two download rows so download_count is 2. + _, err := app.writePool.Exec(ctx, ` + INSERT INTO track_downloads (txhash, blocknumber, parent_track_id, track_id, user_id) + VALUES ('tx-dl-1', 101, 200, 200, 1), ('tx-dl-2', 101, 200, 200, 2) + `) + require.NoError(t, err) + + status, body := testGet(t, app, "/v1/full/tracks/eYJyn/download_count", nil) + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.id": "eYJyn", + "data.download_count": 2, + }) +} + +func TestV1TracksDownloadCounts(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + require.NotNil(t, app.writePool, "test requires write pool") + + // Track 200 (eYJyn) gets 2 downloads; track 201 (eYZmn) has none. + _, err := app.writePool.Exec(ctx, ` + INSERT INTO track_downloads (txhash, blocknumber, parent_track_id, track_id, user_id) + VALUES ('tx-dl-1', 101, 200, 200, 1), ('tx-dl-2', 101, 200, 200, 2) + `) + require.NoError(t, err) + + status, body := testGet(t, app, "/v1/full/tracks/download_counts?id=eYJyn&id=eYZmn", nil) + assert.Equal(t, 200, status) + + // Response order is not guaranteed; assert by id. + data := gjson.GetBytes(body, "data") + assert.True(t, data.IsArray(), "data should be an array") + byID := make(map[string]int64) + for _, el := range data.Array() { + id := el.Get("id").String() + byID[id] = el.Get("download_count").Int() + } + assert.Equal(t, int64(2), byID["eYJyn"], "eYJyn download_count") + assert.Equal(t, int64(0), byID["eYZmn"], "eYZmn download_count") +} diff --git a/api/v1_track_test.go b/api/v1_track_test.go index 010ce65b..54da4699 100644 --- a/api/v1_track_test.go +++ b/api/v1_track_test.go @@ -1,13 +1,11 @@ package api import ( - "context" "testing" "api.audius.co/api/dbv1" "api.audius.co/trashid" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestGetTrack(t *testing.T) { @@ -26,37 +24,6 @@ func TestGetTrack(t *testing.T) { }) } -func TestGetTrackDownloadCount(t *testing.T) { - app := testAppWithFixtures(t) - ctx := context.Background() - require.NotNil(t, app.writePool, "test requires write pool") - - // Track 200 is "Culca Canyon" (eYJyn). Insert two download rows so download_count is 2. - _, err := app.writePool.Exec(ctx, ` - INSERT INTO track_downloads (txhash, blocknumber, parent_track_id, track_id, user_id) - VALUES ('tx-dl-1', 101, 200, 200, 1), ('tx-dl-2', 101, 200, 200, 2) - `) - require.NoError(t, err) - - // Single track download_count endpoint - status, body := testGet(t, app, "/v1/full/tracks/eYJyn/download_count", nil) - assert.Equal(t, 200, status) - jsonAssert(t, body, map[string]any{ - "data.id": "eYJyn", - "data.download_count": 2, - }) - - // Bulk download_counts endpoint - status2, body2 := testGet(t, app, "/v1/full/tracks/download_counts?id=eYJyn&id=eYZmn", nil) - assert.Equal(t, 200, status2) - jsonAssert(t, body2, map[string]any{ - "data.0.id": "eYJyn", - "data.0.download_count": 2, - "data.1.id": "eYZmn", - "data.1.download_count": 0, - }) -} - func TestGetTrackFollowDownloadAcess(t *testing.T) { app := testAppWithFixtures(t) var trackResponse struct { From 72fd1e91add23faa1944fd6a08f5980dc508c873 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 17 Mar 2026 15:22:05 -0700 Subject: [PATCH 5/5] Fix comms test --- api/comms_blasts_test.go | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/api/comms_blasts_test.go b/api/comms_blasts_test.go index 8a795a07..47eb7bb4 100644 --- a/api/comms_blasts_test.go +++ b/api/comms_blasts_test.go @@ -328,7 +328,10 @@ func TestGetNewBlasts(t *testing.T) { }) t.Run("coin holder audience timing logic", func(t *testing.T) { - // Create a separate test to verify coin holder timing: sub.created_at < blast.created_at + // Use a fixed UTC base time so timestamp comparisons are deterministic and + // timezone-independent (avoids flakiness when local TZ differs from CI). + // Order: blast_before (oldest) < balance change < blast_after (newest). + base := time.Date(2026, 3, 17, 15, 0, 0, 0, time.UTC) coinApp := emptyTestApp(t) coinFixtures := database.FixtureMap{ @@ -337,24 +340,24 @@ func TestGetNewBlasts(t *testing.T) { "user_id": 1, "handle": "artist_with_coin", "wallet": "0x7d273271690538cf855e5b3002a0dd8c154bb060", - "created_at": now.Add(-time.Hour * 2), - "updated_at": now.Add(-time.Hour * 2), + "created_at": base.Add(-time.Hour * 2), + "updated_at": base.Add(-time.Hour * 2), "is_current": true, }, { "user_id": 50, "handle": "coin_holder", "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0", - "created_at": now.Add(-time.Hour * 2), - "updated_at": now.Add(-time.Hour * 2), + "created_at": base.Add(-time.Hour * 2), + "updated_at": base.Add(-time.Hour * 2), "is_current": true, }, { "user_id": 51, "handle": "mixed_coin_holder", "wallet": "0x4954d18926ba0ed9378938444731be4e622537b2", - "created_at": now.Add(-time.Hour * 2), - "updated_at": now.Add(-time.Hour * 2), + "created_at": base.Add(-time.Hour * 2), + "updated_at": base.Add(-time.Hour * 2), "is_current": true, }, }, @@ -388,7 +391,7 @@ func TestGetNewBlasts(t *testing.T) { "ticker": "TEST", "mint": "TestMint123456789", "decimals": 8, - "created_at": now.Add(-time.Hour * 2), + "created_at": base.Add(-time.Hour * 2), }, }, "sol_token_account_balance_changes": { @@ -400,7 +403,7 @@ func TestGetNewBlasts(t *testing.T) { "balance": 0, "slot": 2, // Lost balance before old blast was created - "block_timestamp": now.Add(-time.Hour * 8), + "block_timestamp": base.Add(-time.Hour * 8), }, { "signature": "sig_123", @@ -409,7 +412,7 @@ func TestGetNewBlasts(t *testing.T) { "change": 500000000, "balance": 500000000, "slot": 3, - "block_timestamp": now.Add(-time.Hour), + "block_timestamp": base.Add(-time.Hour), }, { // User 51 has half a coin in associated account and half in claimable account @@ -420,7 +423,7 @@ func TestGetNewBlasts(t *testing.T) { "change": 50000000, "balance": 50000000, "slot": 3, - "block_timestamp": now.Add(-time.Hour), + "block_timestamp": base.Add(-time.Hour), }, { "signature": "sig_123", @@ -429,7 +432,7 @@ func TestGetNewBlasts(t *testing.T) { "change": 50000000, "balance": 50000000, "slot": 3, - "block_timestamp": now.Add(-time.Hour), + "block_timestamp": base.Add(-time.Hour), }, }, "chat_blast": { @@ -439,14 +442,14 @@ func TestGetNewBlasts(t *testing.T) { "from_user_id": 1, "audience": "coin_holder_audience", "plaintext": "Blast sent before user got coins (should NOT appear)", - "created_at": now.Add(-time.Hour * 6), + "created_at": base.Add(-time.Hour * 6), }, { "blast_id": "blast_after_balance", "from_user_id": 1, "audience": "coin_holder_audience", "plaintext": "Blast sent after user got coins (SHOULD appear)", - "created_at": now.Add(-time.Minute * 30), // AFTER coin balance + "created_at": base.Add(-time.Minute * 30), // AFTER coin balance }, }, }