From e3c30083fd9c5844815be4a2d775e976d0474cc5 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:56:34 -0800 Subject: [PATCH] post/put requests for track/user/playlist --- api/server.go | 7 + api/v1_playlist.go | 278 +++++++++++++++++++++++++++++++++++ api/v1_track.go | 359 +++++++++++++++++++++++++++++++++++++++++++++ api/v1_users.go | 245 ++++++++++++++++++++++++++++++- 4 files changed, 883 insertions(+), 6 deletions(-) diff --git a/api/server.go b/api/server.go index ff3b9428..cc7004bc 100644 --- a/api/server.go +++ b/api/server.go @@ -339,6 +339,7 @@ func NewApiServer(config config.Config) *ApiServer { for _, g := range []fiber.Router{v1, v1Full} { // Users g.Get("/users", app.v1Users) + g.Post("/users", app.requireAuthMiddleware, app.postV1Users) g.Get("/users/address", app.v1UserIdsByAddresses) g.Get("/users/search", app.v1UsersSearch) g.Get("/users/unclaimed_id", app.v1UsersUnclaimedId) @@ -424,9 +425,11 @@ func NewApiServer(config config.Config) *ApiServer { g.Delete("/users/:userId/subscribe", app.requireAuthMiddleware, app.deleteV1UserSubscribe) g.Post("/users/:userId/mute", app.requireAuthMiddleware, app.postV1UserMute) g.Delete("/users/:userId/mute", app.requireAuthMiddleware, app.deleteV1UserMute) + g.Put("/users/:userId", app.requireAuthMiddleware, app.putV1User) // Tracks g.Get("/tracks", app.v1Tracks) + g.Post("/tracks", app.requireAuthMiddleware, app.postV1Tracks) g.Get("/tracks/search", app.v1TracksSearch) g.Get("/tracks/unclaimed_id", app.v1TracksUnclaimedId) @@ -457,6 +460,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Delete("/tracks/:trackId/favorites", app.requireAuthMiddleware, app.deleteV1TrackFavorite) g.Post("/tracks/:trackId/shares", app.requireAuthMiddleware, app.postV1TrackShare) g.Post("/tracks/:trackId/downloads", app.requireAuthMiddleware, app.postV1TrackDownload) + g.Put("/tracks/:trackId", app.requireAuthMiddleware, app.putV1Track) g.Delete("/tracks/:trackId", app.requireAuthMiddleware, app.deleteV1Track) g.Get("/tracks/:trackId/comments", app.v1TrackComments) g.Get("/tracks/:trackId/comment_count", app.v1TrackCommentCount) @@ -470,6 +474,7 @@ func NewApiServer(config config.Config) *ApiServer { // Playlists g.Get("/playlists", app.v1Playlists) + g.Post("/playlists", app.requireAuthMiddleware, app.postV1Playlists) g.Get("/playlists/search", app.v1PlaylistsSearch) g.Get("/playlists/unclaimed_id", app.v1PlaylistsUnclaimedId) g.Get("/playlists/unclaimed-id", app.v1PlaylistsUnclaimedId) @@ -488,6 +493,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/playlists/:playlistId/favorites", app.requireAuthMiddleware, app.postV1PlaylistFavorite) g.Delete("/playlists/:playlistId/favorites", app.requireAuthMiddleware, app.deleteV1PlaylistFavorite) g.Post("/playlists/:playlistId/shares", app.requireAuthMiddleware, app.postV1PlaylistShare) + g.Put("/playlists/:playlistId", app.requireAuthMiddleware, app.putV1Playlist) + g.Delete("/playlists/:playlistId", app.requireAuthMiddleware, app.deleteV1Playlist) g.Get("/playlists/:playlistId/tracks", app.v1PlaylistTracks) // Explore diff --git a/api/v1_playlist.go b/api/v1_playlist.go index 4d759169..0f3eba98 100644 --- a/api/v1_playlist.go +++ b/api/v1_playlist.go @@ -1,10 +1,70 @@ package api import ( + "encoding/json" + "strconv" + "time" + "api.audius.co/api/dbv1" + "api.audius.co/indexer" + "api.audius.co/trashid" + corev1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/ethereum/go-ethereum/common" "github.com/gofiber/fiber/v2" + "go.uber.org/zap" ) +type PlaylistTrackInfo struct { + TrackId string `json:"track_id" validate:"required"` + Timestamp int64 `json:"timestamp" validate:"required,min=0"` + MetadataTimestamp *int64 `json:"metadata_timestamp,omitempty" validate:"omitempty,min=0"` +} + +type CreatePlaylistRequest struct { + PlaylistId *string `json:"playlist_id,omitempty"` + PlaylistName string `json:"playlist_name" validate:"required,min=1"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + IsPrivate *bool `json:"is_private,omitempty"` + IsAlbum *bool `json:"is_album,omitempty"` + Genre *string `json:"genre,omitempty" validate:"omitempty,min=1"` + Mood *string `json:"mood,omitempty"` + Tags *string `json:"tags,omitempty"` + License *string `json:"license,omitempty"` + Upc *string `json:"upc,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` + CoverArtCid *string `json:"cover_art_cid,omitempty"` + PlaylistContents *[]PlaylistTrackInfo `json:"playlist_contents,omitempty" validate:"omitempty,dive"` + DdexApp *string `json:"ddex_app,omitempty"` + DdexReleaseIds *map[string]string `json:"ddex_release_ids,omitempty"` + Artists *[]DDEXResourceContributor `json:"artists,omitempty" validate:"omitempty,dive"` + CopyrightLine *DDEXCopyright `json:"copyright_line,omitempty" validate:"omitempty"` + ProducerCopyrightLine *DDEXCopyright `json:"producer_copyright_line,omitempty" validate:"omitempty"` + ParentalWarningType *string `json:"parental_warning_type,omitempty"` + IsImageAutogenerated *bool `json:"is_image_autogenerated,omitempty"` +} + +type UpdatePlaylistRequest struct { + PlaylistName *string `json:"playlist_name,omitempty" validate:"omitempty,min=1"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + IsPrivate *bool `json:"is_private,omitempty"` + IsAlbum *bool `json:"is_album,omitempty"` + Genre *string `json:"genre,omitempty" validate:"omitempty,min=1"` + Mood *string `json:"mood,omitempty"` + Tags *string `json:"tags,omitempty"` + License *string `json:"license,omitempty"` + Upc *string `json:"upc,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` + CoverArtCid *string `json:"cover_art_cid,omitempty"` + PlaylistContents *[]PlaylistTrackInfo `json:"playlist_contents,omitempty" validate:"omitempty,dive"` + DdexApp *string `json:"ddex_app,omitempty"` + DdexReleaseIds *map[string]string `json:"ddex_release_ids,omitempty"` + Artists *[]DDEXResourceContributor `json:"artists,omitempty" validate:"omitempty,dive"` + CopyrightLine *DDEXCopyright `json:"copyright_line,omitempty" validate:"omitempty"` + ProducerCopyrightLine *DDEXCopyright `json:"producer_copyright_line,omitempty" validate:"omitempty"` + ParentalWarningType *string `json:"parental_warning_type,omitempty"` + IsImageAutogenerated *bool `json:"is_image_autogenerated,omitempty"` +} + func (app *ApiServer) v1Playlist(c *fiber.Ctx) error { myId := app.getMyId(c) playlistId := c.Locals("playlistId").(int) @@ -27,3 +87,221 @@ func (app *ApiServer) v1Playlist(c *fiber.Ctx) error { return v1PlaylistResponse(c, playlist) } + +func (app *ApiServer) postV1Playlists(c *fiber.Ctx) error { + userID := app.getUserId(c) + + // Parse and validate request body + var req CreatePlaylistRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body: " + err.Error(), + }) + } + + // Validate struct tags + if err := app.requestValidator.Validate(&req); err != nil { + return err + } + + // Determine playlist ID + var playlistID int + if req.PlaylistId != nil { + decodedID, err := trashid.DecodeHashId(*req.PlaylistId) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid playlist_id: " + err.Error(), + }) + } + playlistID = decodedID + } else { + // Generate playlist ID from timestamp if not provided + playlistID = int(time.Now().UnixNano() % 1000000000) + } + + // Convert struct to map for metadata + metadataBytes, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to serialize metadata", + }) + } + + var metadata map[string]interface{} + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to process metadata", + }) + } + + // Remove nil values from metadata + for key, value := range metadata { + if value == nil { + delete(metadata, key) + } + } + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + // Build metadata JSON with cid and data fields + metadataJSON := map[string]interface{}{ + "cid": "", + "data": metadata, + } + finalMetadataBytes, _ := json.Marshal(metadataJSON) + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(playlistID), + Action: indexer.Action_Create, + EntityType: indexer.Entity_Playlist, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(finalMetadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send playlist create transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create playlist", + }) + } + + encodedPlaylistID, _ := trashid.EncodeHashId(playlistID) + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + "playlist_id": encodedPlaylistID, + }) +} + +func (app *ApiServer) putV1Playlist(c *fiber.Ctx) error { + userID := app.getUserId(c) + playlistID, err := trashid.DecodeHashId(c.Params("playlistId")) + if err != nil { + return err + } + + // Parse and validate request body + var req UpdatePlaylistRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body: " + err.Error(), + }) + } + + // Validate struct tags + if err := app.requestValidator.Validate(&req); err != nil { + return err + } + + // Convert struct to map for metadata + metadataBytes, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to serialize metadata", + }) + } + + var metadata map[string]interface{} + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to process metadata", + }) + } + + // Remove nil values from metadata + for key, value := range metadata { + if value == nil { + delete(metadata, key) + } + } + + // Ensure at least one field is being updated + if len(metadata) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "At least one field must be provided for update", + }) + } + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + // Build metadata JSON with cid and data fields + metadataJSON := map[string]interface{}{ + "cid": "", + "data": metadata, + } + finalMetadataBytes, _ := json.Marshal(metadataJSON) + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(playlistID), + Action: indexer.Action_Update, + EntityType: indexer.Entity_Playlist, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(finalMetadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send playlist update transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to update playlist", + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) +} + +func (app *ApiServer) deleteV1Playlist(c *fiber.Ctx) error { + userID := app.getUserId(c) + playlistID, err := trashid.DecodeHashId(c.Params("playlistId")) + if err != nil { + return err + } + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(playlistID), + Action: indexer.Action_Delete, + EntityType: indexer.Entity_Playlist, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: "", + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send playlist delete transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to delete playlist", + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) +} diff --git a/api/v1_track.go b/api/v1_track.go index f93ffe4e..6da12699 100644 --- a/api/v1_track.go +++ b/api/v1_track.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "strconv" "time" @@ -13,6 +14,184 @@ import ( "go.uber.org/zap" ) +// Nested type definitions for track metadata + +type RemixParent struct { + ParentTrackId string `json:"parent_track_id" validate:"required"` +} + +type RemixOf struct { + Tracks []RemixParent `json:"tracks" validate:"required,min=1,dive"` +} + +type StemOf struct { + Category string `json:"category" validate:"required,oneof=BASS DRUMS MELODY VOCALS OTHER"` + ParentTrackId string `json:"parent_track_id" validate:"required"` +} + +type FieldVisibility struct { + Mood *bool `json:"mood,omitempty"` + Tags *bool `json:"tags,omitempty"` + Genre *bool `json:"genre,omitempty"` + Share *bool `json:"share,omitempty"` + PlayCount *bool `json:"play_count,omitempty"` + Remixes *bool `json:"remixes,omitempty"` +} + +type NFTCollection struct { + Chain string `json:"chain" validate:"required,oneof=eth sol"` + Standard *string `json:"standard,omitempty" validate:"omitempty,oneof=ERC721 ERC1155"` + Address string `json:"address" validate:"required"` + Name string `json:"name" validate:"required"` + Slug *string `json:"slug,omitempty"` + ImageUrl *string `json:"image_url,omitempty"` + ExternalLink *string `json:"external_link,omitempty"` +} + +type CollectibleGatedConditions struct { + NftCollection *NFTCollection `json:"nft_collection,omitempty" validate:"omitempty"` +} + +type FollowGatedConditions struct { + FollowUserId int `json:"follow_user_id" validate:"required,min=1"` +} + +type TipGatedConditions struct { + TipUserId int `json:"tip_user_id" validate:"required,min=1"` +} + +type TokenGate struct { + TokenMint string `json:"token_mint" validate:"required"` + TokenAmount int `json:"token_amount" validate:"required,min=1"` +} + +type TokenGatedConditions struct { + TokenGate TokenGate `json:"token_gate" validate:"required"` +} + +type PurchaseSplit struct { + UserId int `json:"user_id" validate:"required,min=1"` + Percentage float64 `json:"percentage" validate:"required,min=0,max=100"` +} + +type USDCPurchase struct { + Price float64 `json:"price" validate:"required,min=0"` + Splits []PurchaseSplit `json:"splits" validate:"required,dive"` +} + +type USDCPurchaseConditions struct { + UsdcPurchase USDCPurchase `json:"usdc_purchase" validate:"required"` +} + +// AccessConditions can be one of: CollectibleGatedConditions, FollowGatedConditions, +// TipGatedConditions, TokenGatedConditions, or USDCPurchaseConditions. +// In Go, we use a flexible approach where the JSON contains the discriminating field. +type AccessConditions struct { + // Exactly one of these should be populated + NftCollection *NFTCollection `json:"nft_collection,omitempty" validate:"omitempty"` + FollowUserId *int `json:"follow_user_id,omitempty" validate:"omitempty,min=1"` + TipUserId *int `json:"tip_user_id,omitempty" validate:"omitempty,min=1"` + TokenGate *TokenGate `json:"token_gate,omitempty" validate:"omitempty"` + UsdcPurchase *USDCPurchase `json:"usdc_purchase,omitempty" validate:"omitempty"` +} + +type DDEXResourceContributor struct { + Name string `json:"name" validate:"required,min=1"` + Roles []string `json:"roles" validate:"required,min=1,dive,min=1"` + SequenceNumber *int `json:"sequence_number,omitempty" validate:"omitempty,min=0"` +} + +type DDEXCopyright struct { + Year string `json:"year" validate:"required,len=4"` + Text string `json:"text" validate:"required,min=1"` +} + +type DDEXRightsController struct { + Name string `json:"name" validate:"required,min=1"` + Roles []string `json:"roles" validate:"required,min=1,dive,min=1"` + RightsShareUnknown *string `json:"rights_share_unknown,omitempty"` +} + +type CreateTrackRequest struct { + TrackId *string `json:"track_id,omitempty"` + Title string `json:"title" validate:"required,min=1"` + Genre string `json:"genre" validate:"required,min=1"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + Mood *string `json:"mood,omitempty"` + Tags *string `json:"tags,omitempty"` + License *string `json:"license,omitempty"` + Isrc *string `json:"isrc,omitempty"` + Iswc *string `json:"iswc,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` + TrackCid string `json:"track_cid" validate:"required"` + CoverArtCid *string `json:"cover_art_cid,omitempty"` + PreviewCid *string `json:"preview_cid,omitempty"` + PreviewStartSeconds *float64 `json:"preview_start_seconds,omitempty" validate:"omitempty,min=0"` + Duration *float64 `json:"duration,omitempty" validate:"omitempty,min=0"` + Downloadable *bool `json:"downloadable,omitempty"` + IsUnlisted *bool `json:"is_unlisted,omitempty"` + FieldVisibility *FieldVisibility `json:"field_visibility,omitempty" validate:"omitempty"` + RemixOf *RemixOf `json:"remix_of,omitempty" validate:"omitempty"` + StemOf *StemOf `json:"stem_of,omitempty" validate:"omitempty"` + DownloadConditions *AccessConditions `json:"download_conditions,omitempty" validate:"omitempty"` + StreamConditions *AccessConditions `json:"stream_conditions,omitempty" validate:"omitempty"` + IsStreamGated *bool `json:"is_stream_gated,omitempty"` + IsDownloadGated *bool `json:"is_download_gated,omitempty"` + AiAttributionUserId *int `json:"ai_attribution_user_id,omitempty" validate:"omitempty,min=0"` + AllowedApiKeys *[]string `json:"allowed_api_keys,omitempty"` + PlacementHosts *string `json:"placement_hosts,omitempty"` + DdexApp *string `json:"ddex_app,omitempty"` + DdexReleaseIds *map[string]string `json:"ddex_release_ids,omitempty"` + Artists *[]DDEXResourceContributor `json:"artists,omitempty" validate:"omitempty,dive"` + ResourceContributors *[]DDEXResourceContributor `json:"resource_contributors,omitempty" validate:"omitempty,dive"` + IndirectResourceContributors *[]DDEXResourceContributor `json:"indirect_resource_contributors,omitempty" validate:"omitempty,dive"` + CopyrightLine *DDEXCopyright `json:"copyright_line,omitempty" validate:"omitempty"` + ProducerCopyrightLine *DDEXCopyright `json:"producer_copyright_line,omitempty" validate:"omitempty"` + ParentalWarningType *string `json:"parental_warning_type,omitempty"` + OrigFileCid *string `json:"orig_file_cid,omitempty"` + OrigFilename *string `json:"orig_filename,omitempty"` + IsOriginalAvailable *bool `json:"is_original_available,omitempty"` + AudioUploadId *string `json:"audio_upload_id,omitempty"` +} + +type UpdateTrackRequest struct { + Title *string `json:"title,omitempty" validate:"omitempty,min=1"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + Genre *string `json:"genre,omitempty" validate:"omitempty,min=1"` + Mood *string `json:"mood,omitempty"` + Tags *string `json:"tags,omitempty"` + License *string `json:"license,omitempty"` + Isrc *string `json:"isrc,omitempty"` + Iswc *string `json:"iswc,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` + Artwork *map[string]interface{} `json:"artwork,omitempty"` + TrackCid *string `json:"track_cid,omitempty"` + CoverArtCid *string `json:"cover_art_cid,omitempty"` + PreviewCid *string `json:"preview_cid,omitempty"` + PreviewStartSeconds *float64 `json:"preview_start_seconds,omitempty" validate:"omitempty,min=0"` + Downloadable *bool `json:"downloadable,omitempty"` + IsUnlisted *bool `json:"is_unlisted,omitempty"` + FieldVisibility *FieldVisibility `json:"field_visibility,omitempty" validate:"omitempty"` + RemixOf *RemixOf `json:"remix_of,omitempty" validate:"omitempty"` + StemOf *StemOf `json:"stem_of,omitempty" validate:"omitempty"` + DownloadConditions *AccessConditions `json:"download_conditions,omitempty" validate:"omitempty"` + StreamConditions *AccessConditions `json:"stream_conditions,omitempty" validate:"omitempty"` + IsStreamGated *bool `json:"is_stream_gated,omitempty"` + IsDownloadGated *bool `json:"is_download_gated,omitempty"` + AiAttributionUserId *int `json:"ai_attribution_user_id,omitempty" validate:"omitempty,min=0"` + AllowedApiKeys *[]string `json:"allowed_api_keys,omitempty"` + PlacementHosts *string `json:"placement_hosts,omitempty"` + DdexApp *string `json:"ddex_app,omitempty"` + DdexReleaseIds *map[string]string `json:"ddex_release_ids,omitempty"` + Artists *[]DDEXResourceContributor `json:"artists,omitempty" validate:"omitempty,dive"` + ResourceContributors *[]DDEXResourceContributor `json:"resource_contributors,omitempty" validate:"omitempty,dive"` + IndirectResourceContributors *[]DDEXResourceContributor `json:"indirect_resource_contributors,omitempty" validate:"omitempty,dive"` + CopyrightLine *DDEXCopyright `json:"copyright_line,omitempty" validate:"omitempty"` + ProducerCopyrightLine *DDEXCopyright `json:"producer_copyright_line,omitempty" validate:"omitempty"` + ParentalWarningType *string `json:"parental_warning_type,omitempty"` + AudioUploadId *string `json:"audio_upload_id,omitempty"` +} + func (app *ApiServer) v1Track(c *fiber.Ctx) error { myId := app.getMyId(c) trackId := c.Locals("trackId").(int) @@ -36,6 +215,186 @@ func (app *ApiServer) v1Track(c *fiber.Ctx) error { return v1TrackResponse(c, track) } +func (app *ApiServer) postV1Tracks(c *fiber.Ctx) error { + userID := app.getUserId(c) + + // Parse and validate request body + var req CreateTrackRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body: " + err.Error(), + }) + } + + // Validate struct tags + if err := app.requestValidator.Validate(&req); err != nil { + return err + } + + // Determine track ID + var trackID int + if req.TrackId != nil { + decodedID, err := trashid.DecodeHashId(*req.TrackId) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid track_id: " + err.Error(), + }) + } + trackID = decodedID + } else { + // Generate track ID from timestamp if not provided + trackID = int(time.Now().UnixNano() % 1000000000) + } + + // Convert struct to map for metadata + metadataBytes, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to serialize metadata", + }) + } + + var metadata map[string]interface{} + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to process metadata", + }) + } + + // Remove nil values from metadata + for key, value := range metadata { + if value == nil { + delete(metadata, key) + } + } + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + // Build metadata JSON with cid and data fields + metadataJSON := map[string]interface{}{ + "cid": "", + "data": metadata, + } + finalMetadataBytes, _ := json.Marshal(metadataJSON) + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(trackID), + Action: indexer.Action_Create, + EntityType: indexer.Entity_Track, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(finalMetadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send track create transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create track", + }) + } + + encodedTrackID, _ := trashid.EncodeHashId(trackID) + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + "track_id": encodedTrackID, + }) +} + +func (app *ApiServer) putV1Track(c *fiber.Ctx) error { + userID := app.getUserId(c) + trackID, err := trashid.DecodeHashId(c.Params("trackId")) + if err != nil { + return err + } + + // Parse and validate request body + var req UpdateTrackRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body: " + err.Error(), + }) + } + + // Validate struct tags + if err := app.requestValidator.Validate(&req); err != nil { + return err + } + + // Convert struct to map for metadata + metadataBytes, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to serialize metadata", + }) + } + + var metadata map[string]interface{} + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to process metadata", + }) + } + + // Remove nil values from metadata + for key, value := range metadata { + if value == nil { + delete(metadata, key) + } + } + + // Ensure at least one field is being updated + if len(metadata) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "At least one field must be provided for update", + }) + } + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + // Build metadata JSON with cid and data fields + metadataJSON := map[string]interface{}{ + "cid": "", + "data": metadata, + } + finalMetadataBytes, _ := json.Marshal(metadataJSON) + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(trackID), + Action: indexer.Action_Update, + EntityType: indexer.Entity_Track, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(finalMetadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send track update transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to update track", + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) +} + func (app *ApiServer) deleteV1Track(c *fiber.Ctx) error { userID := app.getUserId(c) trackID, err := trashid.DecodeHashId(c.Params("trackId")) diff --git a/api/v1_users.go b/api/v1_users.go index 535dafd9..f1c0a80d 100644 --- a/api/v1_users.go +++ b/api/v1_users.go @@ -1,11 +1,72 @@ package api import ( + "encoding/json" + "strconv" + "time" + "api.audius.co/api/dbv1" + "api.audius.co/indexer" + "api.audius.co/trashid" + corev1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/ethereum/go-ethereum/common" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" + "go.uber.org/zap" ) +// PlaylistLibraryItem represents an item in the user's playlist library +type PlaylistLibraryItem struct { + Type string `json:"type" validate:"required,oneof=playlist album explore_playlist temp_playlist"` + PlaylistId *string `json:"playlist_id,omitempty"` + ContentListId *string `json:"content_list_id,omitempty"` +} + +type PlaylistLibrary struct { + Contents []PlaylistLibraryItem `json:"contents" validate:"required,dive"` +} + +type CreateUserRequest struct { + UserId *string `json:"user_id,omitempty"` + Handle string `json:"handle" validate:"required,min=1"` + Wallet string `json:"wallet" validate:"required"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1"` + Bio *string `json:"bio,omitempty" validate:"omitempty,max=256"` + Location *string `json:"location,omitempty"` + Website *string `json:"website,omitempty" validate:"omitempty,url"` + Donation *string `json:"donation,omitempty"` + TwitterHandle *string `json:"twitter_handle,omitempty"` + InstagramHandle *string `json:"instagram_handle,omitempty"` + TiktokHandle *string `json:"tiktok_handle,omitempty"` + ProfilePicture *string `json:"profile_picture,omitempty"` + ProfilePictureSizes *string `json:"profile_picture_sizes,omitempty"` + CoverPhoto *string `json:"cover_photo,omitempty"` + CoverPhotoSizes *string `json:"cover_photo_sizes,omitempty"` + AllowAiAttribution *bool `json:"allow_ai_attribution,omitempty"` + SplUsdcPayoutWallet *string `json:"spl_usdc_payout_wallet,omitempty"` +} + +type UpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1"` + Bio *string `json:"bio,omitempty" validate:"omitempty,max=256"` + Location *string `json:"location,omitempty"` + Website *string `json:"website,omitempty" validate:"omitempty,url"` + Donation *string `json:"donation,omitempty"` + TwitterHandle *string `json:"twitter_handle,omitempty"` + InstagramHandle *string `json:"instagram_handle,omitempty"` + TiktokHandle *string `json:"tiktok_handle,omitempty"` + ProfilePicture *string `json:"profile_picture,omitempty"` + ProfilePictureSizes *string `json:"profile_picture_sizes,omitempty"` + CoverPhoto *string `json:"cover_photo,omitempty"` + CoverPhotoSizes *string `json:"cover_photo_sizes,omitempty"` + IsDeactivated *bool `json:"is_deactivated,omitempty"` + ArtistPickTrackId *string `json:"artist_pick_track_id,omitempty"` + AllowAiAttribution *bool `json:"allow_ai_attribution,omitempty"` + PlaylistLibrary *PlaylistLibrary `json:"playlist_library,omitempty" validate:"omitempty"` + SplUsdcPayoutWallet *string `json:"spl_usdc_payout_wallet,omitempty"` + CoinFlairMint *string `json:"coin_flair_mint,omitempty"` +} + // v1Users is a handler that retrieves full user data func (app *ApiServer) v1Users(c *fiber.Ctx) error { myId := app.getMyId(c) @@ -26,6 +87,100 @@ func (app *ApiServer) v1Users(c *fiber.Ctx) error { return v1UsersResponse(c, users) } +func (app *ApiServer) postV1Users(c *fiber.Ctx) error { + // Parse and validate request body + var req CreateUserRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body: " + err.Error(), + }) + } + + // Validate struct tags + if err := app.requestValidator.Validate(&req); err != nil { + return err + } + + // Determine user ID + var userID int + if req.UserId != nil { + decodedID, err := trashid.DecodeHashId(*req.UserId) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid user_id: " + err.Error(), + }) + } + userID = decodedID + } else { + // Generate user ID from timestamp if not provided + userID = int(time.Now().UnixNano() % 1000000000) + } + + // Convert struct to map for metadata + metadataBytes, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to serialize metadata", + }) + } + + var metadata map[string]interface{} + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to process metadata", + }) + } + + // Remove nil values from metadata + for key, value := range metadata { + if value == nil { + delete(metadata, key) + } + } + + // Add user_id to metadata + metadata["user_id"] = userID + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + // Build metadata JSON with cid and data fields + metadataJSON := map[string]interface{}{ + "cid": "", + "data": metadata, + } + finalMetadataBytes, _ := json.Marshal(metadataJSON) + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(userID), + Action: indexer.Action_Create, + EntityType: indexer.Entity_User, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(finalMetadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send user create transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create user", + }) + } + + encodedUserID, _ := trashid.EncodeHashId(userID) + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + "user_id": encodedUserID, + }) +} + type GetUsersParams struct { Limit int `query:"limit" default:"20" validate:"min=1,max=100"` Offset int `query:"offset" default:"0" validate:"min=0"` @@ -66,14 +221,92 @@ func (app *ApiServer) queryUsers(c *fiber.Ctx, sql string, args pgx.NamedArgs) e return err } - userMap := map[int32]dbv1.User{} - for _, user := range users { - userMap[user.UserID] = user + return v1UsersResponse(c, users) +} + +func (app *ApiServer) putV1User(c *fiber.Ctx) error { + userID := app.getUserId(c) + targetUserID, err := trashid.DecodeHashId(c.Params("userId")) + if err != nil { + return err + } + + // Parse and validate request body + var req UpdateUserRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body: " + err.Error(), + }) } - for idx, id := range userIds { - users[idx] = userMap[id] + // Validate struct tags + if err := app.requestValidator.Validate(&req); err != nil { + return err } - return v1UsersResponse(c, users) + // Convert struct to map for metadata + metadataBytes, err := json.Marshal(req) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to serialize metadata", + }) + } + + var metadata map[string]interface{} + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Failed to process metadata", + }) + } + + // Remove nil values from metadata + for key, value := range metadata { + if value == nil { + delete(metadata, key) + } + } + + // Ensure at least one field is being updated + if len(metadata) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "At least one field must be provided for update", + }) + } + + signer, err := app.getApiSigner(c) + if err != nil { + return err + } + + nonce := time.Now().UnixNano() + + // Build metadata JSON with cid and data fields + metadataJSON := map[string]interface{}{ + "cid": "", + "data": metadata, + } + finalMetadataBytes, _ := json.Marshal(metadataJSON) + + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(signer.Address).String(), + UserId: int64(userID), + EntityId: int64(targetUserID), + Action: indexer.Action_Update, + EntityType: indexer.Entity_User, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(finalMetadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, signer.PrivateKey) + if err != nil { + app.logger.Error("Failed to send user update transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to update user", + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) }