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
This commit is contained in:
Steve Richter 2021-10-30 12:17:42 -04:00 committed by GitHub
parent ccc871d1f7
commit a56d5bc850
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1214 additions and 54 deletions

View File

@ -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 {

View File

@ -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 (

View File

@ -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

View File

@ -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) {

View File

@ -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 != ""
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
})
}
})
}

View File

@ -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))
})
})
})

View File

@ -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{})
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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))
})
})
})
})

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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))
})
})

View File

@ -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"

View File

@ -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

View File

@ -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")

View File

@ -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 {

View File

@ -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__="([^"]*)`)

View File

@ -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"}}}]}

View File

@ -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"}}}]}

View File

@ -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,
},

View File

@ -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,
})

View File

@ -25,6 +25,7 @@ const defaultConfig = {
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
enableCoverAnimation: true,
devShowArtistPage: true,
devListenBrainzEnabled: true,
}
let config

View File

@ -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 (
<>
<Dialog
open={open}
onClose={handleClickClose}
onBackdropClick={handleClickClose}
aria-labelledby="form-dialog-listenbrainz-token"
fullWidth={true}
maxWidth="md"
>
<DialogTitle id="form-dialog-listenbrainz-token">
ListenBrainz
</DialogTitle>
<DialogContent>
<DialogContentText>
{translate('resources.user.message.listenBrainzToken')}{' '}
<Link
href="https://listenbrainz.org/profile/"
onClick={handleLinkClick}
target="_blank"
>
{translate('resources.user.message.clickHereForToken')}
</Link>
</DialogContentText>
<TextField
value={token}
onKeyPress={handleKeyPress}
onChange={handleChange}
disabled={checking}
required
autoFocus
fullWidth={true}
variant={'outlined'}
label={translate('resources.user.fields.token')}
inputRef={inputRef}
/>
{checking && <LinearProgress />}
</DialogContent>
<DialogActions>
<Button
onClick={handleClickClose}
disabled={checking}
color="primary"
>
{translate('ra.action.cancel')}
</Button>
<Button
onClick={handleSave}
disabled={checking || token === ''}
color="primary"
data-testid="listenbrainz-token-save"
>
{translate('ra.action.save')}
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -2,3 +2,4 @@ export * from './AboutDialog'
export * from './AddToPlaylistDialog'
export * from './SelectPlaylistInput'
export * from './HelpDialog'
export * from './ListenBrainzTokenDialog'

View File

@ -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",

View File

@ -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 (
<>
<FormControl>
<FormControlLabel
control={
<Switch
id={'listenbrainz'}
color="primary"
checked={linked === true}
disabled={linked === null}
onChange={toggleScrobble}
/>
}
label={
<span>
{translate('menu.personal.options.listenBrainzScrobbling')}
</span>
}
/>
</FormControl>
<ListenBrainzTokenDialog setLinked={setLinked} />
</>
)
}

View File

@ -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 = () => {
<SelectDefaultView />
<NotificationsToggle />
{config.lastFMEnabled && <LastfmScrobbleToggle />}
{config.devListenBrainzEnabled && <ListenBrainzScrobbleToggle />}
</SimpleForm>
</Card>
)

View File

@ -48,7 +48,7 @@ const PlayerEdit = (props) => (
]}
/>
<BooleanInput source="reportRealPath" fullWidth />
{config.lastFMEnabled && (
{(config.lastFMEnabled || config.devListenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
<TextField source="client" />

View File

@ -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
}
}