From a56d5bc8503bc984d24c2642fa323ac854fff0ef Mon Sep 17 00:00:00 2001 From: Steve Richter Date: Sat, 30 Oct 2021 12:17:42 -0400 Subject: [PATCH] Listenbrainz scrobbling (#1424) * Refactor session_keys to its own package * Adjust play_tracker - Don't send external NowPlaying/Scrobble for tracks with unknown artist - Continue to the next agent on error * Implement ListenBrainz Agent and Auth Router * Implement frontend for ListenBrainz linking * Update listenBrainzRequest - Don't marshal Player to json - Rename Track to Title * Return ErrRetryLater on ListenBrainz server errors * Add tests for listenBrainzAgent * Add tests for ListenBrainz Client * Adjust ListenBrainzTokenDialog to handle errors better * Refactor listenbrainz.formatListen and listenBrainzRequest structs * Refactor agent auth_routers * Refactor session_keys to agents package * Add test for listenBrainzResponse * Add tests for ListenBrainz auth_router * Update ListenBrainzTokenDialog and auth_router * Adjust player scrobble toggle --- cmd/root.go | 3 + cmd/wire_gen.go | 10 +- cmd/wire_injectors.go | 8 + conf/configuration.go | 2 + core/agents/lastfm/agent.go | 13 +- core/agents/lastfm/auth_router.go | 13 +- core/agents/lastfm/session_keys.go | 28 --- core/agents/listenbrainz/agent.go | 113 +++++++++++++ core/agents/listenbrainz/agent_test.go | 158 +++++++++++++++++ core/agents/listenbrainz/auth_router.go | 119 +++++++++++++ core/agents/listenbrainz/auth_router_test.go | 130 ++++++++++++++ core/agents/listenbrainz/client.go | 160 ++++++++++++++++++ core/agents/listenbrainz/client_test.go | 115 +++++++++++++ .../listenbrainz/listenbrainz_suite_test.go | 17 ++ core/agents/session_keys.go | 25 +++ core/agents/session_keys_test.go | 37 ++++ core/external_metadata.go | 1 + core/scrobbler/play_tracker.go | 22 ++- core/scrobbler/play_tracker_test.go | 20 ++- server/serve_index.go | 1 + server/serve_index_test.go | 11 ++ .../listenbrainz.nowplaying.request.json | 1 + .../listenbrainz.scrobble.request.json | 1 + ui/src/App.js | 2 + ui/src/actions/dialogs.js | 10 ++ ui/src/config.js | 1 + ui/src/dialogs/ListenBrainzTokenDialog.js | 138 +++++++++++++++ ui/src/dialogs/index.js | 1 + ui/src/i18n/en.json | 18 +- ui/src/personal/ListenBrainzScrobbleToggle.js | 61 +++++++ ui/src/personal/Personal.js | 2 + ui/src/player/PlayerEdit.js | 2 +- ui/src/reducers/dialogReducer.js | 25 +++ 33 files changed, 1214 insertions(+), 54 deletions(-) delete mode 100644 core/agents/lastfm/session_keys.go create mode 100644 core/agents/listenbrainz/agent.go create mode 100644 core/agents/listenbrainz/agent_test.go create mode 100644 core/agents/listenbrainz/auth_router.go create mode 100644 core/agents/listenbrainz/auth_router_test.go create mode 100644 core/agents/listenbrainz/client.go create mode 100644 core/agents/listenbrainz/client_test.go create mode 100644 core/agents/listenbrainz/listenbrainz_suite_test.go create mode 100644 core/agents/session_keys.go create mode 100644 core/agents/session_keys_test.go create mode 100644 tests/fixtures/listenbrainz.nowplaying.request.json create mode 100644 tests/fixtures/listenbrainz.scrobble.request.json create mode 100644 ui/src/dialogs/ListenBrainzTokenDialog.js create mode 100644 ui/src/personal/ListenBrainzScrobbleToggle.js diff --git a/cmd/root.go b/cmd/root.go index 50c33a09..238042c5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -80,6 +80,9 @@ func startServer() (func() error, func(err error)) { if conf.Server.LastFM.Enabled { a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter()) } + if conf.Server.DevListenBrainzEnabled { + a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) + } return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port)) }, func(err error) { if err != nil { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 32048b63..def3d830 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/agents/lastfm" + "github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/transcoder" "github.com/navidrome/navidrome/db" @@ -68,6 +69,13 @@ func CreateLastFMRouter() *lastfm.Router { return router } +func CreateListenBrainzRouter() *listenbrainz.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + router := listenbrainz.NewRouter(dataStore) + return router +} + func createScanner() scanner.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -82,7 +90,7 @@ func createScanner() scanner.Scanner { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, events.GetBroker, db.Db) +var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db) // Scanner must be a Singleton var ( diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index c03d9b80..bc807dc2 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -9,6 +9,7 @@ import ( "github.com/google/wire" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents/lastfm" + "github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" @@ -24,6 +25,7 @@ var allProviders = wire.NewSet( nativeapi.New, persistence.New, lastfm.NewRouter, + listenbrainz.NewRouter, events.GetBroker, db.Db, ) @@ -54,6 +56,12 @@ func CreateLastFMRouter() *lastfm.Router { )) } +func CreateListenBrainzRouter() *listenbrainz.Router { + panic(wire.Build( + allProviders, + )) +} + // Scanner must be a Singleton var ( onceScanner sync.Once diff --git a/conf/configuration.go b/conf/configuration.go index 53282653..5ddec29f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -74,6 +74,7 @@ type configOptions struct { DevSidebarPlaylists bool DevEnableBufferedScrobble bool DevShowArtistPage bool + DevListenBrainzEnabled bool } type scannerOptions struct { @@ -241,6 +242,7 @@ func init() { viper.SetDefault("devenablebufferedscrobble", true) viper.SetDefault("devsidebarplaylists", true) viper.SetDefault("devshowartistpage", true) + viper.SetDefault("devlistenbrainzenabled", false) } func InitConfig(cfgFile string) { diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go index 7095be5c..670fc29d 100644 --- a/core/agents/lastfm/agent.go +++ b/core/agents/lastfm/agent.go @@ -14,12 +14,13 @@ import ( ) const ( - lastFMAgentName = "lastfm" + lastFMAgentName = "lastfm" + sessionKeyProperty = "LastFMSessionKey" ) type lastfmAgent struct { ds model.DataStore - sessionKeys *sessionKeys + sessionKeys *agents.SessionKeys apiKey string secret string lang string @@ -32,7 +33,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { lang: conf.Server.LastFM.Language, apiKey: conf.Server.LastFM.ApiKey, secret: conf.Server.LastFM.Secret, - sessionKeys: &sessionKeys{ds: ds}, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } hc := &http.Client{ Timeout: consts.DefaultHttpClientTimeOut, @@ -159,7 +160,7 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mb } func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { - sk, err := l.sessionKeys.get(ctx, userId) + sk, err := l.sessionKeys.Get(ctx, userId) if err != nil || sk == "" { return scrobbler.ErrNotAuthorized } @@ -181,7 +182,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode } func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { - sk, err := l.sessionKeys.get(ctx, userId) + sk, err := l.sessionKeys.Get(ctx, userId) if err != nil || sk == "" { return scrobbler.ErrNotAuthorized } @@ -215,7 +216,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S } func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { - sk, err := l.sessionKeys.get(ctx, userId) + sk, err := l.sessionKeys.Get(ctx, userId) return err == nil && sk != "" } diff --git a/core/agents/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go index e72625f5..1ca763fc 100644 --- a/core/agents/lastfm/auth_router.go +++ b/core/agents/lastfm/auth_router.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -25,7 +26,7 @@ var tokenReceivedPage []byte type Router struct { http.Handler ds model.DataStore - sessionKeys *sessionKeys + sessionKeys *agents.SessionKeys client *Client apiKey string secret string @@ -36,7 +37,7 @@ func NewRouter(ds model.DataStore) *Router { ds: ds, apiKey: conf.Server.LastFM.ApiKey, secret: conf.Server.LastFM.Secret, - sessionKeys: &sessionKeys{ds: ds}, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() hc := &http.Client{ @@ -63,9 +64,9 @@ func (s *Router) routes() http.Handler { } func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { - resp := map[string]interface{}{"status": true} + resp := map[string]interface{}{} u, _ := request.UserFrom(r.Context()) - key, err := s.sessionKeys.get(r.Context(), u.ID) + key, err := s.sessionKeys.Get(r.Context(), u.ID) if err != nil && err != model.ErrNotFound { resp["error"] = err resp["status"] = false @@ -78,7 +79,7 @@ func (s *Router) getLinkStatus(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) + err := s.sessionKeys.Delete(r.Context(), u.ID) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) } else { @@ -119,7 +120,7 @@ func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { "requestId", middleware.GetReqID(ctx), err) return err } - err = s.sessionKeys.put(ctx, uid, sessionKey) + err = s.sessionKeys.Put(ctx, uid, sessionKey) if err != nil { log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err) } diff --git a/core/agents/lastfm/session_keys.go b/core/agents/lastfm/session_keys.go deleted file mode 100644 index fdf7a1ec..00000000 --- a/core/agents/lastfm/session_keys.go +++ /dev/null @@ -1,28 +0,0 @@ -package lastfm - -import ( - "context" - - "github.com/navidrome/navidrome/model" -) - -const ( - sessionKeyProperty = "LastFMSessionKey" -) - -// sessionKeys is a simple wrapper around the UserPropsRepository -type sessionKeys struct { - ds model.DataStore -} - -func (sk *sessionKeys) put(ctx context.Context, userId, sessionKey string) error { - return sk.ds.UserProps(ctx).Put(userId, sessionKeyProperty, sessionKey) -} - -func (sk *sessionKeys) get(ctx context.Context, userId string) (string, error) { - return sk.ds.UserProps(ctx).Get(userId, sessionKeyProperty) -} - -func (sk *sessionKeys) delete(ctx context.Context, userId string) error { - return sk.ds.UserProps(ctx).Delete(userId, sessionKeyProperty) -} diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go new file mode 100644 index 00000000..bb699a8a --- /dev/null +++ b/core/agents/listenbrainz/agent.go @@ -0,0 +1,113 @@ +package listenbrainz + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +const ( + listenBrainzAgentName = "listenbrainz" + sessionKeyProperty = "ListenBrainzSessionKey" +) + +type listenBrainzAgent struct { + ds model.DataStore + sessionKeys *agents.SessionKeys + client *Client +} + +func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { + l := &listenBrainzAgent{ + ds: ds, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut) + l.client = NewClient(chc) + return l +} + +func (l *listenBrainzAgent) AgentName() string { + return listenBrainzAgentName +} + +func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo { + li := listenInfo{ + TrackMetadata: trackMetadata{ + ArtistName: track.Artist, + TrackName: track.Title, + ReleaseName: track.Album, + AdditionalInfo: additionalInfo{ + TrackNumber: track.TrackNumber, + ArtistMbzIDs: []string{track.MbzArtistID}, + TrackMbzID: track.MbzTrackID, + ReleaseMbID: track.MbzAlbumID, + }, + }, + } + return li +} + +func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { + sk, err := l.sessionKeys.Get(ctx, userId) + if err != nil || sk == "" { + return scrobbler.ErrNotAuthorized + } + + li := l.formatListen(track) + err = l.client.UpdateNowPlaying(ctx, sk, li) + if err != nil { + log.Warn(ctx, "ListenBrainz UpdateNowPlaying returned error", "track", track.Title, err) + return scrobbler.ErrUnrecoverable + } + return nil +} + +func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { + sk, err := l.sessionKeys.Get(ctx, userId) + if err != nil || sk == "" { + return scrobbler.ErrNotAuthorized + } + + li := l.formatListen(&s.MediaFile) + li.ListenedAt = int(s.TimeStamp.Unix()) + err = l.client.Scrobble(ctx, sk, li) + + if err == nil { + return nil + } + lbErr, isListenBrainzError := err.(*listenBrainzError) + if !isListenBrainzError { + log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err) + return scrobbler.ErrRetryLater + } + if lbErr.Code == 500 || lbErr.Code == 503 { + return scrobbler.ErrRetryLater + } + return scrobbler.ErrUnrecoverable +} + +func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool { + sk, err := l.sessionKeys.Get(ctx, userId) + return err == nil && sk != "" +} + +func init() { + conf.AddHook(func() { + if conf.Server.DevListenBrainzEnabled { + scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { + return listenBrainzConstructor(ds) + }) + } + }) +} diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go new file mode 100644 index 00000000..6f792831 --- /dev/null +++ b/core/agents/listenbrainz/agent_test.go @@ -0,0 +1,158 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" +) + +var _ = Describe("listenBrainzAgent", func() { + var ds model.DataStore + var ctx context.Context + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + var track *model.MediaFile + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = context.Background() + _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") + httpClient = &tests.FakeHttpClient{} + agent = listenBrainzConstructor(ds) + agent.client = NewClient(httpClient) + track = &model.MediaFile{ + ID: "123", + Title: "Track Title", + Album: "Track Album", + Artist: "Track Artist", + TrackNumber: 1, + MbzTrackID: "mbz-123", + MbzAlbumID: "mbz-456", + MbzArtistID: "mbz-789", + } + }) + + Describe("formatListen", func() { + It("constructs the listenInfo properly", func() { + var idArtistId = func(element interface{}) string { + return element.(string) + } + + lr := agent.formatListen(track) + Expect(lr).To(MatchAllFields(Fields{ + "ListenedAt": Equal(0), + "TrackMetadata": MatchAllFields(Fields{ + "ArtistName": Equal(track.Artist), + "TrackName": Equal(track.Title), + "ReleaseName": Equal(track.Album), + "AdditionalInfo": MatchAllFields(Fields{ + "TrackNumber": Equal(track.TrackNumber), + "TrackMbzID": Equal(track.MbzTrackID), + "ReleaseMbID": Equal(track.MbzAlbumID), + "ArtistMbzIDs": MatchAllElements(idArtistId, Elements{ + "mbz-789": Equal(track.MbzArtistID), + }), + }), + }), + })) + }) + }) + + Describe("NowPlaying", func() { + It("updates NowPlaying successfully", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} + + err := agent.NowPlaying(ctx, "user-1", track) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns ErrNotAuthorized if user is not linked", func() { + err := agent.NowPlaying(ctx, "user-2", track) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) + }) + + Describe("Scrobble", func() { + var sc scrobbler.Scrobble + + BeforeEach(func() { + sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()} + }) + + It("sends a Scrobble successfully", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).ToNot(HaveOccurred()) + }) + + It("sets the Timestamp properly", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).ToNot(HaveOccurred()) + + decoder := json.NewDecoder(httpClient.SavedRequest.Body) + var lr listenBrainzRequestBody + err = decoder.Decode(&lr) + + Expect(err).ToNot(HaveOccurred()) + Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix()))) + }) + + It("returns ErrNotAuthorized if user is not linked", func() { + err := agent.Scrobble(ctx, "user-2", sc) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) + + It("returns ErrRetryLater on error 503", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)), + StatusCode: 503, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrRetryLater on error 500", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)), + StatusCode: 500, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrRetryLater on http errors", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)), + StatusCode: 500, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrUnrecoverable on other errors", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)), + StatusCode: 400, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) + }) + }) +}) diff --git a/core/agents/listenbrainz/auth_router.go b/core/agents/listenbrainz/auth_router.go new file mode 100644 index 00000000..96c5599f --- /dev/null +++ b/core/agents/listenbrainz/auth_router.go @@ -0,0 +1,119 @@ +package listenbrainz + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type sessionKeysRepo interface { + Put(ctx context.Context, userId, sessionKey string) error + Get(ctx context.Context, userId string) (string, error) + Delete(ctx context.Context, userId string) error +} + +type Router struct { + http.Handler + ds model.DataStore + sessionKeys sessionKeysRepo + client *Client +} + +func NewRouter(ds model.DataStore) *Router { + r := &Router{ + ds: ds, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + r.Handler = r.routes() + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + r.client = NewClient(hc) + return r +} + +func (s *Router) routes() http.Handler { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(server.Authenticator(s.ds)) + r.Use(server.JWTRefresher) + + r.Get("/link", s.getLinkStatus) + r.Put("/link", s.link) + r.Delete("/link", s.unlink) + }) + + return r +} + +func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{} + u, _ := request.UserFrom(r.Context()) + key, err := s.sessionKeys.Get(r.Context(), u.ID) + if err != nil && err != model.ErrNotFound { + resp["error"] = err + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) + return + } + resp["status"] = key != "" + _ = rest.RespondWithJSON(w, http.StatusOK, resp) +} + +func (s *Router) link(w http.ResponseWriter, r *http.Request) { + type tokenPayload struct { + Token string `json:"token"` + } + var payload tokenPayload + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if payload.Token == "" { + _ = rest.RespondWithError(w, http.StatusBadRequest, "Token is required") + return + } + + u, _ := request.UserFrom(r.Context()) + resp, err := s.client.ValidateToken(r.Context(), payload.Token) + if err != nil { + log.Error(r.Context(), "Could not validate ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + if !resp.Valid { + _ = rest.RespondWithError(w, http.StatusBadRequest, "Invalid token") + return + } + + err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token) + 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()) + return + } + + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName}) +} + +func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + err := s.sessionKeys.Delete(r.Context(), u.ID) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + } else { + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{}) + } +} diff --git a/core/agents/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go new file mode 100644 index 00000000..5eb164c9 --- /dev/null +++ b/core/agents/listenbrainz/auth_router_test.go @@ -0,0 +1,130 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("ListenBrainz Auth Router", func() { + var sk *fakeSessionKeys + var httpClient *tests.FakeHttpClient + var r Router + var req *http.Request + var resp *httptest.ResponseRecorder + + BeforeEach(func() { + sk = &fakeSessionKeys{KeyName: sessionKeyProperty} + httpClient = &tests.FakeHttpClient{} + cl := NewClient(httpClient) + r = Router{ + sessionKeys: sk, + client: cl, + } + resp = httptest.NewRecorder() + }) + + Describe("getLinkStatus", func() { + It("returns false when there is no stored session key", func() { + req = httptest.NewRequest("GET", "/listenbrainz/link", nil) + r.getLinkStatus(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + var parsed map[string]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["status"]).To(Equal(false)) + }) + + It("returns true when there is a stored session key", func() { + sk.KeyValue = "sk-1" + req = httptest.NewRequest("GET", "/listenbrainz/link", nil) + r.getLinkStatus(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + var parsed map[string]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["status"]).To(Equal(true)) + }) + }) + + Describe("link", func() { + It("returns bad request when no token is sent", func() { + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns bad request when the token is invalid", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token invalid.", "valid": false}`)), + StatusCode: 200, + } + + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "invalid-tok-1"}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns true and the username when the token is valid", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + var parsed map[string]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["status"]).To(Equal(true)) + Expect(parsed["user"]).To(Equal("ListenBrainzUser")) + }) + + It("saves the session key when the token is valid", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + Expect(sk.KeyValue).To(Equal("tok-1")) + }) + }) + + Describe("unlink", func() { + It("removes the session key when unlinking", func() { + sk.KeyValue = "tok-1" + req = httptest.NewRequest("DELETE", "/listenbrainz/link", nil) + r.unlink(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + Expect(sk.KeyValue).To(Equal("")) + }) + }) +}) + +type fakeSessionKeys struct { + KeyName string + KeyValue string +} + +func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error { + sk.KeyValue = sessionKey + return nil +} + +func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) { + return sk.KeyValue, nil +} + +func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error { + sk.KeyValue = "" + return nil +} diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go new file mode 100644 index 00000000..dbb0941c --- /dev/null +++ b/core/agents/listenbrainz/client.go @@ -0,0 +1,160 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/navidrome/navidrome/log" +) + +const ( + apiBaseUrl = "https://api.listenbrainz.org/1/" +) + +type listenBrainzError struct { + Code int + Message string +} + +func (e *listenBrainzError) Error() string { + return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message) +} + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +func NewClient(hc httpDoer) *Client { + return &Client{hc} +} + +type Client struct { + hc httpDoer +} + +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"` +} + +type listenBrainzRequest struct { + ApiKey string + Body listenBrainzRequestBody +} + +type listenBrainzRequestBody struct { + ListenType listenType `json:"listen_type,omitempty"` + Payload []listenInfo `json:"payload,omitempty"` +} + +type listenType string + +const ( + Single listenType = "single" + PlayingNow listenType = "playing_now" +) + +type listenInfo struct { + ListenedAt int `json:"listened_at,omitempty"` + TrackMetadata trackMetadata `json:"track_metadata,omitempty"` +} + +type trackMetadata struct { + ArtistName string `json:"artist_name,omitempty"` + TrackName string `json:"track_name,omitempty"` + ReleaseName string `json:"release_name,omitempty"` + AdditionalInfo additionalInfo `json:"additional_info,omitempty"` +} + +type additionalInfo struct { + TrackNumber int `json:"tracknumber,omitempty"` + TrackMbzID string `json:"track_mbid,omitempty"` + ArtistMbzIDs []string `json:"artist_mbids,omitempty"` + ReleaseMbID string `json:"release_mbid,omitempty"` +} + +func (c *Client) ValidateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) { + r := &listenBrainzRequest{ + ApiKey: apiKey, + } + response, err := c.makeRequest(http.MethodGet, "validate-token", r) + if err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) UpdateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error { + r := &listenBrainzRequest{ + ApiKey: apiKey, + Body: listenBrainzRequestBody{ + ListenType: PlayingNow, + Payload: []listenInfo{li}, + }, + } + + resp, err := c.makeRequest(http.MethodPost, "submit-listens", r) + if err != nil { + return err + } + if resp.Status != "ok" { + log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status) + } + return nil +} + +func (c *Client) Scrobble(ctx context.Context, apiKey string, li listenInfo) error { + r := &listenBrainzRequest{ + ApiKey: apiKey, + Body: listenBrainzRequestBody{ + ListenType: Single, + Payload: []listenInfo{li}, + }, + } + resp, err := c.makeRequest(http.MethodPost, "submit-listens", r) + if err != nil { + return err + } + if resp.Status != "ok" { + log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status) + } + return nil +} + +func (c *Client) makeRequest(method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { + b, _ := json.Marshal(r.Body) + req, _ := http.NewRequest(method, apiBaseUrl+endpoint, bytes.NewBuffer(b)) + + if r.ApiKey != "" { + req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey)) + } + + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response listenBrainzResponse + jsonErr := decoder.Decode(&response) + if resp.StatusCode != 200 && jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + if jsonErr != nil { + return nil, jsonErr + } + if response.Code != 0 && response.Code != 200 { + return &response, &listenBrainzError{Code: response.Code, Message: response.Error} + } + + return &response, nil +} diff --git a/core/agents/listenbrainz/client_test.go b/core/agents/listenbrainz/client_test.go new file mode 100644 index 00000000..daeeca3b --- /dev/null +++ b/core/agents/listenbrainz/client_test.go @@ -0,0 +1,115 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "os" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Client", func() { + var httpClient *tests.FakeHttpClient + var client *Client + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client = NewClient(httpClient) + }) + + Describe("listenBrainzResponse", func() { + It("parses a response properly", func() { + var response listenBrainzResponse + err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.Code).To(Equal(200)) + Expect(response.Message).To(Equal("Message")) + Expect(response.UserName).To(Equal("UserName")) + Expect(response.Valid).To(BeTrue()) + Expect(response.Status).To(Equal("ok")) + Expect(response.Error).To(Equal("Error")) + }) + }) + + Describe("ValidateToken", func() { + BeforeEach(func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + }) + + It("formats the request properly", func() { + _, err := client.ValidateToken(context.Background(), "LB-TOKEN") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "validate-token")) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + }) + + It("parses and returns the response", func() { + res, err := client.ValidateToken(context.Background(), "LB-TOKEN") + Expect(err).ToNot(HaveOccurred()) + Expect(res.Valid).To(Equal(true)) + Expect(res.UserName).To(Equal("ListenBrainzUser")) + }) + }) + + Context("with listenInfo", func() { + var li listenInfo + BeforeEach(func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), + StatusCode: 200, + } + li = listenInfo{ + TrackMetadata: trackMetadata{ + ArtistName: "Track Artist", + TrackName: "Track Title", + ReleaseName: "Track Album", + AdditionalInfo: additionalInfo{ + TrackNumber: 1, + TrackMbzID: "mbz-123", + ArtistMbzIDs: []string{"mbz-789"}, + ReleaseMbID: "mbz-456", + }, + }, + } + }) + + Describe("UpdateNowPlaying", func() { + It("formats the request properly", func() { + Expect(client.UpdateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "submit-listens")) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json") + Expect(body).To(MatchJSON(f)) + }) + }) + + Describe("Scrobble", func() { + BeforeEach(func() { + li.ListenedAt = 1635000000 + }) + + It("formats the request properly", func() { + Expect(client.Scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "submit-listens")) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json") + Expect(body).To(MatchJSON(f)) + }) + }) + }) +}) diff --git a/core/agents/listenbrainz/listenbrainz_suite_test.go b/core/agents/listenbrainz/listenbrainz_suite_test.go new file mode 100644 index 00000000..3710f81b --- /dev/null +++ b/core/agents/listenbrainz/listenbrainz_suite_test.go @@ -0,0 +1,17 @@ +package listenbrainz + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestListenBrainz(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "ListenBrainz Test Suite") +} diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go new file mode 100644 index 00000000..cea6005f --- /dev/null +++ b/core/agents/session_keys.go @@ -0,0 +1,25 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/model" +) + +// SessionKeys is a simple wrapper around the UserPropsRepository +type SessionKeys struct { + model.DataStore + KeyName string +} + +func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { + return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey) +} + +func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) { + return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName) +} + +func (sk *SessionKeys) Delete(ctx context.Context, userId string) error { + return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName) +} diff --git a/core/agents/session_keys_test.go b/core/agents/session_keys_test.go new file mode 100644 index 00000000..84f44066 --- /dev/null +++ b/core/agents/session_keys_test.go @@ -0,0 +1,37 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("SessionKeys", func() { + ctx := context.Background() + user := model.User{ID: "u-1"} + ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}} + sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"} + + It("uses the assigned key name", func() { + Expect(sk.KeyName).To(Equal("fakeSessionKey")) + }) + It("stores a value in the DB", func() { + Expect(sk.Put(ctx, user.ID, "test-stored-value")).To(BeNil()) + }) + It("fetches the stored value", func() { + value, err := sk.Get(ctx, user.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal("test-stored-value")) + }) + It("deletes the stored value", func() { + Expect(sk.Delete(ctx, user.ID)).To(BeNil()) + }) + It("handles a not found value", func() { + _, err := sk.Get(ctx, "u-2") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/core/external_metadata.go b/core/external_metadata.go index d15fb8ea..f3569637 100644 --- a/core/external_metadata.go +++ b/core/external_metadata.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" _ "github.com/navidrome/navidrome/core/agents/lastfm" + _ "github.com/navidrome/navidrome/core/agents/listenbrainz" _ "github.com/navidrome/navidrome/core/agents/spotify" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index f83ac725..4400ccd8 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/ReneKroon/ttlcache/v2" "github.com/navidrome/navidrome/log" @@ -85,6 +86,10 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) return } + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } // TODO Parallelize for name, s := range p.scrobblers { if !s.IsAuthorized(ctx, userId) { @@ -94,7 +99,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra err := s.NowPlaying(ctx, userId, t) if err != nil { log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) - return + continue } } } @@ -138,7 +143,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID) log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username) if player.ScrobbleEnabled { - _ = p.dispatchScrobble(ctx, mf, s.Timestamp) + p.dispatchScrobble(ctx, mf, s.Timestamp) } } } @@ -164,7 +169,11 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times }) } -func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) error { +func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } u, _ := request.UserFrom(ctx) scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime} for name, s := range p.scrobblers { @@ -172,17 +181,16 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, continue } if conf.Server.DevEnableBufferedScrobble { - log.Debug(ctx, "Buffering scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) + log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) } else { - log.Debug(ctx, "Sending scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) + log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) } err := s.Scrobble(ctx, u.ID, scrobble) if err != nil { log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) - return err + continue } } - return nil } var constructors map[string]Constructor diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0b42d207..97b5813c 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -78,6 +79,14 @@ var _ = Describe("PlayTracker", func() { err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + Expect(err).ToNot(HaveOccurred()) + Expect(fake.NowPlayingCalled).To(BeFalse()) + }) + It("does not send track to agent if artist is unknown", func() { + track.Artist = consts.UnknownArtist + + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + Expect(err).ToNot(HaveOccurred()) Expect(fake.NowPlayingCalled).To(BeFalse()) }) @@ -146,7 +155,7 @@ var _ = Describe("PlayTracker", func() { Expect(fake.ScrobbleCalled).To(BeFalse()) }) - It("does not send track to agent player is not enabled to send scrobbles", func() { + It("does not send track to agent if player is not enabled to send scrobbles", func() { ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) @@ -155,6 +164,15 @@ var _ = Describe("PlayTracker", func() { Expect(fake.ScrobbleCalled).To(BeFalse()) }) + It("does not send track to agent if artist is unknown", func() { + track.Artist = consts.UnknownArtist + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled).To(BeFalse()) + }) + It("increments play counts even if it cannot scrobble", func() { fake.Error = errors.New("error") diff --git a/server/serve_index.go b/server/serve_index.go index 26abc346..e2f8a5ca 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -49,6 +49,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { "lastFMEnabled": conf.Server.LastFM.Enabled, "lastFMApiKey": conf.Server.LastFM.ApiKey, "devShowArtistPage": conf.Server.DevShowArtistPage, + "devListenBrainzEnabled": conf.Server.DevListenBrainzEnabled, } auth := handleLoginFromHeaders(ds, r) if auth != nil { diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e303dbf6..6b04e62b 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -254,6 +254,7 @@ var _ = Describe("serveIndex", func() { config := extractAppConfig(w.Body.String()) Expect(config).To(HaveKeyWithValue("lastFMApiKey", "APIKEY-123")) }) + It("sets the devShowArtistPage", func() { conf.Server.DevShowArtistPage = true r := httptest.NewRequest("GET", "/index.html", nil) @@ -265,6 +266,16 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("devShowArtistPage", true)) }) + It("sets the devListenBrainzEnabled", func() { + conf.Server.DevListenBrainzEnabled = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("devListenBrainzEnabled", true)) + }) }) var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__="([^"]*)`) diff --git a/tests/fixtures/listenbrainz.nowplaying.request.json b/tests/fixtures/listenbrainz.nowplaying.request.json new file mode 100644 index 00000000..6dec0ed6 --- /dev/null +++ b/tests/fixtures/listenbrainz.nowplaying.request.json @@ -0,0 +1 @@ + {"listen_type": "playing_now", "payload": [{"track_metadata": { "artist_name": "Track Artist", "track_name": "Track Title", "release_name": "Track Album", "additional_info": { "tracknumber": 1, "track_mbid": "mbz-123", "artist_mbids": ["mbz-789"], "release_mbid": "mbz-456"}}}]} diff --git a/tests/fixtures/listenbrainz.scrobble.request.json b/tests/fixtures/listenbrainz.scrobble.request.json new file mode 100644 index 00000000..58c8b839 --- /dev/null +++ b/tests/fixtures/listenbrainz.scrobble.request.json @@ -0,0 +1 @@ + {"listen_type": "single", "payload": [{"listened_at": 1635000000, "track_metadata": { "artist_name": "Track Artist", "track_name": "Track Title", "release_name": "Track Album", "additional_info": { "tracknumber": 1, "track_mbid": "mbz-123", "artist_mbids": ["mbz-789"], "release_mbid": "mbz-456"}}}]} diff --git a/ui/src/App.js b/ui/src/App.js index 118cdc8a..82b90720 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -20,6 +20,7 @@ import { themeReducer, addToPlaylistDialogReducer, expandInfoDialogReducer, + listenBrainzTokenDialogReducer, playerReducer, albumViewReducer, activityReducer, @@ -54,6 +55,7 @@ const App = () => ( theme: themeReducer, addToPlaylistDialog: addToPlaylistDialogReducer, expandInfoDialog: expandInfoDialogReducer, + listenBrainzTokenDialog: listenBrainzTokenDialogReducer, activity: activityReducer, settings: settingsReducer, }, diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js index 10243c45..8feb4475 100644 --- a/ui/src/actions/dialogs.js +++ b/ui/src/actions/dialogs.js @@ -4,6 +4,8 @@ export const DUPLICATE_SONG_WARNING_OPEN = 'DUPLICATE_SONG_WARNING_OPEN' export const DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE' export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN' export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE' +export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN' +export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE' export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({ type: ADD_TO_PLAYLIST_OPEN, @@ -34,3 +36,11 @@ export const openExtendedInfoDialog = (record) => { export const closeExtendedInfoDialog = () => ({ type: EXTENDED_INFO_CLOSE, }) + +export const openListenBrainzTokenDialog = () => ({ + type: LISTENBRAINZ_TOKEN_OPEN, +}) + +export const closeListenBrainzTokenDialog = () => ({ + type: LISTENBRAINZ_TOKEN_CLOSE, +}) diff --git a/ui/src/config.js b/ui/src/config.js index a3ea44f0..965f26ae 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -25,6 +25,7 @@ const defaultConfig = { lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e', enableCoverAnimation: true, devShowArtistPage: true, + devListenBrainzEnabled: true, } let config diff --git a/ui/src/dialogs/ListenBrainzTokenDialog.js b/ui/src/dialogs/ListenBrainzTokenDialog.js new file mode 100644 index 00000000..6ea6000b --- /dev/null +++ b/ui/src/dialogs/ListenBrainzTokenDialog.js @@ -0,0 +1,138 @@ +import React, { createRef, useCallback, useState } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, + Link, + TextField, +} from '@material-ui/core' +import { useNotify, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { closeListenBrainzTokenDialog } from '../actions' +import { httpClient } from '../dataProvider' + +export const ListenBrainzTokenDialog = ({ setLinked }) => { + const dispatch = useDispatch() + const notify = useNotify() + const translate = useTranslate() + const { open } = useSelector((state) => state.listenBrainzTokenDialog) + const [token, setToken] = useState('') + const [checking, setChecking] = useState(false) + const inputRef = createRef() + + const handleChange = (event) => { + setToken(event.target.value) + } + + const handleLinkClick = (event) => { + inputRef.current.focus() + } + + const handleSave = useCallback( + (event) => { + setChecking(true) + httpClient('/api/listenbrainz/link', { + method: 'PUT', + body: JSON.stringify({ token: token }), + }) + .then((response) => { + notify('message.listenBrainzLinkSuccess', 'success', { + user: response.json.user, + }) + setLinked(true) + setToken('') + }) + .catch((error) => { + notify('message.listenBrainzLinkFailure', 'warning', { + error: error.body?.error || error.message, + }) + setLinked(false) + }) + .finally(() => { + setChecking(false) + dispatch(closeListenBrainzTokenDialog()) + event.stopPropagation() + }) + }, + [dispatch, notify, setLinked, token] + ) + + const handleClickClose = (event) => { + if (!checking) { + dispatch(closeListenBrainzTokenDialog()) + event.stopPropagation() + } + } + + const handleKeyPress = useCallback( + (event) => { + if (event.key === 'Enter' && token !== '') { + handleSave(event) + } + }, + [token, handleSave] + ) + + return ( + <> + + + ListenBrainz + + + + {translate('resources.user.message.listenBrainzToken')}{' '} + + {translate('resources.user.message.clickHereForToken')} + + + + {checking && } + + + + + + + + ) +} diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 79c43130..7eb98c3c 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -2,3 +2,4 @@ export * from './AboutDialog' export * from './AddToPlaylistDialog' export * from './SelectPlaylistInput' export * from './HelpDialog' +export * from './ListenBrainzTokenDialog' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 3d615744..d4f143b7 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -95,7 +95,8 @@ "createdAt": "Created at", "changePassword": "Change Password?", "currentPassword": "Current Password", - "newPassword": "New Password" + "newPassword": "New Password", + "token": "Token" }, "helperTexts": { "name": "Changes to your name will only be reflected on next login" @@ -104,6 +105,10 @@ "created": "User created", "updated": "User updated", "deleted": "User deleted" + }, + "message": { + "listenBrainzToken": "Enter your ListenBrainz user token.", + "clickHereForToken": "Click here to get your token" } }, "player": { @@ -116,7 +121,7 @@ "userName": "Username", "lastSeen": "Last Seen At", "reportRealPath": "Report Real Path", - "scrobbleEnabled": "Send Scrobbles to Last.fm" + "scrobbleEnabled": "Send Scrobbles to external services" } }, "transcoding": { @@ -306,7 +311,11 @@ "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", "lastfmLinkFailure": "Last.fm could not be linked", "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", - "lastfmUnlinkFailure": "Last.fm could not unlinked", + "lastfmUnlinkFailure": "Last.fm could not be unlinked", + "listenBrainzLinkSuccess": "ListenBrainz successfully linked and scrobbling enabled as user: %{user}", + "listenBrainzLinkFailure": "ListenBrainz could not be linked: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz unlinked and scrobbling disabled", + "listenBrainzUnlinkFailure": "ListenBrainz could not be unlinked", "openIn": { "lastfm": "Open in Last.fm", "musicbrainz": "Open in MusicBrainz" @@ -325,7 +334,8 @@ "language": "Language", "defaultView": "Default View", "desktop_notifications": "Desktop Notifications", - "lastfmScrobbling": "Scrobble to Last.fm" + "lastfmScrobbling": "Scrobble to Last.fm", + "listenBrainzScrobbling": "Scrobble to ListenBrainz" } }, "albumList": "Albums", diff --git a/ui/src/personal/ListenBrainzScrobbleToggle.js b/ui/src/personal/ListenBrainzScrobbleToggle.js new file mode 100644 index 00000000..72703523 --- /dev/null +++ b/ui/src/personal/ListenBrainzScrobbleToggle.js @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import { useNotify, useTranslate } from 'react-admin' +import { FormControl, FormControlLabel, Switch } from '@material-ui/core' +import { httpClient } from '../dataProvider' +import { ListenBrainzTokenDialog } from '../dialogs' +import { useDispatch } from 'react-redux' +import { openListenBrainzTokenDialog } from '../actions' + +export const ListenBrainzScrobbleToggle = () => { + const dispatch = useDispatch() + const notify = useNotify() + const translate = useTranslate() + const [linked, setLinked] = useState(null) + + const toggleScrobble = () => { + if (linked) { + httpClient('/api/listenbrainz/link', { method: 'DELETE' }) + .then(() => { + setLinked(false) + notify('message.listenBrainzUnlinkSuccess', 'success') + }) + .catch(() => notify('message.listenBrainzUnlinkFailure', 'warning')) + } else { + dispatch(openListenBrainzTokenDialog()) + } + } + + useEffect(() => { + httpClient('/api/listenbrainz/link') + .then((response) => { + setLinked(response.json.status === true) + }) + .catch(() => { + setLinked(false) + }) + }, []) + + return ( + <> + + + } + label={ + + {translate('menu.personal.options.listenBrainzScrobbling')} + + } + /> + + + + ) +} diff --git a/ui/src/personal/Personal.js b/ui/src/personal/Personal.js index 07f4e4c9..7bee5dbb 100644 --- a/ui/src/personal/Personal.js +++ b/ui/src/personal/Personal.js @@ -6,6 +6,7 @@ import { SelectTheme } from './SelectTheme' import { SelectDefaultView } from './SelectDefaultView' import { NotificationsToggle } from './NotificationsToggle' import { LastfmScrobbleToggle } from './LastfmScrobbleToggle' +import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle' import config from '../config' const useStyles = makeStyles({ @@ -25,6 +26,7 @@ const Personal = () => { {config.lastFMEnabled && } + {config.devListenBrainzEnabled && } ) diff --git a/ui/src/player/PlayerEdit.js b/ui/src/player/PlayerEdit.js index ea709ea9..58a695cb 100644 --- a/ui/src/player/PlayerEdit.js +++ b/ui/src/player/PlayerEdit.js @@ -48,7 +48,7 @@ const PlayerEdit = (props) => ( ]} /> - {config.lastFMEnabled && ( + {(config.lastFMEnabled || config.devListenBrainzEnabled) && ( )} diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js index f51007bb..ea95248f 100644 --- a/ui/src/reducers/dialogReducer.js +++ b/ui/src/reducers/dialogReducer.js @@ -5,6 +5,8 @@ import { DUPLICATE_SONG_WARNING_CLOSE, EXTENDED_INFO_OPEN, EXTENDED_INFO_CLOSE, + LISTENBRAINZ_TOKEN_OPEN, + LISTENBRAINZ_TOKEN_CLOSE, } from '../actions' export const addToPlaylistDialogReducer = ( @@ -61,3 +63,26 @@ export const expandInfoDialogReducer = ( return previousState } } + +export const listenBrainzTokenDialogReducer = ( + previousState = { + open: false, + }, + payload +) => { + const { type } = payload + switch (type) { + case LISTENBRAINZ_TOKEN_OPEN: + return { + ...previousState, + open: true, + } + case LISTENBRAINZ_TOKEN_CLOSE: + return { + ...previousState, + open: false, + } + default: + return previousState + } +}