feat(plugins): add PlaybackReport to scrobbler capability (#5452)

* 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 != "" return err == nil && sk != ""
} }
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() { func init() {
conf.AddHook(func() { conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { 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 return songs, nil
} }
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() { func init() {
conf.AddHook(func() { conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled { if conf.Server.ListenBrainz.Enabled {

View file

@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob
return nil 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() { func (b *bufferedScrobbler) sendWakeSignal() {
// Don't block if the previous signal was not read yet // Don't block if the previous signal was not read yet
select { select {

View file

@ -23,6 +23,7 @@ type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool IsAuthorized(ctx context.Context, userId string) bool
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
Scrobble(ctx context.Context, userId string, s Scrobble) error Scrobble(ctx context.Context, userId string, s Scrobble) error
PlaybackReport(ctx context.Context, info PlaybackSession) error
} }
type Constructor func(ds model.DataStore) Scrobbler 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" StatePlaying = "playing"
StatePaused = "paused" StatePaused = "paused"
StateStopped = "stopped" StateStopped = "stopped"
StateExpired = "expired"
) )
var ValidStates = map[string]bool{ var ValidStates = map[string]bool{
@ -31,9 +32,10 @@ var ValidStates = map[string]bool{
StateStopped: true, StateStopped: true,
} }
type NowPlayingInfo struct { type PlaybackSession struct {
MediaFile model.MediaFile MediaFile model.MediaFile
Start time.Time Start time.Time
UserId string
Username string Username string
PlayerId string PlayerId string
PlayerName string PlayerName string
@ -65,8 +67,13 @@ type nowPlayingEntry struct {
position int position int
} }
type playbackReportEntry struct {
ctx context.Context
info PlaybackSession
}
type PlayTracker interface { type PlayTracker interface {
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error)
Submit(ctx context.Context, submissions []Submission) error Submit(ctx context.Context, submissions []Submission) error
ReportPlayback(ctx context.Context, params ReportPlaybackParams) error ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
} }
@ -81,7 +88,7 @@ type PluginLoader interface {
type playTracker struct { type playTracker struct {
ds model.DataStore ds model.DataStore
broker events.Broker broker events.Broker
playMap cache.SimpleCache[string, NowPlayingInfo] playMap cache.SimpleCache[string, PlaybackSession]
builtinScrobblers map[string]Scrobbler builtinScrobblers map[string]Scrobbler
pluginScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader pluginLoader PluginLoader
@ -91,6 +98,10 @@ type playTracker struct {
npSignal chan struct{} npSignal chan struct{}
shutdown chan struct{} shutdown chan struct{}
workerDone 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 { 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 { func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]() m := cache.NewSimpleCache[string, PlaybackSession]()
p := &playTracker{ p := &playTracker{
ds: ds, ds: ds,
playMap: m, playMap: m,
@ -118,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
npSignal: make(chan struct{}, 1), npSignal: make(chan struct{}, 1),
shutdown: make(chan struct{}), shutdown: make(chan struct{}),
workerDone: make(chan struct{}), workerDone: make(chan struct{}),
prSignal: make(chan struct{}, 1),
prWorkerDone: make(chan struct{}),
} }
if conf.Server.EnableNowPlaying { enableNowPlaying := conf.Server.EnableNowPlaying
m.OnExpiration(func(_ string, _ NowPlayingInfo) { 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()}) 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 var enabled []string
for name, constructor := range constructors { 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) log.Debug("List of builtin scrobblers enabled", "names", enabled)
go p.nowPlayingWorker() go p.nowPlayingWorker()
go p.playbackReportWorker()
return p return p
} }
// stopNowPlayingWorker stops the background worker. This is primarily for testing. // stopBackgroundWorkers stops the background workers. This is primarily for testing.
func (p *playTracker) stopNowPlayingWorker() { func (p *playTracker) stopBackgroundWorkers() {
close(p.shutdown) 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. // 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 { if err != nil {
return err return err
} }
info := NowPlayingInfo{ info := PlaybackSession{
MediaFile: *mf, MediaFile: *mf,
Start: now, Start: now,
UserId: user.ID,
Username: user.UserName, Username: user.UserName,
PlayerId: clientId, PlayerId: clientId,
PlayerName: client, 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)) err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
if err != nil { 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: case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId) info, getErr := p.playMap.Get(clientId)
@ -270,9 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if err != nil { if err != nil {
return err return err
} }
info = NowPlayingInfo{ info = PlaybackSession{
MediaFile: *mf, MediaFile: *mf,
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
UserId: user.ID,
Username: user.UserName, Username: user.UserName,
PlayerId: clientId, PlayerId: clientId,
PlayerName: client, PlayerName: client,
@ -286,17 +314,21 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
if params.State == StatePlaying { if params.State == StatePlaying {
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) 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) err := p.playMap.AddWithTTL(clientId, info, ttl)
if err != nil { 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: case StateStopped:
var loadedMF *model.MediaFile
if !params.IgnoreScrobble && player.ScrobbleEnabled { if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil { if err != nil {
return err return err
} }
loadedMF = mf
trackDurationMs := int64(mf.Duration * 1000) trackDurationMs := int64(mf.Duration * 1000)
threshold := min(trackDurationMs*50/100, 240_000) threshold := min(trackDurationMs*50/100, 240_000)
if params.PositionMs >= threshold { if params.PositionMs >= threshold {
@ -307,6 +339,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
p.dispatchScrobble(ctx, mf, now) 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) p.playMap.Remove(clientId)
} }
@ -324,77 +381,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
return nil return nil
} }
func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
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) {
res := p.playMap.Values() 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) return b.Start.Compare(a.Start)
}) })
for i := range res { for i := range res {

View file

@ -48,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
var _ = Describe("PlayTracker", func() { var _ = Describe("PlayTracker", func() {
var ctx context.Context var ctx context.Context
var ds model.DataStore var ds model.DataStore
var tracker PlayTracker var tracker *playTracker
var eventBroker *fakeEventBroker var eventBroker *fakeEventBroker
var track model.MediaFile var track model.MediaFile
var album model.Album var album model.Album
@ -71,7 +71,7 @@ var _ = Describe("PlayTracker", func() {
}) })
eventBroker = &fakeEventBroker{} eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil) 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{ track = model.MediaFile{
ID: "123", ID: "123",
@ -96,12 +96,12 @@ var _ = Describe("PlayTracker", func() {
AfterEach(func() { AfterEach(func() {
// Stop the worker goroutine to prevent data races between tests // Stop the worker goroutine to prevent data races between tests
tracker.(*playTracker).stopNowPlayingWorker() tracker.stopBackgroundWorkers()
}) })
It("does not register disabled scrobblers", func() { It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) Expect(tracker.builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
}) })
Describe("GetNowPlaying", func() { Describe("GetNowPlaying", func() {
@ -138,8 +138,8 @@ var _ = Describe("PlayTracker", func() {
Describe("Expiration events", func() { Describe("Expiration events", func() {
It("sends event when entry expires", func() { It("sends event when entry expires", func() {
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond)
Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0))
eventList := eventBroker.getEvents() eventList := eventBroker.getEvents()
evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount)
@ -150,10 +150,48 @@ var _ = Describe("PlayTracker", func() {
It("does not send event when disabled", func() { It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil) tracker = newPlayTracker(ds, eventBroker, nil)
info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"}
_ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond)
Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) 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() { Describe("Submit", func() {
@ -375,7 +413,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() { BeforeEach(func() {
eventBroker = &fakeEventBroker{} eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil) tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake tracker.builtinScrobblers["fake"] = fake
}) })
It("broadcasts NowPlayingCount on every state change", func() { 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() { It("does NOT broadcast when EnableNowPlaying is false", func() {
conf.Server.EnableNowPlaying = false conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil) tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake tracker.builtinScrobblers["fake"] = fake
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, 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()) 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() { Describe("Plugin scrobbler logic", func() {
@ -712,8 +840,8 @@ var _ = Describe("PlayTracker", func() {
tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader)
// Bypass buffering for both built-in and plugin scrobblers // Bypass buffering for both built-in and plugin scrobblers
tracker.(*playTracker).builtinScrobblers["fake"] = fake tracker.builtinScrobblers["fake"] = fake
tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake tracker.pluginScrobblers["plugin1"] = pluginFake
}) })
It("registers and uses plugin scrobbler for NowPlaying", func() { It("registers and uses plugin scrobbler for NowPlaying", func() {
@ -830,7 +958,7 @@ var _ = Describe("PlayTracker", func() {
}) })
AfterEach(func() { AfterEach(func() {
pTracker.stopNowPlayingWorker() pTracker.stopBackgroundWorkers()
}) })
It("uses the new plugin instance after reload (simulating config update)", func() { It("uses the new plugin instance after reload (simulating config update)", func() {
@ -940,11 +1068,13 @@ type fakeScrobbler struct {
Authorized bool Authorized bool
nowPlayingCalled atomic.Bool nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool ScrobbleCalled atomic.Bool
PlaybackReportCalled atomic.Bool
userID atomic.Pointer[string] userID atomic.Pointer[string]
username atomic.Pointer[string] username atomic.Pointer[string]
track atomic.Pointer[model.MediaFile] track atomic.Pointer[model.MediaFile]
position atomic.Int32 position atomic.Int32
LastScrobble atomic.Pointer[Scrobble] LastScrobble atomic.Pointer[Scrobble]
LastPlaybackReport atomic.Pointer[PlaybackSession]
Error error Error error
} }
@ -998,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble)
return nil 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 { func _p(id, name string, sortName ...string) model.Participant {
p := model.Participant{Artist: model.Artist{ID: id, Name: name}} p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
if len(sortName) > 0 { 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 { func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
return m.wrapped.Scrobble(ctx, userId, s) 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. // ListenBrainz, or custom scrobbling backends.
// //
// All methods are required - plugins implementing this capability must provide // 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 //nd:capability name=scrobbler required=true
type Scrobbler interface { type Scrobbler interface {
@ -20,6 +20,10 @@ type Scrobbler interface {
// Scrobble submits a completed scrobble to the scrobbling service. // Scrobble submits a completed scrobble to the scrobbling service.
//nd:export name=nd_scrobbler_scrobble //nd:export name=nd_scrobbler_scrobble
Scrobble(ScrobbleRequest) error 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. // IsAuthorizedRequest is the request for authorization check.
@ -96,6 +100,26 @@ type ScrobbleRequest struct {
Timestamp int64 `json:"timestamp"` 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. // ScrobblerError represents an error type for scrobbling operations.
type ScrobblerError string type ScrobblerError string

View file

@ -18,6 +18,11 @@ exports:
input: input:
$ref: '#/components/schemas/ScrobbleRequest' $ref: '#/components/schemas/ScrobbleRequest'
contentType: application/json 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: components:
schemas: schemas:
ArtistRef: ArtistRef:
@ -59,6 +64,45 @@ components:
- username - username
- track - track
- position - 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: ScrobbleRequest:
description: ScrobbleRequest is the request for submitting a scrobble. description: ScrobbleRequest is the request for submitting a scrobble.
properties: properties:

View file

@ -52,6 +52,26 @@ type NowPlayingRequest struct {
Position int32 `json:"position"` 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. // ScrobbleRequest is the request for submitting a scrobble.
type ScrobbleRequest struct { type ScrobbleRequest struct {
// Username is the username of the user. // Username is the username of the user.
@ -106,7 +126,7 @@ type TrackInfo struct {
// ListenBrainz, or custom scrobbling backends. // ListenBrainz, or custom scrobbling backends.
// //
// All methods are required - plugins implementing this capability must provide // 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 { type Scrobbler interface {
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
IsAuthorized(IsAuthorizedRequest) (bool, error) IsAuthorized(IsAuthorizedRequest) (bool, error)
@ -114,11 +134,14 @@ type Scrobbler interface {
NowPlaying(NowPlayingRequest) error NowPlaying(NowPlayingRequest) error
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. // Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
Scrobble(ScrobbleRequest) error Scrobble(ScrobbleRequest) error
// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service.
PlaybackReport(PlaybackReportRequest) error
} // Internal implementation holders } // Internal implementation holders
var ( var (
isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) isAuthorizedImpl func(IsAuthorizedRequest) (bool, error)
nowPlayingImpl func(NowPlayingRequest) error nowPlayingImpl func(NowPlayingRequest) error
scrobbleImpl func(ScrobbleRequest) error scrobbleImpl func(ScrobbleRequest) error
playbackReportImpl func(PlaybackReportRequest) error
) )
// Register registers a scrobbler implementation. // Register registers a scrobbler implementation.
@ -127,6 +150,7 @@ func Register(impl Scrobbler) {
isAuthorizedImpl = impl.IsAuthorized isAuthorizedImpl = impl.IsAuthorized
nowPlayingImpl = impl.NowPlaying nowPlayingImpl = impl.NowPlaying
scrobbleImpl = impl.Scrobble scrobbleImpl = impl.Scrobble
playbackReportImpl = impl.PlaybackReport
} }
// NotImplementedCode is the standard return code for unimplemented functions. // NotImplementedCode is the standard return code for unimplemented functions.
@ -201,3 +225,24 @@ func _NdScrobblerScrobble() int32 {
return 0 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"` 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. // ScrobbleRequest is the request for submitting a scrobble.
type ScrobbleRequest struct { type ScrobbleRequest struct {
// Username is the username of the user. // Username is the username of the user.
@ -103,7 +123,7 @@ type TrackInfo struct {
// ListenBrainz, or custom scrobbling backends. // ListenBrainz, or custom scrobbling backends.
// //
// All methods are required - plugins implementing this capability must provide // 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 { type Scrobbler interface {
// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
IsAuthorized(IsAuthorizedRequest) (bool, error) IsAuthorized(IsAuthorizedRequest) (bool, error)
@ -111,6 +131,8 @@ type Scrobbler interface {
NowPlaying(NowPlayingRequest) error NowPlaying(NowPlayingRequest) error
// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. // Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
Scrobble(ScrobbleRequest) error 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. // NotImplementedCode is the standard return code for unimplemented functions.

View file

@ -62,6 +62,35 @@ pub struct NowPlayingRequest {
#[serde(default)] #[serde(default)]
pub position: i32, 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. /// ScrobbleRequest is the request for submitting a scrobble.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -158,7 +187,7 @@ impl Error {
/// ListenBrainz, or custom scrobbling backends. /// ListenBrainz, or custom scrobbling backends.
/// ///
/// All methods are required - plugins implementing this capability must provide /// 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 { pub trait Scrobbler {
/// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. /// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service.
fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error>; fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error>;
@ -166,6 +195,8 @@ pub trait Scrobbler {
fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>; fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>;
/// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. /// Scrobble - Scrobble submits a completed scrobble to the scrobbling service.
fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>; 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. /// Register all exports for the Scrobbler capability.
@ -197,5 +228,13 @@ macro_rules! register_scrobbler {
$crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?; $crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?;
Ok(()) 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 ( import (
"context" "context"
"errors"
"strings" "strings"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
@ -19,6 +20,7 @@ const (
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
FuncScrobblerScrobble = "nd_scrobbler_scrobble" FuncScrobblerScrobble = "nd_scrobbler_scrobble"
FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report"
) )
func init() { func init() {
@ -27,6 +29,7 @@ func init() {
FuncScrobblerIsAuthorized, FuncScrobblerIsAuthorized,
FuncScrobblerNowPlaying, FuncScrobblerNowPlaying,
FuncScrobblerScrobble, 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 // Verify interface implementation at compile time
var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil) 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() { Describe("PluginNames", func() {
It("returns plugin names with Scrobbler capability", func() { It("returns plugin names with Scrobbler capability", func() {
names := scrobblerManager.PluginNames("Scrobbler") names := scrobblerManager.PluginNames("Scrobbler")

View file

@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error {
return nil 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. // checkConfigError checks if the plugin is configured to return an error.
// If "error" config is set, it returns the appropriate ScrobblerError. // If "error" config is set, it returns the appropriate ScrobblerError.
// Error types: "not_authorized", "retry_later", "unrecoverable" // 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 := newResponse()
response.NowPlaying = &responses.NowPlaying{} response.NowPlaying = &responses.NowPlaying{}
var i int32 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++ i++
return responses.NowPlayingEntry{ return responses.NowPlayingEntry{
Child: childFromMediaFile(ctx, np.MediaFile), Child: childFromMediaFile(ctx, np.MediaFile),

View file

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