Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Webhook scrobbler #2229

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/root.go
Expand Up @@ -96,6 +96,9 @@ func startServer(ctx context.Context) func() error {
core.WriteInitialMetrics()
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
}
for name, hook := range *CreateWebhookRouters() {
a.MountRouter("Webhook "+name+" Auth", consts.URLPathNativeAPI+"/webhook/"+name, hook)
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
}
Expand Down
10 changes: 9 additions & 1 deletion cmd/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions cmd/wire_injectors.go
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/agents/webhook"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
Expand All @@ -30,6 +31,7 @@ var allProviders = wire.NewSet(
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
webhook.NewRouters,
db.Db,
)

Expand Down Expand Up @@ -71,6 +73,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}

func CreateWebhookRouters() *webhook.WebhookRoutes {
panic(wire.Build(
allProviders,
))
}

// Scanner must be a Singleton
var (
onceScanner sync.Once
Expand Down
10 changes: 10 additions & 0 deletions conf/configuration.go
Expand Up @@ -79,6 +79,8 @@ type configOptions struct {
Spotify spotifyOptions
ListenBrainz listenBrainzOptions

Webhooks []WebhookOption

// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogSourceLine bool
DevLogLevels map[string]string
Expand Down Expand Up @@ -122,6 +124,12 @@ type prometheusOptions struct {
MetricsPath string
}

type WebhookOption struct {
Name string
Url string
ApiKey string
}

var (
Server = &configOptions{}
hooks []func()
Expand Down Expand Up @@ -300,6 +308,8 @@ func init() {
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")

viper.SetDefault("webhooks", []WebhookOption{})

// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devautocreateadminpassword", "")
Expand Down
122 changes: 122 additions & 0 deletions core/agents/webhook/agent.go
@@ -0,0 +1,122 @@
package webhook

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 (
webhookBaseAgentName = "webhook-"
sessionBaseKeyProperty = "WebHookSessionKey"
)

type webhookAgent struct {
apiKey string
name string
sessionKeys *agents.SessionKeys
url string
client *client
}

func sessionKey(ds model.DataStore, name string) *agents.SessionKeys {
return &agents.SessionKeys{
DataStore: ds,
KeyName: sessionBaseKeyProperty + name,
}
}

func webhookConstructor(ds model.DataStore, name, url, apiKey string) *webhookAgent {
w := &webhookAgent{
apiKey: apiKey,
name: name,
sessionKeys: sessionKey(ds, name),
url: url,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
w.client = newClient(w.url, w.apiKey, chc)
return w
}

func (w *webhookAgent) AgentName() string {
return webhookBaseAgentName + w.name
}

func (w *webhookAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := w.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}

err = w.client.scrobble(ctx, sk, false, ScrobbleInfo{
artist: track.Artist,
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzTrackID,
duration: int(track.Duration),
albumArtist: track.AlbumArtist,
})

if err != nil {
log.Warn(ctx, "Webhook client.updateNowPlaying returned error", "track", track.Title, "webhook", w.name, err)
return scrobbler.ErrUnrecoverable
}

return nil
}

func (w *webhookAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := w.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}

if s.Duration <= 30 {
log.Debug(ctx, "Skipping webhook scrobble for short song", "track", s.Title, "duration", s.Duration, "webhook", w.name)
return nil
}

err = w.client.scrobble(ctx, sk, true, ScrobbleInfo{
artist: s.Artist,
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzTrackID,
duration: int(s.Duration),
albumArtist: s.AlbumArtist,
timestamp: s.TimeStamp,
})

if err != nil {
log.Warn(ctx, "Webhook client.scrobble returned error", "track", s.Title, "webhook", w.name, err)
return scrobbler.ErrUnrecoverable
}

return nil
}

func (w *webhookAgent) IsAuthorized(ctx context.Context, userId string) bool {
sk, err := w.sessionKeys.Get(ctx, userId)
return err == nil && sk != ""
}

func init() {
conf.AddHook(func() {
for _, webhook := range conf.Server.Webhooks {
scrobbler.Register(webhookBaseAgentName+webhook.Name, func(ds model.DataStore) scrobbler.Scrobbler {
return webhookConstructor(ds, webhook.Name, webhook.Url, webhook.ApiKey)
})
}
})
}
113 changes: 113 additions & 0 deletions core/agents/webhook/agent_test.go
@@ -0,0 +1,113 @@
package webhook

import (
"bytes"
"context"
"io"
"net/http"
"os"
"strconv"
"time"

"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("agent", func() {
var ds model.DataStore
var ctx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
})

Describe("webhookConstructor", func() {
It("Creates an agent", func() {
agent := webhookConstructor(ds, "Service", "BASE_URL", "API_KEY")

Expect(agent.apiKey).To(Equal("API_KEY"))
Expect(agent.name).To(Equal("Service"))
Expect(agent.url).To(Equal("BASE_URL"))
})
})

Describe("scrobbling", func() {
var agent *webhookAgent
var httpClient *tests.FakeHttpClient
var track *model.MediaFile

BeforeEach(func() {
_ = ds.UserProps(ctx).Put("user-1", sessionBaseKeyProperty+"Service", "token")
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", "API_KEY", httpClient)
agent = webhookConstructor(ds, "Service", "BASE_URL", "API_KEY")
agent.client = client
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Album Artist",
TrackNumber: 5,
Duration: 410,
MbzTrackID: "mbz-356",
}
})

testParams := func(isSubmission bool) {
b := strconv.FormatBool(isSubmission)
It("calls scrobble with submission = "+b, func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}

ts := time.Unix(0, 0)

var err error
if isSubmission {
err = agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
} else {
err = agent.NowPlaying(ctx, "user-1", track)
}

Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/scrobble"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))

body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/webhook.scrobble." + b + ".request.json")
Expect(body).To(MatchJSON(f))
})
}

Describe("NowPlaying", func() {
testParams(false)

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() {
testParams(true)

It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})

It("skips songs with less than 31 seconds", func() {
track.Duration = 29
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}

err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})

Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest).To(BeNil())
})
})
})
})