Skip to content

Commit bad77f3

Browse files
JaragonCRclaude
authored andcommitted
fix: skip tracks Spotify refuses an audio key for instead of freezing
Spotify makes a per-track, context-dependent decision on granting the legacy AES audio key. License-gated tracks are refused (AesKeyError, e.g. code 1) during ordinary playlist/transfer playback even though they play on official clients, which establish a licensed context. go-librespot only implements the legacy key path, so a refused track currently freezes the player with no way forward. Treat a refused key (audio.KeyProviderError) like restricted/unsupported media: skip forward to the first playable track. Covers all load paths — loadContext, transfer/cast (via a shared loadCurrentTrackOrSkip helper), and advanceNext — and bounds the forward walk with consecutiveUnplayableSkips (cap 50) so a fully-gated or RepeatingContext context advances to the first playable track instead of looping forever. This is a mitigation; the real fix is implementing the modern PlayPlay license flow. Tracked separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 40ffa1f commit bad77f3

2 files changed

Lines changed: 61 additions & 10 deletions

File tree

daemon/controls.go

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
librespot "github.com/devgianlu/go-librespot"
16+
"github.com/devgianlu/go-librespot/audio"
1617
"github.com/devgianlu/go-librespot/mpris"
1718
"github.com/devgianlu/go-librespot/player"
1819
connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate"
@@ -290,14 +291,35 @@ func (p *AppPlayer) loadContext(ctx context.Context, spotCtx *connectpb.Context,
290291
p.state.player.NextTracks = ctxTracks.NextTracks(ctx, nil)
291292
p.state.player.Index = ctxTracks.Index()
292293

293-
// load current track into stream
294-
if err := p.loadCurrentTrack(ctx, paused, drop); err != nil {
294+
// load current track into stream — skip forward if it (or a run of tracks) is unplayable.
295+
if err := p.loadCurrentTrackOrSkip(ctx, paused, drop); err != nil {
295296
return fmt.Errorf("failed loading current track (load context): %w", err)
296297
}
297298

298299
return nil
299300
}
300301

302+
// loadCurrentTrackOrSkip loads the current track; if it is unplayable (restricted/unsupported,
303+
// or Spotify refused its audio key), it advances forward to the first playable track instead of
304+
// returning the error — so a transfer/cast/context-load that lands on a refused track does not
305+
// freeze the player. advanceNext walks through a run of unplayable tracks (bounded). Non-
306+
// skippable failures and "ran out of tracks" are returned as-is.
307+
func (p *AppPlayer) loadCurrentTrackOrSkip(ctx context.Context, paused, drop bool) error {
308+
err := p.loadCurrentTrack(ctx, paused, drop)
309+
if err == nil {
310+
return nil
311+
}
312+
var keyErr *audio.KeyProviderError
313+
if errors.Is(err, librespot.ErrMediaRestricted) || errors.Is(err, librespot.ErrNoSupportedFormats) || errors.As(err, &keyErr) {
314+
p.app.log.WithError(err).Warnf("current track unplayable, skipping forward: %s", p.state.player.Track.Uri)
315+
if _, aerr := p.advanceNext(ctx, true, drop); aerr != nil {
316+
return fmt.Errorf("failed advancing past unplayable track: %w", aerr)
317+
}
318+
return nil
319+
}
320+
return err
321+
}
322+
301323
func (p *AppPlayer) loadCurrentTrack(ctx context.Context, paused, drop bool) error {
302324
if p.primaryStream != nil {
303325
p.sess.Events().OnPrimaryStreamUnload(p.primaryStream, p.player.PositionMs())
@@ -620,6 +642,10 @@ func (p *AppPlayer) skipNext(ctx context.Context, track *connectpb.ContextTrack)
620642
}
621643
}
622644

645+
// maxConsecutiveUnplayableSkips caps how many refused/restricted tracks advanceNext will skip
646+
// past in a row before stopping, so a fully-gated context can't loop forever.
647+
const maxConsecutiveUnplayableSkips = 50
648+
623649
func (p *AppPlayer) advanceNext(ctx context.Context, forceNext, drop bool) (bool, error) {
624650
var uri string
625651
var hasNextTrack bool
@@ -696,19 +722,37 @@ func (p *AppPlayer) advanceNext(ctx context.Context, forceNext, drop bool) (bool
696722
p.state.player.IsBuffering = false
697723
}
698724

699-
// load current track into stream
700-
if err := p.loadCurrentTrack(ctx, !hasNextTrack, drop); errors.Is(err, librespot.ErrMediaRestricted) || errors.Is(err, librespot.ErrNoSupportedFormats) {
701-
p.app.log.WithError(err).Infof("skipping unplayable media: %s", uri)
702-
if forceNext {
703-
// we failed in finding another track to play, just stop
704-
return false, err
725+
// load current track into stream.
726+
//
727+
// BAND-AID: Spotify makes a per-track, context-dependent decision on granting the legacy
728+
// AES audio key. License-gated tracks are refused (AesKeyError, e.g. code 1) in ordinary
729+
// playlist playback — even though they play on official clients, which establish a licensed
730+
// context. We cannot decrypt a refused track, so skip it instead of freezing the player.
731+
// Remove once proper key licensing (PlayPlay) is implemented — tracked separately.
732+
var keyErr *audio.KeyProviderError
733+
if err := p.loadCurrentTrack(ctx, !hasNextTrack, drop); errors.Is(err, librespot.ErrMediaRestricted) || errors.Is(err, librespot.ErrNoSupportedFormats) || errors.As(err, &keyErr) {
734+
if keyErr != nil {
735+
p.app.log.WithError(err).Warnf("skipping track: Spotify refused the audio key (code %d) for this playback context: %s", keyErr.Code, uri)
736+
} else {
737+
p.app.log.WithError(err).Infof("skipping unplayable media: %s", uri)
705738
}
706739

740+
// Walk forward through a run of unplayable tracks (a context whose first — or several —
741+
// tracks are refused), bounded so a fully gated or RepeatingContext context advances to
742+
// the first playable track instead of freezing, and can never recurse forever.
743+
p.consecutiveUnplayableSkips++
744+
if p.consecutiveUnplayableSkips > maxConsecutiveUnplayableSkips {
745+
p.app.log.WithError(err).Warnf("stopping after %d consecutive unplayable tracks", p.consecutiveUnplayableSkips)
746+
p.consecutiveUnplayableSkips = 0
747+
return false, err
748+
}
707749
return p.advanceNext(ctx, true, drop)
708750
} else if err != nil {
751+
p.consecutiveUnplayableSkips = 0
709752
return false, fmt.Errorf("failed loading current track (advance to %s): %w", uri, err)
710753
}
711754

755+
p.consecutiveUnplayableSkips = 0
712756
return hasNextTrack, nil
713757
}
714758

daemon/player.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ type AppPlayer struct {
5555
secondaryStream *player.Stream
5656

5757
prefetchTimer *time.Timer
58+
59+
// consecutiveUnplayableSkips bounds how many unplayable tracks in a row advanceNext will
60+
// skip past (Spotify-refused audio keys / restricted media) before giving up — so a run
61+
// of refused tracks (even at the very start of a context) advances to the first playable
62+
// one instead of freezing, and can never loop forever. Reset to 0 on any successful load.
63+
consecutiveUnplayableSkips int
5864
}
5965

6066
func (p *AppPlayer) playbackReady() bool {
@@ -271,8 +277,9 @@ func (p *AppPlayer) handlePlayerCommand(ctx context.Context, req dealer.RequestP
271277
p.state.player.NextTracks = ctxTracks.NextTracks(ctx, nil)
272278
p.state.player.Index = ctxTracks.Index()
273279

274-
// load current track into stream
275-
if err := p.loadCurrentTrack(ctx, pause, true); err != nil {
280+
// load current track into stream — skip forward if the transferred track is unplayable
281+
// (Spotify refused its key / restricted), so a cast onto a refused track doesn't freeze.
282+
if err := p.loadCurrentTrackOrSkip(ctx, pause, true); err != nil {
276283
return fmt.Errorf("failed loading current track (transfer): %w", err)
277284
}
278285

0 commit comments

Comments
 (0)