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:
@@ -80,6 +80,9 @@ func startServer() (func() error, func(err error)) {
|
|||||||
if conf.Server.LastFM.Enabled {
|
if conf.Server.LastFM.Enabled {
|
||||||
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
|
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))
|
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||||
}, func(err error) {
|
}, func(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+9
-1
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
"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/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/transcoder"
|
"github.com/navidrome/navidrome/core/transcoder"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
@@ -68,6 +69,13 @@ func CreateLastFMRouter() *lastfm.Router {
|
|||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
|
sqlDB := db.Db()
|
||||||
|
dataStore := persistence.New(sqlDB)
|
||||||
|
router := listenbrainz.NewRouter(dataStore)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
func createScanner() scanner.Scanner {
|
func createScanner() scanner.Scanner {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
@@ -82,7 +90,7 @@ func createScanner() scanner.Scanner {
|
|||||||
|
|
||||||
// wire_injectors.go:
|
// 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
|
// Scanner must be a Singleton
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
@@ -24,6 +25,7 @@ var allProviders = wire.NewSet(
|
|||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
lastfm.NewRouter,
|
lastfm.NewRouter,
|
||||||
|
listenbrainz.NewRouter,
|
||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
db.Db,
|
db.Db,
|
||||||
)
|
)
|
||||||
@@ -54,6 +56,12 @@ func CreateLastFMRouter() *lastfm.Router {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
|
panic(wire.Build(
|
||||||
|
allProviders,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// Scanner must be a Singleton
|
// Scanner must be a Singleton
|
||||||
var (
|
var (
|
||||||
onceScanner sync.Once
|
onceScanner sync.Once
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type configOptions struct {
|
|||||||
DevSidebarPlaylists bool
|
DevSidebarPlaylists bool
|
||||||
DevEnableBufferedScrobble bool
|
DevEnableBufferedScrobble bool
|
||||||
DevShowArtistPage bool
|
DevShowArtistPage bool
|
||||||
|
DevListenBrainzEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
@@ -241,6 +242,7 @@ func init() {
|
|||||||
viper.SetDefault("devenablebufferedscrobble", true)
|
viper.SetDefault("devenablebufferedscrobble", true)
|
||||||
viper.SetDefault("devsidebarplaylists", true)
|
viper.SetDefault("devsidebarplaylists", true)
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
|
viper.SetDefault("devlistenbrainzenabled", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitConfig(cfgFile string) {
|
func InitConfig(cfgFile string) {
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
lastFMAgentName = "lastfm"
|
lastFMAgentName = "lastfm"
|
||||||
|
sessionKeyProperty = "LastFMSessionKey"
|
||||||
)
|
)
|
||||||
|
|
||||||
type lastfmAgent struct {
|
type lastfmAgent struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
sessionKeys *sessionKeys
|
sessionKeys *agents.SessionKeys
|
||||||
apiKey string
|
apiKey string
|
||||||
secret string
|
secret string
|
||||||
lang string
|
lang string
|
||||||
@@ -32,7 +33,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||||||
lang: conf.Server.LastFM.Language,
|
lang: conf.Server.LastFM.Language,
|
||||||
apiKey: conf.Server.LastFM.ApiKey,
|
apiKey: conf.Server.LastFM.ApiKey,
|
||||||
secret: conf.Server.LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &sessionKeys{ds: ds},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
hc := &http.Client{
|
hc := &http.Client{
|
||||||
Timeout: consts.DefaultHttpClientTimeOut,
|
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 {
|
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 == "" {
|
if err != nil || sk == "" {
|
||||||
return scrobbler.ErrNotAuthorized
|
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 {
|
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 == "" {
|
if err != nil || sk == "" {
|
||||||
return scrobbler.ErrNotAuthorized
|
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 {
|
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 != ""
|
return err == nil && sk != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
@@ -25,7 +26,7 @@ var tokenReceivedPage []byte
|
|||||||
type Router struct {
|
type Router struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
sessionKeys *sessionKeys
|
sessionKeys *agents.SessionKeys
|
||||||
client *Client
|
client *Client
|
||||||
apiKey string
|
apiKey string
|
||||||
secret string
|
secret string
|
||||||
@@ -36,7 +37,7 @@ func NewRouter(ds model.DataStore) *Router {
|
|||||||
ds: ds,
|
ds: ds,
|
||||||
apiKey: conf.Server.LastFM.ApiKey,
|
apiKey: conf.Server.LastFM.ApiKey,
|
||||||
secret: conf.Server.LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &sessionKeys{ds: ds},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
hc := &http.Client{
|
hc := &http.Client{
|
||||||
@@ -63,9 +64,9 @@ func (s *Router) routes() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
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())
|
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 {
|
if err != nil && err != model.ErrNotFound {
|
||||||
resp["error"] = err
|
resp["error"] = err
|
||||||
resp["status"] = false
|
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) {
|
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||||
u, _ := request.UserFrom(r.Context())
|
u, _ := request.UserFrom(r.Context())
|
||||||
err := s.sessionKeys.delete(r.Context(), u.ID)
|
err := s.sessionKeys.Delete(r.Context(), u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||||
} else {
|
} else {
|
||||||
@@ -119,7 +120,7 @@ func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
|||||||
"requestId", middleware.GetReqID(ctx), err)
|
"requestId", middleware.GetReqID(ctx), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = s.sessionKeys.put(ctx, uid, sessionKey)
|
err = s.sessionKeys.Put(ctx, uid, sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err)
|
log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
_ "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/core/agents/spotify"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
|
||||||
"github.com/ReneKroon/ttlcache/v2"
|
"github.com/ReneKroon/ttlcache/v2"
|
||||||
"github.com/navidrome/navidrome/log"
|
"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)
|
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||||
return
|
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
|
// TODO Parallelize
|
||||||
for name, s := range p.scrobblers {
|
for name, s := range p.scrobblers {
|
||||||
if !s.IsAuthorized(ctx, userId) {
|
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)
|
err := s.NowPlaying(ctx, userId, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
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)
|
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
|
||||||
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username)
|
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username)
|
||||||
if player.ScrobbleEnabled {
|
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)
|
u, _ := request.UserFrom(ctx)
|
||||||
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
|
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
|
||||||
for name, s := range p.scrobblers {
|
for name, s := range p.scrobblers {
|
||||||
@@ -172,17 +181,16 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if conf.Server.DevEnableBufferedScrobble {
|
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 {
|
} 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)
|
err := s.Scrobble(ctx, u.ID, scrobble)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
|
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
|
var constructors map[string]Constructor
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
@@ -78,6 +79,14 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
|
|
||||||
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
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(err).ToNot(HaveOccurred())
|
||||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
Expect(fake.NowPlayingCalled).To(BeFalse())
|
||||||
})
|
})
|
||||||
@@ -146,7 +155,7 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
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})
|
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
|
||||||
|
|
||||||
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
|
||||||
@@ -155,6 +164,15 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
Expect(fake.ScrobbleCalled).To(BeFalse())
|
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() {
|
It("increments play counts even if it cannot scrobble", func() {
|
||||||
fake.Error = errors.New("error")
|
fake.Error = errors.New("error")
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
|||||||
"lastFMEnabled": conf.Server.LastFM.Enabled,
|
"lastFMEnabled": conf.Server.LastFM.Enabled,
|
||||||
"lastFMApiKey": conf.Server.LastFM.ApiKey,
|
"lastFMApiKey": conf.Server.LastFM.ApiKey,
|
||||||
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
||||||
|
"devListenBrainzEnabled": conf.Server.DevListenBrainzEnabled,
|
||||||
}
|
}
|
||||||
auth := handleLoginFromHeaders(ds, r)
|
auth := handleLoginFromHeaders(ds, r)
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ var _ = Describe("serveIndex", func() {
|
|||||||
config := extractAppConfig(w.Body.String())
|
config := extractAppConfig(w.Body.String())
|
||||||
Expect(config).To(HaveKeyWithValue("lastFMApiKey", "APIKEY-123"))
|
Expect(config).To(HaveKeyWithValue("lastFMApiKey", "APIKEY-123"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("sets the devShowArtistPage", func() {
|
It("sets the devShowArtistPage", func() {
|
||||||
conf.Server.DevShowArtistPage = true
|
conf.Server.DevShowArtistPage = true
|
||||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||||
@@ -265,6 +266,16 @@ var _ = Describe("serveIndex", func() {
|
|||||||
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
|
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__="([^"]*)`)
|
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__="([^"]*)`)
|
||||||
|
|||||||
@@ -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"}}}]}
|
||||||
@@ -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"}}}]}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
themeReducer,
|
themeReducer,
|
||||||
addToPlaylistDialogReducer,
|
addToPlaylistDialogReducer,
|
||||||
expandInfoDialogReducer,
|
expandInfoDialogReducer,
|
||||||
|
listenBrainzTokenDialogReducer,
|
||||||
playerReducer,
|
playerReducer,
|
||||||
albumViewReducer,
|
albumViewReducer,
|
||||||
activityReducer,
|
activityReducer,
|
||||||
@@ -54,6 +55,7 @@ const App = () => (
|
|||||||
theme: themeReducer,
|
theme: themeReducer,
|
||||||
addToPlaylistDialog: addToPlaylistDialogReducer,
|
addToPlaylistDialog: addToPlaylistDialogReducer,
|
||||||
expandInfoDialog: expandInfoDialogReducer,
|
expandInfoDialog: expandInfoDialogReducer,
|
||||||
|
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||||
activity: activityReducer,
|
activity: activityReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE'
|
||||||
export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
||||||
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
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 }) => ({
|
export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({
|
||||||
type: ADD_TO_PLAYLIST_OPEN,
|
type: ADD_TO_PLAYLIST_OPEN,
|
||||||
@@ -34,3 +36,11 @@ export const openExtendedInfoDialog = (record) => {
|
|||||||
export const closeExtendedInfoDialog = () => ({
|
export const closeExtendedInfoDialog = () => ({
|
||||||
type: EXTENDED_INFO_CLOSE,
|
type: EXTENDED_INFO_CLOSE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const openListenBrainzTokenDialog = () => ({
|
||||||
|
type: LISTENBRAINZ_TOKEN_OPEN,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const closeListenBrainzTokenDialog = () => ({
|
||||||
|
type: LISTENBRAINZ_TOKEN_CLOSE,
|
||||||
|
})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const defaultConfig = {
|
|||||||
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
|
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
|
||||||
enableCoverAnimation: true,
|
enableCoverAnimation: true,
|
||||||
devShowArtistPage: true,
|
devShowArtistPage: true,
|
||||||
|
devListenBrainzEnabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
let config
|
let config
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from './AboutDialog'
|
|||||||
export * from './AddToPlaylistDialog'
|
export * from './AddToPlaylistDialog'
|
||||||
export * from './SelectPlaylistInput'
|
export * from './SelectPlaylistInput'
|
||||||
export * from './HelpDialog'
|
export * from './HelpDialog'
|
||||||
|
export * from './ListenBrainzTokenDialog'
|
||||||
|
|||||||
+14
-4
@@ -95,7 +95,8 @@
|
|||||||
"createdAt": "Created at",
|
"createdAt": "Created at",
|
||||||
"changePassword": "Change Password?",
|
"changePassword": "Change Password?",
|
||||||
"currentPassword": "Current Password",
|
"currentPassword": "Current Password",
|
||||||
"newPassword": "New Password"
|
"newPassword": "New Password",
|
||||||
|
"token": "Token"
|
||||||
},
|
},
|
||||||
"helperTexts": {
|
"helperTexts": {
|
||||||
"name": "Changes to your name will only be reflected on next login"
|
"name": "Changes to your name will only be reflected on next login"
|
||||||
@@ -104,6 +105,10 @@
|
|||||||
"created": "User created",
|
"created": "User created",
|
||||||
"updated": "User updated",
|
"updated": "User updated",
|
||||||
"deleted": "User deleted"
|
"deleted": "User deleted"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"listenBrainzToken": "Enter your ListenBrainz user token.",
|
||||||
|
"clickHereForToken": "Click here to get your token"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
@@ -116,7 +121,7 @@
|
|||||||
"userName": "Username",
|
"userName": "Username",
|
||||||
"lastSeen": "Last Seen At",
|
"lastSeen": "Last Seen At",
|
||||||
"reportRealPath": "Report Real Path",
|
"reportRealPath": "Report Real Path",
|
||||||
"scrobbleEnabled": "Send Scrobbles to Last.fm"
|
"scrobbleEnabled": "Send Scrobbles to external services"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transcoding": {
|
"transcoding": {
|
||||||
@@ -306,7 +311,11 @@
|
|||||||
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
|
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
|
||||||
"lastfmLinkFailure": "Last.fm could not be linked",
|
"lastfmLinkFailure": "Last.fm could not be linked",
|
||||||
"lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled",
|
"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": {
|
"openIn": {
|
||||||
"lastfm": "Open in Last.fm",
|
"lastfm": "Open in Last.fm",
|
||||||
"musicbrainz": "Open in MusicBrainz"
|
"musicbrainz": "Open in MusicBrainz"
|
||||||
@@ -325,7 +334,8 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"defaultView": "Default View",
|
"defaultView": "Default View",
|
||||||
"desktop_notifications": "Desktop Notifications",
|
"desktop_notifications": "Desktop Notifications",
|
||||||
"lastfmScrobbling": "Scrobble to Last.fm"
|
"lastfmScrobbling": "Scrobble to Last.fm",
|
||||||
|
"listenBrainzScrobbling": "Scrobble to ListenBrainz"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"albumList": "Albums",
|
"albumList": "Albums",
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { SelectTheme } from './SelectTheme'
|
|||||||
import { SelectDefaultView } from './SelectDefaultView'
|
import { SelectDefaultView } from './SelectDefaultView'
|
||||||
import { NotificationsToggle } from './NotificationsToggle'
|
import { NotificationsToggle } from './NotificationsToggle'
|
||||||
import { LastfmScrobbleToggle } from './LastfmScrobbleToggle'
|
import { LastfmScrobbleToggle } from './LastfmScrobbleToggle'
|
||||||
|
import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@@ -25,6 +26,7 @@ const Personal = () => {
|
|||||||
<SelectDefaultView />
|
<SelectDefaultView />
|
||||||
<NotificationsToggle />
|
<NotificationsToggle />
|
||||||
{config.lastFMEnabled && <LastfmScrobbleToggle />}
|
{config.lastFMEnabled && <LastfmScrobbleToggle />}
|
||||||
|
{config.devListenBrainzEnabled && <ListenBrainzScrobbleToggle />}
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const PlayerEdit = (props) => (
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<BooleanInput source="reportRealPath" fullWidth />
|
<BooleanInput source="reportRealPath" fullWidth />
|
||||||
{config.lastFMEnabled && (
|
{(config.lastFMEnabled || config.devListenBrainzEnabled) && (
|
||||||
<BooleanInput source="scrobbleEnabled" fullWidth />
|
<BooleanInput source="scrobbleEnabled" fullWidth />
|
||||||
)}
|
)}
|
||||||
<TextField source="client" />
|
<TextField source="client" />
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
DUPLICATE_SONG_WARNING_CLOSE,
|
DUPLICATE_SONG_WARNING_CLOSE,
|
||||||
EXTENDED_INFO_OPEN,
|
EXTENDED_INFO_OPEN,
|
||||||
EXTENDED_INFO_CLOSE,
|
EXTENDED_INFO_CLOSE,
|
||||||
|
LISTENBRAINZ_TOKEN_OPEN,
|
||||||
|
LISTENBRAINZ_TOKEN_CLOSE,
|
||||||
} from '../actions'
|
} from '../actions'
|
||||||
|
|
||||||
export const addToPlaylistDialogReducer = (
|
export const addToPlaylistDialogReducer = (
|
||||||
@@ -61,3 +63,26 @@ export const expandInfoDialogReducer = (
|
|||||||
return previousState
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user