diff --git a/cmd/root.go b/cmd/root.go
index 50c33a09c..238042c5e 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 32048b63c..def3d830d 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 c03d9b804..bc807dc2e 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 53282653c..5ddec29f7 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 7095be5c8..670fc29db 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 e72625f52..1ca763fcb 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 fdf7a1ec8..000000000
--- 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 000000000..bb699a8a1
--- /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 000000000..6f7928311
--- /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 000000000..96c5599f5
--- /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 000000000..5eb164c94
--- /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 000000000..dbb0941ce
--- /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 000000000..daeeca3bf
--- /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 000000000..3710f81bd
--- /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 000000000..cea6005ff
--- /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 000000000..84f440662
--- /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 d15fb8ea5..f3569637e 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 f83ac7257..4400ccd89 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 0b42d2072..97b5813cb 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 26abc346b..e2f8a5ca0 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 e303dbf6a..6b04e62b3 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 000000000..6dec0ed66
--- /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 000000000..58c8b8398
--- /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 118cdc8ae..82b90720c 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 10243c45a..8feb44755 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 a3ea44f04..965f26ae7 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 000000000..6ea6000bf
--- /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 (
+ <>
+
+ >
+ )
+}
diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js
index 79c431304..7eb98c3c2 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 3d6157449..d4f143b75 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 000000000..727035235
--- /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 07f4e4c91..7bee5dbbe 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 ea709ea9c..58a695cbe 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 f51007bbb..ea95248f4 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
+ }
+}