From 7bc5fd7925fb61c116ba74ad664ab809631705bc Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 6 Nov 2026 13:26:51 -0800 Subject: [PATCH] feat(api): track feedback requests Adds tracks.feedback_requested_at (set by the indexer from track metadata), returns feedback_requested / feedback_requested_at on tracks, accepts feedback_requested on track create/update, and adds GET /v1/tracks/feedback listing tracks asking for feedback, newest request first. Co-Authored-By: Claude Opus 5.5 --- api/dbv1/get_tracks.sql.go | 8 ++- api/dbv1/models.go | 23 ++++-- api/dbv1/queries/get_tracks.sql | 4 +- api/server.go | 1 + api/swagger/swagger-v1.yaml | 61 ++++++++++++++++ api/v1_track.go | 2 + api/v1_tracks_feedback.go | 69 ++++++++++++++++++ api/v1_tracks_feedback_test.go | 70 +++++++++++++++++++ .../0243_tracks_feedback_requested_at.sql | 9 +++ sql/01_schema.sql | 7 ++ 10 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 api/v1_tracks_feedback.go create mode 100644 api/v1_tracks_feedback_test.go create mode 100644 ddl/migrations/0243_tracks_feedback_requested_at.sql diff --git a/api/dbv1/get_tracks.sql.go b/api/dbv1/get_tracks.sql.go index 16229f94..efff33ec 100644 --- a/api/dbv1/get_tracks.sql.go +++ b/api/dbv1/get_tracks.sql.go @@ -231,7 +231,9 @@ SELECT cover_original_song_title, cover_original_artist, is_owned_by_user, - t.access_authorities + t.access_authorities, + t.feedback_requested_at, + (t.feedback_requested_at IS NOT NULL)::bool AS feedback_requested FROM tracks t JOIN aggregate_track using (track_id) @@ -331,6 +333,8 @@ type GetTracksRow struct { CoverOriginalArtist pgtype.Text `json:"cover_original_artist"` IsOwnedByUser bool `json:"is_owned_by_user"` AccessAuthorities []string `json:"access_authorities"` + FeedbackRequestedAt *time.Time `json:"feedback_requested_at"` + FeedbackRequested bool `json:"feedback_requested"` } // MATERIALIZED forces this CTE to compute once per call. Without it the @@ -426,6 +430,8 @@ func (q *Queries) GetTracks(ctx context.Context, arg GetTracksParams) ([]GetTrac &i.CoverOriginalArtist, &i.IsOwnedByUser, &i.AccessAuthorities, + &i.FeedbackRequestedAt, + &i.FeedbackRequested, ); err != nil { return nil, err } diff --git a/api/dbv1/models.go b/api/dbv1/models.go index 9c6da6e6..bafa9a10 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -1628,6 +1628,10 @@ type NewChainQueue struct { TxData []byte `json:"tx_data"` // Block height on the old chain where this transaction was confirmed. NULL if confirmation was not recorded (e.g. relay restart). ConfirmedBlock pgtype.Int8 `json:"confirmed_block"` + // When this row was forwarded to the new chain, or when it was marked skipped. NULL means pending. + FlushedAt pgtype.Timestamptz `json:"flushed_at"` + // Set when flushed_at was recorded without actually forwarding: 'backfilled' (covered by the genesis backfill) or 'corrupt' (tx_data failed to unmarshal). + SkipReason pgtype.Text `json:"skip_reason"` } type Notification struct { @@ -2660,11 +2664,12 @@ type TrackRow struct { // Artist of the original song if this track is a cover CoverOriginalArtist pgtype.Text `json:"cover_original_artist"` // Indicates whether the track is owned by the user for publishing payouts - IsOwnedByUser bool `json:"is_owned_by_user"` - NoAiUse pgtype.Bool `json:"no_ai_use"` - ParentalWarning NullParentalWarningType `json:"parental_warning"` - TerritoryCodes []string `json:"territory_codes"` - AccessAuthorities []string `json:"access_authorities"` + IsOwnedByUser bool `json:"is_owned_by_user"` + NoAiUse pgtype.Bool `json:"no_ai_use"` + ParentalWarning NullParentalWarningType `json:"parental_warning"` + TerritoryCodes []string `json:"territory_codes"` + AccessAuthorities []string `json:"access_authorities"` + FeedbackRequestedAt *time.Time `json:"feedback_requested_at"` } type TrackTrendingScore struct { @@ -2988,3 +2993,11 @@ type VolumeLeaderExclusion struct { Address string `json:"address"` Description pgtype.Text `json:"description"` } + +// Ranked Weekly Rotation track ids per listener per period (period_start = Wednesday 00:00 UTC), written on first request of the period. +type WeeklyRotationMix struct { + UserID int32 `json:"user_id"` + PeriodStart pgtype.Date `json:"period_start"` + TrackIds []int32 `json:"track_ids"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/api/dbv1/queries/get_tracks.sql b/api/dbv1/queries/get_tracks.sql index 0e7af399..ff543458 100644 --- a/api/dbv1/queries/get_tracks.sql +++ b/api/dbv1/queries/get_tracks.sql @@ -220,7 +220,9 @@ SELECT cover_original_song_title, cover_original_artist, is_owned_by_user, - t.access_authorities + t.access_authorities, + t.feedback_requested_at, + (t.feedback_requested_at IS NOT NULL)::bool AS feedback_requested FROM tracks t JOIN aggregate_track using (track_id) diff --git a/api/server.go b/api/server.go index bfe49569..784e25fb 100644 --- a/api/server.go +++ b/api/server.go @@ -567,6 +567,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/tracks/unclaimed_id", app.v1TracksUnclaimedId) g.Get("/tracks/latest", app.v1TracksLatest) + g.Get("/tracks/feedback", app.v1TracksFeedback) g.Get("/tracks/trending", app.v1TracksTrending) g.Get("/tracks/trending/ids", app.v1TracksTrendingIds) g.Get("/tracks/trending/winners", app.v1TracksTrendingWinners) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 79115de6..23591c8e 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -3241,6 +3241,52 @@ paths: "500": description: Server error content: {} + /tracks/feedback: + get: + tags: + - tracks + description: Gets tracks whose artists are asking for feedback, newest request first + operationId: Get Feedback Tracks + security: + - {} + - OAuth2: + - read + parameters: + - name: offset + in: query + description: + The number of items to skip. Useful for pagination (page number + * limit) + schema: + type: integer + - name: limit + in: query + description: The number of items to fetch + schema: + type: integer + - name: user_id + in: query + description: The user ID of the user making the request + schema: + type: string + - name: genre + in: query + description: Filter to a specified genre + 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: get: tags: @@ -11403,6 +11449,10 @@ components: items: type: string description: Wallet addresses that can sign to authorize stream access (programmable distribution). When empty or omitted, the track is public and validator/creator nodes can serve it. + feedback_requested: + type: boolean + nullable: true + description: Whether the artist is asking listeners for feedback on the track allowed_api_keys: type: array nullable: true @@ -11641,6 +11691,10 @@ components: items: type: string description: Wallet addresses that can sign to authorize stream access (programmable distribution). When empty or omitted, the track is public and validator/creator nodes can serve it. + feedback_requested: + type: boolean + nullable: true + description: Whether the artist is asking listeners for feedback on the track allowed_api_keys: type: array nullable: true @@ -15402,6 +15456,13 @@ components: items: type: string description: Wallet addresses that can sign to authorize stream access (programmable distribution). When empty or omitted, the track is public and validator/creator nodes can serve it. + feedback_requested: + type: boolean + description: Whether the artist is asking listeners for feedback on the track + feedback_requested_at: + type: string + nullable: true + description: When the artist asked for feedback, or null when they are not asking stream_conditions: type: object description: How to unlock stream access to the track diff --git a/api/v1_track.go b/api/v1_track.go index ed6c667d..dd9a2dd7 100644 --- a/api/v1_track.go +++ b/api/v1_track.go @@ -139,6 +139,7 @@ type CreateTrackRequest struct { StreamConditions *AccessConditions `json:"stream_conditions,omitempty" validate:"omitempty"` IsStreamGated *bool `json:"is_stream_gated,omitempty"` AccessAuthorities *[]string `json:"access_authorities,omitempty"` + FeedbackRequested *bool `json:"feedback_requested,omitempty"` IsDownloadGated *bool `json:"is_download_gated,omitempty"` AiAttributionUserId *trashid.HashId `json:"ai_attribution_user_id,omitempty" validate:"omitempty,min=0"` AllowedApiKeys *[]string `json:"allowed_api_keys,omitempty"` @@ -182,6 +183,7 @@ type UpdateTrackRequest struct { StreamConditions *AccessConditions `json:"stream_conditions,omitempty" validate:"omitempty"` IsStreamGated *bool `json:"is_stream_gated,omitempty"` AccessAuthorities *[]string `json:"access_authorities,omitempty"` + FeedbackRequested *bool `json:"feedback_requested,omitempty"` IsDownloadGated *bool `json:"is_download_gated,omitempty"` AiAttributionUserId *trashid.HashId `json:"ai_attribution_user_id,omitempty" validate:"omitempty,min=0"` AllowedApiKeys *[]string `json:"allowed_api_keys,omitempty"` diff --git a/api/v1_tracks_feedback.go b/api/v1_tracks_feedback.go new file mode 100644 index 00000000..d63f9cef --- /dev/null +++ b/api/v1_tracks_feedback.go @@ -0,0 +1,69 @@ +package api + +import ( + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type GetFeedbackTracksParams struct { + Limit int `query:"limit" default:"20" validate:"min=1,max=100"` + Offset int `query:"offset" default:"0" validate:"min=0,max=500"` + Genre string `query:"genre" default:""` +} + +// v1TracksFeedback lists tracks whose artist asked for feedback, newest +// request first. +func (app *ApiServer) v1TracksFeedback(c *fiber.Ctx) error { + var params GetFeedbackTracksParams + if err := app.ParseAndValidateQueryParams(c, ¶ms); err != nil { + return err + } + + sql := ` + SELECT t.track_id + FROM tracks t + JOIN users u ON u.user_id = t.owner_id + WHERE t.feedback_requested_at IS NOT NULL + AND t.is_current = true + AND t.is_delete = false + AND t.is_unlisted = false + AND t.is_available = true + AND t.is_stream_gated = false + AND t.access_authorities IS NULL + AND COALESCE(t.comments_disabled, false) = false + AND u.is_deactivated = false + AND u.is_available = true + AND (@genre = '' OR t.genre = @genre) + ORDER BY + t.feedback_requested_at DESC, + t.track_id DESC + LIMIT @limit + OFFSET @offset + ` + rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ + "genre": params.Genre, + "limit": params.Limit, + "offset": params.Offset, + }) + if err != nil { + return err + } + trackIds, err := pgx.CollectRows(rows, pgx.RowTo[int32]) + if err != nil { + return err + } + + tracks, err := app.queries.Tracks(c.Context(), dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{ + Ids: trackIds, + MyID: app.getMyId(c), + AuthedWallet: app.tryGetAuthedWallet(c), + }, + }) + if err != nil { + return err + } + + return v1TracksResponse(c, tracks) +} diff --git a/api/v1_tracks_feedback_test.go b/api/v1_tracks_feedback_test.go new file mode 100644 index 00000000..c021e4d8 --- /dev/null +++ b/api/v1_tracks_feedback_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "testing" + + "api.audius.co/api/dbv1" + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +func feedbackTestApp(t *testing.T) *ApiServer { + app := testAppWithFixtures(t) + database.SeedTable(app.pool.Replicas[0], "users", []map[string]any{ + {"user_id": 8900, "handle": "feedbackdeactivated", "is_deactivated": true}, + }) + database.SeedTable(app.pool.Replicas[0], "tracks", []map[string]any{ + {"track_id": 900, "genre": "FeedbackGenreA", "owner_id": 1, "title": "Asked First", "feedback_requested_at": "2026-01-01 00:00:00"}, + {"track_id": 901, "genre": "FeedbackGenreA", "owner_id": 1, "title": "Asked Last", "feedback_requested_at": "2026-03-01 00:00:00"}, + {"track_id": 902, "genre": "FeedbackGenreB", "owner_id": 1, "title": "Asked Middle", "feedback_requested_at": "2026-02-01 00:00:00"}, + {"track_id": 903, "genre": "FeedbackGenreA", "owner_id": 1, "title": "Not Asking"}, + {"track_id": 904, "genre": "FeedbackGenreA", "owner_id": 1, "title": "Unlisted", "is_unlisted": "t", "feedback_requested_at": "2026-03-02 00:00:00"}, + {"track_id": 905, "genre": "FeedbackGenreA", "owner_id": 1, "title": "Comments Off", "comments_disabled": "t", "feedback_requested_at": "2026-03-03 00:00:00"}, + {"track_id": 906, "genre": "FeedbackGenreA", "owner_id": 8900, "title": "Deactivated Owner", "feedback_requested_at": "2026-03-04 00:00:00"}, + }) + return app +} + +func TestGetFeedbackTracks(t *testing.T) { + app := feedbackTestApp(t) + var resp struct { + Data []dbv1.Track + } + status, _ := testGet(t, app, "/v1/tracks/feedback", &resp) + assert.Equal(t, 200, status) + + ids := []string{} + for _, track := range resp.Data { + ids = append(ids, track.ID) + assert.True(t, track.FeedbackRequested) + assert.NotNil(t, track.FeedbackRequestedAt) + } + assert.Equal(t, []string{ + trashid.MustEncodeHashID(901), + trashid.MustEncodeHashID(902), + trashid.MustEncodeHashID(900), + }, ids) +} + +func TestGetFeedbackTracksGenreAndPaging(t *testing.T) { + app := feedbackTestApp(t) + var resp struct { + Data []dbv1.Track + } + status, _ := testGet(t, app, "/v1/tracks/feedback?genre=FeedbackGenreA&limit=1&offset=1", &resp) + assert.Equal(t, 200, status) + assert.Equal(t, 1, len(resp.Data)) + assert.Equal(t, trashid.MustEncodeHashID(900), resp.Data[0].ID) +} + +func TestTrackFeedbackRequestedField(t *testing.T) { + app := feedbackTestApp(t) + var resp struct { + Data dbv1.Track + } + status, _ := testGet(t, app, "/v1/tracks/"+trashid.MustEncodeHashID(903), &resp) + assert.Equal(t, 200, status) + assert.False(t, resp.Data.FeedbackRequested) + assert.Nil(t, resp.Data.FeedbackRequestedAt) +} diff --git a/ddl/migrations/0243_tracks_feedback_requested_at.sql b/ddl/migrations/0243_tracks_feedback_requested_at.sql new file mode 100644 index 00000000..2954ebec --- /dev/null +++ b/ddl/migrations/0243_tracks_feedback_requested_at.sql @@ -0,0 +1,9 @@ +-- When the artist turned on "looking for feedback"; NULL when off. +-- The indexer (go-openaudio pkg/etl) writes it from track metadata. +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS feedback_requested_at timestamp without time zone; + +-- Serves /v1/tracks/feedback (newest request first). Almost every row is +-- NULL, so the partial index stays tiny. +CREATE INDEX CONCURRENTLY IF NOT EXISTS tracks_feedback_requested_at_idx + ON public.tracks USING btree (feedback_requested_at DESC, track_id DESC) + WHERE feedback_requested_at IS NOT NULL AND is_current = true AND is_delete = false; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 28b6a124..637b6d8a 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -6614,6 +6614,7 @@ CREATE TABLE public.tracks ( parental_warning public.parental_warning_type, territory_codes text[], access_authorities text[], + feedback_requested_at timestamp without time zone, CONSTRAINT check_territory_codes CHECK (public.validate_territory_codes(territory_codes)) ); @@ -15430,6 +15431,12 @@ CREATE INDEX tracks_ai_attribution_user_id ON public.tracks USING btree (ai_attr CREATE INDEX tracks_blocknumber_idx ON public.tracks USING btree (blocknumber); +-- +-- Name: tracks_feedback_requested_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX tracks_feedback_requested_at_idx ON public.tracks USING btree (feedback_requested_at DESC, track_id DESC) WHERE ((feedback_requested_at IS NOT NULL) AND (is_current = true) AND (is_delete = false)); + -- -- Name: tracks_isrc_normalized_idx; Type: INDEX; Schema: public; Owner: - --