From d500035780fb06fedaff917232edb2b79f40b92f Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 19 Mar 2023 14:47:19 -0700 Subject: [PATCH 1/6] initial work --- cmd/wire_gen.go | 4 +- core/agents/interfaces.go | 4 +- core/agents/listenbrainz/agent.go | 175 ++++++++++++ core/agents/listenbrainz/auth_router.go | 3 +- core/agents/listenbrainz/auth_router_test.go | 16 ++ core/agents/listenbrainz/client.go | 126 ++++++++- core/agents/session_keys.go | 48 ++++ core/external_playlists/external_playlists.go | 196 +++++++++++++ core/external_playlists/interfaces.go | 46 +++ core/wire_providers.go | 2 + ...230318140335_add_playlist_external_info.go | 30 ++ model/mediafile.go | 1 + model/playlist.go | 7 + persistence/mediafile_repository.go | 9 + persistence/playlist_repository.go | 33 +++ server/nativeapi/external_playlists.go | 163 +++++++++++ server/nativeapi/native_api.go | 8 +- ui/src/App.js | 2 + .../ExternalPlaylistCreate.js | 264 ++++++++++++++++++ ui/src/externalPlaylist/index.js | 5 + ui/src/i18n/en.json | 39 ++- ui/src/playlist/ImportButton.js | 11 + ui/src/playlist/PlaylistActions.js | 63 ++++- ui/src/playlist/PlaylistList.js | 81 +++++- ui/src/playlist/PlaylistListActions.js | 4 + 25 files changed, 1312 insertions(+), 28 deletions(-) create mode 100644 core/external_playlists/external_playlists.go create mode 100644 core/external_playlists/interfaces.go create mode 100644 db/migration/20230318140335_add_playlist_external_info.go create mode 100644 server/nativeapi/external_playlists.go create mode 100644 ui/src/externalPlaylist/ExternalPlaylistCreate.js create mode 100644 ui/src/externalPlaylist/index.js create mode 100644 ui/src/playlist/ImportButton.js diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 65ddb435..22a7f524 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/core/agents/lastfm" "github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/db" @@ -40,7 +41,8 @@ func CreateNativeAPIRouter() *nativeapi.Router { dataStore := persistence.New(sqlDB) broker := events.GetBroker() share := core.NewShare(dataStore) - router := nativeapi.New(dataStore, broker, share) + playlistRetriever := external_playlists.GetPlaylistRetriever(dataStore) + router := nativeapi.New(dataStore, broker, share, playlistRetriever) return router } diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 00f75627..91dc8413 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/microcosm-cc/bluemonday" "github.com/navidrome/navidrome/model" ) @@ -37,7 +38,8 @@ type Song struct { } var ( - ErrNotFound = errors.New("not found") + ErrNotFound = errors.New("not found") + StripAllTags = bluemonday.StrictPolicy() ) // TODO Break up this interface in more specific methods, like artists diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go index f98c9185..ec91f68d 100644 --- a/core/agents/listenbrainz/agent.go +++ b/core/agents/listenbrainz/agent.go @@ -3,11 +3,14 @@ package listenbrainz import ( "context" "errors" + "fmt" "net/http" + "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -19,6 +22,10 @@ const ( sessionKeyProperty = "ListenBrainzSessionKey" ) +var ( + playlistTypes = []string{"user", "collab", "created"} +) + type listenBrainzAgent struct { ds model.DataStore sessionKeys *agents.SessionKeys @@ -103,6 +110,170 @@ func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrob return scrobbler.ErrUnrecoverable } +func (l *listenBrainzAgent) GetPlaylistTypes() []string { + return playlistTypes +} + +func getIdentifier(url string) string { + split := strings.Split(url, "/") + return split[len(split)-1] +} + +func (l *listenBrainzAgent) GetPlaylists(ctx context.Context, offset, count int, userId, playlistType string) (*external_playlists.ExternalPlaylists, error) { + token, err := l.sessionKeys.GetWithUser(ctx, userId) + + if err == agents.ErrNoUsername { + resp, err := l.client.validateToken(ctx, token.Key) + + if err != nil { + return nil, err + } + + token.User = resp.UserName + + err = l.sessionKeys.PutWithUser(ctx, userId, token.Key, resp.UserName) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + resp, err := l.client.getPlaylists(ctx, offset, count, token.Key, token.User, playlistType) + + if err != nil { + return nil, err + } + + lists := make([]external_playlists.ExternalPlaylist, len(resp.Playlists)) + + for i, playlist := range resp.Playlists { + pls := playlist.Playlist + + lists[i] = external_playlists.ExternalPlaylist{ + Name: pls.Title, + Description: utils.SanitizeText(pls.Annotation), + Creator: pls.Creator, + ID: getIdentifier(pls.Identifier), + Url: pls.Identifier, + CreatedAt: pls.Date, + UpdatedAt: pls.Extension.Extension.LastModified, + } + } + + return &external_playlists.ExternalPlaylists{ + Total: resp.PlaylistCount, + Lists: lists, + }, nil +} + +func (l *listenBrainzAgent) ImportPlaylist(ctx context.Context, update bool, userId, id, name string) error { + token, err := l.sessionKeys.Get(ctx, userId) + if err != nil { + return err + } + + pls, err := l.client.getPlaylist(ctx, token, id) + if err != nil { + return err + } + + err = l.ds.WithTx(func(tx model.DataStore) error { + ids := make([]string, len(pls.Playlist.Tracks)) + for i, track := range pls.Playlist.Tracks { + ids[i] = getIdentifier(track.Identifier) + } + + matched_tracks, err := tx.MediaFile(ctx).FindWithMbid(ids) + + if err != nil { + return err + } + + var playlist *model.Playlist = nil + + comment := agents.StripAllTags.Sanitize(pls.Playlist.Annotation) + + if update { + playlist, err = tx.Playlist(ctx).GetByExternalInfo(listenBrainzAgentName, id) + + if err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Failed to query for playlist", "error", err) + } + } else if playlist.ExternalAgent != listenBrainzAgentName { + return fmt.Errorf("existing agent %s does not match current agent %s", playlist.ExternalAgent, listenBrainzAgentName) + } else if userId != playlist.OwnerID { + return model.ErrNotAuthorized + } else { + playlist.Name = name + playlist.Comment = comment + } + } + + if playlist == nil { + playlist = &model.Playlist{ + Name: name, + Comment: comment, + OwnerID: userId, + Public: false, + ExternalAgent: listenBrainzAgentName, + ExternalId: id, + ExternalUrl: pls.Playlist.Identifier, + } + } + + playlist.AddMediaFiles(matched_tracks) + + err = tx.Playlist(ctx).Put(playlist) + + if err != nil { + log.Error(ctx, "Failed to import playlist", "id", id, err) + } + + return err + }) + + return err +} + +func (l *listenBrainzAgent) SyncPlaylist(ctx context.Context, tx model.DataStore, pls *model.Playlist) error { + token, err := l.sessionKeys.Get(ctx, pls.OwnerID) + if err != nil { + return err + } + + external, err := l.client.getPlaylist(ctx, token, pls.ExternalId) + if err != nil { + return err + } + + ids := make([]string, len(external.Playlist.Tracks)) + for i, track := range external.Playlist.Tracks { + ids[i] = getIdentifier(track.Identifier) + } + + matched_tracks, err := tx.MediaFile(ctx).FindWithMbid(ids) + + if err != nil { + return err + } + + comment := agents.StripAllTags.Sanitize(external.Playlist.Annotation) + + pls.Comment = comment + + pls.AddMediaFiles(matched_tracks) + + err = tx.Playlist(ctx).Put(pls) + + if err != nil { + log.Error(ctx, "Failed to sync playlist", "id", pls.ID, err) + } + + return err +} + func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool { sk, err := l.sessionKeys.Get(ctx, userId) return err == nil && sk != "" @@ -114,6 +285,10 @@ func init() { scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { return listenBrainzConstructor(ds) }) + + external_playlists.Register(listenBrainzAgentName, func(ds model.DataStore) external_playlists.PlaylistAgent { + return listenBrainzConstructor(ds) + }) } }) } diff --git a/core/agents/listenbrainz/auth_router.go b/core/agents/listenbrainz/auth_router.go index 2382aeb7..7ccc652d 100644 --- a/core/agents/listenbrainz/auth_router.go +++ b/core/agents/listenbrainz/auth_router.go @@ -20,6 +20,7 @@ import ( type sessionKeysRepo interface { Put(ctx context.Context, userId, sessionKey string) error + PutWithUser(ctx context.Context, userId, sessionKey, remoteUser string) error Get(ctx context.Context, userId string) (string, error) Delete(ctx context.Context, userId string) error } @@ -100,7 +101,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) { return } - err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token) + err = s.sessionKeys.PutWithUser(r.Context(), u.ID, payload.Token, resp.UserName) if err != nil { log.Error("Could not save ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err) _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) diff --git a/core/agents/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go index dc705dbc..0ad7eb62 100644 --- a/core/agents/listenbrainz/auth_router_test.go +++ b/core/agents/listenbrainz/auth_router_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -113,6 +114,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { type fakeSessionKeys struct { KeyName string KeyValue string + UserName string } func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error { @@ -120,11 +122,25 @@ func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) e return nil } +func (sk *fakeSessionKeys) PutWithUser(ctx context.Context, userId, sessionKey, remoteUser string) error { + sk.KeyValue = sessionKey + sk.UserName = remoteUser + return nil +} + func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) { return sk.KeyValue, nil } +func (sk *fakeSessionKeys) GetWithUser(ctx context.Context, userId string) (agents.KeyWithUser, error) { + return agents.KeyWithUser{ + Key: sk.KeyValue, + User: sk.UserName, + }, nil +} + func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error { sk.KeyValue = "" + sk.UserName = "" return nil } diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go index 8ed4d169..327e4619 100644 --- a/core/agents/listenbrainz/client.go +++ b/core/agents/listenbrainz/client.go @@ -8,8 +8,11 @@ import ( "net/http" "net/url" "path" + "time" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" ) type listenBrainzError struct { @@ -35,17 +38,51 @@ type client struct { } type listenBrainzResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Error string `json:"error"` - Status string `json:"status"` - Valid bool `json:"valid"` - UserName string `json:"user_name"` + Code int `json:"code"` + Message string `json:"message"` + Error string `json:"error"` + Status string `json:"status"` + Valid bool `json:"valid"` + UserName string `json:"user_name"` + PlaylistCount int `json:"playlist_count"` + Playlists []overallPlaylist `json:"playlists,omitempty"` + Playlist lbPlaylist `json:"playlist"` } type listenBrainzRequest struct { ApiKey string - Body listenBrainzRequestBody + Body *listenBrainzRequestBody +} + +type overallPlaylist struct { + Playlist lbPlaylist `json:"playlist"` +} + +type lbPlaylist struct { + Annotation string `json:"annotation"` + Creator string `json:"creator"` + Date time.Time `json:"date"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Extension plsExtension `json:"extension"` + Tracks []lbTrack `json:"track"` +} + +type plsExtension struct { + Extension playlistExtension `json:"https://musicbrainz.org/doc/jspf#playlist"` +} + +type playlistExtension struct { + Collaborators []string `json:"collaborators"` + CreatedFor string `json:"created_for"` + LastModified time.Time `json:"last_modified_at"` + Public bool `json:"public"` +} + +type lbTrack struct { + Creator string `json:"creator"` + Identifier string `json:"identifier"` + Title string `json:"title"` } type listenBrainzRequestBody struct { @@ -85,7 +122,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain r := &listenBrainzRequest{ ApiKey: apiKey, } - response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r) + response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", "", r) if err != nil { return nil, err } @@ -95,13 +132,13 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error { r := &listenBrainzRequest{ ApiKey: apiKey, - Body: listenBrainzRequestBody{ + Body: &listenBrainzRequestBody{ ListenType: PlayingNow, Payload: []listenInfo{li}, }, } - resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", "", r) if err != nil { return err } @@ -114,12 +151,12 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error { r := &listenBrainzRequest{ ApiKey: apiKey, - Body: listenBrainzRequestBody{ + Body: &listenBrainzRequestBody{ ListenType: Single, Payload: []listenInfo{li}, }, } - resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", "", r) if err != nil { return err } @@ -129,6 +166,53 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err return nil } +func (c *client) getPlaylists(ctx context.Context, offset, count int, apiKey, user, plsType string) (*listenBrainzResponse, error) { + r := &listenBrainzRequest{ + ApiKey: apiKey, + Body: nil, + } + + var endpoint string + + switch plsType { + case "user": + endpoint = "user/" + user + "/playlists" + case "created": + endpoint = "user/" + user + "/playlists/createdfor" + case "collab": + endpoint = "user/" + user + "/playlists/collaborator" + default: + return nil, external_playlists.ErrorUnsupportedType + } + + extra := fmt.Sprintf("?count=%d&offset=%d", count, offset) + + resp, err := c.makeRequest(ctx, http.MethodGet, endpoint, extra, r) + + if err != nil { + return nil, err + } + + return resp, err +} + +func (c *client) getPlaylist(ctx context.Context, apiKey, plsId string) (*listenBrainzResponse, error) { + r := &listenBrainzRequest{ + ApiKey: apiKey, + } + + endpoint := fmt.Sprintf("playlist/%s", plsId) + + resp, err := c.makeRequest(ctx, http.MethodGet, endpoint, "", r) + + if resp.Code == 404 { + return nil, model.ErrNotFound + } else if err != nil { + return nil, err + } + return resp, nil +} + func (c *client) path(endpoint string) (string, error) { u, err := url.Parse(c.baseURL) if err != nil { @@ -138,13 +222,25 @@ func (c *client) path(endpoint string) (string, error) { return u.String(), nil } -func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { - b, _ := json.Marshal(r.Body) +func (c *client) makeRequest(ctx context.Context, method string, endpoint string, query string, r *listenBrainzRequest) (*listenBrainzResponse, error) { uri, err := c.path(endpoint) if err != nil { return nil, err } - req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b)) + + if query != "" { + uri += query + } + + var req *http.Request + + if r.Body != nil { + b, _ := json.Marshal(r.Body) + req, _ = http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b)) + } else { + req, _ = http.NewRequestWithContext(ctx, method, uri, nil) + } + req.Header.Add("Content-Type", "application/json; charset=UTF-8") if r.ApiKey != "" { diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index cea6005f..29320c93 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -2,10 +2,15 @@ package agents import ( "context" + "errors" "github.com/navidrome/navidrome/model" ) +const ( + UserSuffix = "-user" +) + // SessionKeys is a simple wrapper around the UserPropsRepository type SessionKeys struct { model.DataStore @@ -16,10 +21,53 @@ func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey) } +func (sk *SessionKeys) PutWithUser(ctx context.Context, userId, sessionKey, remoteUser string) error { + return sk.WithTx(func(tx model.DataStore) error { + err := tx.UserProps(ctx).Put(userId, sk.KeyName, sessionKey) + + if err != nil { + return err + } + return tx.UserProps(ctx).Put(userId, sk.KeyName+UserSuffix, remoteUser) + }) +} + func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) { return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName) } +type KeyWithUser struct { + Key, User string +} + +var ( + ErrNoUsername = errors.New("Token with no username") +) + +func (sk *SessionKeys) GetWithUser(ctx context.Context, userId string) (KeyWithUser, error) { + result := KeyWithUser{} + err := sk.WithTx(func(tx model.DataStore) error { + key, err := tx.UserProps(ctx).Get(userId, sk.KeyName) + + if err != nil { + return err + } + + result.Key = key + + user, err := tx.UserProps(ctx).Get(userId, sk.KeyName+UserSuffix) + + if err != nil { + return ErrNoUsername + } + + result.User = user + return nil + }) + + return result, err +} + func (sk *SessionKeys) Delete(ctx context.Context, userId string) error { return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName) } diff --git a/core/external_playlists/external_playlists.go b/core/external_playlists/external_playlists.go new file mode 100644 index 00000000..e93add2b --- /dev/null +++ b/core/external_playlists/external_playlists.go @@ -0,0 +1,196 @@ +package external_playlists + +import ( + "context" + "errors" + "fmt" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/singleton" +) + +type PlaylistRetriever interface { + GetAvailableAgents(ctx context.Context, userId string) []AgentType + GetPlaylists(ctx context.Context, offset, count int, userId, agent, playlistType string) (*ExternalPlaylists, error) + ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping map[string]string) error + SyncPlaylist(ctx context.Context, playlistId string) error +} + +type playlistRetriever struct { + ds model.DataStore + retrievers map[string]PlaylistAgent + supportedTypes map[string]map[string]bool +} + +var ( + ErrorMissingAgent = errors.New("agent not found") + ErrorUnsupportedType = errors.New("unsupported playlist type") +) + +func GetPlaylistRetriever(ds model.DataStore) PlaylistRetriever { + return singleton.GetInstance(func() *playlistRetriever { + return newPlaylistRetriever(ds) + }) +} + +func newPlaylistRetriever(ds model.DataStore) *playlistRetriever { + p := &playlistRetriever{ + ds: ds, + retrievers: make(map[string]PlaylistAgent), + supportedTypes: make(map[string]map[string]bool), + } + for name, constructor := range constructors { + s := constructor(ds) + p.retrievers[name] = s + + mapping := map[string]bool{} + + for _, plsType := range s.GetPlaylistTypes() { + mapping[plsType] = true + } + p.supportedTypes[name] = mapping + } + return p +} + +func (p *playlistRetriever) GetAvailableAgents(ctx context.Context, userId string) []AgentType { + user, _ := request.UserFrom(ctx) + + agents := []AgentType{} + + for name, agent := range p.retrievers { + if agent.IsAuthorized(ctx, user.ID) { + agents = append(agents, AgentType{ + Name: name, + Types: agent.GetPlaylistTypes(), + }) + } + } + + return agents +} + +func (p *playlistRetriever) validateTypeAgent(ctx context.Context, agent, playlistType string) (PlaylistAgent, error) { + ag, ok := p.retrievers[agent] + + if !ok { + log.Error(ctx, "Agent not found", "agent", agent) + return nil, ErrorMissingAgent + } + + _, ok = p.supportedTypes[agent][playlistType] + + if !ok { + log.Error(ctx, "Unsupported playlist type", "agent", agent, "playlist type", playlistType) + return nil, ErrorUnsupportedType + } + + return ag, nil +} + +func (p *playlistRetriever) GetPlaylists(ctx context.Context, offset, count int, userId, agent, playlistType string) (*ExternalPlaylists, error) { + ag, err := p.validateTypeAgent(ctx, agent, playlistType) + + if err != nil { + return nil, err + } + + pls, err := ag.GetPlaylists(ctx, offset, count, userId, playlistType) + + if err != nil { + log.Error(ctx, "Error retrieving playlist", "agent", agent, "user", userId, err) + return nil, err + } + + ids := make([]string, len(pls.Lists)) + + for i, list := range pls.Lists { + ids[i] = list.ID + } + + existingIDs, err := p.ds.Playlist(ctx).CheckExternalIds(agent, ids) + + if err != nil { + log.Error(ctx, "Error checking for existing ids", "agent", agent, "user", userId, err) + return nil, err + } + + existingMap := map[string]bool{} + + for _, id := range existingIDs { + existingMap[id] = true + } + + for i, list := range pls.Lists { + _, ok := existingMap[list.ID] + pls.Lists[i].Existing = ok + } + + return pls, nil +} + +func (p *playlistRetriever) ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping map[string]string) error { + ag, ok := p.retrievers[agent] + + if !ok { + return ErrorMissingAgent + } + + fail := 0 + var err error + + for id, name := range mapping { + err = ag.ImportPlaylist(ctx, update, userId, id, name) + + if err != nil { + fail++ + log.Error(ctx, "Could not import playlist", "agent", agent, "id", id, "error", err) + } + } + + if err != nil { + return fmt.Errorf("failed to sync %d playlist(s): %s", fail, err.Error()) + } + + return nil +} + +func (p *playlistRetriever) SyncPlaylist(ctx context.Context, playlistId string) error { + return p.ds.WithTx(func(tx model.DataStore) error { + pls, err := tx.Playlist(ctx).Get(playlistId) + + if err != nil { + return err + } + + if pls.ExternalId == "" { + return model.ErrNotAvailable + } + + user, _ := request.UserFrom(ctx) + + if user.ID != pls.OwnerID { + return model.ErrNotAuthorized + } + + ag, ok := p.retrievers[pls.ExternalAgent] + + if !ok { + log.Error(ctx, "No retriever for playlist", "type", ag, "id", playlistId) + return ErrorMissingAgent + } + + return ag.SyncPlaylist(ctx, tx, pls) + }) +} + +var constructors map[string]Constructor + +func Register(name string, init Constructor) { + if constructors == nil { + constructors = make(map[string]Constructor) + } + constructors[name] = init +} diff --git a/core/external_playlists/interfaces.go b/core/external_playlists/interfaces.go new file mode 100644 index 00000000..eb4c315a --- /dev/null +++ b/core/external_playlists/interfaces.go @@ -0,0 +1,46 @@ +package external_playlists + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" +) + +type AgentType struct { + Name string `json:"name"` + Types []string `json:"types"` +} + +type ExternalPlaylist struct { + Name string `json:"name"` + Description string `json:"description"` + ID string `json:"id"` + Url string `json:"url,omitempty"` + Creator string `json:"creator"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Existing bool `json:"existing"` + Tracks []ExternalTrack `json:"-"` +} + +type ExternalTrack struct { + Title string + Artist string + ID string +} + +type ExternalPlaylists struct { + Total int + Lists []ExternalPlaylist +} + +type PlaylistAgent interface { + GetPlaylistTypes() []string + GetPlaylists(ctx context.Context, offset, count int, userId, playlistType string) (*ExternalPlaylists, error) + ImportPlaylist(ctx context.Context, update bool, userId, id, name string) error + IsAuthorized(ctx context.Context, userId string) bool + SyncPlaylist(ctx context.Context, tx model.DataStore, pls *model.Playlist) error +} + +type Constructor func(ds model.DataStore) PlaylistAgent diff --git a/core/wire_providers.go b/core/wire_providers.go index d7ee2b69..99284958 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -3,6 +3,7 @@ package core import ( "github.com/google/wire" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/scrobbler" ) @@ -18,4 +19,5 @@ var Set = wire.NewSet( agents.New, ffmpeg.New, scrobbler.GetPlayTracker, + external_playlists.GetPlaylistRetriever, ) diff --git a/db/migration/20230318140335_add_playlist_external_info.go b/db/migration/20230318140335_add_playlist_external_info.go new file mode 100644 index 00000000..6e502094 --- /dev/null +++ b/db/migration/20230318140335_add_playlist_external_info.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose" +) + +func init() { + goose.AddMigration(upAddPlaylistExternalInfo, downAddPlaylistExternalInfo) +} + +func upAddPlaylistExternalInfo(tx *sql.Tx) error { + // Note: Ideally, we would also change the type of "comment" to be longer than 255 + // characters, but since this is Sqlite, the length doesn't matter + _, err := tx.Exec(` +alter table playlist + add external_agent varchar default '' not null; +alter table playlist + add external_id varchar default '' not null; +alter table playlist + add external_url varchar default '' not null; + `) + return err +} + +func downAddPlaylistExternalInfo(tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/mediafile.go b/model/mediafile.go index da74ec97..4a967e7d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -216,6 +216,7 @@ type MediaFileRepository interface { FindAllByPath(path string) (MediaFiles, error) FindByPath(path string) (*MediaFile, error) FindPathsRecursively(basePath string) ([]string, error) + FindWithMbid(ids []string) (MediaFiles, error) DeleteByPath(path string) (int64, error) AnnotatedRepository diff --git a/model/playlist.go b/model/playlist.go index f9f7288d..626fa05f 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -26,6 +26,11 @@ type Playlist struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + // External Info + ExternalAgent string `structs:"external_agent" json:"external_agent"` + ExternalId string `structs:"external_id" json:"externalId"` + ExternalUrl string `structs:"external_url" json:"externalUrl"` + // SmartPlaylist attributes Rules *criteria.Criteria `structs:"-" json:"rules"` EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"` @@ -108,6 +113,8 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + CheckExternalIds(agent string, ids []string) ([]string, error) + GetByExternalInfo(agent, id string) (*Playlist, error) FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 1b86d507..5c90c645 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -162,6 +162,15 @@ func (r *mediaFileRepository) deleteNotInPath(basePath string) error { return err } +func (r *mediaFileRepository) FindWithMbid(ids []string) (model.MediaFiles, error) { + sel := r.newSelect().Column("id").Where(Eq{"mbz_track_id": ids}) + + res := model.MediaFiles{} + err := r.queryAll(sel, &res) + + return res, err +} + func (r *mediaFileRepository) Delete(id string) error { return r.delete(Eq{"id": id}) } diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index fe1ecd5e..66f3d399 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -464,6 +464,39 @@ func (r *playlistRepository) isWritable(playlistId string) bool { return err == nil && pls.OwnerID == usr.ID } +func (r *playlistRepository) GetByExternalInfo(agent, id string) (*model.Playlist, error) { + sql := Select("*").From(r.tableName).Where(Eq{"external_agent": agent, "external_id": id}) + var pls model.Playlist + + err := r.queryOne(sql, &pls) + if err != nil { + return nil, err + } + + return &pls, nil +} + +func (r *playlistRepository) CheckExternalIds(agent string, ids []string) ([]string, error) { + // Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit + chunks := utils.BreakUpStringSlice(ids, 200) + + var lists []string + + for _, chunk := range chunks { + sql := Select("external_id").From(r.tableName).Where(Eq{"external_agent": agent, "external_id": chunk}) + var partial []string + + err := r.queryAll(sql, &partial) + if err != nil { + return nil, err + } + + lists = append(lists, partial...) + } + + return lists, nil +} + var _ model.PlaylistRepository = (*playlistRepository)(nil) var _ rest.Repository = (*playlistRepository)(nil) var _ rest.Persistable = (*playlistRepository)(nil) diff --git a/server/nativeapi/external_playlists.go b/server/nativeapi/external_playlists.go new file mode 100644 index 00000000..c55640bc --- /dev/null +++ b/server/nativeapi/external_playlists.go @@ -0,0 +1,163 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils" +) + +func requiredParamString(w *http.ResponseWriter, r *http.Request, param string) (string, bool) { + p := utils.ParamString(r, param) + if p == "" { + http.Error(*w, "required param '"+param+"' is missing", http.StatusBadRequest) + return p, false + } + return p, true +} + +func replyJson(ctx context.Context, w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + resp, _ := json.Marshal(data) + _, err := w.Write(resp) + + if err != nil { + log.Error(ctx, "Error sending json", "Error", err) + } +} + +func (n *Router) getAgents() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(r.Context()) + + agents := n.pls.GetAvailableAgents(ctx, user.ID) + + replyJson(ctx, w, agents) + } +} + +func (n *Router) getPlaylists() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(r.Context()) + + start := utils.ParamInt(r, "_start", 0) + end := utils.ParamInt(r, "_end", 0) + + if start >= end { + http.Error(w, "End must me greater than start", http.StatusBadRequest) + return + } + + count := end - start + + agent, ok := requiredParamString(&w, r, "agent") + if !ok { + return + } + + plsType, ok := requiredParamString(&w, r, "type") + if !ok { + return + } + + lists, err := n.pls.GetPlaylists(ctx, start, count, user.ID, agent, plsType) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + w.Header().Set("X-Total-Count", strconv.Itoa(lists.Total)) + + replyJson(ctx, w, lists.Lists) + } + } +} + +type externalImport struct { + Agent string `json:"agent"` + Playlists map[string]string `json:"playlists"` + Update bool `json:"update"` +} + +func (n *Router) fetchPlaylists() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(r.Context()) + + defer r.Body.Close() + + data, err := io.ReadAll(r.Body) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var plsImport externalImport + err = json.Unmarshal(data, &plsImport) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = n.pls.ImportPlaylists(ctx, plsImport.Update, user.ID, plsImport.Agent, plsImport.Playlists) + + if err != nil { + if errors.Is(model.ErrNotAuthorized, err) { + http.Error(w, err.Error(), http.StatusForbidden) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + replyJson(ctx, w, "") + } +} + +func (n *Router) syncPlaylist() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + plsId := chi.URLParam(r, "playlistId") + + err := n.pls.SyncPlaylist(ctx, plsId) + + if err != nil { + log.Error(ctx, "Failed to sync playlist", "id", plsId, err) + var code int + + switch err { + case model.ErrNotAuthorized: + code = http.StatusForbidden + case model.ErrNotFound: + code = http.StatusNotFound + default: + code = http.StatusInternalServerError + } + + http.Error(w, err.Error(), code) + } else { + replyJson(ctx, w, "") + } + } +} + +func (n *Router) externalPlaylistRoutes(r chi.Router) { + r.Route("/externalPlaylist", func(r chi.Router) { + r.Get("/", n.getPlaylists()) + r.Post("/", n.fetchPlaylists()) + r.Put("/sync/{playlistId}", n.syncPlaylist()) + + r.Get("/agents", n.getAgents()) + }) +} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f3a58cf0..486b6013 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" @@ -18,10 +19,11 @@ type Router struct { ds model.DataStore broker events.Broker share core.Share + pls external_playlists.PlaylistRetriever } -func New(ds model.DataStore, broker events.Broker, share core.Share) *Router { - r := &Router{ds: ds, broker: broker, share: share} +func New(ds model.DataStore, broker events.Broker, share core.Share, pls external_playlists.PlaylistRetriever) *Router { + r := &Router{ds: ds, broker: broker, share: share, pls: pls} r.Handler = r.routes() return r } @@ -51,6 +53,8 @@ func (n *Router) routes() http.Handler { n.addPlaylistTrackRoute(r) + n.externalPlaylistRoutes(r) + // Keepalive endpoint to be used to keep the session valid (ex: while playing songs) r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) diff --git a/ui/src/App.js b/ui/src/App.js index 6d185334..3eea9c68 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -16,6 +16,7 @@ import artist from './artist' import playlist from './playlist' import radio from './radio' import share from './share' +import externalPlaylist from './externalPlaylist' import { Player } from './audioplayer' import customRoutes from './routes' import { @@ -132,6 +133,7 @@ const Admin = (props) => { ), , + , , , , diff --git a/ui/src/externalPlaylist/ExternalPlaylistCreate.js b/ui/src/externalPlaylist/ExternalPlaylistCreate.js new file mode 100644 index 00000000..5c831260 --- /dev/null +++ b/ui/src/externalPlaylist/ExternalPlaylistCreate.js @@ -0,0 +1,264 @@ +import { Link } from '@material-ui/core' +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + BooleanField, + BooleanInput, + Create, + Datagrid, + DateField, + List, + Loading, + SaveButton, + SelectInput, + SimpleForm, + TextField, + TextInput, + Toolbar, + useMutation, + useNotify, + useRecordContext, + useRedirect, + useTranslate, +} from 'react-admin' +import { Title } from '../common' +import { REST_URL } from '../consts' +import { httpClient } from '../dataProvider' + +const Expand = ({ record }) => ( +
+
+ + {record.url} + +
+) + +const NameInput = (props) => { + const { id, name } = useRecordContext(props) + + return ( + val || ''} + placeholder={name} + onClick={(event) => { + event.stopPropagation() + }} + /> + ) +} + +const MyDataGrid = forwardRef( + ({ onUnselectItems, selectedIds, setIds, ...props }, ref) => { + useEffect(() => { + setIds(selectedIds) + }, [selectedIds, setIds]) + + useEffect(() => { + return () => { + // This will run on dismount to clear up state + onUnselectItems() + } + }, [onUnselectItems]) + + ref.current = onUnselectItems + + return ( + } + rowClick="toggleSelection" + selectedIds={selectedIds} + > + + + + + + + ) + } +) + +const Dummy = () => + +const ExternalPlaylistSelect = forwardRef( + ({ fullWidth, playlists, setIds, filter, ...props }, ref) => { + return ( + <> + } + bulkActionButtons={} + exporter={false} + actions={} + > + + + + ) + } +) + +const ExternalPlaylistCreate = (props) => { + const clearRef = useRef() + + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + const translate = useTranslate() + + const [agents, setAgents] = useState(null) + const [selectedAgent, setSelectedAgent] = useState(null) + const [selectedType, setSelectedType] = useState(null) + const [ids, setIds] = useState([]) + + const resourceName = translate('resources.externalPlaylist.name', { + smart_count: 1, + }) + + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + useEffect(() => { + httpClient(`${REST_URL}/externalPlaylist/agents`) + .then((resp) => { + const mapping = {} + for (const agent of resp.json) { + mapping[agent.name] = agent.types + } + setAgents(mapping) + }) + .catch((err) => { + console.log(err) + }) + }, []) + + useEffect(() => { + if (clearRef.current) { + clearRef.current() + } + }, []) + + const allAgents = useMemo( + () => + agents === null + ? [] + : Object.keys(agents).map((k) => ({ id: k, name: k })), + [agents] + ) + + const agentKeys = useMemo( + () => + selectedAgent === null + ? [] + : agents[selectedAgent].map((type) => ({ + id: type, + name: translate( + `resources.externalPlaylist.agent.${selectedAgent}.${type}` + ), + })), + [agents, selectedAgent, translate] + ) + + const changeAgent = (event) => { + if (clearRef.current) { + clearRef.current() + } + setSelectedAgent(event.target.value) + } + + const changeType = (event) => { + if (clearRef.current) { + clearRef.current() + } + setSelectedType(event.target.value) + } + + const save = useCallback( + async (values) => { + const { agent, name, update, type } = values + const selectedNames = {} + + let count = 0 + + for (const id of ids) { + selectedNames[id] = name[id] + count++ + } + + try { + await mutate( + { + type: 'create', + resource: 'externalPlaylist', + payload: { + data: { agent, type, update, playlists: selectedNames }, + }, + }, + { returnPromise: true } + ) + notify('resources.externalPlaylist.notifications.created', 'info', { + smart_count: count, + }) + redirect('/playlist') + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [ids, mutate, notify, redirect] + ) + + return ( + } {...props}> + {agents === null ? ( + + ) : ( + + + + } + save={save} + > + + + + {selectedType && ( + + )} + + )} + + ) +} + +export default ExternalPlaylistCreate diff --git a/ui/src/externalPlaylist/index.js b/ui/src/externalPlaylist/index.js new file mode 100644 index 00000000..9c473cab --- /dev/null +++ b/ui/src/externalPlaylist/index.js @@ -0,0 +1,5 @@ +import ExternalPlaylistCreate from './ExternalPlaylistCreate' + +export default { + create: ExternalPlaylistCreate, +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 2c9c565e..8801c180 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -151,14 +151,17 @@ "songCount": "Songs", "comment": "Comment", "sync": "Auto-import", - "path": "Import from" + "path": "Import from", + "external": "External" }, "actions": { "selectPlaylist": "Select a playlist:", "addNewPlaylist": "Create \"%{name}\"", "export": "Export", "makePublic": "Make Public", - "makePrivate": "Make Private" + "makePrivate": "Make Private", + "viewOriginal": "View original", + "sync": "Sync from source" }, "message": { "duplicate_song": "Add duplicated songs", @@ -198,6 +201,29 @@ }, "actions": { } + }, + "externalPlaylist": { + "name": "External Playlist |||| External Playlists", + "fields": { + "name": "Name", + "agent": "Provider", + "type": "Playlist type", + "creator": "Creator", + "updatedAt": "Updated at", + "createdAt": "Created at", + "existing": "Existing", + "update": "Update existing playlists" + }, + "notifications": { + "created": "External playlist imported |||| %{smart_count} external playlists imported" + }, + "agent": { + "listenbrainz": { + "user": "Created by you", + "collab": "Playlist you collaborated", + "created": "Recommended playlist" + } + } } }, "ra": { @@ -259,7 +285,8 @@ "unselect": "Unselect", "skip": "Skip", "share": "Share", - "download": "Download" + "download": "Download", + "import": "Import" }, "boolean": { "true": "Yes", @@ -374,7 +401,11 @@ "shareSuccess": "URL copied to clipboard: %{url}", "shareFailure": "Error copying URL %{url} to clipboard", "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Download in original format" + "downloadOriginalFormat": "Download in original format", + "playlistSyncConfirmTitle": "Are you sure you want to sync %{name}?", + "playlistSyncConfirmBody": "This will overwrite your current playlist", + "playlistSyncSuccess": "Successfully synced playlist", + "playlistSyncError": "Failed to sync playlist: %{error}" }, "menu": { "library": "Library", diff --git a/ui/src/playlist/ImportButton.js b/ui/src/playlist/ImportButton.js new file mode 100644 index 00000000..c6301796 --- /dev/null +++ b/ui/src/playlist/ImportButton.js @@ -0,0 +1,11 @@ +import GetAppIcon from '@material-ui/icons/GetApp' +import { CreateButton } from 'react-admin' + +export const ImportButton = (props) => ( + } + basePath="externalPlaylist" + label={'ra.action.import'} + /> +) diff --git a/ui/src/playlist/PlaylistActions.js b/ui/src/playlist/PlaylistActions.js index d98596c3..142fac5a 100644 --- a/ui/src/playlist/PlaylistActions.js +++ b/ui/src/playlist/PlaylistActions.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { useDispatch } from 'react-redux' import { Button, @@ -7,10 +7,13 @@ import { useTranslate, useDataProvider, useNotify, + Confirm, + useRefresh, } from 'react-admin' -import { useMediaQuery, makeStyles } from '@material-ui/core' +import { useMediaQuery, makeStyles, Link } from '@material-ui/core' import PlayArrowIcon from '@material-ui/icons/PlayArrow' import ShuffleIcon from '@material-ui/icons/Shuffle' +import SyncIcon from '@material-ui/icons/Sync' import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri' import QueueMusicIcon from '@material-ui/icons/QueueMusic' @@ -30,6 +33,7 @@ import PropTypes from 'prop-types' import { formatBytes } from '../utils' import config from '../config' import { ToggleFieldsMenu } from '../common' +import { OpenInNewOutlined } from '@material-ui/icons' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -41,9 +45,13 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { const classes = useStyles() const dataProvider = useDataProvider() const notify = useNotify() + const refresh = useRefresh() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const [shouldConfirm, setShouldConfirm] = useState(false) + const [syncing, setSyncing] = useState(false) + const getAllSongsAndDispatch = React.useCallback( (action) => { if (ids?.length === record.songCount) { @@ -111,6 +119,27 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { [record] ) + const handleResync = React.useCallback(async () => { + setShouldConfirm(false) + + if (!syncing) { + setSyncing(true) + httpClient(`${REST_URL}/externalPlaylist/sync/${record.id}`, { + method: 'PUT', + }) + .then(() => { + notify('message.playlistSyncSuccess', 'success') + refresh() + }) + .catch((error) => { + notify('message.playlistSyncError', 'warning', { + error: error.message, + }) + }) + .finally(() => setSyncing(false)) + } + }, [notify, record.id, refresh, syncing]) + return (
@@ -161,6 +190,36 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { > + {record.externalUrl && ( + <> + + + setShouldConfirm(false)} + /> + + )}
{isNotSmall && }
diff --git a/ui/src/playlist/PlaylistList.js b/ui/src/playlist/PlaylistList.js index 9f5667d7..29fc1d22 100644 --- a/ui/src/playlist/PlaylistList.js +++ b/ui/src/playlist/PlaylistList.js @@ -14,9 +14,13 @@ import { useRecordContext, BulkDeleteButton, usePermissions, + useListContext, + CreateButton, + useTranslate, + BooleanField, } from 'react-admin' import Switch from '@material-ui/core/Switch' -import { useMediaQuery } from '@material-ui/core' +import { styled, Typography, useMediaQuery } from '@material-ui/core' import { DurationField, List, @@ -27,6 +31,77 @@ import { } from '../common' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' +import { Inbox } from '@material-ui/icons' +import config from '../config' +import { ImportButton } from './ImportButton' + +const PREFIX = 'RaEmpty' + +export const EmptyClasses = { + message: `${PREFIX}-message`, + icon: `${PREFIX}-icon`, + toolbar: `${PREFIX}-toolbar`, +} + +const Root = styled('span', { + name: PREFIX, + overridesResolver: (props, styles) => styles.root, +})(({ theme }) => ({ + flex: 1, + [`& .${EmptyClasses.message}`]: { + textAlign: 'center', + opacity: theme.palette.mode === 'light' ? 0.5 : 0.8, + margin: '0 1em', + color: + theme.palette.mode === 'light' ? 'inherit' : theme.palette.text.primary, + }, + + [`& .${EmptyClasses.icon}`]: { + width: '9em', + height: '9em', + }, + + [`& .${EmptyClasses.toolbar}`]: { + textAlign: 'center', + marginTop: '1em', + }, +})) + +const Empty = () => { + const translate = useTranslate() + const { resource } = useListContext() + + const resourceName = translate(`resources.${resource}.forcedCaseName`, { + smart_count: 0, + _: resource, + }) + + const emptyMessage = translate('ra.page.empty', { name: resourceName }) + const inviteMessage = translate('ra.page.invite') + + return ( + +
+ + + {translate(`resources.${resource}.empty`, { + _: emptyMessage, + })} + + + {translate(`resources.${resource}.invite`, { + _: inviteMessage, + })} + +
+
+ {' '} + {config.listenBrainzEnabled && } +
+
+
+ ) +} const PlaylistFilter = (props) => { const { permissions } = usePermissions() @@ -107,6 +182,7 @@ const PlaylistList = (props) => { ), comment: , + external: , }), [isDesktop, isXsmall] ) @@ -114,12 +190,13 @@ const PlaylistList = (props) => { const columns = useSelectedFields({ resource: 'playlist', columns: toggleableFields, - defaultOff: ['comment'], + defaultOff: ['comment', 'external'], }) return ( } exporter={false} filters={} actions={} diff --git a/ui/src/playlist/PlaylistListActions.js b/ui/src/playlist/PlaylistListActions.js index 3cde0a20..d9f79c41 100644 --- a/ui/src/playlist/PlaylistListActions.js +++ b/ui/src/playlist/PlaylistListActions.js @@ -7,6 +7,8 @@ import { } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { ToggleFieldsMenu } from '../common' +import { ImportButton } from './ImportButton' +import config from '../config' const PlaylistListActions = ({ className, ...rest }) => { const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) @@ -18,6 +20,8 @@ const PlaylistListActions = ({ className, ...rest }) => { {translate('ra.action.create')} + {config.listenBrainzEnabled && } + {isNotSmall && } ) From 71ef3d11081a5510cc60ef9f70a087e125f444d5 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 19 Mar 2023 23:49:39 -0700 Subject: [PATCH 2/6] some tests --- core/agents/listenbrainz/agent.go | 2 +- core/agents/listenbrainz/agent_test.go | 118 +++++++++++++++ core/agents/listenbrainz/client_test.go | 137 ++++++++++++++++++ core/external_playlists/external_playlists.go | 2 +- server/nativeapi/external_playlists.go | 7 +- tests/fixtures/listenbrainz.playlist.json | 1 + tests/fixtures/listenbrainz.playlists.json | 1 + tests/mock_playlist_repo.go | 16 ++ .../ExternalPlaylistCreate.js | 2 + 9 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/listenbrainz.playlist.json create mode 100644 tests/fixtures/listenbrainz.playlists.json diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go index ec91f68d..3ab3ba49 100644 --- a/core/agents/listenbrainz/agent.go +++ b/core/agents/listenbrainz/agent.go @@ -122,7 +122,7 @@ func getIdentifier(url string) string { func (l *listenBrainzAgent) GetPlaylists(ctx context.Context, offset, count int, userId, playlistType string) (*external_playlists.ExternalPlaylists, error) { token, err := l.sessionKeys.GetWithUser(ctx, userId) - if err == agents.ErrNoUsername { + if errors.Is(agents.ErrNoUsername, err) { resp, err := l.client.validateToken(ctx, token.Key) if err != nil { diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go index e1a17736..25134a7d 100644 --- a/core/agents/listenbrainz/agent_test.go +++ b/core/agents/listenbrainz/agent_test.go @@ -6,9 +6,12 @@ import ( "encoding/json" "io" "net/http" + "os" "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -28,6 +31,7 @@ var _ = Describe("listenBrainzAgent", func() { ds = &tests.MockDataStore{} ctx = context.Background() _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") + _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty+agents.UserSuffix, "example") httpClient = &tests.FakeHttpClient{} agent = listenBrainzConstructor(ds) agent.client = newClient("http://localhost:8080", httpClient) @@ -158,4 +162,118 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) }) + + Describe("GetPlaylistTypes", func() { + It("should return data", func() { + Expect(agent.GetPlaylistTypes()).To(Equal([]string{"user", "collab", "created"})) + }) + }) + + Describe("playlists", func() { + parseTime := func(t string) time.Time { + resp, _ := time.Parse(time.RFC3339Nano, t) + return resp + } + + Describe("GetPlaylists", func() { + It("should return playlist", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.playlists.json") + + httpClient.Res = http.Response{ + Body: f, + StatusCode: 200, + } + + resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "user") + Expect(err).To(BeNil()) + Expect(resp.Total).To(Equal(3)) + + match := func(idx int, pls external_playlists.ExternalPlaylist) { + list := resp.Lists[idx] + Expect(list.Name).To(Equal(pls.Name)) + Expect(list.Description).To(Equal(pls.Description)) + Expect(list.ID).To(Equal(pls.ID)) + Expect(list.Url).To(Equal(pls.Url)) + Expect(list.Creator).To(Equal(pls.Creator)) + Expect(list.CreatedAt).To(Equal(pls.CreatedAt)) + Expect(list.UpdatedAt).To(Equal(pls.UpdatedAt)) + Expect(list.Existing).To(Equal(pls.Existing)) + Expect(list.Tracks).To(BeNil()) + } + + match(0, external_playlists.ExternalPlaylist{ + Name: "Daily Jams for example, 2023-03-18 Sat", + Description: "Daily jams playlist!", + ID: "00000000-0000-0000-0000-000000000000", + Url: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000000", + Creator: "troi-bot", + CreatedAt: parseTime("2023-03-18T11:01:46.635538+00:00"), + UpdatedAt: parseTime("2023-03-18T11:01:46.635538+00:00"), + Existing: false, // This is false, because the agent itself does not check for this property + }) + match(1, external_playlists.ExternalPlaylist{ + Name: "Top Discoveries of 2022 for example", + Description: "

\n This playlist contains the top tracks for example that were first listened to in 2022.\n

\n

\n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n

\n ", + ID: "00000000-0000-0000-0000-000000000001", + Url: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000001", + Creator: "troi-bot", + CreatedAt: parseTime("2023-01-05T04:12:45.668903+00:00"), + UpdatedAt: parseTime("2023-01-05T04:12:45.668903+00:00"), + Existing: false, + }) + match(2, external_playlists.ExternalPlaylist{ + Name: "Top Missed Recordings of 2022 for example", + Description: "

\n This playlist features recordings that were listened to by users similar to example in 2022.\n It is a discovery playlist that aims to introduce you to new music that other similar users\n enjoy. It may require more active listening and may contain tracks that are not to your taste.\n

\n

\n The users similar to you who contributed to this playlist: example1, example2, example3.\n

\n

\n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n

\n ", + ID: "00000000-0000-0000-0000-000000000003", + Url: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000003", + Creator: "troi-bot", + CreatedAt: parseTime("2023-01-05T04:12:45.317875+00:00"), + UpdatedAt: parseTime("2023-01-05T04:12:45.317875+00:00"), + Existing: false, + }) + }) + + It("should error for nonexistent user", func() { + resp, err := agent.GetPlaylists(ctx, 0, 25, "user-2", "user") + Expect(resp).To(BeNil()) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("should error when trying to fetch token and failing", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"Code":400,"Error":"No"}`)), + StatusCode: 400, + } + _ = ds.UserProps(ctx).Delete("user-1", sessionKeyProperty+agents.UserSuffix) + resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "user") + Expect(resp).To(BeNil()) + Expect(err).To(Equal(&listenBrainzError{ + Code: 400, + Message: "No", + })) + }) + + It("should update session key when missing (and then fail)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + + _ = ds.UserProps(ctx).Delete("user-1", sessionKeyProperty+agents.UserSuffix) + resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "user") + Expect(resp).To(BeNil()) + Expect(err).ToNot(BeNil()) + + key, err := ds.UserProps(ctx).Get("user-1", sessionKeyProperty+agents.UserSuffix) + Expect(err).To(BeNil()) + Expect(key).To(Equal("ListenBrainzUser")) + }) + + It("should error for invalid type", func() { + resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "abcde") + Expect(resp).To(BeNil()) + Expect(err).To(MatchError(external_playlists.ErrorUnsupportedType)) + }) + }) + }) }) diff --git a/core/agents/listenbrainz/client_test.go b/core/agents/listenbrainz/client_test.go index 42fc2dc1..f249529e 100644 --- a/core/agents/listenbrainz/client_test.go +++ b/core/agents/listenbrainz/client_test.go @@ -4,10 +4,14 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "os" + "time" + "github.com/navidrome/navidrome/core/external_playlists" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,6 +38,74 @@ var _ = Describe("client", func() { Expect(response.Status).To(Equal("ok")) Expect(response.Error).To(Equal("Error")) }) + + It("parses playlists response properly", func() { + var response listenBrainzResponse + f, _ := os.ReadFile("tests/fixtures/listenbrainz.playlists.json") + date, _ := time.Parse(time.RFC3339Nano, "2023-03-18T11:01:46.635538+00:00") + modified, _ := time.Parse(time.RFC3339Nano, "2023-03-18T11:01:46.635538+00:00") + + err := json.Unmarshal(f, &response) + Expect(err).ToNot(HaveOccurred()) + Expect(response.PlaylistCount).To(Equal(3)) + Expect(response.Playlists).To(HaveLen(3)) + Expect(response.Playlists[0]).To(Equal(overallPlaylist{ + Playlist: lbPlaylist{ + Annotation: "Daily jams playlist!", + Creator: "troi-bot", + Date: date, + Identifier: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000000", + Title: "Daily Jams for example, 2023-03-18 Sat", + Extension: plsExtension{ + Extension: playlistExtension{ + Collaborators: []string{"example"}, + CreatedFor: "example", + LastModified: modified, + Public: true, + }, + }, + Tracks: []lbTrack{}, + }, + })) + }) + + It("parses playlist response properly", func() { + var response listenBrainzResponse + f, _ := os.ReadFile("tests/fixtures/listenbrainz.playlist.json") + + date, _ := time.Parse(time.RFC3339Nano, "2023-01-05T04:12:45.668903+00:00") + modified, _ := time.Parse(time.RFC3339Nano, "2023-01-05T04:12:45.668903+00:00") + + err := json.Unmarshal(f, &response) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Playlist).To(Equal(lbPlaylist{ + Annotation: "

\n This playlist contains the top tracks for example that were first listened to in 2022.\n

\n

\n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n

\n ", + Creator: "troi-bot", + Date: date, + Identifier: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000004", + Title: "Top Discoveries of 2022 for example", + Extension: plsExtension{ + Extension: playlistExtension{ + Collaborators: []string{"example"}, + CreatedFor: "example", + LastModified: modified, + Public: true, + }, + }, + Tracks: []lbTrack{ + { + Creator: "Poets of the Fall", + Identifier: "https://musicbrainz.org/recording/684bedbb-78d7-4946-9038-5402d5fa83b0", + Title: "Requiem for My Harlequin", + }, + { + Creator: "Old Gods of Asgard", + Identifier: "https://musicbrainz.org/recording/9f42783a-423b-4ed6-8a10-fdf4cb44456f", + Title: "Take Control", + }, + }, + })) + }) }) Describe("validateToken", func() { @@ -115,4 +187,69 @@ var _ = Describe("client", func() { }) }) }) + + Describe("getPlaylists", func() { + Describe("successful responses", func() { + BeforeEach(func() { + f, _ := os.Open("tests/fixtures/listenbrainz.playlists.json") + + httpClient.Res = http.Response{ + Body: f, + StatusCode: 200, + } + }) + + test := func(offset, count int, plsType, extra string) { + It("Handles "+plsType+" correctly", func() { + resp, err := client.getPlaylists(context.Background(), offset, count, "LB-TOKEN", "example", plsType) + Expect(err).To(BeNil()) + Expect(resp.Playlists).To(HaveLen(3)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(fmt.Sprintf("BASE_URL/user/example/playlists%s?count=%d&offset=%d", extra, count, offset))) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + } + + test(10, 30, "user", "") + test(11, 50, "created", "/createdfor") + test(0, 25, "collab", "/collaborator") + }) + + It("fails when provided an unsupported type", func() { + resp, err := client.getPlaylists(context.Background(), 0, 25, "LB-TOKEN", "example", "notatype") + Expect(resp).To(BeNil()) + Expect(err).To(Equal(external_playlists.ErrorUnsupportedType)) + }) + }) + + Describe("getPlaylist", func() { + It("Should fetch playlist properly", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.playlist.json") + + httpClient.Res = http.Response{ + Body: f, + StatusCode: 200, + } + + list, err := client.getPlaylist(context.Background(), "LB-TOKEN", "00000000-0000-0000-0000-000000000004") + Expect(err).To(BeNil()) + Expect(list.Playlist.Tracks).To(HaveLen(2)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/playlist/00000000-0000-0000-0000-000000000004")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("Returns correct error on 404", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"Code":404}`)), + StatusCode: 404, + } + + resp, err := client.getPlaylist(context.Background(), "LB-TOKEN", "00000000-0000-0000-0000-000000000004") + Expect(resp).To(BeNil()) + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) }) diff --git a/core/external_playlists/external_playlists.go b/core/external_playlists/external_playlists.go index e93add2b..4be30c1d 100644 --- a/core/external_playlists/external_playlists.go +++ b/core/external_playlists/external_playlists.go @@ -151,7 +151,7 @@ func (p *playlistRetriever) ImportPlaylists(ctx context.Context, update bool, us } if err != nil { - return fmt.Errorf("failed to sync %d playlist(s): %s", fail, err.Error()) + return fmt.Errorf("failed to sync %d playlist(s): %w", fail, err) } return nil diff --git a/server/nativeapi/external_playlists.go b/server/nativeapi/external_playlists.go index c55640bc..dcea73c6 100644 --- a/server/nativeapi/external_playlists.go +++ b/server/nativeapi/external_playlists.go @@ -136,12 +136,11 @@ func (n *Router) syncPlaylist() http.HandlerFunc { log.Error(ctx, "Failed to sync playlist", "id", plsId, err) var code int - switch err { - case model.ErrNotAuthorized: + if errors.Is(err, model.ErrNotAuthorized) { code = http.StatusForbidden - case model.ErrNotFound: + } else if errors.Is(err, model.ErrNotFound) { code = http.StatusNotFound - default: + } else { code = http.StatusInternalServerError } diff --git a/tests/fixtures/listenbrainz.playlist.json b/tests/fixtures/listenbrainz.playlist.json new file mode 100644 index 00000000..a7c51822 --- /dev/null +++ b/tests/fixtures/listenbrainz.playlist.json @@ -0,0 +1 @@ +{"playlist":{"annotation":"

\n This playlist contains the top tracks for example that were first listened to in 2022.\n

\n

\n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n

\n ","creator":"troi-bot","date":"2023-01-05T04:12:45.668903+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"top-discoveries-for-year"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-01-05T04:12:45.668903+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000004","title":"Top Discoveries of 2022 for example","track":[{"creator":"Poets of the Fall","extension":{"https://musicbrainz.org/doc/jspf#track":{"added_at":"2023-01-05T04:12:45.682643+00:00","added_by":"troi-bot","additional_metadata":{"caa_id":32371992730,"caa_release_mbid":"ed46e340-e9e0-49a5-b0fa-702beedc9b0c"},"artist_identifiers":["https://musicbrainz.org/artist/482a5456-7d8a-4366-a2e9-6f38853df632"]}},"identifier":"https://musicbrainz.org/recording/684bedbb-78d7-4946-9038-5402d5fa83b0","title":"Requiem for My Harlequin"},{"creator":"Old Gods of Asgard","extension":{"https://musicbrainz.org/doc/jspf#track":{"added_at":"2023-01-05T04:12:45.682643+00:00","added_by":"troi-bot","additional_metadata":{"caa_id":24118518720,"caa_release_mbid":"4a042f5c-0808-44e1-94af-2a7bb1dd6ea8"},"artist_identifiers":["https://musicbrainz.org/artist/3586e8bd-0c38-43dd-bd2a-b2dd93dbc8bb"]}},"identifier":"https://musicbrainz.org/recording/9f42783a-423b-4ed6-8a10-fdf4cb44456f","title":"Take Control"}]}} diff --git a/tests/fixtures/listenbrainz.playlists.json b/tests/fixtures/listenbrainz.playlists.json new file mode 100644 index 00000000..ea307401 --- /dev/null +++ b/tests/fixtures/listenbrainz.playlists.json @@ -0,0 +1 @@ +{"count":25,"offset":0,"playlist_count":3,"playlists":[{"playlist":{"annotation":"Daily jams playlist!","creator":"troi-bot","date":"2023-03-18T11:01:46.635538+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"daily-jams"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-03-18T11:01:46.635538+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000000","title":"Daily Jams for example, 2023-03-18 Sat","track":[]}},{"playlist":{"annotation":"

\n This playlist contains the top tracks for example that were first listened to in 2022.\n

\n

\n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n

\n ","creator":"troi-bot","date":"2023-01-05T04:12:45.668903+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"top-discoveries-for-year"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-01-05T04:12:45.668903+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000001","title":"Top Discoveries of 2022 for example","track":[]}},{"playlist":{"annotation":"

\n This playlist features recordings that were listened to by users similar to example in 2022.\n It is a discovery playlist that aims to introduce you to new music that other similar users\n enjoy. It may require more active listening and may contain tracks that are not to your taste.\n

\n

\n The users similar to you who contributed to this playlist: example1, example2, example3.\n

\n

\n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n

\n ","creator":"troi-bot","date":"2023-01-05T04:12:45.317875+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"top-missed-recordings-for-year"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-01-05T04:12:45.317875+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000003","title":"Top Missed Recordings of 2022 for example","track":[]}}]} diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 60dc98be..d1eb1493 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -22,6 +22,22 @@ func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) { return m.Entity, nil } +func (m *MockPlaylistRepo) Put(entity *model.Playlist) error { + if m.Error != nil { + return m.Error + } + m.Entity = entity + return nil +} + +func (m *MockPlaylistRepo) Delete(_ string) error { + if m.Error != nil { + return m.Error + } + m.Entity = nil + return nil +} + func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { if m.Error != nil { return 0, m.Error diff --git a/ui/src/externalPlaylist/ExternalPlaylistCreate.js b/ui/src/externalPlaylist/ExternalPlaylistCreate.js index 5c831260..6b294b53 100644 --- a/ui/src/externalPlaylist/ExternalPlaylistCreate.js +++ b/ui/src/externalPlaylist/ExternalPlaylistCreate.js @@ -227,6 +227,8 @@ const ExternalPlaylistCreate = (props) => { } {...props}> {agents === null ? ( + ) : agents.length === 0 ? ( +
) : ( Date: Sun, 26 Mar 2023 02:11:35 -0700 Subject: [PATCH 3/6] more tests --- core/agents/listenbrainz/agent_test.go | 65 +++++++++++++++++++ core/agents/listenbrainz/auth_router_test.go | 1 + persistence/persistence_suite_test.go | 3 +- tests/mock_mediafile_repo.go | 34 ++++++++++ .../ExternalPlaylistCreate.js | 47 +++++++------- ui/src/i18n/en.json | 3 +- 6 files changed, 129 insertions(+), 24 deletions(-) diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go index 25134a7d..b3ec4266 100644 --- a/core/agents/listenbrainz/agent_test.go +++ b/core/agents/listenbrainz/agent_test.go @@ -275,5 +275,70 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).To(MatchError(external_playlists.ErrorUnsupportedType)) }) }) + + Describe("SyncPlaylist", func() { + var pls model.Playlist + + BeforeEach(func() { + pls = model.Playlist{ + ID: "1000", + OwnerID: "user-1", + ExternalId: "00000000-0000-0000-0000-000000000004", + Tracks: model.PlaylistTracks{}, + } + }) + + AfterEach(func() { + _ = ds.Playlist(ctx).Delete(pls.ID) + }) + + Describe("Successful test", func() { + WithMbid := model.MediaFile{ID: "1", Title: "Take Control", MbzTrackID: "9f42783a-423b-4ed6-8a10-fdf4cb44456f", Artist: "Old Gods of Asgard"} + + BeforeEach(func() { + _ = ds.MediaFile(ctx).Put(&WithMbid) + }) + + AfterEach(func() { + _ = ds.MediaFile(ctx).Delete(WithMbid.ID) + }) + + It("should return playlist", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.playlist.json") + + httpClient.Res = http.Response{ + Body: f, + StatusCode: 200, + } + + err := agent.SyncPlaylist(ctx, ds, &pls) + Expect(err).To(BeNil()) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].ID).To(Equal("1")) + Expect(pls.Comment).To(Equal("\n This playlist contains the top tracks for example that were first listened to in 2022.\n \n \n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n \n ")) + }) + }) + + Describe("Failed tests", func() { + It("should error when trying to fetch token and failing", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"Code":404,"Error":"No"}`)), + StatusCode: 404, + } + _ = ds.UserProps(ctx).Delete("user-1", sessionKeyProperty+agents.UserSuffix) + err := agent.SyncPlaylist(ctx, ds, &pls) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("should error when playlist is not found", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"Code":404,"Error":"No"}`)), + StatusCode: 404, + } + err := agent.SyncPlaylist(ctx, ds, &pls) + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + }) }) }) diff --git a/core/agents/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go index 0ad7eb62..1c5c6a1c 100644 --- a/core/agents/listenbrainz/auth_router_test.go +++ b/core/agents/listenbrainz/auth_router_test.go @@ -97,6 +97,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { r.link(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) Expect(sk.KeyValue).To(Equal("tok-1")) + Expect(sk.UserName).To(Equal("ListenBrainzUser")) }) }) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 21267121..7c8f2ae1 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -61,7 +61,8 @@ var ( songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"} songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"} songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"} - testSongs = model.MediaFiles{ + + testSongs = model.MediaFiles{ songDayInALife, songComeTogether, songRadioactivity, diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 03db44d0..4562920c 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -101,4 +101,38 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro return res, nil } +func (m *MockMediaFileRepo) FindWithMbid(ids []string) (model.MediaFiles, error) { + if m.err { + return nil, errors.New("error") + } + res := make(model.MediaFiles, 0) + id_set := map[string]bool{} + + for _, id := range ids { + id_set[id] = true + } + + for _, file := range m.data { + if _, ok := m.data[file.ID]; ok { + res = append(res, *file) + } + } + return res, nil +} + +func (m *MockMediaFileRepo) Delete(id string) error { + if m.err { + return errors.New("Error!") + } + + _, found := m.data[id] + + if !found { + return errors.New("not found") + } + + delete(m.data, id) + return nil +} + var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) diff --git a/ui/src/externalPlaylist/ExternalPlaylistCreate.js b/ui/src/externalPlaylist/ExternalPlaylistCreate.js index 6b294b53..19c89ccd 100644 --- a/ui/src/externalPlaylist/ExternalPlaylistCreate.js +++ b/ui/src/externalPlaylist/ExternalPlaylistCreate.js @@ -25,6 +25,7 @@ import { useNotify, useRecordContext, useRedirect, + useRefresh, useTranslate, } from 'react-admin' import { Title } from '../common' @@ -117,6 +118,7 @@ const ExternalPlaylistCreate = (props) => { const [mutate] = useMutation() const notify = useNotify() const redirect = useRedirect() + const refresh = useRefresh() const translate = useTranslate() const [agents, setAgents] = useState(null) @@ -213,6 +215,7 @@ const ExternalPlaylistCreate = (props) => { notify('resources.externalPlaylist.notifications.created', 'info', { smart_count: count, }) + refresh() redirect('/playlist') } catch (error) { if (error.body.errors) { @@ -220,15 +223,33 @@ const ExternalPlaylistCreate = (props) => { } } }, - [ids, mutate, notify, redirect] + [ids, mutate, notify, redirect, refresh] ) + let formBody + + if (allAgents.length === 0) { + formBody =
{translate('message.noPlaylistAgent')}
+ } else { + formBody = [ + , + , + , + selectedType && ( + + ), + ] + } + return ( } {...props}> {agents === null ? ( - ) : agents.length === 0 ? ( -
) : ( { } save={save} > - - - - {selectedType && ( - - )} + {formBody} )}
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 8801c180..e36a64dc 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -405,7 +405,8 @@ "playlistSyncConfirmTitle": "Are you sure you want to sync %{name}?", "playlistSyncConfirmBody": "This will overwrite your current playlist", "playlistSyncSuccess": "Successfully synced playlist", - "playlistSyncError": "Failed to sync playlist: %{error}" + "playlistSyncError": "Failed to sync playlist: %{error}", + "noPlaylistAgent": "You have no accounts to sync playlists. Please link a ListenBrainz account to proceed." }, "menu": { "library": "Library", From 19446e0a97378c50bfa720c28eed63cf3a86996f Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 7 May 2023 01:39:19 -0700 Subject: [PATCH 4/6] sync recommended --- cmd/root.go | 30 +++++ cmd/wire_gen.go | 3 +- conf/configuration.go | 24 +++- core/agents/listenbrainz/agent.go | 127 ++++++++++++++++-- core/agents/listenbrainz/auth_router.go | 51 +++++++ core/agents/listenbrainz/client.go | 17 ++- core/external_playlists/external_playlists.go | 43 ++++-- core/external_playlists/interfaces.go | 6 +- ...30318140345_add_playlist_external_info.go} | 7 +- model/playlist.go | 11 +- model/user_props.go | 9 ++ persistence/playlist_repository.go | 28 ++++ persistence/user_props_repository.go | 10 ++ scanner/scanner.go | 61 ++++++++- server/nativeapi/external_playlists.go | 40 ++++-- .../ExternalPlaylistCreate.js | 43 +++++- ui/src/i18n/en.json | 12 +- ui/src/personal/ListenBrainzPlaylistToggle.js | 75 +++++++++++ ui/src/personal/ListenBrainzScrobbleToggle.js | 6 + ui/src/playlist/PlaylistEdit.js | 1 + ui/src/playlist/PlaylistList.js | 3 +- 21 files changed, 551 insertions(+), 56 deletions(-) rename db/migration/{20230318140335_add_playlist_external_info.go => 20230318140345_add_playlist_external_info.go} (78%) create mode 100644 ui/src/personal/ListenBrainzPlaylistToggle.js diff --git a/cmd/root.go b/cmd/root.go index 2df6c2cb..4a266a40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,6 +74,7 @@ func runNavidrome() { g.Go(startSignaler(ctx)) g.Go(startScheduler(ctx)) g.Go(schedulePeriodicScan(ctx)) + g.Go(schedulePlaylistSync(ctx)) if err := g.Wait(); err != nil && !errors.Is(err, interrupted) { log.Error("Fatal error in Navidrome. Aborting", err) @@ -136,6 +137,35 @@ func schedulePeriodicScan(ctx context.Context) func() error { } } +func schedulePlaylistSync(ctx context.Context) func() error { + return func() error { + schedule := conf.Server.PlaylistSyncSchedule + if schedule == "" { + log.Warn("Periodic playlist sync is DISABLED") + return nil + } + + scanner := GetScanner() + schedulerInstance := scheduler.GetInstance() + + log.Info("Scheduling periodic playlist sync", "schedule", schedule) + err := schedulerInstance.Add(schedule, func() { + _ = scanner.SyncPlaylists(ctx) + }) + if err != nil { + log.Error("Error scheduling periodic playlist sync", err) + } + + time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan + log.Debug("Executing initial playlist sync") + if err := scanner.SyncPlaylists(ctx); err != nil { + log.Error("Error executing initial playlist sync", err) + } + log.Debug("Finished initial playlist sync") + return nil + } +} + func startScheduler(ctx context.Context) func() error { log.Info(ctx, "Starting scheduler") schedulerInstance := scheduler.GetInstance() diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 22a7f524..a2f0099a 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -108,7 +108,8 @@ func createScanner() scanner.Scanner { artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() - scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker) + playlistRetriever := external_playlists.GetPlaylistRetriever(dataStore) + scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker, playlistRetriever) return scannerScanner } diff --git a/conf/configuration.go b/conf/configuration.go index 77cab4a3..257cfdac 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -76,6 +76,7 @@ type configOptions struct { ReverseProxyWhitelist string Prometheus prometheusOptions Scanner scannerOptions + PlaylistSyncSchedule string Agents string LastFM lastfmOptions @@ -161,6 +162,10 @@ func Load() { os.Exit(1) } + if err := validateSchedule(&Server.PlaylistSyncSchedule); err != nil { + os.Exit(1) + } + if Server.BaseURL != "" { u, err := url.Parse(Server.BaseURL) if err != nil { @@ -218,17 +223,22 @@ func validateScanSchedule() error { log.Warn("Setting ScanSchedule", "schedule", Server.ScanSchedule) } } - if Server.ScanSchedule == "0" || Server.ScanSchedule == "" { - Server.ScanSchedule = "" + return validateSchedule(&Server.ScanSchedule) +} + +func validateSchedule(schedule *string) error { + extracted := *schedule + if extracted == "0" || extracted == "" { + *schedule = "" return nil } - if _, err := time.ParseDuration(Server.ScanSchedule); err == nil { - Server.ScanSchedule = "@every " + Server.ScanSchedule + if _, err := time.ParseDuration(extracted); err == nil { + *schedule = "@every " + extracted } c := cron.New() - _, err := c.AddFunc(Server.ScanSchedule, func() {}) + _, err := c.AddFunc(*schedule, func() {}) if err != nil { - log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err) + log.Error("Invalid schedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", *schedule, err) } return err } @@ -306,6 +316,8 @@ func init() { viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") + viper.SetDefault("playlistsyncschedule", "") + // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) viper.SetDefault("devenableprofiler", false) diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go index 3ab3ba49..b0fbe529 100644 --- a/core/agents/listenbrainz/agent.go +++ b/core/agents/listenbrainz/agent.go @@ -20,10 +20,16 @@ import ( const ( listenBrainzAgentName = "listenbrainz" sessionKeyProperty = "ListenBrainzSessionKey" + troiBot = "troi-bot" + playlistTypeUser = "user" + playlistTypeCollab = "collab" + playlistTypeCreated = "created" + defaultFetch = 25 + sourceDaily = "daily-jams" ) var ( - playlistTypes = []string{"user", "collab", "created"} + playlistTypes = []string{playlistTypeUser, playlistTypeCollab, playlistTypeCreated} ) type listenBrainzAgent struct { @@ -158,6 +164,7 @@ func (l *listenBrainzAgent) GetPlaylists(ctx context.Context, offset, count int, Url: pls.Identifier, CreatedAt: pls.Date, UpdatedAt: pls.Extension.Extension.LastModified, + Syncable: pls.Creator != troiBot, } } @@ -167,7 +174,7 @@ func (l *listenBrainzAgent) GetPlaylists(ctx context.Context, offset, count int, }, nil } -func (l *listenBrainzAgent) ImportPlaylist(ctx context.Context, update bool, userId, id, name string) error { +func (l *listenBrainzAgent) ImportPlaylist(ctx context.Context, update bool, sync bool, userId, id, name string) error { token, err := l.sessionKeys.Get(ctx, userId) if err != nil { return err @@ -178,6 +185,12 @@ func (l *listenBrainzAgent) ImportPlaylist(ctx context.Context, update bool, use return err } + syncable := pls.Playlist.Creator != troiBot + + if sync && !syncable { + return external_playlists.ErrSyncUnsupported + } + err = l.ds.WithTx(func(tx model.DataStore) error { ids := make([]string, len(pls.Playlist.Tracks)) for i, track := range pls.Playlist.Tracks { @@ -213,13 +226,15 @@ func (l *listenBrainzAgent) ImportPlaylist(ctx context.Context, update bool, use if playlist == nil { playlist = &model.Playlist{ - Name: name, - Comment: comment, - OwnerID: userId, - Public: false, - ExternalAgent: listenBrainzAgentName, - ExternalId: id, - ExternalUrl: pls.Playlist.Identifier, + Name: name, + Comment: comment, + OwnerID: userId, + Public: false, + ExternalAgent: listenBrainzAgentName, + ExternalId: id, + ExternalSync: sync, + ExternalSyncable: syncable, + ExternalUrl: pls.Playlist.Identifier, } } @@ -274,6 +289,100 @@ func (l *listenBrainzAgent) SyncPlaylist(ctx context.Context, tx model.DataStore return err } +func (l *listenBrainzAgent) SyncRecommended(ctx context.Context, userId string) error { + token, err := l.sessionKeys.GetWithUser(ctx, userId) + + if errors.Is(agents.ErrNoUsername, err) { + resp, err := l.client.validateToken(ctx, token.Key) + + if err != nil { + return err + } + + token.User = resp.UserName + + err = l.sessionKeys.PutWithUser(ctx, userId, token.Key, resp.UserName) + if err != nil { + return err + } + } else if err != nil { + return err + } + + resp, err := l.client.getPlaylists(ctx, 0, defaultFetch, token.Key, token.User, playlistTypeCreated) + + if err != nil { + return err + } + + var full_pls *listenBrainzResponse = nil + var id string + + for _, pls := range resp.Playlists { + if pls.Playlist.Extension.Extension.AdditionalMetadata.AlgorithmMetadata.SourcePatch == sourceDaily { + id = getIdentifier(pls.Playlist.Identifier) + + full_pls, err = l.client.getPlaylist(ctx, token.Key, id) + break + } + } + + if err != nil { + return err + } else if full_pls == nil { + return agents.ErrNotFound + } + + err = l.ds.WithTx(func(tx model.DataStore) error { + ids := make([]string, len(full_pls.Playlist.Tracks)) + for i, track := range full_pls.Playlist.Tracks { + ids[i] = getIdentifier(track.Identifier) + } + + matched_tracks, err := tx.MediaFile(ctx).FindWithMbid(ids) + + if err != nil { + return err + } + + playlist, err := tx.Playlist(ctx).GetRecommended(userId, listenBrainzAgentName) + + comment := agents.StripAllTags.Sanitize(full_pls.Playlist.Annotation) + + if err != nil { + playlist = &model.Playlist{ + Name: "ListenBrainz Daily Playlist", + Comment: comment, + OwnerID: userId, + Public: false, + ExternalAgent: listenBrainzAgentName, + ExternalId: id, + ExternalSync: false, + ExternalSyncable: false, + ExternalRecommended: true, + } + + if !errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Failed to query for playlist", "error", err) + } + } + + playlist.ExternalId = id + playlist.ExternalUrl = full_pls.Playlist.Identifier + + playlist.AddMediaFiles(matched_tracks) + err = tx.Playlist(ctx).Put(playlist) + + if err != nil { + log.Error(ctx, "Failed to import playlist", "id", id, err) + } + + return err + }) + + return err +} + func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool { sk, err := l.sessionKeys.Get(ctx, userId) return err == nil && sk != "" diff --git a/core/agents/listenbrainz/auth_router.go b/core/agents/listenbrainz/auth_router.go index 7ccc652d..c78d87ba 100644 --- a/core/agents/listenbrainz/auth_router.go +++ b/core/agents/listenbrainz/auth_router.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -55,11 +56,18 @@ func (s *Router) routes() http.Handler { r.Get("/link", s.getLinkStatus) r.Put("/link", s.link) r.Delete("/link", s.unlink) + r.Get("/playlist", s.getSyncStatus) + r.Put("/playlist", s.syncPlaylist) + r.Delete("/playlist", s.unsyncPlaylist) }) return r } +const ( + userAgentKey = external_playlists.UserAgentKey + listenBrainzAgentName +) + func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { resp := map[string]interface{}{} u, _ := request.UserFrom(r.Context()) @@ -114,6 +122,49 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) { func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { u, _ := request.UserFrom(r.Context()) err := s.sessionKeys.Delete(r.Context(), u.ID) + + _ = s.ds.UserProps(r.Context()).Delete(u.ID, userAgentKey) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + } else { + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{}) + } +} + +func (s *Router) getSyncStatus(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{} + u, _ := request.UserFrom(r.Context()) + _, err := s.ds.UserProps(r.Context()).Get(u.ID, userAgentKey) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusOK, resp) + } else { + resp["error"] = err + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) + } + return + } + resp["status"] = true + _ = rest.RespondWithJSON(w, http.StatusOK, resp) +} + +func (s *Router) syncPlaylist(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + + err := s.ds.UserProps(r.Context()).Put(u.ID, userAgentKey, "") + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + } else { + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{}) + } +} + +func (s *Router) unsyncPlaylist(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + + err := s.ds.UserProps(r.Context()).Delete(u.ID, userAgentKey) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) } else { diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go index 327e4619..4bb9bdd1 100644 --- a/core/agents/listenbrainz/client.go +++ b/core/agents/listenbrainz/client.go @@ -73,10 +73,19 @@ type plsExtension struct { } type playlistExtension struct { - Collaborators []string `json:"collaborators"` - CreatedFor string `json:"created_for"` - LastModified time.Time `json:"last_modified_at"` - Public bool `json:"public"` + AdditionalMetadata additionalMeta `json:"additional_metadata"` + Collaborators []string `json:"collaborators"` + CreatedFor string `json:"created_for"` + LastModified time.Time `json:"last_modified_at"` + Public bool `json:"public"` +} + +type additionalMeta struct { + AlgorithmMetadata algoMeta `json:"algorithm_metadata"` +} + +type algoMeta struct { + SourcePatch string `json:"source_patch"` } type lbTrack struct { diff --git a/core/external_playlists/external_playlists.go b/core/external_playlists/external_playlists.go index 4be30c1d..90016522 100644 --- a/core/external_playlists/external_playlists.go +++ b/core/external_playlists/external_playlists.go @@ -11,11 +11,23 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) +const ( + UserAgentKey = "agent-" +) + +type playlistImportData struct { + Name string `json:"name"` + Sync bool `json:"sync"` +} + +type ImportMap = map[string]playlistImportData + type PlaylistRetriever interface { - GetAvailableAgents(ctx context.Context, userId string) []AgentType + GetAvailableAgents(ctx context.Context, userId string) []PlaylistSourceInfo GetPlaylists(ctx context.Context, offset, count int, userId, agent, playlistType string) (*ExternalPlaylists, error) - ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping map[string]string) error + ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping ImportMap) error SyncPlaylist(ctx context.Context, playlistId string) error + SyncRecommended(ctx context.Context, userrId, agent string) error } type playlistRetriever struct { @@ -26,6 +38,7 @@ type playlistRetriever struct { var ( ErrorMissingAgent = errors.New("agent not found") + ErrSyncUnsupported = errors.New("cannot sync playlist") ErrorUnsupportedType = errors.New("unsupported playlist type") ) @@ -55,14 +68,14 @@ func newPlaylistRetriever(ds model.DataStore) *playlistRetriever { return p } -func (p *playlistRetriever) GetAvailableAgents(ctx context.Context, userId string) []AgentType { +func (p *playlistRetriever) GetAvailableAgents(ctx context.Context, userId string) []PlaylistSourceInfo { user, _ := request.UserFrom(ctx) - agents := []AgentType{} + agents := []PlaylistSourceInfo{} for name, agent := range p.retrievers { if agent.IsAuthorized(ctx, user.ID) { - agents = append(agents, AgentType{ + agents = append(agents, PlaylistSourceInfo{ Name: name, Types: agent.GetPlaylistTypes(), }) @@ -131,7 +144,7 @@ func (p *playlistRetriever) GetPlaylists(ctx context.Context, offset, count int, return pls, nil } -func (p *playlistRetriever) ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping map[string]string) error { +func (p *playlistRetriever) ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping ImportMap) error { ag, ok := p.retrievers[agent] if !ok { @@ -141,8 +154,8 @@ func (p *playlistRetriever) ImportPlaylists(ctx context.Context, update bool, us fail := 0 var err error - for id, name := range mapping { - err = ag.ImportPlaylist(ctx, update, userId, id, name) + for id, data := range mapping { + err = ag.ImportPlaylist(ctx, update, data.Sync, userId, id, data.Name) if err != nil { fail++ @@ -186,6 +199,20 @@ func (p *playlistRetriever) SyncPlaylist(ctx context.Context, playlistId string) }) } +func (p *playlistRetriever) SyncRecommended(ctx context.Context, userId, agent string) error { + ag, ok := p.retrievers[agent] + + if !ok { + return ErrorMissingAgent + } + + err := ag.SyncRecommended(ctx, userId) + if err != nil { + log.Error(ctx, "Failed to sync recommended playlists", "agent", agent, "user", userId, err) + } + return err +} + var constructors map[string]Constructor func Register(name string, init Constructor) { diff --git a/core/external_playlists/interfaces.go b/core/external_playlists/interfaces.go index eb4c315a..47450086 100644 --- a/core/external_playlists/interfaces.go +++ b/core/external_playlists/interfaces.go @@ -7,7 +7,7 @@ import ( "github.com/navidrome/navidrome/model" ) -type AgentType struct { +type PlaylistSourceInfo struct { Name string `json:"name"` Types []string `json:"types"` } @@ -21,6 +21,7 @@ type ExternalPlaylist struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Existing bool `json:"existing"` + Syncable bool `json:"syncable"` Tracks []ExternalTrack `json:"-"` } @@ -38,9 +39,10 @@ type ExternalPlaylists struct { type PlaylistAgent interface { GetPlaylistTypes() []string GetPlaylists(ctx context.Context, offset, count int, userId, playlistType string) (*ExternalPlaylists, error) - ImportPlaylist(ctx context.Context, update bool, userId, id, name string) error + ImportPlaylist(ctx context.Context, update bool, sync bool, userId, id, name string) error IsAuthorized(ctx context.Context, userId string) bool SyncPlaylist(ctx context.Context, tx model.DataStore, pls *model.Playlist) error + SyncRecommended(ctx context.Context, userId string) error } type Constructor func(ds model.DataStore) PlaylistAgent diff --git a/db/migration/20230318140335_add_playlist_external_info.go b/db/migration/20230318140345_add_playlist_external_info.go similarity index 78% rename from db/migration/20230318140335_add_playlist_external_info.go rename to db/migration/20230318140345_add_playlist_external_info.go index 6e502094..2058117d 100644 --- a/db/migration/20230318140335_add_playlist_external_info.go +++ b/db/migration/20230318140345_add_playlist_external_info.go @@ -20,11 +20,16 @@ alter table playlist add external_id varchar default '' not null; alter table playlist add external_url varchar default '' not null; +alter table playlist + add external_sync bool default false; +alter table playlist + add external_syncable bool default false; +alter table playlist + add external_recommended bool default false; `) return err } func downAddPlaylistExternalInfo(tx *sql.Tx) error { - // This code is executed when the migration is rolled back. return nil } diff --git a/model/playlist.go b/model/playlist.go index 626fa05f..df0aa0ba 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -27,9 +27,12 @@ type Playlist struct { UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // External Info - ExternalAgent string `structs:"external_agent" json:"external_agent"` - ExternalId string `structs:"external_id" json:"externalId"` - ExternalUrl string `structs:"external_url" json:"externalUrl"` + ExternalAgent string `structs:"external_agent" json:"external_agent"` + ExternalId string `structs:"external_id" json:"externalId"` + ExternalSync bool `structs:"external_sync" json:"externalSync"` + ExternalSyncable bool `structs:"external_syncable" json:"externalSyncable"` + ExternalUrl string `structs:"external_url" json:"externalUrl"` + ExternalRecommended bool `structs:"external_recommended" json:"externalRecommended"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"-" json:"rules"` @@ -111,10 +114,12 @@ type PlaylistRepository interface { Exists(id string) (bool, error) Put(pls *Playlist) error Get(id string) (*Playlist, error) + GetSyncedPlaylists() (Playlists, error) GetWithTracks(id string, refreshSmartPlaylist bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) CheckExternalIds(agent string, ids []string) ([]string, error) GetByExternalInfo(agent, id string) (*Playlist, error) + GetRecommended(userId, agent string) (*Playlist, error) FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository diff --git a/model/user_props.go b/model/user_props.go index c2eb536e..d1af83ce 100644 --- a/model/user_props.go +++ b/model/user_props.go @@ -1,8 +1,17 @@ package model +type UserProp struct { + UserID string `structs:"user_id" orm:"column(user_id)"` + Key string `structs:"key"` + Value string `structs:"value"` +} + +type UserProps []UserProp + type UserPropsRepository interface { Put(userId, key string, value string) error Get(userId, key string) (string, error) + GetAllWithPrefix(key string) (UserProps, error) Delete(userId, key string) error DefaultGet(userId, key string, defaultValue string) (string, error) } diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 66f3d399..90fc8baa 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -10,6 +10,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -87,6 +88,10 @@ func (r *playlistRepository) Delete(id string) error { } func (r *playlistRepository) Put(p *model.Playlist) error { + if p.ExternalSync && !p.ExternalSyncable { + return external_playlists.ErrSyncUnsupported + } + pls := dbPlaylist{Playlist: *p} if p.IsSmartPlaylist() { j, err := json.Marshal(p.Rules) @@ -129,6 +134,17 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) { return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()}) } +func (r *playlistRepository) GetSyncedPlaylists() (model.Playlists, error) { + sel := r.newSelect().Columns("id", "owner_id") + var res model.Playlists + err := r.queryAll(sel, &res) + if err != nil { + return nil, err + } + + return res, err +} + func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist bool) (*model.Playlist, error) { pls, err := r.Get(id) if err != nil { @@ -476,6 +492,18 @@ func (r *playlistRepository) GetByExternalInfo(agent, id string) (*model.Playlis return &pls, nil } +func (r *playlistRepository) GetRecommended(userId, agent string) (*model.Playlist, error) { + sql := Select("*").From(r.tableName).Where(Eq{"external_agent": agent, "owner_id": userId, "external_recommended": true}) + var pls model.Playlist + + err := r.queryOne(sql, &pls) + if err != nil { + return nil, err + } + + return &pls, nil +} + func (r *playlistRepository) CheckExternalIds(agent string, ids []string) ([]string, error) { // Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit chunks := utils.BreakUpStringSlice(ids, 200) diff --git a/persistence/user_props_repository.go b/persistence/user_props_repository.go index 1fe9c7b9..e4a4f292 100644 --- a/persistence/user_props_repository.go +++ b/persistence/user_props_repository.go @@ -47,6 +47,16 @@ func (r userPropsRepository) Get(userId, key string) (string, error) { return resp.Value, nil } +func (r userPropsRepository) GetAllWithPrefix(key string) (model.UserProps, error) { + sel := Select("*").From(r.tableName).Where(Like{"key": key + "%"}) + resp := model.UserProps{} + err := r.queryAll(sel, &resp) + if err != nil { + return nil, err + } + return resp, nil +} + func (r userPropsRepository) DefaultGet(userId, key string, defaultValue string) (string, error) { value, err := r.Get(userId, key) if errors.Is(err, model.ErrNotFound) { diff --git a/scanner/scanner.go b/scanner/scanner.go index b9b28338..13e0db54 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -5,19 +5,23 @@ import ( "errors" "fmt" "strconv" + "strings" "sync" "time" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" ) type Scanner interface { RescanAll(ctx context.Context, fullRescan bool) error Status(mediaFolder string) (*StatusInfo, error) + SyncPlaylists(ctx context.Context) error } type StatusInfo struct { @@ -48,6 +52,7 @@ type scanner struct { pls core.Playlists broker events.Broker cacheWarmer artwork.CacheWarmer + retriever external_playlists.PlaylistRetriever } type scanStatus struct { @@ -57,7 +62,13 @@ type scanStatus struct { lastUpdate time.Time } -func New(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, broker events.Broker) Scanner { +func New( + ds model.DataStore, + playlists core.Playlists, + cacheWarmer artwork.CacheWarmer, + broker events.Broker, + retriever external_playlists.PlaylistRetriever, +) Scanner { s := &scanner{ ds: ds, pls: playlists, @@ -66,6 +77,7 @@ func New(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.Cache status: map[string]*scanStatus{}, lock: &sync.RWMutex{}, cacheWarmer: cacheWarmer, + retriever: retriever, } s.loadFolders() return s @@ -249,3 +261,50 @@ func (s *scanner) loadFolders() { func (s *scanner) newScanner(f model.MediaFolder) FolderScanner { return NewTagScanner(f.Path, s.ds, s.pls, s.cacheWarmer) } + +// Why is this in the Scanner, when it isn't directly related to scanning files? +// Because the operation it runs can contend with scanning. +// To make sure scanning and playlist syncing are mutually exclusive, both +// exist here under the same lock +func (s *scanner) SyncPlaylists(ctx context.Context) error { + isScanning.Lock() + defer isScanning.Unlock() + + playlists, err := s.ds.Playlist(ctx).GetSyncedPlaylists() + + if err != nil { + return err + } + + for _, playlist := range playlists { + user := model.User{ + ID: playlist.OwnerID, + } + nestedCtx := request.WithUser(ctx, user) + err = s.retriever.SyncPlaylist(nestedCtx, playlist.ID) + if err != nil { + log.Error(nestedCtx, "Failed to sync playlist", "id", playlist.ID, err) + } + } + + props, err := s.ds.UserProps(ctx).GetAllWithPrefix(external_playlists.UserAgentKey) + + if err != nil { + return err + } + + for _, prop := range props { + split := strings.Split(prop.Key, external_playlists.UserAgentKey) + user := model.User{ + ID: prop.UserID, + } + nestedCtx := request.WithUser(ctx, user) + err = s.retriever.SyncRecommended(nestedCtx, prop.UserID, split[1]) + + if err != nil { + log.Error(ctx, "Failed to fetch recommended playlists", "user", prop.UserID, "agent", split[1]) + } + } + + return err +} diff --git a/server/nativeapi/external_playlists.go b/server/nativeapi/external_playlists.go index dcea73c6..da3c8273 100644 --- a/server/nativeapi/external_playlists.go +++ b/server/nativeapi/external_playlists.go @@ -4,26 +4,44 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "strconv" "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/external_playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" ) +var ( + errBadRange = errors.New("end must me greater than start") +) + +type webError struct { + Error string `json:"error"` +} + func requiredParamString(w *http.ResponseWriter, r *http.Request, param string) (string, bool) { p := utils.ParamString(r, param) if p == "" { - http.Error(*w, "required param '"+param+"' is missing", http.StatusBadRequest) + replyError(r.Context(), *w, fmt.Errorf(`required param "%s" is missing`, param), http.StatusBadRequest) return p, false } return p, true } +func replyError(ctx context.Context, w http.ResponseWriter, err error, status int) { + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + error := webError{Error: err.Error()} + resp, _ := json.Marshal(error) + w.Write(resp) +} + func replyJson(ctx context.Context, w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") resp, _ := json.Marshal(data) @@ -54,7 +72,7 @@ func (n *Router) getPlaylists() http.HandlerFunc { end := utils.ParamInt(r, "_end", 0) if start >= end { - http.Error(w, "End must me greater than start", http.StatusBadRequest) + replyError(ctx, w, errBadRange, http.StatusBadRequest) return } @@ -73,7 +91,7 @@ func (n *Router) getPlaylists() http.HandlerFunc { lists, err := n.pls.GetPlaylists(ctx, start, count, user.ID, agent, plsType) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + replyError(ctx, w, err, http.StatusInternalServerError) } else { w.Header().Set("X-Total-Count", strconv.Itoa(lists.Total)) @@ -83,9 +101,9 @@ func (n *Router) getPlaylists() http.HandlerFunc { } type externalImport struct { - Agent string `json:"agent"` - Playlists map[string]string `json:"playlists"` - Update bool `json:"update"` + Agent string `json:"agent"` + Playlists external_playlists.ImportMap `json:"playlists"` + Update bool `json:"update"` } func (n *Router) fetchPlaylists() http.HandlerFunc { @@ -98,7 +116,7 @@ func (n *Router) fetchPlaylists() http.HandlerFunc { data, err := io.ReadAll(r.Body) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + replyError(ctx, w, err, http.StatusBadRequest) return } @@ -106,7 +124,7 @@ func (n *Router) fetchPlaylists() http.HandlerFunc { err = json.Unmarshal(data, &plsImport) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + replyError(ctx, w, err, http.StatusBadRequest) return } @@ -114,9 +132,9 @@ func (n *Router) fetchPlaylists() http.HandlerFunc { if err != nil { if errors.Is(model.ErrNotAuthorized, err) { - http.Error(w, err.Error(), http.StatusForbidden) + replyError(ctx, w, err, http.StatusForbidden) } else { - http.Error(w, err.Error(), http.StatusInternalServerError) + replyError(ctx, w, err, http.StatusInternalServerError) } return } @@ -144,7 +162,7 @@ func (n *Router) syncPlaylist() http.HandlerFunc { code = http.StatusInternalServerError } - http.Error(w, err.Error(), code) + replyError(ctx, w, err, code) } else { replyJson(ctx, w, "") } diff --git a/ui/src/externalPlaylist/ExternalPlaylistCreate.js b/ui/src/externalPlaylist/ExternalPlaylistCreate.js index 19c89ccd..afce169c 100644 --- a/ui/src/externalPlaylist/ExternalPlaylistCreate.js +++ b/ui/src/externalPlaylist/ExternalPlaylistCreate.js @@ -59,6 +59,21 @@ const NameInput = (props) => { ) } +const SyncInput = (props) => { + const { id } = useRecordContext(props) + + return ( + { + event.stopPropagation() + }} + /> + ) +} + const MyDataGrid = forwardRef( ({ onUnselectItems, selectedIds, setIds, ...props }, ref) => { useEffect(() => { @@ -72,6 +87,14 @@ const MyDataGrid = forwardRef( } }, [onUnselectItems]) + const canSync = useMemo(() => { + let canSync = false + for (const id of props.ids) { + canSync ||= props.data[id].syncable + } + return canSync + }, [props.data, props.ids]) + ref.current = onUnselectItems return ( @@ -82,6 +105,7 @@ const MyDataGrid = forwardRef( selectedIds={selectedIds} > + {canSync && } @@ -191,13 +215,18 @@ const ExternalPlaylistCreate = (props) => { const save = useCallback( async (values) => { - const { agent, name, update, type } = values - const selectedNames = {} + const { agent, name, type, update } = values + const playlists = {} + + let sync = values.sync ?? {} let count = 0 for (const id of ids) { - selectedNames[id] = name[id] + playlists[id] = { + name: name[id], + sync: sync[id], + } count++ } @@ -207,7 +236,7 @@ const ExternalPlaylistCreate = (props) => { type: 'create', resource: 'externalPlaylist', payload: { - data: { agent, type, update, playlists: selectedNames }, + data: { agent, type, update, playlists }, }, }, { returnPromise: true } @@ -218,9 +247,9 @@ const ExternalPlaylistCreate = (props) => { refresh() redirect('/playlist') } catch (error) { - if (error.body.errors) { - return error.body.errors - } + notify('resources.externalPlaylist.notifications.failed', 'error', { + cause: error.body.error, + }) } }, [ids, mutate, notify, redirect, refresh] diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index e36a64dc..84aec06d 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -152,7 +152,8 @@ "comment": "Comment", "sync": "Auto-import", "path": "Import from", - "external": "External" + "external": "External", + "externalSync": "Sync from source" }, "actions": { "selectPlaylist": "Select a playlist:", @@ -212,10 +213,12 @@ "updatedAt": "Updated at", "createdAt": "Created at", "existing": "Existing", + "sync": "Periodically fetch", "update": "Update existing playlists" }, "notifications": { - "created": "External playlist imported |||| %{smart_count} external playlists imported" + "created": "External playlist imported |||| %{smart_count} external playlists imported", + "failed": "Could not import playlists: %{cause}" }, "agent": { "listenbrainz": { @@ -389,6 +392,10 @@ "listenBrainzLinkFailure": "ListenBrainz could not be linked: %{error}", "listenBrainzUnlinkSuccess": "ListenBrainz unlinked and scrobbling disabled", "listenBrainzUnlinkFailure": "ListenBrainz could not be unlinked", + "agentRecommendedSyncSuccess": "%{agent} enabled to sync recommended playlists", + "agentRecommendedSyncFail": "%{agent} could not enable playlist syncing: %{error}", + "agentRecommendedUnsyncSuccess": "%{agent} disabled syncing recommended playlists", + "agentRecommendedUnsyncFail": "%{agent} could not disable playlist syncing: %{error}", "openIn": { "lastfm": "Open in Last.fm", "musicbrainz": "Open in MusicBrainz" @@ -422,6 +429,7 @@ "desktop_notifications": "Desktop Notifications", "lastfmScrobbling": "Scrobble to Last.fm", "listenBrainzScrobbling": "Scrobble to ListenBrainz", + "listenBrainzPlaylistSync": "Sync daily playlists (this requires following troi-bot)", "replaygain": "ReplayGain Mode", "preAmp": "ReplayGain PreAmp (dB)", "gain": { diff --git a/ui/src/personal/ListenBrainzPlaylistToggle.js b/ui/src/personal/ListenBrainzPlaylistToggle.js new file mode 100644 index 00000000..77aace0a --- /dev/null +++ b/ui/src/personal/ListenBrainzPlaylistToggle.js @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { useNotify, useTranslate } from 'react-admin' +import { FormControl, FormControlLabel, Switch } from '@material-ui/core' +import { httpClient } from '../dataProvider' + +export const ListenBrainzPlaylistToggle = () => { + const notify = useNotify() + const translate = useTranslate() + const [linked, setLinked] = useState(null) + + const togglePlaylist = () => { + if (linked) { + httpClient('/api/listenbrainz/playlist', { method: 'DELETE' }) + .then(() => { + setLinked(false) + notify('message.agentRecommendedUnsyncSuccess', 'success', { + agent: 'ListenBrainz', + }) + }) + .catch((error) => + notify('message.agentRecommendedUnsyncFail', 'warning', { + agent: 'ListenBrainz', + error: error.body?.error || error.message, + }) + ) + } else { + httpClient('/api/listenbrainz/playlist', { method: 'PUT' }) + .then(() => { + setLinked(true) + notify('message.agentRecommendedSyncSuccess', 'success', { + agent: 'ListenBrainz', + }) + }) + .catch((error) => + notify('message.agentRecommendedSyncFail', 'warning', { + agent: 'ListenBrainz', + error: error.body?.error || error.message, + }) + ) + } + } + + useEffect(() => { + httpClient('/api/listenbrainz/playlist') + .then((response) => { + setLinked(response.json.status === true) + }) + .catch(() => { + setLinked(false) + }) + }, []) + + return ( + <> + + + } + label={ + + {translate('menu.personal.options.listenBrainzPlaylistSync')} + + } + /> + + + ) +} diff --git a/ui/src/personal/ListenBrainzScrobbleToggle.js b/ui/src/personal/ListenBrainzScrobbleToggle.js index 72703523..ebf8968c 100644 --- a/ui/src/personal/ListenBrainzScrobbleToggle.js +++ b/ui/src/personal/ListenBrainzScrobbleToggle.js @@ -5,6 +5,7 @@ import { httpClient } from '../dataProvider' import { ListenBrainzTokenDialog } from '../dialogs' import { useDispatch } from 'react-redux' import { openListenBrainzTokenDialog } from '../actions' +import { ListenBrainzPlaylistToggle } from './ListenBrainzPlaylistToggle' export const ListenBrainzScrobbleToggle = () => { const dispatch = useDispatch() @@ -56,6 +57,11 @@ export const ListenBrainzScrobbleToggle = () => { /> + {linked && ( +
+ +
+ )} ) } diff --git a/ui/src/playlist/PlaylistEdit.js b/ui/src/playlist/PlaylistEdit.js index 23f9cc1b..a54b0e8f 100644 --- a/ui/src/playlist/PlaylistEdit.js +++ b/ui/src/playlist/PlaylistEdit.js @@ -53,6 +53,7 @@ const PlaylistEditForm = (props) => { )} + {record.externalSyncable && } {(formDataProps) => } diff --git a/ui/src/playlist/PlaylistList.js b/ui/src/playlist/PlaylistList.js index 29fc1d22..52a04bba 100644 --- a/ui/src/playlist/PlaylistList.js +++ b/ui/src/playlist/PlaylistList.js @@ -183,6 +183,7 @@ const PlaylistList = (props) => { ), comment: , external: , + externalSync: , }), [isDesktop, isXsmall] ) @@ -190,7 +191,7 @@ const PlaylistList = (props) => { const columns = useSelectedFields({ resource: 'playlist', columns: toggleableFields, - defaultOff: ['comment', 'external'], + defaultOff: ['comment', 'external', 'externalSync'], }) return ( From 810d212e0ae94ea1a470f5ab42b83ab46eb62364 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 5 Nov 2023 11:36:11 -0800 Subject: [PATCH 5/6] make things work --- core/agents/listenbrainz/agent_test.go | 2 +- core/agents/listenbrainz/auth_router_test.go | 1 + core/agents/listenbrainz/client_test.go | 10 ++++++++++ server/nativeapi/external_playlists.go | 5 ++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go index 60d42943..4cf84b1d 100644 --- a/core/agents/listenbrainz/agent_test.go +++ b/core/agents/listenbrainz/agent_test.go @@ -295,7 +295,7 @@ var _ = Describe("listenBrainzAgent", func() { }) Describe("Successful test", func() { - WithMbid := model.MediaFile{ID: "1", Title: "Take Control", MbzTrackID: "9f42783a-423b-4ed6-8a10-fdf4cb44456f", Artist: "Old Gods of Asgard"} + WithMbid := model.MediaFile{ID: "1", Title: "Take Control", MbzRecordingID: "9f42783a-423b-4ed6-8a10-fdf4cb44456f", Artist: "Old Gods of Asgard"} BeforeEach(func() { _ = ds.MediaFile(ctx).Put(&WithMbid) diff --git a/core/agents/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go index 1c5c6a1c..159721a9 100644 --- a/core/agents/listenbrainz/auth_router_test.go +++ b/core/agents/listenbrainz/auth_router_test.go @@ -27,6 +27,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { httpClient = &tests.FakeHttpClient{} cl := newClient("http://localhost/", httpClient) r = Router{ + ds: &tests.MockDataStore{}, sessionKeys: sk, client: cl, } diff --git a/core/agents/listenbrainz/client_test.go b/core/agents/listenbrainz/client_test.go index 84899fb1..1c42e818 100644 --- a/core/agents/listenbrainz/client_test.go +++ b/core/agents/listenbrainz/client_test.go @@ -58,6 +58,11 @@ var _ = Describe("client", func() { Title: "Daily Jams for example, 2023-03-18 Sat", Extension: plsExtension{ Extension: playlistExtension{ + AdditionalMetadata: additionalMeta{ + AlgorithmMetadata: algoMeta{ + SourcePatch: "daily-jams", + }, + }, Collaborators: []string{"example"}, CreatedFor: "example", LastModified: modified, @@ -86,6 +91,11 @@ var _ = Describe("client", func() { Title: "Top Discoveries of 2022 for example", Extension: plsExtension{ Extension: playlistExtension{ + AdditionalMetadata: additionalMeta{ + AlgorithmMetadata: algoMeta{ + SourcePatch: "top-discoveries-for-year", + }, + }, Collaborators: []string{"example"}, CreatedFor: "example", LastModified: modified, diff --git a/server/nativeapi/external_playlists.go b/server/nativeapi/external_playlists.go index da3c8273..b7b32972 100644 --- a/server/nativeapi/external_playlists.go +++ b/server/nativeapi/external_playlists.go @@ -39,7 +39,10 @@ func replyError(ctx context.Context, w http.ResponseWriter, err error, status in w.Header().Set("Content-Type", "application/json") error := webError{Error: err.Error()} resp, _ := json.Marshal(error) - w.Write(resp) + _, writeErr := w.Write(resp) + if writeErr != nil { + log.Error(ctx, "Error sending json", "Error", err) + } } func replyJson(ctx context.Context, w http.ResponseWriter, data interface{}) { From 789b83d76e0f7e0f215a285e33fe3d9a423e1ab9 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 21 Jan 2024 09:16:31 -0800 Subject: [PATCH 6/6] why didn't make lint catch this --- go.mod | 2 -- go.sum | 1 - 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index e8fbbb3b..4ce00d14 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,6 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect - github.com/google/subcommands v1.0.1 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -93,7 +92,6 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect go.uber.org/goleak v1.1.11 // indirect golang.org/x/crypto v0.17.0 // indirect - golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/tools v0.16.1 // indirect diff --git a/go.sum b/go.sum index 62af36e0..6f3649c4 100644 --- a/go.sum +++ b/go.sum @@ -175,7 +175,6 @@ github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20230323073829-e72429f035bd h1:r8yyd+DJDmsUhGrRBxH5Pj7KeFK5l+Y3FsgT8keqKtk= github.com/google/pprof v0.0.0-20230323073829-e72429f035bd/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=