Skip to content

Feature/dj clean#296

Open
JaragonCR wants to merge 7 commits intodevgianlu:masterfrom
JaragonCR:feature/dj-clean
Open

Feature/dj clean#296
JaragonCR wants to merge 7 commits intodevgianlu:masterfrom
JaragonCR:feature/dj-clean

Conversation

@JaragonCR
Copy link

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.

JaragonCR and others added 7 commits March 17, 2026 04:56
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>
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