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
17 changes: 4 additions & 13 deletions api/dbv1/tracks.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,19 +201,10 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32]
// account was delisted by the trusted notifier.
isStreamable := !rawTrack.IsDelete && !user.IsDeactivated

// Two reasons to leave a media link nil, both with the same effect: the
// URL should never be handed out, and the endpoints report the track as
// unavailable instead.
//
// A track row can have empty cid columns (e.g. an upload-v2 row whose
// track_cid/orig_file_cid backfill never ran), and signing an empty cid
// produces a content-node URL that is guaranteed to 404.
//
// A non-streamable track is worse: the cid is real, so the signed URL
// works. The stream and download endpoints reject these, but that only
// closes those two routes - anyone reading the track response could
// still fetch the audio straight from the content node. Preview is
// included because a preview clip is still the artist's audio.
// Media links stay nil when there is no cid to sign (the URL would 404)
// or the track is not streamable (the cid is real, so a signed URL would
// bypass the stream and download endpoint checks). Previews count as
// the artist's audio too.
var stream *MediaLink
if isStreamable && access.Stream && rawTrack.TrackCid.String != "" {
stream, err = mediaLink(rawTrack.TrackCid.String, rawTrack.TrackID, arg.MyID.(int32), id3Tags)
Expand Down
8 changes: 3 additions & 5 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,9 @@ func NewApiServer(config config.Config) *ApiServer {
}

// Caches the track-id list returned by the /v1/users/:userId/weekly-rotation
// query. The mix is deterministic for the whole ISO week and the cache key
// carries the year/week, so entries are immutable for their lifetime and a
// long TTL is safe — a stale entry is the correct answer, not a stale one.
// Sized larger than the other recommendation caches because this is the
// most expensive query of the three and the least likely to be re-derived.
// query. The key includes the period, so entries stay correct for their
// whole TTL. Sized larger than the other recommendation caches because this
// is the most expensive query of the three.
weeklyRotationCache, err := otter.MustBuilder[string, []int32](50_000).
WithTTL(6 * time.Hour).
CollectStats().
Expand Down
10 changes: 5 additions & 5 deletions api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7114,8 +7114,8 @@ paths:
description:
Gets the user's Weekly Rotation mix - a personalized set of tracks
they have not heard, weighted toward artists they do not already
follow. The mix is fixed for the calendar week (ISO week, UTC) and
rotates when the week rolls over. Unlike suggested-follows, this
follow. The mix is fixed for the week and rolls over Wednesday
00:00 UTC. Unlike suggested-follows, this
returns results for users with no listening history.
operationId: Get Weekly Rotation
security:
Expand Down Expand Up @@ -7157,9 +7157,9 @@ paths:
tags:
- users
description:
Gets artists to suggest the user follow, based on the tracks and
albums they have favorited or reposted but whose artist they do not
already follow. Returns an empty list for users with no favorites or
Gets artists to suggest the user follow, based on the tracks, albums
and playlists they have favorited or reposted but whose owner they do
not already follow. Returns an empty list for users with no favorites or
reposts.
operationId: Get Suggested Follows
security:
Expand Down
7 changes: 3 additions & 4 deletions api/v1_coin_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (app *ApiServer) v1CoinMetadata(c *fiber.Ctx) error {
artist_coins.logo_uri,
users.handle
FROM artist_coins
LEFT JOIN users ON users.user_id = artist_coins.user_id
LEFT JOIN users ON users.user_id = artist_coins.user_id AND users.is_current = true
WHERE artist_coins.mint = $1
`, mint).Scan(&name, &ticker, &description, &logoUri, &handle)
if err != nil {
Expand All @@ -62,9 +62,8 @@ func (app *ApiServer) v1CoinMetadata(c *fiber.Ctx) error {
if description != nil && *description != "" {
metadata.Description = *description
} else if handle != nil {
// The launchpad never persists a description - the Fan Club page builds
// this same sentence client-side, and storing it would make the page cite
// itself. Regenerate it here so wallets and explorers still get one.
// Launchpad coins store no description. Rebuild the default the web
// client shows so wallets and explorers still get one.
metadata.Description = defaultCoinDescription(*handle, ticker, appUrl)
}
if logoUri != nil {
Expand Down
2 changes: 2 additions & 0 deletions api/v1_coin_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ func TestV1CoinMetadata(t *testing.T) {
"users": {
{"user_id": 1, "handle": "bearartist", "is_current": true},
{"user_id": 2, "handle": "bareartist", "is_current": true},
// Superseded row from before a handle change.
{"user_id": 2, "handle": "oldbare", "is_current": false, "txhash": "0xold"},
},
"artist_coins": {
{
Expand Down
4 changes: 1 addition & 3 deletions api/v1_playlist_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,7 @@ func (app *ApiServer) v1PlaylistStream(c *fiber.Ctx) error {
continue
}

// Leave deleted tracks, and tracks whose owner is no longer active, out
// of the m3u8 entirely rather than emitting a URL the stream endpoint
// will now reject.
// Skip tracks the stream endpoint rejects (deleted, or owner deactivated).
if !track.IsStreamable {
continue
}
Expand Down
31 changes: 12 additions & 19 deletions api/v1_track_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ import (
)

func createFilename(track *dbv1.Track) string {
// The original upload is what a download serves whenever the row kept one,
// so its own name is the right one.
// Downloads serve the original upload when the row has one, so use its name.
if track.OrigFileCid.String != "" && track.OrigFilename.String != "" {
return track.OrigFilename.String
}

// Otherwise the bytes are the mp3 transcode, and the name must not promise
// the format the artist uploaded: a .wav name on mp3 bytes is a file most
// editors refuse to open. Only the recorded filename is stripped of its
// extension - a title is free text and "Vol. 2" has no extension to trim.
// Otherwise the bytes are the mp3 transcode, so swap the recorded
// filename's extension for .mp3. Titles are used as-is since they have no
// real extension.
if name := track.OrigFilename.String; name != "" {
return strings.TrimSuffix(name, path.Ext(name)) + ".mp3"
}
Expand Down Expand Up @@ -61,11 +59,9 @@ func (app *ApiServer) v1TrackDownload(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusNotFound, "track not found")
}

// track.Download is only populated for tracks the public may download, so
// an artist who left downloads off - about four in five tracks - could not
// get their own file back. The edit page's "Download File" button and the
// replace-file flow both come through here, and both were 404ing for the
// owner of the track, surfacing as a generic "something went wrong".
// track.Download is only set when the public may download. Fall back to an
// owner-signed link so the artist or their manager can always fetch their
// own file (used by the edit page's "Download File" and replace-file flows).
downloadLink := track.Download
if downloadLink == nil {
downloadLink, err = app.ownerDownloadLink(c, &track)
Expand Down Expand Up @@ -95,14 +91,11 @@ func (app *ApiServer) v1TrackDownload(c *fiber.Ctx) error {
return c.Redirect(downloadUrl.String(), fiber.StatusFound)
}

// ownerDownloadLink signs a download link for a requester who has proven they
// own the track, or manage the account that does. Ownership comes from the
// wallet recovered from the request signature, not from the user_id query
// param behind myId: user_id is the caller's own claim, and it is only
// trustworthy here because this route sits off authMiddleware's advisory-
// user_id allowlist. Handing out an artist's original master should not rest
// on that list continuing to exclude this route. Returns nil - not an error -
// for everyone else, which leaves the caller's 404 in place.
// ownerDownloadLink signs a download link when the wallet recovered from the
// request signature owns the track or holds an approved grant from the owner.
// It checks the signature rather than the user_id query param so it does not
// depend on authMiddleware's user_id handling for this route. Returns nil for
// anyone else.
func (app *ApiServer) ownerDownloadLink(c *fiber.Ctx, track *dbv1.Track) (*dbv1.MediaLink, error) {
wallet := app.tryGetAuthedWallet(c)
if wallet == "" {
Expand Down
17 changes: 6 additions & 11 deletions api/v1_track_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,8 @@ const (
managerWallet = "0x4954d18926ba0ed9378938444731be4e622537b2"
)

// seedNonDownloadableTrack sets up the shape most tracks on the network have:
// an artist who never turned downloads on, whose original upload is still
// sitting on the content node.
// seedNonDownloadableTrack seeds a track with downloads off and an original
// upload kept.
func seedNonDownloadableTrack(t *testing.T) *ApiServer {
app := emptyTestApp(t)
database.Seed(app.pool.Replicas[0], database.FixtureMap{
Expand Down Expand Up @@ -120,8 +119,7 @@ func downloadWithWallet(t *testing.T, app *ApiServer, path string, wallet string
return res.StatusCode, res.Header.Get("Location")
}

// An artist must be able to get their own upload back even with downloads off
// for everyone else - that is what the edit page's "Download File" button does.
// The owner can download their own track with downloads off.
func TestGetTrackDownload_OwnerOfNonDownloadableTrack(t *testing.T) {
app := seedNonDownloadableTrack(t)
path := "/v1/tracks/" + trashid.MustEncodeHashID(1) + "/download"
Expand Down Expand Up @@ -150,10 +148,8 @@ func TestGetTrackDownload_ManagerOfNonDownloadableTrack(t *testing.T) {
assert.Contains(t, location, "tracks/cidstream/QmOriginal")
}

// Everyone else still gets the 404 the artist asked for by leaving downloads
// off. Claiming to be the owner through the user_id query param must not be
// enough: the bypass keys on the recovered signature, and the auth middleware
// separately refuses a user_id no signature backs (403 rather than 404).
// Non-owners get 404. A user_id param naming the owner without a matching
// signature gets 403 from the auth middleware.
func TestGetTrackDownload_NonOwnerOfNonDownloadableTrack(t *testing.T) {
app := seedNonDownloadableTrack(t)
trackPath := "/v1/tracks/" + trashid.MustEncodeHashID(1) + "/download"
Expand All @@ -172,8 +168,7 @@ func TestGetTrackDownload_NonOwnerOfNonDownloadableTrack(t *testing.T) {
assert.Equal(t, 403, status, "user_id claiming to be the owner, signed by another wallet")
}

// With no original kept, the download serves the mp3 transcode, so the name it
// is served under has to say mp3 rather than the format that was uploaded.
// With no original kept, the download serves the mp3 transcode under a .mp3 name.
func TestGetTrackDownload_FilenameFallsBackToMp3(t *testing.T) {
app := emptyTestApp(t)
database.Seed(app.pool.Replicas[0], database.FixtureMap{
Expand Down
9 changes: 3 additions & 6 deletions api/v1_track_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,9 @@ func (app *ApiServer) v1TrackStream(c *fiber.Ctx) error {

track := tracks[0]

// `is_streamable` is false when the track is deleted or its owner is no
// longer active - either the artist deactivated their own account or the
// account was delisted by the trusted notifier. The track response has
// always reported this, but nothing enforced it, so the audio stayed
// reachable to anyone holding the URL. Treat it as not found rather than
// forbidden so we don't distinguish these from a missing track.
// is_streamable is false when the track is deleted or its owner is
// deactivated or delisted. Return 404 rather than 403 so these look like a
// missing track.
if !track.IsStreamable {
return fiber.NewError(fiber.StatusNotFound, "track not found")
}
Expand Down
11 changes: 3 additions & 8 deletions api/v1_track_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,8 @@ func TestGetTrackUsdcPurchaseSelfAccess(t *testing.T) {
})
}

// A track whose owner is no longer active - the artist deactivated their own
// account, or the account was delisted by the trusted notifier - must not carry
// signed content-node URLs in its response. The stream and download endpoints
// already reject these, but the media links in the track response bypass those
// endpoints entirely: the cid is real, so the signed URL serves the full audio
// straight from the content node to anyone who reads the response.
// A non-streamable track (deleted, or owner deactivated) must not carry signed
// media links in its response.
func TestGetTrack_NonStreamableOmitsMediaLinks(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down Expand Up @@ -226,8 +222,7 @@ func TestGetTrack_NonStreamableOmitsMediaLinks(t *testing.T) {
}
}

// The guard above is scoped to non-streamable tracks: an ordinary track with an
// active owner must still get its signed media links.
// A streamable track keeps its signed media links.
func TestGetTrack_StreamableKeepsMediaLinks(t *testing.T) {
app := emptyTestApp(t)
database.Seed(app.pool.Replicas[0], database.FixtureMap{
Expand Down
37 changes: 10 additions & 27 deletions api/v1_users_suggested_follows.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,11 @@ type GetUsersSuggestedFollowsParams struct {
}

const (
// Only the most recent N favorites and N reposts are considered. A user's
// engagement history is unbounded, and everything downstream of it joins
// per-row, so this caps the worst case for heavy users. Recent engagement
// is also the better signal, so the cap costs little.
//
// Note this cap, not the decay below, is the effective window for anyone
// with a large library: a user who favorites 50 tracks a day is scored on
// their last ~40 days, well inside the decay's range. For everyone else the
// cap never binds and the decay does the shaping.
//
// Lowering it further is tempting for latency but trades against fill rate:
// the cap bounds the candidate pool *before* already-followed artists are
// filtered out, so a user who follows most of the artists they recently
// engaged with gets a short list. saves_user_created_at_active_idx
// (migration 0239) removes the reason to make that trade.
// Only the most recent N favorites and N reposts are scored, which bounds
// the per-row joins for heavy users. For large libraries this cap, not the
// decay, sets the effective window. It applies before followed artists are
// filtered out, so lowering it shortens the list for users who follow most
// of what they engage with.
suggestedFollowsEngagementCap = 2000

// Engagement weight decays with e^(-age/tau). At tau = 180 days a favorite
Expand All @@ -44,19 +34,12 @@ const (
)

/*
Suggests artists to follow based on the user's own favorites and reposts.
Suggests users to follow: owners of tracks, albums and playlists the user has
favorited or reposted but does not follow, ranked by recency-weighted
engagement.

This is the "direct owner" pass: artists whose tracks or albums the user has
already favorited or reposted but has not followed. It is deliberately not a
collaborative filter — those candidates are already engaged with, so they need
no graph traversal to justify, and "you saved three of their tracks and never
followed them" is both the cheapest and the most legible suggestion available.

Distinct from /users/:userId/related, which is artist-anchored ("followers of X
also follow Y"). This one is viewer-anchored.

Suggestions exclude anyone the seed user already follows, so the result depends
only on the path userId — not on the caller — and is cached on that alone.
Unlike /users/:userId/related (artist-anchored), this is viewer-anchored. The
result depends only on the path userId, so it is cached on that.
*/
func (app *ApiServer) v1UsersSuggestedFollows(c *fiber.Ctx) error {
params := GetUsersSuggestedFollowsParams{}
Expand Down
13 changes: 5 additions & 8 deletions api/v1_users_suggested_follows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func TestV1UsersSuggestedFollows(t *testing.T) {
assert.Equal(t, "onerepost", resp.Data[1].Handle.String)
}

// Offset walks the same ordering rather than reshuffling it.
// Offset continues the same ordering.
{
status, _ := testGet(t, app, "/v1/users/7eP5n/suggested-follows?limit=2&offset=2", &resp)
assert.Equal(t, 200, status)
Expand All @@ -102,18 +102,15 @@ func TestV1UsersSuggestedFollows(t *testing.T) {
assert.Equal(t, "albumowner", resp.Data[1].Handle.String)
}

// A user with no favorites or reposts gets nothing rather than an error --
// the caller is expected to fall back to a non-personalized surface.
// A user with no favorites or reposts gets an empty list.
{
status, _ := testGet(t, app, "/v1/users/ML51L/suggested-follows", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 0)
}
}

// The decay term is the only reason a single favorite can outrank another
// single favorite, so it needs a case of its own -- the fixtures above all
// share a created_at and would pass with the decay removed entirely.
// The fixtures above share a created_at, so the decay term needs its own case.
func TestV1UsersSuggestedFollowsRecencyDecay(t *testing.T) {
app := emptyTestApp(t)

Expand Down Expand Up @@ -145,8 +142,8 @@ func TestV1UsersSuggestedFollowsRecencyDecay(t *testing.T) {
Data []dbv1.User
}

// Equal raw engagement (one favorite each), so recency alone decides. User 2
// sorts first on user_id, which makes this fail loudly if decay stops working.
// Equal engagement, so recency decides. Without decay, user 2 would sort
// first on user_id.
status, _ := testGet(t, app, "/v1/users/7eP5n/suggested-follows", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 2)
Expand Down
Loading
Loading