Open
Conversation
Implements full Spotify DJ mode for go-librespot: - LexiconContextResolve: new spclient method calling /lexicon-session-provider/context-resolve/v2/session to get DJ tracks and full session metadata. Uses reason=state_restore for both fresh-start and transfer paths to obtain complete metadata including playlist_volatile_context_id, lexicon_current_time, session_control_display, and localized_jump_button fields required for "Switch it up" to work. - Fresh DJ start: call lexicon first (state_restore), merge full session metadata, send IsPlaying=false to register the session server-side, then immediately start playing from cached tracks without waiting for a cluster. - Transfer path: use LexiconContextResolve(state_restore) when no cached tracks available, providing 100+ tracks for seamless handover. - Track refill (djPollContextResolve): use LexiconContextResolve(interactive) so tracks no longer run out mid-session. - DJ cluster detection: four signals — featureId=dynamic-sessions, IsDJTrack scan across all NextTracks, djCachedContextUri match, and fresh-start guard (djAwaitingLoad + URI match) for Balena cold-start. - State guards: djAwaitingLoad prevents reload loops; djCacheIsOurs ensures stale phone cache is not used before our device becomes active; 0-track cluster guard prevents cache wipe from Spotify heartbeat clusters. - Skip/forward: skip_next works correctly within DJ sessions. - Mercury event logging for AP channel visibility. - putConnectState debug logging for DJ interactivity fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds support for Spotify DJ mode (featureId=dynamic-sessions / YourDJ):
Infrastructure:
- player/dj.go: IsDJTrack(), NarrationForTrack(), AutomixCueMs(),
NarrationSpotifyId() helpers; DJNarration struct for TTS metadata
- spclient/context_resolver.go: NewStaticContextResolver() for dynamic
contexts whose spclient pages are empty; allPagesEmpty guard so callers
get a clear error instead of a silent no-op
- spclient/spclient.go: LexiconContextResolve() — calls
GET /lexicon-session-provider/context-resolve/v2/session to fetch DJ
tracks and session metadata; used for both fresh-start and transfer paths
- ids.go: ProvidedTrackToContextTrack() to rebuild a track list from
NextTracks received in a ClusterUpdate
- tracks/tracks.go: NewTrackListFromResolver() to create a List from an
already-constructed ContextResolver (avoids a second spclient round-trip)
- player/player.go: Mercury client field + getMercuryTrack() /
NewNarrationStream() for future TTS narration support (narration tracks
are absent from ExtendedMetadata and require the Mercury AP channel)
- mercury/client.go: call startReceiving() in NewClient so Mercury events
that arrive immediately after AP authentication are not dropped; log
PacketTypeMercuryEvent at debug level for AP-channel visibility
Daemon:
- cmd/daemon/main.go: wire Mercury client into Player Options
- cmd/daemon/state.go: debug-log DJ interactivity fields on every
putConnectState call (djEnabled, jumpBtn) for session tracing
- cmd/daemon/api_server.go: minor additions
- cmd/daemon/player.go:
- isDJCluster() detection (featureId, IsDJTrack scan, cached URI match,
djAwaitingLoad + URI match for cold-start)
- djCachedNextTracks / djCachedContextUri / djCacheIsOurs fields to carry
the DJ queue across cluster updates and transfers
- handleTransfer: use LexiconContextResolve(state_restore) when no cached
queue is available, providing 100+ tracks for the session
- djPollContextResolve: periodic refill using LexiconContextResolve so
tracks never run out mid-session
- advanceNext: set djAwaitingLoad + trigger poll when queue is exhausted
- playlist-update handler: start playback when djAwaitingLoad is set and
Spotify pushes a companion playlist for the DJ context
- cmd/daemon/controls.go:
- loadContext DJ path: detect empty-pages error for dynamic-sessions
contexts, call LexiconContextResolve(interactive) for initial tracks,
fall through to NewStaticContextResolver for immediate playback;
djAwaitingLoad fallback if lexicon fails
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lve state_restore Root cause: Spotify only enables the "Switch it up" button on the phone when it receives a putConnectState with IsPlaying=false containing the full DJ session metadata — specifically playlist_volatile_context_id, lexicon_current_time, lexicon_expiration_time, and session_control_display.* fields. These fields are only returned by LexiconContextResolve when called with reason=state_restore (100+ tracks, full metadata); reason=interactive returns only 5 tracks and minimal metadata which Spotify does not recognize as a registered session. Changes to cmd/daemon/controls.go fresh-start path: - Switch LexiconContextResolve reason from "interactive" to "state_restore" - After merging lexicon metadata into ContextMetadata, send an explicit IsPlaying=false putConnectState before starting playback; this is the signal Spotify uses to register the fresh DJ session server-side and subsequently enable "Switch it up" on connected phone clients The transfer path (player.go handleTransfer) already used state_restore and the Switch it up button worked there; this commit brings the fresh-start path to parity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… sessions In persistent/interactive credential sessions Spotify already considers the device permanently registered, so sending IsPlaying=false with state_restore has no effect on "Switch it up" (no new session registration is triggered). Use credential type to select behaviour at fresh DJ start: - zeroconf: LexiconContextResolve(state_restore) + IsPlaying=false pre-registration signal — Spotify creates a new DJ session and enables "Switch it up" on the phone - interactive/other: LexiconContextResolve(interactive) + start playing immediately — no pre-registration needed since the device is already persistently known to Spotify Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Buffer vibe-section playlists (hm://playlist/ pushes received at DJ startup) and pop a fresh section when the lexicon 15-track window runs low, instead of repeating the same 15 state_restore tracks in a loop. Also remove the IsPlaying=false re-registration from advanceNext — it was disrupting the phone's DJ state and causing Switch it up to remain grey even after the queue was refreshed to 15 tracks. The lexicon poll fetches directly via HTTP and does not need a server-push trigger. Result: unlimited Switch it up through ~50 buffered vibe sections before any repetition, and Switch it up no longer goes permanently grey after the first 3 uses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t up
Root cause of the 7-8 Switch it up limit in both zeroconf and interactive
mode: the Spotify server delivers vibe-section playlist pushes through TWO
separate channels simultaneously —
1. The Dealer WebSocket (hm://playlist/v2/playlist/…)
2. The Mercury AP event channel (PacketTypeMercuryEvent, same URI)
go-librespot already subscribed to the Dealer path, so a handful of pushes
were processed. But the overwhelming majority — the initial burst of ~68
sections at DJ startup plus 1-4 new sections pushed every ~30 s as
switch-ups consume the queue — arrived via PacketTypeMercuryEvent.
The Mercury client's receive loop had a hard `continue` after logging each
event, so every AP-channel playlist push was silently discarded. Only the
small subset that happened to duplicate onto the Dealer channel ever reached
the section buffer. With ~6 sections instead of ~68, the buffer ran dry after
roughly 7 switch-ups and djPoll fell back to repeating the same 35 lexicon
tracks, causing the looping the user observed.
Why Mercury is 100% needed
--------------------------
TLS capture of the official Spotify desktop client confirmed that during a
single DJ session with 15 switch-ups it received 100 playlist-push events
across 37 unique vibe-section playlists:
• +11 s — initial burst: 68 pushes (34 unique playlists)
• +97 s onward — continuous trickle: 1-4 new sections every ~30 s,
perfectly correlated with active switch-ups
Zero of these appeared as HTTP/2 calls — the desktop makes no lexicon
requests at switch-up time. It purely navigates a local queue that the server
keeps topped up via Mercury AP events. go-librespot must receive and buffer
these events or the queue will always exhaust after a handful of jumps.
Changes
-------
mercury/client.go
- Add eventSubscriber / EventMessage types and eventSubs list to Client.
- In the PacketTypeMercuryEvent branch of recvLoop(), route events to any
matching subscriber channel (non-blocking, buffered 64) instead of
discarding with `continue`.
- Add SubscribeEvent(uriPrefixes…) method so callers can tap the stream.
cmd/daemon/player.go
- Subscribe to "hm://playlist/v2/playlist/" on the Mercury client at
startup alongside the existing Dealer subscription.
- Add a select case that converts the eventMessage into a dealer.Message
and hands it to the existing handleDealerMessage path — no duplicate
handling code.
- Fix section-buffer gating: remove the ContextUri == djCachedContextUri
guard from the playlist-update handler so sections received during a
context transition (Switch it up briefly loads a regular playlist) are
not silently dropped.
cmd/daemon/controls.go
- Mirror the proactive low-queue djPoll trigger into skipNext's targeted-
skip path. Previously the check only ran inside advanceNext, which is
never called when skip_next carries an explicit target track (the DJ
Switch it up case), so the lexicon refresh was never scheduled and the
queue silently drained to zero before djPoll fired.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brings in all DJ fixes from master: - Mercury AP event routing for unlimited Switch it up - Section buffer fix (unconditional buffering during context transitions) - skipNext low-queue trigger for targeted DJ skips - DJ playlist looping prevention Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced Mar 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Spotify DJ Mode — full Switch it up support
Adds complete DJ mode (YourDJ / Lexicon) support to go-librespot, including unlimited Switch it up, playlist variety, and correct queue management.
What works
DJ session transfer (handover from phone/desktop)
Forward skip and track rotation in DJ mode
Switch it up — unlimited, no greying out
Fresh DJ start with immediate playback
No looping back to the same tracks after queue exhaustion
Root causes fixed
Mercury AP event routing (the critical one): The Spotify server delivers vibe-section playlists through two channels simultaneously — the Dealer WebSocket and the Mercury AP event channel (PacketTypeMercuryEvent). The AP channel carries ~10× more pushes: a burst of ~68 sections at DJ startup plus 1–4 new sections every ~30 s as switch-ups consume the queue. Every one of these was silently dropped by a continue in the Mercury receive loop. With only ~6 sections reaching the buffer instead of 68+, the queue exhausted after ~7 switch-ups and the same 35 tracks looped.
skipNext low-queue trigger: The proactive djPoll check only lived inside advanceNext. Switch it up sends skip_next with an explicit target track, which bypasses advanceNext entirely — so the queue silently drained to zero before any refresh was scheduled.
Section buffer gating: Playlist sections pushed during a context transition (Switch it up briefly loads a regular playlist URI before re-entering DJ) were dropped because the buffer guard checked ContextUri == djCachedContextUri, which was false during the transition.
IsPlaying=false disruption: Setting IsPlaying=false in the low-queue path permanently greyed Switch it up on the phone even after the queue was refreshed.
Confirmed by TLS capture
A full TLS capture of the official Spotify desktop client during 15 switch-ups showed 100 playlist push events across 37 unique vibe sections — zero via HTTP/2, all via Mercury/WebSocket. The desktop makes no lexicon calls at switch time; it navigates a local queue the server keeps topped up via Mercury AP events.