Skip to content
45 changes: 25 additions & 20 deletions api/dbv1/tracks.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@ const IncludeID3TagsCtxKey = "includeID3Tags"
type Track struct {
GetTracksRow

Permalink string `json:"permalink"`
IsStreamable bool `json:"is_streamable"`
Artwork *SquareImage `json:"artwork"`
Stream *MediaLink `json:"stream"`
Download *MediaLink `json:"download"`
Preview *MediaLink `json:"preview"`
UserID trashid.HashId `json:"user_id"`
User User `json:"user"`
Collaborators []User `json:"collaborators"`
Permalink string `json:"permalink"`
IsStreamable bool `json:"is_streamable"`
// IsAudioAllowed is false when the track is deleted or its owner is
// deactivated or delisted. IsStreamable also requires a track_cid, so
// downloads (which fall back to orig_file_cid) check this instead.
IsAudioAllowed bool `json:"-"`
Artwork *SquareImage `json:"artwork"`
Stream *MediaLink `json:"stream"`
Download *MediaLink `json:"download"`
Preview *MediaLink `json:"preview"`
UserID trashid.HashId `json:"user_id"`
User User `json:"user"`
Collaborators []User `json:"collaborators"`
// PendingCollaborators is populated only on the requester's own tracks (so
// the owner's edit form can preserve still-pending invites); empty otherwise.
PendingCollaborators []User `json:"pending_collaborators"`
Expand Down Expand Up @@ -196,25 +200,25 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32]
}
}

// A track is streamable unless it was deleted or its owner is no longer
// active - either the artist deactivated their own account or the
// account was delisted by the trusted notifier.
isStreamable := !rawTrack.IsDelete && !user.IsDeactivated
// No media links (stream, download or preview) for a deleted track or
// an inactive owner. The cid is real, so a signed link in the response
// would bypass the stream and download endpoint checks.
isAudioAllowed := !rawTrack.IsDelete && !user.IsDeactivated

// Streaming also needs a track_cid; without one there is nothing to
// play. Downloads don't, since they fall back to orig_file_cid.
isStreamable := isAudioAllowed && rawTrack.TrackCid.String != ""

// 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 != "" {
if isStreamable && access.Stream {
stream, err = mediaLink(rawTrack.TrackCid.String, rawTrack.TrackID, arg.MyID.(int32), id3Tags)
if err != nil {
return nil, err
}
}

var download *MediaLink
if isStreamable && rawTrack.IsDownloadable && access.Download {
if isAudioAllowed && rawTrack.IsDownloadable && access.Download {
if cid := rawTrack.DownloadCid(); cid != "" {
download, err = mediaLink(cid, rawTrack.TrackID, arg.MyID.(int32), nil)
if err != nil {
Expand All @@ -224,7 +228,7 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32]
}

var preview *MediaLink
if isStreamable && rawTrack.PreviewCid.String != "" {
if isAudioAllowed && rawTrack.PreviewCid.String != "" {
preview, err = mediaLink(rawTrack.PreviewCid.String, rawTrack.TrackID, arg.MyID.(int32), id3Tags)
if err != nil {
return nil, err
Expand All @@ -234,6 +238,7 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32]
track := Track{
GetTracksRow: rawTrack,
IsStreamable: isStreamable,
IsAudioAllowed: isAudioAllowed,
Permalink: fmt.Sprintf("/%s/%s", user.Handle.String, rawTrack.Slug.String),
Artwork: squareImageStruct(rawTrack.CoverArtSizes, rawTrack.CoverArt),
Stream: stream,
Expand Down
7 changes: 4 additions & 3 deletions api/v1_track_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ func (app *ApiServer) v1TrackDownload(c *fiber.Ctx) error {

track := tracks[0]

// Same guard as the stream endpoint: a deleted track, or one whose owner is
// no longer active, must not have its audio served here either.
if !track.IsStreamable {
// Deleted tracks and inactive owners get 404, as on the stream endpoint.
// Checks IsAudioAllowed rather than IsStreamable because downloads fall
// back to orig_file_cid.
if !track.IsAudioAllowed {
return fiber.NewError(fiber.StatusNotFound, "track not found")
}

Expand Down
29 changes: 29 additions & 0 deletions api/v1_track_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,32 @@ func TestGetTrackDownload_FilenameFallsBackToMp3(t *testing.T) {
assert.Contains(t, location, "tracks/cidstream/QmTranscode")
assert.Contains(t, location, "filename=Vol.+2.mp3")
}

// The owner can download a track with no track_cid and downloads off; the link
// falls back to orig_file_cid.
func TestGetTrackDownload_OwnerOfCidlessTrack(t *testing.T) {
app := emptyTestApp(t)
database.Seed(app.pool.Replicas[0], database.FixtureMap{
"tracks": []map[string]any{
{
"track_id": 1,
"owner_id": 1,
"title": "No Track Cid",
"orig_file_cid": "QmOriginal",
"orig_filename": "NoCid.wav",
"is_downloadable": false,
},
},
"users": []map[string]any{
{"user_id": 1, "handle": "artist", "wallet": ownerWallet},
},
})
path := "/v1/tracks/" + trashid.MustEncodeHashID(1) + "/download"

status, location := downloadWithWallet(t, app, path, ownerWallet)
assert.Equal(t, 302, status)
assert.Contains(t, location, "tracks/cidstream/QmOriginal")

status, _ = downloadWithWallet(t, app, path, "")
assert.Equal(t, 404, status, "anonymous")
}
59 changes: 59 additions & 0 deletions api/v1_track_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,62 @@ func TestGetTrackStream_DeletedTrack(t *testing.T) {
assert.Equal(t, 404, res.StatusCode)
assert.Empty(t, res.Header.Get("Location"))
}

// A track with no track_cid reports is_streamable=false and a null stream.
func TestGetTrack_NoCidIsNotStreamable(t *testing.T) {
app := emptyTestApp(t)
fixtures := database.FixtureMap{
"tracks": []map[string]any{
{
"track_id": 1,
"owner_id": 1,
"title": "No Cid",
"orig_file_cid": "QmNoCidOriginal",
"is_downloadable": true,
},
},
"users": []map[string]any{
{
"user_id": 1,
"handle": "testuser1",
},
},
}
database.Seed(app.pool.Replicas[0], fixtures)

status, body := testGet(t, app, "/v1/tracks/"+trashid.MustEncodeHashID(1))
assert.Equal(t, 200, status)
jsonAssert(t, body, map[string]any{
"data.is_streamable": false,
"data.stream": nil,
})
}

// A track with no track_cid is still downloadable via orig_file_cid.
func TestGetTrackDownload_NoTrackCidStillDownloadable(t *testing.T) {
app := emptyTestApp(t)
fixtures := database.FixtureMap{
"tracks": []map[string]any{
{
"track_id": 1,
"owner_id": 1,
"title": "No Track Cid",
"orig_file_cid": "QmNoCidOriginal",
"orig_filename": "NoCid.wav",
"is_downloadable": true,
},
},
"users": []map[string]any{
{
"user_id": 1,
"handle": "testuser1",
},
},
}
database.Seed(app.pool.Replicas[0], fixtures)
req := httptest.NewRequest("GET", "/v1/tracks/"+trashid.MustEncodeHashID(1)+"/download", nil)
res, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, 302, res.StatusCode)
assert.Contains(t, res.Header.Get("Location"), "QmNoCidOriginal")
}
6 changes: 6 additions & 0 deletions indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ func (ci *CoreIndexer) startParityJobs(ctx context.Context) {
// schedule ran every 3 minutes. Needs the SDK for content-node discovery.
jobs.NewRepairAudioAnalysesJob(ci.Config, ci.pool, ci.openAudioSDK).
ScheduleEvery(ctx, 3*time.Minute)

// Backfill track_cid for uploads that transcoded but were indexed without
// it. Runs less often than the analysis repair since these are rare and
// each candidate costs a content-node lookup.
jobs.NewRepairTrackCidsJob(ci.Config, ci.pool, ci.openAudioSDK).
ScheduleEvery(ctx, 15*time.Minute)
}

func (ci *CoreIndexer) Close() {
Expand Down
Loading
Loading