feat(plugins): add PlaybackReport to scrobbler capability (#5452)
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions

* feat(plugins): add PlaybackReport to Scrobbler interface and all implementations

* feat(plugins): add PlaybackReport worker and dispatch in PlayTracker

* feat(plugins): add PlaybackReportRequest to plugin scrobbler capability

* chore(plugins): regenerate PDK files with PlaybackReport

* feat(plugins): add PlaybackReport to test scrobbler plugin

* feat(plugins): add PlaybackReport to plugin scrobbler adapter

* refactor(plugins): fix double DB fetch in StateStopped and batch getActiveScrobblers

- Hoist mf from scrobble branch so PlaybackReport reuses it instead of
  fetching again from DB
- Call getActiveScrobblers once per drain batch instead of per-entry

* chore(plugins): include generated scrobbler schema with PlaybackReport

* fix(plugins): skip PlaybackReport for plugins that don't export it

Plugins detected as scrobblers only need to export one scrobbler
function. Older plugins that don't export nd_scrobbler_playback_report
would cause noisy error logs on every reportPlayback call. Now
errFunctionNotFound and errNotImplemented are treated as no-ops.

* refactor: rename NowPlayingInfo to PlaybackReport

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename stopNowPlayingWorker to stopBackgroundWorkers

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move NowPlaying and PlaybackReport logic to separate worker files

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(scrobbler): rename NowPlayingInfo to PlaybackSession and add expired state

Rename NowPlayingInfo struct to PlaybackSession to better reflect its role
as a complete playback session representation. Add UserId field to make
sessions self-contained, removing redundant userId parameters from
PlaybackReport interface method and internal dispatch functions. Introduce
StateExpired internal state that fires when a session cache entry expires
without an explicit stop, ensuring plugins always receive a terminal event
regardless of client behavior.

* fix(scrobbler): update playback state description to include 'expired'

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scrobbler): resolve data race in OnExpiration callback

Capture conf.Server.EnableNowPlaying at construction time instead of
reading it from the background ttlcache eviction goroutine. The previous
code raced with test config cleanup that writes to the same field
concurrently.

* fix(scrobbler): return error when media file lookup fails in StateStopped

Simplify the MediaFile population logic in the stopped case to return an
error if the track cannot be found. A stop report with an empty MediaFile
is useless to plugins, and returning the error allows clients to retry
or alert the user when auto-scrobble is enabled.

* refactor(scrobbler): use session data directly in PlaybackReport adapter

Use info.Username from PlaybackSession instead of extracting it from
context in the plugin adapter, since the session is now self-contained.
Add debug/trace logging for session expiration and enqueue the expired
report with a user-enriched context so downstream handlers can identify
the user.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-05-02 16:14:53 -04:00 committed by GitHub
parent 13c48b38a0
commit ae0e0c89d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 680 additions and 120 deletions

View file

@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
return err == nil && sk != ""
}
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {

View file

@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
return songs, nil
}
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {

View file

@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
return nil
}
func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
s, ok := b.loader()
if !ok {
return errors.New("scrobbler not available")
}
return s.PlaybackReport(ctx, info)
}
func (b *bufferedScrobbler) sendWakeSignal() {
// Don't block if the previous signal was not read yet
select {

View file

@ -23,6 +23,7 @@ type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
Scrobble(ctx context.Context, userId string, s Scrobble) error
PlaybackReport(ctx context.Context, info PlaybackSession) error
}
type Constructor func(ds model.DataStore) Scrobbler

View file

@ -0,0 +1,78 @@
package scrobbler
import (
"context"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId,
track: track,
position: position,
}
p.sendNowPlayingSignal()
}
func (p *playTracker) sendNowPlayingSignal() {
// Don't block if the previous signal was not read yet
select {
case p.npSignal <- struct{}{}:
default:
}
}
func (p *playTracker) nowPlayingWorker() {
defer close(p.workerDone)
for {
select {
case <-p.shutdown:
return
case <-time.After(time.Second):
case <-p.npSignal:
}
p.npMu.Lock()
if len(p.npQueue) == 0 {
p.npMu.Unlock()
continue
}
// Keep a copy of the entries to process and clear the queue
entries := p.npQueue
p.npQueue = make(map[string]nowPlayingEntry)
p.npMu.Unlock()
// Process entries without holding lock
for _, entry := range entries {
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
allScrobblers := p.getActiveScrobblers()
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, userId) {
continue
}
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
err := s.NowPlaying(ctx, userId, t, position)
if err != nil {
log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue
}
}
}

View file

@ -22,6 +22,7 @@ const (
StatePlaying = "playing"
StatePaused = "paused"
StateStopped = "stopped"
StateExpired = "expired"
)
var ValidStates = map[string]bool{
@ -31,9 +32,10 @@ var ValidStates = map[string]bool{
StateStopped: true,
}
type NowPlayingInfo struct {
type PlaybackSession struct {
MediaFile model.MediaFile
Start time.Time
UserId string
Username string
PlayerId string
PlayerName string
@ -65,8 +67,13 @@ type nowPlayingEntry struct {
position int
}
type playbackReportEntry struct {
ctx context.Context
info PlaybackSession
}
type PlayTracker interface {
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
Submit(ctx context.Context, submissions []Submission) error
ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
}
@ -81,7 +88,7 @@ type PluginLoader interface {
type playTracker struct {
ds model.DataStore
broker events.Broker
playMap cache.SimpleCache[string, NowPlayingInfo]
playMap cache.SimpleCache[string, PlaybackSession]
builtinScrobblers map[string]Scrobbler
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
@ -91,6 +98,10 @@ type playTracker struct {
npSignal chan struct{}
shutdown chan struct{}
workerDone chan struct{}
prQueue []playbackReportEntry
prMu sync.Mutex
prSignal chan struct{}
prWorkerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@ -106,7 +117,7 @@ func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
}
func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
m := cache.NewSimpleCache[string, PlaybackSession]()
p := &playTracker{
ds: ds,
playMap: m,
@ -118,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}),
workerDone: make(chan struct{}),
prSignal: make(chan struct{}, 1),
prWorkerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
enableNowPlaying := conf.Server.EnableNowPlaying
m.OnExpiration(func(_ string, info PlaybackSession) {
log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state",
info.State, "username", info.Username, "userId", info.UserId)
if enableNowPlaying {
broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
})
}
}
ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username})
if info.State != StateStopped {
log.Trace("Enqueueing PlaybackReport for expired session", "session", info)
info.State = StateExpired
info.LastReport = time.Now()
p.enqueuePlaybackReport(ctx, info)
}
})
var enabled []string
for name, constructor := range constructors {
@ -138,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker()
go p.playbackReportWorker()
return p
}
// stopNowPlayingWorker stops the background worker. This is primarily for testing.
func (p *playTracker) stopNowPlayingWorker() {
// stopBackgroundWorkers stops the background workers. This is primarily for testing.
func (p *playTracker) stopBackgroundWorkers() {
close(p.shutdown)
<-p.workerDone // Wait for worker to finish
<-p.workerDone // Wait for nowPlaying worker to finish
<-p.prWorkerDone // Wait for playbackReport worker to finish
}
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
@ -247,9 +272,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil {
return err
}
info := NowPlayingInfo{
info := PlaybackSession{
MediaFile: *mf,
Start: now,
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
@ -260,8 +286,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
}
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
if err != nil {
log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info)
case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId)
@ -270,9 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil {
return err
}
info = NowPlayingInfo{
info = PlaybackSession{
MediaFile: *mf,
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
@ -286,17 +314,21 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if params.State == StatePlaying {
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
}
log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl)
err := p.playMap.AddWithTTL(clientId, info, ttl)
if err != nil {
log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
p.enqueuePlaybackReport(ctx, info)
case StateStopped:
var loadedMF *model.MediaFile
if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
loadedMF = mf
trackDurationMs := int64(mf.Duration * 1000)
threshold := min(trackDurationMs*50/100, 240_000)
if params.PositionMs >= threshold {
@ -307,6 +339,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
p.dispatchScrobble(ctx, mf, now)
}
}
stoppedInfo := PlaybackSession{
UserId: user.ID,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
if info, getErr := p.playMap.Get(clientId); getErr == nil {
stoppedInfo.MediaFile = info.MediaFile
stoppedInfo.Start = info.Start
} else {
mf := loadedMF
if mf == nil {
var mfErr error
mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if mfErr != nil {
return mfErr
}
}
stoppedInfo.MediaFile = *mf
}
p.enqueuePlaybackReport(ctx, stoppedInfo)
p.playMap.Remove(clientId)
}
@ -324,77 +381,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
return nil
}
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) {
p.npMu.Lock()
defer p.npMu.Unlock()
ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing
p.npQueue[playerId] = nowPlayingEntry{
ctx: ctx,
userId: userId,
track: track,
position: position,
}
p.sendNowPlayingSignal()
}
func (p *playTracker) sendNowPlayingSignal() {
// Don't block if the previous signal was not read yet
select {
case p.npSignal <- struct{}{}:
default:
}
}
func (p *playTracker) nowPlayingWorker() {
defer close(p.workerDone)
for {
select {
case <-p.shutdown:
return
case <-time.After(time.Second):
case <-p.npSignal:
}
p.npMu.Lock()
if len(p.npQueue) == 0 {
p.npMu.Unlock()
continue
}
// Keep a copy of the entries to process and clear the queue
entries := p.npQueue
p.npQueue = make(map[string]nowPlayingEntry)
p.npMu.Unlock()
// Process entries without holding lock
for _, entry := range entries {
p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position)
}
}
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
allScrobblers := p.getActiveScrobblers()
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, userId) {
continue
}
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
err := s.NowPlaying(ctx, userId, t, position)
if err != nil {
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue
}
}
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
res := p.playMap.Values()
slices.SortFunc(res, func(a, b NowPlayingInfo) int {
slices.SortFunc(res, func(a, b PlaybackSession) int {
return b.Start.Compare(a.Start)
})
for i := range res {

View file

@ -48,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
var _ = Describe("PlayTracker", func() {
var ctx context.Context
var ds model.DataStore
var tracker PlayTracker
var tracker *playTracker
var eventBroker *fakeEventBroker
var track model.MediaFile
var album model.Album
@ -71,7 +71,7 @@ var _ = Describe("PlayTracker", func() {
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@ -96,12 +96,12 @@ var _ = Describe("PlayTracker", func() {
AfterEach(func() {
// Stop the worker goroutine to prevent data races between tests
tracker.(*playTracker).stopNowPlayingWorker()
tracker.stopBackgroundWorkers()
})
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("GetNowPlaying", func() {
@ -138,8 +138,8 @@ var _ = Describe("PlayTracker", func() {
Describe("Expiration events", func() {
It("sends event when entry expires", func() {
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
eventList := eventBroker.getEvents()
evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
@ -150,10 +150,48 @@ var _ = Describe("PlayTracker", func() {
It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0))
})
It("sends expired playback report when session expires", func() {
info := PlaybackSession{
MediaFile: track,
Start: time.Now(),
UserId: "u-1",
Username: "user",
PlayerId: "player-3",
PlayerName: "test-player",
State: StatePlaying,
PositionMs: 5000,
}
_ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond)
Eventually(func() *PlaybackSession {
return fake.LastPlaybackReport.Load()
}).ShouldNot(BeNil())
report := fake.LastPlaybackReport.Load()
Expect(report.State).To(Equal(StateExpired))
Expect(report.MediaFile.ID).To(Equal("123"))
Expect(report.PlayerId).To(Equal("player-3"))
})
It("does not send expired report when session was already stopped", func() {
info := PlaybackSession{
MediaFile: track,
Start: time.Now(),
UserId: "u-1",
Username: "user",
PlayerId: "player-4",
PlayerName: "test-player",
State: StateStopped,
PositionMs: 180000,
}
_ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond)
Consistently(func() *PlaybackSession {
return fake.LastPlaybackReport.Load()
}).Should(BeNil())
})
})
Describe("Submit", func() {
@ -375,7 +413,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.builtinScrobblers["fake"] = fake
})
It("broadcasts NowPlayingCount on every state change", func() {
@ -420,7 +458,7 @@ var _ = Describe("PlayTracker", func() {
It("does NOT broadcast when EnableNowPlaying is false", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.builtinScrobblers["fake"] = fake
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
@ -697,6 +735,96 @@ var _ = Describe("PlayTracker", func() {
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
})
Describe("PlaybackReport dispatch", func() {
It("dispatches PlaybackReport for starting state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool {
return fake.PlaybackReportCalled.Load()
}).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info).ToNot(BeNil())
Expect(info.MediaFile.ID).To(Equal("123"))
Expect(info.State).To(Equal(StateStarting))
Expect(info.PositionMs).To(Equal(int64(0)))
Expect(info.PlaybackRate).To(Equal(1.0))
Expect(info.PlayerId).To(Equal("client-1"))
Expect(info.PlayerName).To(Equal("Test Player"))
})
It("dispatches PlaybackReport for playing state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info.State).To(Equal(StatePlaying))
Expect(info.PositionMs).To(Equal(int64(30000)))
Expect(info.PlaybackRate).To(Equal(1.5))
})
It("dispatches PlaybackReport for paused state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info.State).To(Equal(StatePaused))
Expect(info.PositionMs).To(Equal(int64(45000)))
})
It("dispatches PlaybackReport for stopped state", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0,
ClientId: "client-1", ClientName: "Test Player",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
info := fake.LastPlaybackReport.Load()
Expect(info.State).To(Equal(StateStopped))
Expect(info.PositionMs).To(Equal(int64(100000)))
})
})
})
Describe("Plugin scrobbler logic", func() {
@ -712,8 +840,8 @@ var _ = Describe("PlayTracker", func() {
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
tracker.builtinScrobblers["fake"] = fake
tracker.pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
@ -830,7 +958,7 @@ var _ = Describe("PlayTracker", func() {
})
AfterEach(func() {
pTracker.stopNowPlayingWorker()
pTracker.stopBackgroundWorkers()
})
It("uses the new plugin instance after reload (simulating config update)", func() {
@ -937,15 +1065,17 @@ var _ = DescribeTable("remainingTTL",
)
type fakeScrobbler struct {
Authorized bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
Authorized bool
nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
PlaybackReportCalled atomic.Bool
userID atomic.Pointer[string]
username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile]
position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
LastPlaybackReport atomic.Pointer[PlaybackSession]
Error error
}
func (f *fakeScrobbler) GetNowPlayingCalled() bool {
@ -998,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble)
return nil
}
func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
f.PlaybackReportCalled.Store(true)
if f.Error != nil {
return f.Error
}
uid := info.UserId
f.userID.Store(&uid)
f.LastPlaybackReport.Store(&info)
return nil
}
func _p(id, name string, sortName ...string) model.Participant {
p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
if len(sortName) > 0 {
@ -1053,3 +1194,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t
func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
return m.wrapped.Scrobble(ctx, userId, s)
}
func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error {
return m.wrapped.PlaybackReport(ctx, info)
}

View file

@ -0,0 +1,64 @@
package scrobbler
import (
"context"
"github.com/navidrome/navidrome/log"
)
func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) {
p.prMu.Lock()
defer p.prMu.Unlock()
ctx = context.WithoutCancel(ctx)
p.prQueue = append(p.prQueue, playbackReportEntry{
ctx: ctx,
info: info,
})
p.sendPlaybackReportSignal()
}
func (p *playTracker) sendPlaybackReportSignal() {
select {
case p.prSignal <- struct{}{}:
default:
}
}
func (p *playTracker) playbackReportWorker() {
defer close(p.prWorkerDone)
for {
select {
case <-p.shutdown:
return
case <-p.prSignal:
}
p.prMu.Lock()
if len(p.prQueue) == 0 {
p.prMu.Unlock()
continue
}
entries := p.prQueue
p.prQueue = nil
p.prMu.Unlock()
allScrobblers := p.getActiveScrobblers()
for _, entry := range entries {
p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers)
}
}
}
func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) {
for name, s := range allScrobblers {
if !s.IsAuthorized(ctx, info.UserId) {
continue
}
log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs)
err := s.PlaybackReport(ctx, info)
if err != nil {
log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err)
continue
}
}
}

View file

@ -5,7 +5,7 @@ package capabilities
// ListenBrainz, or custom scrobbling backends.
//
// All methods are required - plugins implementing this capability must provide
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
//
//nd:capability name=scrobbler required=true
type Scrobbler interface {
@ -20,6 +20,10 @@ type Scrobbler interface {
// Scrobble submits a completed scrobble to the scrobbling service.
//nd:export name=nd_scrobbler_scrobble
Scrobble(ScrobbleRequest) error
// PlaybackReport sends a playback state report to the scrobbling service.
//nd:export name=nd_scrobbler_playback_report
PlaybackReport(PlaybackReportRequest) error
}
// IsAuthorizedRequest is the request for authorization check.
@ -96,6 +100,26 @@ type ScrobbleRequest struct {
Timestamp int64 `json:"timestamp"`
}
// PlaybackReportRequest is the request for playback report notifications.
type PlaybackReportRequest struct {
// Username is the username of the user.
Username string `json:"username"`
// Track is the track being played.
Track TrackInfo `json:"track"`
// State is the current playback state (starting/playing/paused/stopped/expired).
State string `json:"state"`
// PositionMs is the current playback position in milliseconds.
PositionMs int64 `json:"positionMs"`
// PlaybackRate is the playback speed (1.0 = normal).
PlaybackRate float64 `json:"playbackRate"`
// PlayerId is the unique client identifier.
PlayerId string `json:"playerId"`
// PlayerName is the human-readable player name.
PlayerName string `json:"playerName"`
// Timestamp is the Unix timestamp when this report was generated.
Timestamp int64 `json:"timestamp"`
}
// ScrobblerError represents an error type for scrobbling operations.
type ScrobblerError string

View file

@ -18,6 +18,11 @@ exports:
input:
$ref: '#/components/schemas/ScrobbleRequest'
contentType: application/json
nd_scrobbler_playback_report:
description: PlaybackReport sends a playback state report to the scrobbling service.
input:
$ref: '#/components/schemas/PlaybackReportRequest'
contentType: application/json
components:
schemas:
ArtistRef:
@ -59,6 +64,45 @@ components:
- username
- track
- position
PlaybackReportRequest:
description: PlaybackReportRequest is the request for playback report notifications.
properties:
username:
type: string
description: Username is the username of the user.
track:
$ref: '#/components/schemas/TrackInfo'
description: Track is the track being played.
state:
type: string
description: State is the current playback state (starting/playing/paused/stopped/expired).
positionMs:
type: integer
format: int64
description: PositionMs is the current playback position in milliseconds.
playbackRate:
type: number
format: float
description: PlaybackRate is the playback speed (1.0 = normal).
playerId:
type: string
description: PlayerId is the unique client identifier.
playerName:
type: string
description: PlayerName is the human-readable player name.
timestamp:
type: integer
format: int64
description: Timestamp is the Unix timestamp when this report was generated.
required:
- username
- track
- state
- positionMs
- playbackRate
- playerId
- playerName
- timestamp
ScrobbleRequest:
description: ScrobbleRequest is the request for submitting a scrobble.
properties:

View file

@ -52,6 +52,26 @@ type NowPlayingRequest struct {
Position int32 `json:"position"`
}
// PlaybackReportRequest is the request for playback report notifications.
type PlaybackReportRequest struct {
// Username is the username of the user.
Username string `json:"username"`
// Track is the track being played.
Track TrackInfo `json:"track"`
// State is the current playback state (starting/playing/paused/stopped/expired).
State string `json:"state"`
// PositionMs is the current playback position in milliseconds.
PositionMs int64 `json:"positionMs"`
// PlaybackRate is the playback speed (1.0 = normal).
PlaybackRate float64 `json:"playbackRate"`
// PlayerId is the unique client identifier.
PlayerId string `json:"playerId"`
// PlayerName is the human-readable player name.
PlayerName string `json:"playerName"`
// Timestamp is the Unix timestamp when this report was generated.
Timestamp int64 `json:"timestamp"`
}
// ScrobbleRequest is the request for submitting a scrobble.
type ScrobbleRequest struct {
// Username is the username of the user.
@ -106,7 +126,7 @@ type TrackInfo struct {
// ListenBrainz, or custom scrobbling backends.
//
// All methods are required - plugins implementing this capability must provide
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
type Scrobbler interface {
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
IsAuthorized(IsAuthorizedRequest) (bool, error)
@ -114,11 +134,14 @@ type Scrobbler interface {
NowPlaying(NowPlayingRequest) error
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
Scrobble(ScrobbleRequest) error
// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
PlaybackReport(PlaybackReportRequest) error
} // Internal implementation holders
var (
isAuthorizedImpl func(IsAuthorizedRequest) (bool, error)
nowPlayingImpl func(NowPlayingRequest) error
scrobbleImpl func(ScrobbleRequest) error
isAuthorizedImpl func(IsAuthorizedRequest) (bool, error)
nowPlayingImpl func(NowPlayingRequest) error
scrobbleImpl func(ScrobbleRequest) error
playbackReportImpl func(PlaybackReportRequest) error
)
// Register registers a scrobbler implementation.
@ -127,6 +150,7 @@ func Register(impl Scrobbler) {
isAuthorizedImpl = impl.IsAuthorized
nowPlayingImpl = impl.NowPlaying
scrobbleImpl = impl.Scrobble
playbackReportImpl = impl.PlaybackReport
}
// NotImplementedCode is the standard return code for unimplemented functions.
@ -201,3 +225,24 @@ func _NdScrobblerScrobble() int32 {
return 0
}
//go:wasmexport nd_scrobbler_playback_report
func _NdScrobblerPlaybackReport() int32 {
if playbackReportImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input PlaybackReportRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
if err := playbackReportImpl(input); err != nil {
pdk.SetError(err)
return -1
}
return 0
}

View file

@ -49,6 +49,26 @@ type NowPlayingRequest struct {
Position int32 `json:"position"`
}
// PlaybackReportRequest is the request for playback report notifications.
type PlaybackReportRequest struct {
// Username is the username of the user.
Username string `json:"username"`
// Track is the track being played.
Track TrackInfo `json:"track"`
// State is the current playback state (starting/playing/paused/stopped/expired).
State string `json:"state"`
// PositionMs is the current playback position in milliseconds.
PositionMs int64 `json:"positionMs"`
// PlaybackRate is the playback speed (1.0 = normal).
PlaybackRate float64 `json:"playbackRate"`
// PlayerId is the unique client identifier.
PlayerId string `json:"playerId"`
// PlayerName is the human-readable player name.
PlayerName string `json:"playerName"`
// Timestamp is the Unix timestamp when this report was generated.
Timestamp int64 `json:"timestamp"`
}
// ScrobbleRequest is the request for submitting a scrobble.
type ScrobbleRequest struct {
// Username is the username of the user.
@ -103,7 +123,7 @@ type TrackInfo struct {
// ListenBrainz, or custom scrobbling backends.
//
// All methods are required - plugins implementing this capability must provide
// all three functions: IsAuthorized, NowPlaying, and Scrobble.
// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
type Scrobbler interface {
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
IsAuthorized(IsAuthorizedRequest) (bool, error)
@ -111,6 +131,8 @@ type Scrobbler interface {
NowPlaying(NowPlayingRequest) error
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
Scrobble(ScrobbleRequest) error
// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
PlaybackReport(PlaybackReportRequest) error
}
// NotImplementedCode is the standard return code for unimplemented functions.

View file

@ -62,6 +62,35 @@ pub struct NowPlayingRequest {
#[serde(default)]
pub position: i32,
}
/// PlaybackReportRequest is the request for playback report notifications.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackReportRequest {
/// Username is the username of the user.
#[serde(default)]
pub username: String,
/// Track is the track being played.
#[serde(default)]
pub track: TrackInfo,
/// State is the current playback state (starting/playing/paused/stopped/expired).
#[serde(default)]
pub state: String,
/// PositionMs is the current playback position in milliseconds.
#[serde(default)]
pub position_ms: i64,
/// PlaybackRate is the playback speed (1.0 = normal).
#[serde(default)]
pub playback_rate: f64,
/// PlayerId is the unique client identifier.
#[serde(default)]
pub player_id: String,
/// PlayerName is the human-readable player name.
#[serde(default)]
pub player_name: String,
/// Timestamp is the Unix timestamp when this report was generated.
#[serde(default)]
pub timestamp: i64,
}
/// ScrobbleRequest is the request for submitting a scrobble.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -158,7 +187,7 @@ impl Error {
/// ListenBrainz, or custom scrobbling backends.
///
/// All methods are required - plugins implementing this capability must provide
/// all three functions: IsAuthorized, NowPlaying, and Scrobble.
/// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport.
pub trait Scrobbler {
/// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error>;
@ -166,6 +195,8 @@ pub trait Scrobbler {
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>;
/// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>;
/// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error>;
}
/// Register all exports for the Scrobbler capability.
@ -197,5 +228,13 @@ macro_rules! register_scrobbler {
$crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?;
Ok(())
}
#[extism_pdk::plugin_fn]
pub fn nd_scrobbler_playback_report(
req: extism_pdk::Json<$crate::scrobbler::PlaybackReportRequest>
) -> extism_pdk::FnResult<()> {
let plugin = <$plugin_type>::default();
$crate::scrobbler::Scrobbler::playback_report(&plugin, req.into_inner())?;
Ok(())
}
};
}

View file

@ -2,6 +2,7 @@ package plugins
import (
"context"
"errors"
"strings"
"github.com/navidrome/navidrome/core/scrobbler"
@ -16,9 +17,10 @@ const CapabilityScrobbler Capability = "Scrobbler"
// Scrobbler function names (snake_case as per design)
const (
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
FuncScrobblerScrobble = "nd_scrobbler_scrobble"
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
FuncScrobblerScrobble = "nd_scrobbler_scrobble"
FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report"
)
func init() {
@ -27,6 +29,7 @@ func init() {
FuncScrobblerIsAuthorized,
FuncScrobblerNowPlaying,
FuncScrobblerScrobble,
FuncScrobblerPlaybackReport,
)
}
@ -182,5 +185,25 @@ func mapScrobblerError(err error) error {
}
}
// PlaybackReport sends a playback state report to the scrobbler
func (s *ScrobblerPlugin) PlaybackReport(ctx context.Context, info scrobbler.PlaybackSession) error {
input := capabilities.PlaybackReportRequest{
Username: info.Username,
Track: mediaFileToTrackInfo(s.plugin, &info.MediaFile),
State: info.State,
PositionMs: info.PositionMs,
PlaybackRate: info.PlaybackRate,
PlayerId: info.PlayerId,
PlayerName: info.PlayerName,
Timestamp: info.LastReport.Unix(),
}
err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerPlaybackReport, input)
if errors.Is(err, errFunctionNotFound) || errors.Is(err, errNotImplemented) {
return nil
}
return mapScrobblerError(err)
}
// Verify interface implementation at compile time
var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil)

View file

@ -229,6 +229,62 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
})
})
Describe("PlaybackReport", func() {
It("successfully calls the plugin", func() {
info := scrobbler.PlaybackSession{
MediaFile: model.MediaFile{
ID: "track-1",
Title: "Test Song",
Album: "Test Album",
Artist: "Test Artist",
AlbumArtist: "Test Album Artist",
Duration: 180,
TrackNumber: 1,
DiscNumber: 1,
Participants: model.Participants{
model.RoleArtist: {{Artist: model.Artist{ID: "artist-1", Name: "Test Artist"}}},
model.RoleAlbumArtist: {{Artist: model.Artist{ID: "album-artist-1", Name: "Test Album Artist"}}},
},
},
Username: "testuser",
PlayerId: "player-1",
PlayerName: "Test Player",
State: "playing",
PositionMs: 30000,
PlaybackRate: 1.0,
LastReport: time.Now(),
}
err := s.PlaybackReport(ctxWithUser(), info)
Expect(err).ToNot(HaveOccurred())
})
Context("when plugin returns error", Ordered, func() {
var retryScrobbler scrobbler.Scrobbler
BeforeAll(func() {
mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"},
}, "test-scrobbler"+PackageExtension)
var ok bool
retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler")
Expect(ok).To(BeTrue())
})
It("returns ErrRetryLater", func() {
info := scrobbler.PlaybackSession{
MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
State: "playing",
LastReport: time.Now(),
}
err := retryScrobbler.PlaybackReport(ctxWithUser(), info)
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
})
})
Describe("PluginNames", func() {
It("returns plugin names with Scrobbler capability", func() {
names := scrobblerManager.PluginNames("Scrobbler")

View file

@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error {
return nil
}
// PlaybackReport receives a playback state report.
func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error {
if err := checkConfigError(); err != nil {
return err
}
artistName := ""
if len(input.Track.Artists) > 0 {
artistName = input.Track.Artists[0].Name
}
pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State)
return nil
}
// checkConfigError checks if the plugin is configured to return an error.
// If "error" config is set, it returns the appropriate ScrobblerError.
// Error types: "not_authorized", "retry_later", "unrecoverable"

View file

@ -212,7 +212,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
response := newResponse()
response.NowPlaying = &responses.NowPlaying{}
var i int32
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry {
i++
return responses.NowPlayingEntry{
Child: childFromMediaFile(ctx, np.MediaFile),

View file

@ -193,7 +193,7 @@ type fakePlayTracker struct {
Error error
}
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) {
return nil, f.Error
}