From 63fb1554b25bf31a8633a1dead4d633fd8cc6a9d Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 19 Oct 2026 21:48:27 -0700 Subject: [PATCH] fix: enforce scheduled release visibility and preserve track write metadata --- api/feed_scheduled_release.go | 13 ++++ api/feed_scheduled_release_test.go | 96 ++++++++++++++++++++++++++++ api/swagger/swagger-v1.yaml | 14 ++-- api/track_scheduling_request_test.go | 40 ++++++++++++ api/v1_track.go | 2 + api/v1_users_feed.go | 31 +++++---- api/v1_users_feed_for_you.go | 57 +++++++++++------ 7 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 api/feed_scheduled_release.go create mode 100644 api/feed_scheduled_release_test.go create mode 100644 api/track_scheduling_request_test.go diff --git a/api/feed_scheduled_release.go b/api/feed_scheduled_release.go new file mode 100644 index 00000000..93574568 --- /dev/null +++ b/api/feed_scheduled_release.go @@ -0,0 +1,13 @@ +package api + +import "github.com/gofiber/fiber/v2" + +// Feed viewer IDs can be advisory (notably on For You). Only a verified +// caller may use the owner exception for an unreleased scheduled item. +func (app *ApiServer) feedScheduledOwnerID(c *fiber.Ctx) int32 { + viewerID := app.getMyId(c) + if viewerID != 0 && app.isAuthorizedRequest(c.Context(), viewerID, app.tryGetAuthedWallet(c)) { + return viewerID + } + return 0 +} diff --git a/api/feed_scheduled_release_test.go b/api/feed_scheduled_release_test.go new file mode 100644 index 00000000..29b2c48e --- /dev/null +++ b/api/feed_scheduled_release_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "fmt" + "testing" + "time" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestFeedScheduledReleases(t *testing.T) { + app := emptyTestApp(t) + const wallet = "0x7d273271690538cf855e5b3002a0dd8c154bb060" + now := time.Now().UTC() + fixtures := feedForYouFixtures() + fixtures["users"][1]["wallet"] = wallet // owner 2, verified with a real test signature + fixtures["tracks"] = nil + fixtures["playlists"] = nil + fixtures["reposts"] = nil + fixtures["track_trending_scores"] = nil + fixtures["playlist_trending_scores"] = nil + // Include public-but-scheduled rows: visibility flags alone must not leak them. + cases := []struct { + name string + scheduled, hidden bool + release any + visible bool + }{ + {"scheduled public future", true, false, now.Add(time.Hour), false}, + {"scheduled hidden future", true, true, now.Add(time.Hour), false}, + {"released", true, false, now.Add(-time.Hour), true}, + {"public future date", false, false, now.Add(time.Hour), true}, + {"ordinary hidden", false, true, nil, false}, + {"scheduled missing date", true, false, nil, false}, + } + for i, tc := range cases { + id := 500 + i + fixtures["tracks"] = append(fixtures["tracks"], map[string]any{ + "track_id": id, "owner_id": 2, "title": tc.name, "created_at": now.Add(-time.Hour), + "is_scheduled_release": tc.scheduled, "is_unlisted": tc.hidden, "release_date": tc.release, + }) + fixtures["playlists"] = append(fixtures["playlists"], map[string]any{ + "playlist_id": id, "playlist_owner_id": 2, "playlist_name": tc.name, "is_album": true, + "playlist_contents": "{}", "created_at": now.Add(-time.Hour), "updated_at": now, + "is_scheduled_release": tc.scheduled, "is_private": tc.hidden, "release_date": tc.release, + }) + for _, kind := range []string{"track", "album"} { + fixtures["reposts"] = append(fixtures["reposts"], map[string]any{ + "user_id": 2, "repost_type": kind, "repost_item_id": id, "created_at": now.Add(-time.Minute), + }) + } + fixtures["track_trending_scores"] = append(fixtures["track_trending_scores"], map[string]any{"track_id": id, "score": 1000 - i, "time_range": "week"}) + fixtures["playlist_trending_scores"] = append(fixtures["playlist_trending_scores"], map[string]any{"playlist_id": id, "score": 1000 - i, "type": "PLAYLISTS", "version": "pnagD", "time_range": "week"}) + } + database.Seed(app.pool.Replicas[0], fixtures) + for _, route := range []string{"feed?filter=original", "feed?filter=repost", "feed/for-you?max_per_artist=10"} { + path := "/v1/users/" + trashid.MustEncodeHashID(1) + "/" + route + "&limit=100" + t.Run(route, func(t *testing.T) { + for _, owner := range []bool{false, true} { + url, signer := path, "" + if owner { + url += "&user_id=" + trashid.MustEncodeHashID(2) + signer = wallet + } + status, body := testGetWithWallet(t, app, url, signer) + require.Equal(t, 200, status, string(body)) + for i, tc := range cases { + expected := tc.visible || (owner && tc.scheduled) + for _, field := range []string{"title", "playlist_name"} { + found := false + for _, item := range gjson.GetBytes(body, "data").Array() { + if item.Get("item."+field).String() == tc.name { + found = true + } + } + assert.Equal(t, expected, found, "%s case %d %s owner=%v: %s", field, i, tc.name, owner, body) + } + } + } + }) + } + // For You permits advisory viewer IDs; unsigned ownership must not unlock releases. + path := fmt.Sprintf("/v1/users/%s/feed/for-you?user_id=%s&max_per_artist=10", trashid.MustEncodeHashID(1), trashid.MustEncodeHashID(2)) + status, body := testGet(t, app, path) + require.Equal(t, 200, status, string(body)) + for _, item := range gjson.GetBytes(body, "data").Array() { + assert.NotEqual(t, "scheduled public future", item.Get("item.title").String()) + assert.NotEqual(t, "scheduled public future", item.Get("item.playlist_name").String()) + assert.NotEqual(t, "scheduled hidden future", item.Get("item.title").String()) + assert.NotEqual(t, "scheduled hidden future", item.Get("item.playlist_name").String()) + } +} diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 17923ed0..80be36e9 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -11267,8 +11267,11 @@ components: description: International Standard Musical Work Code release_date: type: string - format: date - description: Release date + format: date-time + description: Release date and time + is_scheduled_release: + type: boolean + description: Whether the track is scheduled for release track_cid: type: string description: CID for the track audio file (required) @@ -11503,8 +11506,11 @@ components: description: International Standard Musical Work Code release_date: type: string - format: date - description: Release date + format: date-time + description: Release date and time + is_scheduled_release: + type: boolean + description: Whether the track is scheduled for release track_cid: type: string description: CID for the track audio file diff --git a/api/track_scheduling_request_test.go b/api/track_scheduling_request_test.go new file mode 100644 index 00000000..aff72947 --- /dev/null +++ b/api/track_scheduling_request_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// Track write handlers decode the request then re-encode it as chain metadata. +// Both true and false must survive; omission must leave existing state alone. +func TestTrackSchedulingRequestMetadata(t *testing.T) { + for _, operation := range []string{"create", "update"} { + for _, flag := range []string{"true", "false", "omitted"} { + t.Run(operation+"/"+flag, func(t *testing.T) { + input := map[string]any{"release_date": "2026-10-20T07:00:00.000Z"} + if flag != "omitted" { + input["is_scheduled_release"] = flag == "true" + } + body, err := json.Marshal(input) + require.NoError(t, err) + var request any = &CreateTrackRequest{} + if operation == "update" { + request = &UpdateTrackRequest{} + } + require.NoError(t, json.Unmarshal(body, request)) + metadataBytes, err := json.Marshal(request) + require.NoError(t, err) + var metadata map[string]any + require.NoError(t, json.Unmarshal(metadataBytes, &metadata)) + require.Equal(t, input["release_date"], metadata["release_date"]) + if flag == "omitted" { + require.NotContains(t, metadata, "is_scheduled_release") + } else { + require.Equal(t, input["is_scheduled_release"], metadata["is_scheduled_release"]) + } + }) + } + } +} diff --git a/api/v1_track.go b/api/v1_track.go index 3f1fcb1b..ed6c667d 100644 --- a/api/v1_track.go +++ b/api/v1_track.go @@ -124,6 +124,7 @@ type CreateTrackRequest struct { Isrc *string `json:"isrc,omitempty"` Iswc *string `json:"iswc,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` + IsScheduledRelease *bool `json:"is_scheduled_release,omitempty"` TrackCid string `json:"track_cid" validate:"required"` CoverArtSizes *string `json:"cover_art_sizes,omitempty"` PreviewCid *string `json:"preview_cid,omitempty"` @@ -166,6 +167,7 @@ type UpdateTrackRequest struct { Isrc *string `json:"isrc,omitempty"` Iswc *string `json:"iswc,omitempty"` ReleaseDate *string `json:"release_date,omitempty"` + IsScheduledRelease *bool `json:"is_scheduled_release,omitempty"` Artwork *map[string]interface{} `json:"artwork,omitempty"` TrackCid *string `json:"track_cid,omitempty"` CoverArtSizes *string `json:"cover_art_sizes,omitempty"` diff --git a/api/v1_users_feed.go b/api/v1_users_feed.go index 770c80a5..5af2c558 100644 --- a/api/v1_users_feed.go +++ b/api/v1_users_feed.go @@ -82,7 +82,9 @@ func (app *ApiServer) v1UsersFeed(c *fiber.Ctx) error { ) r JOIN tracks ON r.repost_item_id = tracks.track_id AND tracks.is_delete = false - AND tracks.is_unlisted = false + AND (tracks.is_unlisted = false + OR (tracks.owner_id = @scheduledOwnerId AND tracks.is_scheduled_release = true AND tracks.release_date > NOW())) + AND (tracks.is_scheduled_release IS NOT TRUE OR tracks.release_date <= NOW() OR tracks.owner_id = @scheduledOwnerId) AND tracks.is_available = true WHERE @filter in ('all', 'repost') GROUP BY entity_id @@ -111,7 +113,9 @@ func (app *ApiServer) v1UsersFeed(c *fiber.Ctx) error { ) r JOIN playlists ON r.repost_item_id = playlists.playlist_id AND playlists.is_delete = false - AND playlists.is_private = false + AND (playlists.is_private = false + OR (playlists.playlist_owner_id = @scheduledOwnerId AND playlists.is_scheduled_release = true AND playlists.release_date > NOW())) + AND (playlists.is_scheduled_release IS NOT TRUE OR playlists.release_date <= NOW() OR playlists.playlist_owner_id = @scheduledOwnerId) WHERE @filter in ('all', 'repost') GROUP BY entity_type, entity_id ) @@ -130,7 +134,9 @@ func (app *ApiServer) v1UsersFeed(c *fiber.Ctx) error { WHERE owner_id = fs.user_id AND created_at < @before AND created_at >= @before::timestamp - INTERVAL '1 YEAR' - AND is_unlisted = false + AND (is_unlisted = false + OR (owner_id = @scheduledOwnerId AND is_scheduled_release = true AND release_date > NOW())) + AND (is_scheduled_release IS NOT TRUE OR release_date <= NOW() OR owner_id = @scheduledOwnerId) AND is_delete = false AND stem_of IS NULL AND (access_authorities IS NULL @@ -158,7 +164,9 @@ func (app *ApiServer) v1UsersFeed(c *fiber.Ctx) error { AND created_at < @before AND created_at >= @before - INTERVAL '1 YEAR' AND is_delete = false - AND is_private = false + AND (is_private = false + OR (playlist_owner_id = @scheduledOwnerId AND is_scheduled_release = true AND release_date > NOW())) + AND (is_scheduled_release IS NOT TRUE OR release_date <= NOW() OR playlist_owner_id = @scheduledOwnerId) ORDER BY created_at DESC LIMIT 50 OFFSET 0 @@ -179,13 +187,14 @@ func (app *ApiServer) v1UsersFeed(c *fiber.Ctx) error { ` rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ - "userId": app.getUserId(c), - "before": time.Now(), - "limit": params.Limit, - "offset": params.Offset, - "filter": params.Filter, // original, repost - "followeeIds": followeeIds, - "authed_wallet": app.tryGetAuthedWallet(c), + "scheduledOwnerId": app.feedScheduledOwnerID(c), + "userId": app.getUserId(c), + "before": time.Now(), + "limit": params.Limit, + "offset": params.Offset, + "filter": params.Filter, // original, repost + "followeeIds": followeeIds, + "authed_wallet": app.tryGetAuthedWallet(c), }) if err != nil { return err diff --git a/api/v1_users_feed_for_you.go b/api/v1_users_feed_for_you.go index c283a228..08782f8f 100644 --- a/api/v1_users_feed_for_you.go +++ b/api/v1_users_feed_for_you.go @@ -28,21 +28,21 @@ type GetUsersFeedForYouParams struct { // below; the candidate retrieval / diversity passes carry over directly // so a learned model can drop in later. // -// 1. CANDIDATE RETRIEVAL — six sources, three per entity type, each -// capped: -// - cand_in_network_track — tracks uploaded in the last 14 days +// 1. CANDIDATE RETRIEVAL — six sources, three per entity type, each +// capped: +// - cand_in_network_track — tracks uploaded in the last 14 days // by users I follow. Capped at 200. -// - cand_trending_track — top week-trending tracks +// - cand_trending_track — top week-trending tracks // (track_trending_scores). Mirrors GET /tracks/trending. Capped at 100. -// - cand_underground_track — week-trending tracks whose owner has +// - cand_underground_track — week-trending tracks whose owner has // < 1500 follower & following count // (mirrors GET /tracks/trending/underground). Capped at 50. -// - cand_in_network_playlist — playlists/albums created in the last +// - cand_in_network_playlist — playlists/albums created in the last // 14 days by users I follow. Capped at 30. -// - cand_trending_playlist — top week-trending playlists/albums +// - cand_trending_playlist — top week-trending playlists/albums // (playlist_trending_scores). Mirrors GET /playlists/trending. // Capped at 30. -// - cand_underground_playlist — week-trending playlists/albums whose +// - cand_underground_playlist — week-trending playlists/albums whose // owner has < 1500 follower & following count. Capped at 15. // // Per-source caps are smaller for playlists because they're heavier @@ -303,7 +303,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { 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_unlisted = false + OR (t.owner_id = @scheduledOwnerId AND t.is_scheduled_release = true AND t.release_date > NOW())) + AND (t.is_scheduled_release IS NOT TRUE OR t.release_date <= NOW() OR t.owner_id = @scheduledOwnerId) AND t.is_available = true AND t.stem_of IS NULL AND t.created_at >= NOW() - INTERVAL '14 days' @@ -319,7 +321,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { 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_unlisted = false + OR (t.owner_id = @scheduledOwnerId AND t.is_scheduled_release = true AND t.release_date > NOW())) + AND (t.is_scheduled_release IS NOT TRUE OR t.release_date <= NOW() OR t.owner_id = @scheduledOwnerId) AND t.is_available = true WHERE tts.type = 'TRACKS' AND tts.version = 'pnagD' @@ -337,7 +341,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { 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_unlisted = false + OR (t.owner_id = @scheduledOwnerId AND t.is_scheduled_release = true AND t.release_date > NOW())) + AND (t.is_scheduled_release IS NOT TRUE OR t.release_date <= NOW() OR t.owner_id = @scheduledOwnerId) AND t.is_available = true JOIN aggregate_user au ON au.user_id = t.owner_id WHERE tts.type = 'TRACKS' @@ -358,7 +364,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { JOIN follow_set f ON f.user_id = p.playlist_owner_id WHERE p.is_current = true AND p.is_delete = false - AND p.is_private = false + AND (p.is_private = false + OR (p.playlist_owner_id = @scheduledOwnerId AND p.is_scheduled_release = true AND p.release_date > NOW())) + AND (p.is_scheduled_release IS NOT TRUE OR p.release_date <= NOW() OR p.playlist_owner_id = @scheduledOwnerId) AND p.created_at >= NOW() - INTERVAL '14 days' ORDER BY p.created_at DESC LIMIT 30 @@ -372,7 +380,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { JOIN playlists p ON p.playlist_id = pts.playlist_id AND p.is_current = true AND p.is_delete = false - AND p.is_private = false + AND (p.is_private = false + OR (p.playlist_owner_id = @scheduledOwnerId AND p.is_scheduled_release = true AND p.release_date > NOW())) + AND (p.is_scheduled_release IS NOT TRUE OR p.release_date <= NOW() OR p.playlist_owner_id = @scheduledOwnerId) WHERE pts.type = 'PLAYLISTS' AND pts.version = 'pnagD' AND pts.time_range = 'week' @@ -388,7 +398,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { JOIN playlists p ON p.playlist_id = pts.playlist_id AND p.is_current = true AND p.is_delete = false - AND p.is_private = false + AND (p.is_private = false + OR (p.playlist_owner_id = @scheduledOwnerId AND p.is_scheduled_release = true AND p.release_date > NOW())) + AND (p.is_scheduled_release IS NOT TRUE OR p.release_date <= NOW() OR p.playlist_owner_id = @scheduledOwnerId) JOIN aggregate_user au ON au.user_id = p.playlist_owner_id WHERE pts.type = 'PLAYLISTS' AND pts.version = 'pnagD' @@ -445,7 +457,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { WHERE c.entity_type = 'track' AND t.is_current = true AND t.is_delete = false - AND t.is_unlisted = false + AND (t.is_unlisted = false + OR (t.owner_id = @scheduledOwnerId AND t.is_scheduled_release = true AND t.release_date > NOW())) + AND (t.is_scheduled_release IS NOT TRUE OR t.release_date <= NOW() OR t.owner_id = @scheduledOwnerId) AND t.is_available = true AND t.stem_of IS NULL AND u.is_current = true @@ -484,7 +498,9 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { WHERE c.entity_type = 'playlist' AND p.is_current = true AND p.is_delete = false - AND p.is_private = false + AND (p.is_private = false + OR (p.playlist_owner_id = @scheduledOwnerId AND p.is_scheduled_release = true AND p.release_date > NOW())) + AND (p.is_scheduled_release IS NOT TRUE OR p.release_date <= NOW() OR p.playlist_owner_id = @scheduledOwnerId) AND u.is_current = true AND u.is_deactivated = false AND u.is_available = true @@ -570,10 +586,11 @@ func (app *ApiServer) v1FeedForYou(c *fiber.Ctx) error { ` rows, err := app.pool.Query(c.Context(), sql, pgx.NamedArgs{ - "userId": userId, - "poolSize": candidatePoolSize, - "maxPerArtist": params.MaxPerArtist, - "authed_wallet": authedWallet, + "scheduledOwnerId": app.feedScheduledOwnerID(c), + "userId": userId, + "poolSize": candidatePoolSize, + "maxPerArtist": params.MaxPerArtist, + "authed_wallet": authedWallet, }) if err != nil { return err