Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions daemon/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"math"
Expand All @@ -25,6 +26,7 @@ import (
"github.com/devgianlu/go-librespot/player"
connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate"
"github.com/devgianlu/go-librespot/session"
"github.com/devgianlu/go-librespot/spclient"
"github.com/devgianlu/go-librespot/tracks"
)

Expand All @@ -39,6 +41,11 @@ type AppPlayer struct {
initialVolumeOnce sync.Once
volumeUpdate chan float32

stateTimer *time.Timer
stateDirty bool
statePutScheduled bool
lastStatePut time.Time

spotConnId string

prodInfo *ProductInfo
Expand Down Expand Up @@ -711,6 +718,9 @@ func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest, mprisRec
volumeTimer := time.NewTimer(time.Minute)
volumeTimer.Stop() // don't emit a volume change event at start

p.stateTimer = time.NewTimer(time.Minute)
p.stateTimer.Stop() // armed on demand by updateState

for {
select {
case <-p.stop:
Expand Down Expand Up @@ -785,6 +795,30 @@ func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest, mprisRec
case <-volumeTimer.C:
// We've gone some time without update, send the new value now.
p.volumeUpdated(ctx)
case <-p.stateTimer.C:
p.statePutScheduled = false
if !p.stateDirty {
break
}
p.flushState(ctx)
}
}
}

// flushState PUTs the latest connect-state and records the send time. On a rate-limit it
// schedules a coalesced resend after the cooldown. Runs on the Run goroutine.
func (p *AppPlayer) flushState(ctx context.Context) {
p.stateDirty = false
p.lastStatePut = time.Now()
if err := p.putConnectState(ctx, connectpb.PutStateReason_PLAYER_STATE_CHANGED); err != nil {
p.app.log.WithError(err).Error("failed put state after update")

// Rate-limited: resend the latest state after the cooldown instead of dropping it.
var rl *spclient.RateLimitedError
if errors.As(err, &rl) {
p.stateDirty = true
p.statePutScheduled = true
p.stateTimer.Reset(rl.RetryAfter)
}
}
}
16 changes: 14 additions & 2 deletions daemon/player_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,22 @@ func (p *AppPlayer) initState() {
p.state.reset()
}

// statePutMinInterval is the minimum spacing between connect-state PUTs.
const statePutMinInterval = 200 * time.Millisecond

// updateState PUTs the latest connect-state, at most one per statePutMinInterval: immediately
// and synchronously when the budget allows, else deferred to the timer so a burst coalesces.
func (p *AppPlayer) updateState(ctx context.Context) {
if err := p.putConnectState(ctx, connectpb.PutStateReason_PLAYER_STATE_CHANGED); err != nil {
p.app.log.WithError(err).Error("failed put state after update")
p.stateDirty = true
if p.statePutScheduled {
return
}
if wait := statePutMinInterval - time.Since(p.lastStatePut); wait > 0 {
p.statePutScheduled = true
p.stateTimer.Reset(wait)
return
}
p.flushState(ctx)
}

func (p *AppPlayer) putConnectState(ctx context.Context, reason connectpb.PutStateReason) error {
Expand Down
43 changes: 42 additions & 1 deletion spclient/spclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,18 @@ func (c *Spclient) PutConnectState(ctx context.Context, spotConnId string, reqPr
return nil, fmt.Errorf("failed reading error response: %w", err)
}
c.log.Debugf("put state request failed with status %d: %s", resp.StatusCode, putError.Message)
return nil, fmt.Errorf("put state request failed with status %d: %s", resp.StatusCode, putError.Message)
reqErr := fmt.Errorf("put state request failed with status %d: %s", resp.StatusCode, putError.Message)

// 4xx isn't transient: retrying (especially a 429) just adds load, and the next
// transition re-sends state anyway. Stop here; a 429 carries a cooldown for a
// coalesced resend. Only 5xx / network errors keep the retry budget.
if resp.StatusCode == http.StatusTooManyRequests {
return nil, backoff.Permanent(&RateLimitedError{RetryAfter: parseRetryAfter(resp.Header), err: reqErr})
}
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, backoff.Permanent(reqErr)
}
return nil, reqErr
} else {
c.log.Debugf("put connect state because %s", reqProto.PutStateReason)
return resp, nil
Expand All @@ -202,6 +213,36 @@ func (c *Spclient) PutConnectState(ctx context.Context, spotConnId string, reqPr
return nil
}

// RateLimitedError reports a connect-state 429; RetryAfter is the advised cooldown.
type RateLimitedError struct {
RetryAfter time.Duration
err error
}

func (e *RateLimitedError) Error() string { return e.err.Error() }
func (e *RateLimitedError) Unwrap() error { return e.err }

// parseRetryAfter reads a Retry-After header (seconds or HTTP-date), with a default fallback.
func parseRetryAfter(h http.Header) time.Duration {
const def = 10 * time.Second
v := h.Get("Retry-After")
if v == "" {
return def
}
if secs, err := strconv.Atoi(v); err == nil {
if secs <= 0 {
return def
}
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return def
}

func (c *Spclient) ResolveStorageInteractive(ctx context.Context, fileId []byte, format *metadatapb.AudioFile_Format, prefetch bool) (*storagepb.StorageResolveResponse, error) {
var path string
if prefetch {
Expand Down
Loading