Skip to content
Open
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
70 changes: 41 additions & 29 deletions api/dbv1/tracks.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,21 @@ 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 reports whether the track and its owner are in good
// standing - not deleted, not deactivated, not delisted. It is the gate on
// serving the artist's audio in any form. IsStreamable narrows it further
// by also requiring a cid to stream, so downloads (which fall back to
// orig_file_cid) must check this rather than IsStreamable.
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,34 +202,39 @@ 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

// 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.
// The artist's audio is off-limits once the track was deleted or its
// owner stopped being active - either the artist deactivated their own
// account or the account was delisted by the trusted notifier. The cid
// in those rows is real, so a signed URL would still work: the stream
// and download endpoints reject these, but that only closes two routes,
// and anyone reading the track response could otherwise fetch the audio
// straight from the content node. Preview is included because a preview
// clip is still the artist's audio.
isAudioAllowed := !rawTrack.IsDelete && !user.IsDeactivated

// Streaming needs one more thing: a transcoded cid to point at. An
// upload that never got its track_cid written has audio sitting on the
// content node that no reader can address - nothing to sign, nothing to
// play, and signing an empty cid just produces a URL guaranteed to 404.
// Such rows used to report is_streamable=true, so every client treated
// them as healthy: the player spun on a dead URL, and mobile's
// share-to-story fed that URL to ffmpeg and failed with a generic
// "something went wrong". Say plainly that there is nothing to stream.
//
// 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.
// Downloads are deliberately not gated on this - they fall back to
// orig_file_cid, which a row missing its track_cid still has.
isStreamable := isAudioAllowed && rawTrack.TrackCid.String != ""

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 {
cid := rawTrack.OrigFileCid.String
if cid == "" {
cid = rawTrack.TrackCid.String
Expand All @@ -237,7 +248,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 @@ -247,6 +258,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: 5 additions & 2 deletions api/v1_track_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ 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 {
// no longer active, must not have its audio served here either. This checks
// IsAudioAllowed rather than IsStreamable because a download falls back to
// orig_file_cid - a row that never got its track_cid is unstreamable but
// still perfectly downloadable.
if !track.IsAudioAllowed {
return fiber.NewError(fiber.StatusNotFound, "track not found")
}

Expand Down
64 changes: 64 additions & 0 deletions api/v1_track_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,67 @@ func TestGetTrackStream_DeletedTrack(t *testing.T) {
assert.Equal(t, 404, res.StatusCode)
assert.Empty(t, res.Header.Get("Location"))
}

// The track response must say plainly that a row with no track_cid has nothing
// to stream. It used to report is_streamable=true with a null stream link,
// which left every client believing the track was healthy: the player spun on a
// dead URL, and mobile's share-to-story handed that URL to ffmpeg and failed
// with a generic error rather than explaining the track had no audio.
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,
})
}

// Losing is_streamable must not cost the artist their downloads: a download
// falls back to orig_file_cid, which a row missing its track_cid still has.
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")
}
7 changes: 7 additions & 0 deletions indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ 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 successfully but were
// indexed without their cid, leaving the track unplayable. Same
// content-node source as the analysis repair above. Runs less often
// because these are rare and each pass costs a lookup per candidate.
jobs.NewRepairTrackCidsJob(ci.Config, ci.pool, ci.openAudioSDK).
ScheduleEvery(ctx, 15*time.Minute)
}

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