diff --git a/api/dbv1/full_tracks.go b/api/dbv1/full_tracks.go index 2a9371e5..4e3d88e1 100644 --- a/api/dbv1/full_tracks.go +++ b/api/dbv1/full_tracks.go @@ -80,14 +80,23 @@ func (q *Queries) FullTracksKeyed(ctx context.Context, arg GetTracksParams) (map // Collect media links // TODO(API-49): support self-access via grants // see https://github.com/AudiusProject/audius-protocol/blob/4bd9fe80d8cca519844596061505ad8737579019/packages/discovery-provider/src/queries/query_helpers.py#L905 - stream := mediaLink(track.TrackCid.String, track.TrackID, arg.MyID.(int32)) + stream, err := mediaLink(track.TrackCid.String, track.TrackID, arg.MyID.(int32)) + if err != nil { + return nil, err + } var download *MediaLink if track.IsDownloadable { - download = mediaLink(track.OrigFileCid.String, track.TrackID, arg.MyID.(int32)) + download, err = mediaLink(track.OrigFileCid.String, track.TrackID, arg.MyID.(int32)) + if err != nil { + return nil, err + } } var preview *MediaLink if track.PreviewCid.String != "" { - preview = mediaLink(track.PreviewCid.String, track.TrackID, arg.MyID.(int32)) + preview, err = mediaLink(track.PreviewCid.String, track.TrackID, arg.MyID.(int32)) + if err != nil { + return nil, err + } } if track.FieldVisibility == nil || string(track.FieldVisibility) == "null" { diff --git a/api/dbv1/media_link.go b/api/dbv1/media_link.go index 5b6fd063..b239e29c 100644 --- a/api/dbv1/media_link.go +++ b/api/dbv1/media_link.go @@ -19,7 +19,7 @@ type MediaLink struct { Mirrors []string `json:"mirrors"` } -func mediaLink(cid string, trackId int32, userId int32) *MediaLink { +func mediaLink(cid string, trackId int32, userId int32) (*MediaLink, error) { first, rest := rendezvous.GlobalHasher.ReplicaSet3(cid) timestamp := time.Now().Unix() * 1000 @@ -32,7 +32,7 @@ func mediaLink(cid string, trackId int32, userId int32) *MediaLink { signature, err := generateSignature(data) if err != nil { - return nil + return nil, err } // Convert the data map to a JSON string @@ -52,7 +52,7 @@ func mediaLink(cid string, trackId int32, userId int32) *MediaLink { return &MediaLink{ Url: fmt.Sprintf("%s/%s", first, path), Mirrors: rest, - } + }, nil } func generateSignature(data map[string]interface{}) (string, error) { diff --git a/api/server.go b/api/server.go index 446eb47c..b928c523 100644 --- a/api/server.go +++ b/api/server.go @@ -228,12 +228,16 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/tracks/trending", app.v1TracksTrending) g.Get("/tracks/trending/ids", app.v1TracksTrendingIds) g.Get("/tracks/recommended", app.v1TracksTrending) + g.Get("/tracks/inspect", app.v1TracksInspect) g.Use("/tracks/:trackId", app.requireTrackIdMiddleware) g.Get("/tracks/:trackId", app.v1Track) - g.Get("/tracks/:trackId/reposts", app.v1TracksReposts) - g.Get("/tracks/:trackId/favorites", app.v1TracksFavorites) - g.Get("/tracks/:trackId/comments", app.v1TracksComments) + g.Get("/tracks/:trackId/stream", app.v1TrackStream) + g.Get("/tracks/:trackId/download", app.v1TrackDownload) + g.Get("/tracks/:trackId/inspect", app.v1TrackInspect) + g.Get("/tracks/:trackId/reposts", app.v1TrackReposts) + g.Get("/tracks/:trackId/favorites", app.v1TrackFavorites) + g.Get("/tracks/:trackId/comments", app.v1TrackComments) // Playlists g.Get("/playlists", app.v1playlists) @@ -241,8 +245,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Use("/playlists/:playlistId", app.requirePlaylistIdMiddleware) g.Get("/playlists/:playlistId", app.v1Playlist) - g.Get("/playlists/:playlistId/reposts", app.v1PlaylistsReposts) - g.Get("/playlists/:playlistId/favorites", app.v1PlaylistsFavorites) + g.Get("/playlists/:playlistId/reposts", app.v1PlaylistReposts) + g.Get("/playlists/:playlistId/favorites", app.v1PlaylistFavorites) // Developer Apps g.Get("/developer_apps/:address", app.v1DeveloperApps) diff --git a/api/stream_util.go b/api/stream_util.go new file mode 100644 index 00000000..c65a56d1 --- /dev/null +++ b/api/stream_util.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "net/url" + "time" + + "bridgerton.audius.co/api/dbv1" +) + +// tryFindWorkingUrl attempts to validate a media link by checking if it can serve content. +// It tries the primary URL first, then falls back to mirrors if needed. +// Returns the first valid URL found or the main URL if nothing works. +func tryFindWorkingUrl(mediaLink *dbv1.MediaLink) *url.URL { + mainURL, err := url.Parse(mediaLink.Url) + if err != nil { + return nil + } + + // Construct all URLs to try + urls := make([]*url.URL, 0, len(mediaLink.Mirrors)+1) + urls = append(urls, mainURL) + for _, mirror := range mediaLink.Mirrors { + mirrorURL := *mainURL + mirrorURL.Host = mirror + urls = append(urls, &mirrorURL) + } + + client := &http.Client{ + Timeout: 5 * time.Second, + } + for _, u := range urls { + q := u.Query() + q.Set("skip_play_count", "true") + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + continue + } + req.Header.Set("Range", "bytes=0-1") + + resp, err := client.Do(req) + if err != nil { + continue + } + resp.Body.Close() + + if resp.StatusCode == http.StatusPartialContent || + resp.StatusCode == http.StatusOK || + resp.StatusCode == http.StatusNoContent { + return u + } + } + + return mainURL +} diff --git a/api/testdata/track_fixtures.csv b/api/testdata/track_fixtures.csv index 83d4f6bf..258fe1de 100644 --- a/api/testdata/track_fixtures.csv +++ b/api/testdata/track_fixtures.csv @@ -1,12 +1,12 @@ -track_id,genre,owner_id,title,is_unlisted,stream_conditions,download_conditions -100,Electronic,1,T1,f,, -101,Alternative,1,T2,f,, -200,Electronic,2,Culca Canyon,f,, -201,Alternative,2,Turkey Time DEMO,t,, -202,Alternative,2,Turkey Time (live),f,, -300,Electronic,3,Follow Gated Download,f,,"{""follow_user_id"": 3}" -301,Electronic,3,Pay Gated Download,f,,"{""usdc_purchase"": {""price"": 135, ""splits"": [{""user_id"": 3, ""percentage"": 100.0}]}}" -302,Electronic,3,Tip Gated Stream,f,"{""tip_user_id"": 3}","{""tip_user_id"": 3}" -303,Electronic,3,Pay Gated Stream,f,"{""usdc_purchase"": {""price"": 135, ""splits"": [{""user_id"": 3, ""percentage"": 100.0}]}}", -400,Folk,5,Trending Month Folk,f,, -500,Experimental,6,track by permalink,f,, \ No newline at end of file +track_id,genre,owner_id,title,is_unlisted,stream_conditions,download_conditions,is_downloadable +100,Electronic,1,T1,f,,,t +101,Alternative,1,T2,f,,,f +200,Electronic,2,Culca Canyon,f,,,f +201,Alternative,2,Turkey Time DEMO,t,,,f +202,Alternative,2,Turkey Time (live),f,,,f +300,Electronic,3,Follow Gated Download,f,,"{""follow_user_id"": 3}",t +301,Electronic,3,Pay Gated Download,f,,"{""usdc_purchase"": {""price"": 135, ""splits"": [{""user_id"": 3, ""percentage"": 100.0}]}}",t +302,Electronic,3,Tip Gated Stream,f,"{""tip_user_id"": 3}","{""tip_user_id"": 3}",f +303,Electronic,3,Pay Gated Stream,f,"{""usdc_purchase"": {""price"": 135, ""splits"": [{""user_id"": 3, ""percentage"": 100.0}]}}",,f +400,Folk,5,Trending Month Folk,f,,,f +500,Experimental,6,track by permalink,f,,,f \ No newline at end of file diff --git a/api/v1_playlists_favorites.go b/api/v1_playlist_favorites.go similarity index 90% rename from api/v1_playlists_favorites.go rename to api/v1_playlist_favorites.go index a60d26f1..3ab41b94 100644 --- a/api/v1_playlists_favorites.go +++ b/api/v1_playlist_favorites.go @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5" ) -func (app *ApiServer) v1PlaylistsFavorites(c *fiber.Ctx) error { +func (app *ApiServer) v1PlaylistFavorites(c *fiber.Ctx) error { sql := ` SELECT user_id FROM saves diff --git a/api/v1_playlists_reposts.go b/api/v1_playlist_reposts.go similarity index 90% rename from api/v1_playlists_reposts.go rename to api/v1_playlist_reposts.go index 355ed8ad..0437da24 100644 --- a/api/v1_playlists_reposts.go +++ b/api/v1_playlist_reposts.go @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5" ) -func (app *ApiServer) v1PlaylistsReposts(c *fiber.Ctx) error { +func (app *ApiServer) v1PlaylistReposts(c *fiber.Ctx) error { sql := ` SELECT user_id FROM reposts r diff --git a/api/v1_tracks_comments.go b/api/v1_track_comments.go similarity index 87% rename from api/v1_tracks_comments.go rename to api/v1_track_comments.go index 2569153c..14ab4c84 100644 --- a/api/v1_tracks_comments.go +++ b/api/v1_track_comments.go @@ -5,7 +5,7 @@ import ( "github.com/jackc/pgx/v5" ) -func (app *ApiServer) v1TracksComments(c *fiber.Ctx) error { +func (app *ApiServer) v1TrackComments(c *fiber.Ctx) error { sql := ` SELECT comment_id as id diff --git a/api/v1_tracks_comments_test.go b/api/v1_track_comments_test.go similarity index 100% rename from api/v1_tracks_comments_test.go rename to api/v1_track_comments_test.go diff --git a/api/v1_track_download.go b/api/v1_track_download.go new file mode 100644 index 00000000..a57b0bc5 --- /dev/null +++ b/api/v1_track_download.go @@ -0,0 +1,40 @@ +package api + +import ( + "bridgerton.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" +) + +func (app *ApiServer) v1TrackDownload(c *fiber.Ctx) error { + myId := app.getMyId(c) + trackId := c.Locals("trackId").(int) + filename := c.Query("filename") + + tracks, err := app.queries.FullTracks(c.Context(), dbv1.GetTracksParams{ + MyID: myId, + Ids: []int32{int32(trackId)}, + }) + if err != nil { + return err + } + + if len(tracks) == 0 { + return sendError(c, 404, "track not found") + } + + track := tracks[0] + if !track.Access.Download { + return sendError(c, 403, "track not downloadable") + } + + downloadUrl := tryFindWorkingUrl(track.Download) + + q := downloadUrl.Query() + q.Set("skip_play_count", "true") + if filename != "" { + q.Set("filename", filename) + } + downloadUrl.RawQuery = q.Encode() + + return c.Redirect(downloadUrl.String(), fiber.StatusFound) +} diff --git a/api/v1_track_download_test.go b/api/v1_track_download_test.go new file mode 100644 index 00000000..1c3461be --- /dev/null +++ b/api/v1_track_download_test.go @@ -0,0 +1,15 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetTrackDownload(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/tracks/eYZmn/download", nil) + res, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Contains(t, res.Header.Get("Location"), "https://dummynode.com/tracks/cidstream/?signature=%7B%22data%22%3A%22%7B%5C%22cid%5C%22%3A%5C%22%5C%22%2C%5C%22timestamp%5C%22%3") +} diff --git a/api/v1_tracks_favorites.go b/api/v1_track_favorites.go similarity index 90% rename from api/v1_tracks_favorites.go rename to api/v1_track_favorites.go index e76c8b76..76a726c4 100644 --- a/api/v1_tracks_favorites.go +++ b/api/v1_track_favorites.go @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5" ) -func (app *ApiServer) v1TracksFavorites(c *fiber.Ctx) error { +func (app *ApiServer) v1TrackFavorites(c *fiber.Ctx) error { sql := ` SELECT user_id FROM saves diff --git a/api/v1_track_inspect.go b/api/v1_track_inspect.go new file mode 100644 index 00000000..d7c733e2 --- /dev/null +++ b/api/v1_track_inspect.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "bridgerton.audius.co/api/dbv1" + "bridgerton.audius.co/rendezvous" + "github.com/gofiber/fiber/v2" + "golang.org/x/sync/errgroup" +) + +type blobInspect struct { + ContentType string `json:"ContentType"` + Size int64 `json:"Size"` +} + +type inspectResponse struct { + Size int64 `json:"size"` + ContentType string `json:"content_type"` +} + +func inspectTrack(track dbv1.FullTrack, original bool) (*inspectResponse, error) { + var cid string + if original { + cid = track.OrigFileCid.String + } else { + cid = track.TrackCid.String + } + + first, rest := rendezvous.GlobalHasher.ReplicaSet3(cid) + + hosts := append([]string{first}, rest...) + var info blobInspect + var lastErr error + + for _, host := range hosts { + client := &http.Client{} + req, err := http.NewRequest("GET", fmt.Sprintf("%s/internal/blobs/info/%s", host, cid), nil) + if err != nil { + lastErr = err + continue + } + + resp, err := client.Do(req) + if err != nil { + lastErr = err + continue + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + lastErr = fmt.Errorf("host %s returned status %d", host, resp.StatusCode) + continue + } + + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + resp.Body.Close() + lastErr = err + continue + } + resp.Body.Close() + + return &inspectResponse{ + Size: info.Size, + ContentType: info.ContentType, + }, nil + } + + return nil, fmt.Errorf("failed to fetch blob info from any host: %v", lastErr) +} + +func (app *ApiServer) v1TrackInspect(c *fiber.Ctx) error { + myId := app.getMyId(c) + trackId := c.Locals("trackId").(int) + original := c.Query("original") == "true" + + tracks, err := app.queries.FullTracks(c.Context(), dbv1.GetTracksParams{ + MyID: myId, + Ids: []int32{int32(trackId)}, + }) + if err != nil { + return err + } + + if len(tracks) == 0 { + return sendError(c, 404, "track not found") + } + + track := tracks[0] + info, err := inspectTrack(track, original) + if err != nil { + return sendError(c, 500, err.Error()) + } + + return c.JSON(map[string]any{ + "data": info, + }) +} + +func (app *ApiServer) v1TracksInspect(c *fiber.Ctx) error { + myId := app.getMyId(c) + ids := decodeIdList(c) + original := c.Query("original") == "true" + + tracks, err := app.queries.FullTracks(c.Context(), dbv1.GetTracksParams{ + MyID: myId, + Ids: ids, + }) + if err != nil { + return err + } + + if len(tracks) == 0 { + return sendError(c, 404, "track not found") + } + + infos := make([]*inspectResponse, len(tracks)) + g := &errgroup.Group{} + + for i, track := range tracks { + idx, t := i, track // Create new variables for the goroutine + g.Go(func() error { + info, err := inspectTrack(t, original) + if err != nil { + infos[idx] = nil + return err + } + infos[idx] = info + return nil + }) + } + + if err := g.Wait(); err != nil { + return sendError(c, 500, err.Error()) + } + + return c.JSON(map[string]any{ + "data": infos, + }) +} diff --git a/api/v1_tracks_reposts.go b/api/v1_track_reposts.go similarity index 90% rename from api/v1_tracks_reposts.go rename to api/v1_track_reposts.go index 03a8b74f..c89a0a84 100644 --- a/api/v1_tracks_reposts.go +++ b/api/v1_track_reposts.go @@ -6,7 +6,7 @@ import ( "github.com/jackc/pgx/v5" ) -func (app *ApiServer) v1TracksReposts(c *fiber.Ctx) error { +func (app *ApiServer) v1TrackReposts(c *fiber.Ctx) error { sql := ` SELECT user_id FROM reposts r diff --git a/api/v1_track_stream.go b/api/v1_track_stream.go new file mode 100644 index 00000000..7468c904 --- /dev/null +++ b/api/v1_track_stream.go @@ -0,0 +1,38 @@ +package api + +import ( + "bridgerton.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" +) + +func (app *ApiServer) v1TrackStream(c *fiber.Ctx) error { + myId := app.getMyId(c) + trackId := c.Locals("trackId").(int) + + tracks, err := app.queries.FullTracks(c.Context(), dbv1.GetTracksParams{ + MyID: myId, + Ids: []int32{int32(trackId)}, + }) + if err != nil { + return err + } + + if len(tracks) == 0 { + return sendError(c, 404, "track not found") + } + + track := tracks[0] + if !track.Access.Stream { + return sendError(c, 403, "track not streamable") + } + + streamURL := tryFindWorkingUrl(track.Stream) + + if skipPlayCount := c.Query("skip_play_count"); skipPlayCount != "" { + q := streamURL.Query() + q.Set("skip_play_count", skipPlayCount) + streamURL.RawQuery = q.Encode() + } + + return c.Redirect(streamURL.String(), fiber.StatusFound) +} diff --git a/api/v1_track_stream_test.go b/api/v1_track_stream_test.go new file mode 100644 index 00000000..6cf39401 --- /dev/null +++ b/api/v1_track_stream_test.go @@ -0,0 +1,15 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetTrackStream(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/tracks/eYJyn/stream", nil) + res, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Contains(t, res.Header.Get("Location"), "https://dummynode.com/tracks/cidstream/?signature=%7B%22data%22%3A%22%7B%5C%22cid%5C%22%3A%5C%22%5C%22%2C%5C%22timestamp%5C%22%3") +} diff --git a/config/config.go b/config/config.go index b4e5f5cd..30a8005a 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,9 @@ func init() { fallthrough case "": Cfg.AntiAbuseOracles = []string{"http://audius-protocol-discovery-provider-1"} + Cfg.Nodes = DevNodes + // Dummy key + Cfg.DelegatePrivateKey = "13422b9affd75ff80f94f1ea394e6a6097830cb58cda2d3542f37464ecaee7df" case "stage": fallthrough case "staging": diff --git a/config/nodes.go b/config/nodes.go index 287faeb6..1c8b4b48 100644 --- a/config/nodes.go +++ b/config/nodes.go @@ -743,7 +743,6 @@ var ( }, } StageNodes = []Node{ - { DelegateOwnerWallet: "0x8fcFA10Bd3808570987dbb5B1EF4AB74400FbfDA", Endpoint: "https://discoveryprovider.staging.audius.co", @@ -799,4 +798,11 @@ var ( OwnerWallet: "0x5E98cBEEAA2aCEDEc0833AC3D1634E2A7aE0f3c2", }, } + DevNodes = []Node{ + { + DelegateOwnerWallet: "0x0000000000000000000000000000000000000001", + Endpoint: "https://dummynode.com", + OwnerWallet: "0x0000000000000000000000000000000000000001", + }, + } )