Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/feed_scheduled_release.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions api/feed_scheduled_release_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
14 changes: 10 additions & 4 deletions api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions api/track_scheduling_request_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
})
}
}
}
2 changes: 2 additions & 0 deletions api/v1_track.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
31 changes: 20 additions & 11 deletions api/v1_users_feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
57 changes: 37 additions & 20 deletions api/v1_users_feed_for_you.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading