package scrobbler
import (
"context"
"errors"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
const (
minRetryDelay = 5 * time.Second
maxRetryDelay = 4 * time.Minute
maxRetryShift = 6
)
func backoffDelay(failures int) time.Duration {
if failures < 0 {
failures = 0
}
if failures >= maxRetryShift {
return maxRetryDelay
}
d := minRetryDelay << failures
if d > maxRetryDelay {
return maxRetryDelay
}
return d
}
type Loader func() (Scrobbler, bool)
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
return newBufferedScrobblerWithLoader(ds, service, func() (Scrobbler, bool) {
return s, true
})
}
func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader Loader) *bufferedScrobbler {
ctx, cancel := context.WithCancel(context.Background())
b := &bufferedScrobbler{
ds: ds,
loader: loader,
service: service,
wakeSignal: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
go b.run(ctx)
return b
}
type bufferedScrobbler struct {
ds model.DataStore
loader Loader
service string
wakeSignal chan struct{}
ctx context.Context
cancel context.CancelFunc
}
func (b *bufferedScrobbler) Stop() {
if b.cancel != nil {
b.cancel()
}
}
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
s, ok := b.loader()
if !ok {
return false
}
return s.IsAuthorized(ctx, userId)
}
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
s, ok := b.loader()
if !ok {
return errors.New("scrobbler not available")
}
return s.NowPlaying(ctx, userId, track, position)
}
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
err := b.ds.ScrobbleBuffer().Enqueue(ctx, b.service, userId, s.ID, s.TimeStamp)
if err != nil {
return err
}
b.sendWakeSignal()
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() {
select {
case b.wakeSignal <- struct{}{}:
default:
}
}
func (b *bufferedScrobbler) run(ctx context.Context) {
timer := time.NewTimer(time.Hour)
timer.Stop()
defer timer.Stop()
failures := 0
backingOff := false
for {
if !backingOff {
if ok, retryIn := b.processQueue(ctx); ok {
failures = 0
timer.Stop()
} else {
timer.Reset(max(backoffDelay(failures), retryIn))
backingOff = true
if failures < maxRetryShift {
failures++
}
}
}
select {
case <-b.wakeSignal:
case <-timer.C:
backingOff = false
case <-ctx.Done():
return
}
}
}
func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) {
buffer := b.ds.ScrobbleBuffer()
userIds, err := buffer.UserIDs(ctx, b.service)
if err != nil {
log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err)
return false, 0
}
result := true
var retryIn time.Duration
for _, userId := range userIds {
ok, d := b.processUserQueue(ctx, userId)
if !ok {
result = false
retryIn = max(retryIn, d)
}
}
return result, retryIn
}
func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) (bool, time.Duration) {
if user, err := b.ds.User().Get(ctx, userId); err != nil {
log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err)
} else {
ctx = request.WithUser(ctx, *user)
}
buffer := b.ds.ScrobbleBuffer()
for {
entry, err := buffer.Next(ctx, b.service, userId)
if err != nil {
log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err)
return false, 0
}
if entry == nil {
return true, 0
}
s, ok := b.loader()
if !ok {
log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service)
return false, 0
}
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
err = s.Scrobble(ctx, entry.UserID, Scrobble{
MediaFile: entry.MediaFile,
TimeStamp: entry.PlayTime,
})
if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok {
log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID,
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
return false, retry.RetryIn
}
if err != nil {
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
}
err = buffer.Dequeue(ctx, entry)
if err != nil {
log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID,
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
return false, 0
}
}
}
var _ Scrobbler = (*bufferedScrobbler)(nil)