Skip to content

Confirm a playback action landed before returning the state - #23

Open
jonico wants to merge 1 commit into
jamiew:mainfrom
jonico:fix/playback-state-read-back
Open

Confirm a playback action landed before returning the state#23
jonico wants to merge 1 commit into
jamiew:mainfrom
jonico:fix/playback-state-read-back

Conversation

@jonico

@jonico jonico commented Aug 27, 2026

Copy link
Copy Markdown

Problem

control_playback performs its action and then reads the state back exactly once. Spotify's player writes are asynchronous — PUT /me/player/play and friends answer 204 immediately — so that single read races the change and frequently returns the state from before the action.

Two shapes, both hit repeatedly against the live API:

1. Idle device. current_playback() returns None while the device wakes, which _playback_state maps to is_playing=False with every field empty. A successful play reads back as "nothing is playing":

control_playback(play, context_uri=...)
  -> {"is_playing": false, "track": null, "device": null, "volume": null, "progress_ms": null}
get_playback_state()          # moments later
  -> {"is_playing": true, "track": "Bigger", "device": "PM-...", "progress_ms": 3511}

2. Pre-action state. Enabling shuffle reports shuffle: false; starting a playlist reports the previous track with is_playing: false:

control_playback(shuffle, state=on)   -> shuffle: false      # actually on
control_playback(play, context_uri=…) -> previous track, is_playing: false
get_playback_state()                  -> Silence — Delerium, shuffle: true

A caller can't distinguish either from a genuine failure. The honest reading of a successful call becomes "ignore this return value and poll get_playback_state", which defeats the purpose of returning PlaybackState at all. It also makes the tool misleading to an LLM caller, which will happily report "playback didn't start" to the user.

Fix

Confirm the action rather than trusting one read: poll until an action-specific condition holds, up to 5 attempts 200 ms apart, and return the last state read if it never does.

  • Failure mode is unchanged — a device that never reports the change returns the same state today's code would, rather than hanging or raising.
  • Happy path is still a single request — an already-correct first read returns immediately, so no latency is added to the common case.

Conditions are deliberately conservative rather than clever:

action condition
play is_playing only — with shuffle on, a context need not start on its first track, so the track can't be predicted
pause not is_playing
next / previous track id differs from the outgoing one (these read state once before acting to learn it)
volume / shuffle / repeat matches what was requested
seek a window ahead of the target, since playback keeps advancing — and rejects a position from before the seek

Testing

  • ruff check, ruff format --check, mypy src/, bandit -r src/ all clean
  • pytest: 178 passed (9 new), suite still runs in 0.16s
  • A static mock never satisfies a condition, so an autouse fixture zeroes the delay in tests; the attempt count is left alone so the retry behaviour itself stays under test. Without that the suite went from 0.14s to 4.24s, which is worth knowing if you add player tests later.
  • New tests drive the stale-then-fresh sequences directly, plus the two boundaries that matter: it gives up rather than hanging (returns the unconfirmed state after exactly 5 reads), and it makes no extra reads when already confirmed.

Live-verified against the real API, reproducing each case that failed before:

1. shuffle on          -> returned shuffle=True
2. play playlist ctx   -> returned is_playing=True track='Flaming June'
3. next                -> returned track='Love Stimulation - Love Club Mix'
4. volume 55           -> returned volume=55
5. pause               -> returned is_playing=False
independent check      -> is_playing=False shuffle=True volume=55

Relationship to my other PRs

Independent of #19, #20, #21 and #22 — no shared lines, any merge order works. This one is a bit different in kind: those are withheld-endpoint defects, this is an eventual-consistency race in our own read-back.

control_playback performs its action and then reads the state back exactly once.
Spotify's player writes are asynchronous -- /me/player/play and friends answer 204
immediately -- so that single read races the change and frequently reports the
state from before the action.

Two shapes, both seen repeatedly against the live API:

- current_playback() returns None while an idle device wakes, which _playback_state
  maps to is_playing=False with every field empty. A successful play reads back as
  "nothing is playing".
- current_playback() returns the pre-action state. Enabling shuffle reported
  shuffle=false; starting a playlist reported the previous track and
  is_playing=false.

A caller cannot distinguish either from a genuine failure, so the honest reading of
a successful call is "ignore this return value and poll get_playback_state" -- which
defeats the point of returning PlaybackState at all.

So confirm the action instead of trusting one read: poll until an action-specific
condition holds, up to 5 attempts 200ms apart, and return the last state read if it
never does. That keeps the failure mode identical to today's behaviour rather than
hanging or raising, and the happy path stays a single request -- an already-correct
first read returns immediately.

Conditions are chosen to be reliable rather than clever. play only waits for
is_playing, because with shuffle on a context need not start on its first track, so
the track cannot be predicted. next/previous wait for the track id to differ, which
needs the outgoing id, so those two read the state once before acting. seek accepts
a window ahead of the target since playback keeps advancing, and rejects a position
from before the seek. volume/shuffle/repeat compare against what was requested.

A static mock never satisfies a condition, so an autouse fixture zeroes the delay in
tests; the attempt count is left alone so the retry behaviour stays under test.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant