Skip to content

feat: report playback on other devices and take it over - #420

Closed
leodurandfr wants to merge 2 commits into
devgianlu:masterfrom
leodurandfr:feat/remote-playback
Closed

leodurandfr wants to merge 2 commits into
devgianlu:masterfrom
leodurandfr:feat/remote-playback

Conversation

@leodurandfr

@leodurandfr leodurandfr commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Lets a client show what the account plays on another Connect device and bring it here, like the "Playing on iPhone" bar of the Spotify apps.

go-librespot already receives the account's Connect cluster (the active device, what it plays, its position) but only uses it to know when to stop. The Web API's /me/player would answer the same question; with the daemon's token I observed it answering 429.

API

  • GET /status gains remote: the active device (device_id, device_name, device_type), paused, and its track in the shape of track, with the position as of the request. null when no device is active, this one is, or the active one has nothing loaded. paused is true whenever the playhead stands still there (paused, stopped or buffering), so a client extrapolates the position only when it is false; the position follows the playback speed (a podcast played faster).
  • A remote event on /events carries the same object on every change, and null when this device becomes active. The cluster names no artist, so with metadata.enabled the track is resolved through the metadata fetcher and cache, once per track; track is null until then, and the event is raised again once it is resolved. With metadata.enabled off the daemon makes no request playback does not need, and track stays null.
  • POST /player/transfer takes the session over from the active device: its context, track, position, queue and options, through the existing transfer handling. This device sends the transfer request to itself (the path names the sender and the device to play on), and Spotify takes the session from the active device. It asks for restore_paused: "resume", so the session plays here even if it was paused; the Spotify apps default to "restore", which keeps a paused session paused, but a client asking to play here wants it to play. It is sent once (RequestOnce): Spotify carries out every transfer it receives, even one resent with the same command_id (measured below), so a resend after Spotify acted would transfer the session again. A 403 or 404 from Spotify answers 403 or 404, as the library requests do; I have not observed what Spotify answers when no device is active. It does nothing if this device is already active, and answers 204 without a session like the other endpoints.

How

  • The cluster is read from the dealer's updates while this device is inactive, and from the answer to the state push, which is the only cluster a device gets right after connecting.
  • The two arrive on different channels, so a push answer no newer than the last cluster the remote was read from (the player state keeps its timestamp when a device leaves or takes over), or answering a push made while this device was active (it names this device), is not applied: it would bring back a remote that has moved on. Push answers stay out of lastClusterTimestamp, which decides when this device was taken over: each device stamps its state with its own clock.
  • With no active device, or an active id the cluster lists no device for, the cluster still carries the last player state. That is not reported.
  • A cluster re-sent for a change unrelated to playback raises no event.
  • remote needs a session: with zeroconf, the daemon logs out once another device takes the playback over, and reports the remote again once a session is back.

Fix in the first commit

A paused session transferred with restore_paused: "resume" started further on than where it was paused, by the time it spent paused. The transfer handler kept the playback's Timestamp, which is the moment of the pause, and once the session resumed, trackPosition() counted the paused time as played. The first commit anchors a paused transfer at the moment it arrives, on the claim and on the fallback that loads the transfer again when its stream changed while resolving, and again as the track loads, so the time the context takes to resolve does not count either. It stands on its own; it is in this PR because the transfer of the second commit, from a phone that paused, is how it is reached in practice.

Tested

  • Unit tests, each seen failing with its rule removed where it guards one:
    • announced, then named once resolved, with one metadata request per track (a pause or a seek meanwhile asks for nothing more) and none for a track the cache holds; not named, and nothing fetched, with metadata.enabled off;
    • no event on an unrelated cluster change; nothing reported with no active device;
    • late metadata of a previous track dropped;
    • the position anchor, at the playback speed, standing still while paused or buffering;
    • nothing reported while active, forgotten on becoming active, and shown at once when another device takes the playback over;
    • a push answer shows the remote; one older than the dealer's cluster, one with the same timestamp after the device left or another took over, one answering a push made while active, or one seen while active, is dropped; and push answers do not move the timestamp that decides when this device was taken over;
    • the transfer request is sent by this device to itself, asks to resume, is sent once on a 5xx, and does nothing while active; a 403 or 404 maps to ErrForbidden or ErrNotFound;
    • an active id the cluster lists no device for reports no remote;
    • a paused transfer resumes where it paused, the time its context takes to resolve not counted; the transfer anchor keeps a playing session's own;
    • the /status wire format with remote set and null.
  • With the official iOS and macOS apps:
    • play, pause, skip, seek and track end on the other device are all followed;
    • a transfer request sent twice with the same command_id was carried out twice: the player reloaded the session both times, as it did for a new id. Hence one attempt only;
    • POST /player/transfer plays here within a second (0.2 to 0.8 s over four transfers), at the other device's position, with its queue;
    • remote turns null when this device takes over, and comes back when the phone takes the session back;
    • a phone that pauses and goes to the background leaves the cluster, and remote turns null. A phone that loses its network while playing stays active until it reconnects.
  • The fix, with the iOS app paused for about 8 s and then POST /player/transfer: without it, paused at 35.721 s and resumed at 44.097 s; with it, paused at 87.953 s and resumed at 88.023 s.
  • The generated code matches the spec (regenerated with oapi-codegen v2.5.0); gofmt, go vet, build and go test -tags "test_unit test_integration" ./... are clean, for each commit on its own.

This PR and #421 both add to API.md, README.md, api_server.go, api_server_test.go, app.go, player.go and the generated code next to each other; the conflicts are trivial and I'll rebase whichever lands second.

🤖 Generated with Claude Code

leodurandfr and others added 2 commits October 16, 2026 10:25
A paused session transferred with restore_paused "resume" kept the
playback timestamp of the pause, so the position, extrapolated from it as
if playing, counted the time spent paused as played: paused for eight
seconds, the track resumed eight seconds further on. A paused position
does not move, so the anchor is taken at the transfer, on the claim and
on the fallback that loads the transfer again when its stream changed
while resolving; and it is taken again as the track loads, so the time
the context took to resolve does not count either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Connect device of an account is pushed the account's cluster: which
device is active, what it plays, where its playhead is. go-librespot
already reads it to know when to stop; it now keeps what it says about the
other devices and exposes it, so a client can show what plays elsewhere on
the account and bring it here.

- GET /status gains `remote`: the active device (id, name, type), whether
  its playhead stands still (paused, stopped or buffering), and its track
  in the shape of `track`, its position read when the status is, at the
  playback speed. Null when no device is active, this one is, or nothing
  is loaded there. With metadata.enabled the track is resolved through
  the metadata fetcher (the cluster names a title but no artist), once
  per track, and cached like the others; with it off the daemon makes no
  request playback does not need, and the track stays null.
- A `remote` event carries the same object on every change, again once the
  track's metadata is resolved, and null when this device becomes active.
- POST /player/transfer takes the session over from the active device: its
  context, track, position, queue and options, through the transfer
  go-librespot already handles. This device sends it to itself, and
  Spotify takes the session from the active device. It asks for the
  session to resume, so it plays here even if it was paused there, where
  the Spotify apps keep the paused state by default. It is sent once:
  Spotify carries out every transfer it receives, even one resent with
  the same command id, so a resend after Spotify acted would transfer
  the session again. A 403 or 404 from Spotify answers 403 or 404, as
  the library requests do.

The cluster is read from the dealer's updates while inactive and from the
answer to the state push, the only cluster a device that just connected
gets. The two arrive on different channels, so a push answer made while
this device was active, or no newer than the last cluster the remote was
read from, is not applied; and push answers stay out of the timestamp
that decides when this device was taken over, since each device stamps
its state with its own clock. With no active device, or an active id the
cluster lists no device for, the cluster still carries the last player
state, which describes nothing playing anywhere, so it is not reported.

Tested with the official iOS and macOS apps: an inactive daemon follows
every play, pause, skip, seek and track end on the other device, and the
transfer plays here within a second, at the other device's position and
with its queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devgianlu

Copy link
Copy Markdown
Owner

I have merged #428, I feel like this PR duplicates most of that logic. You may want to double check.

@leodurandfr

Copy link
Copy Markdown
Contributor Author

Thanks — #428 covers what this PR did (what plays on another device, and a transfer back to this one), so I'm closing it. If the paused-transfer position issue fixed in this PR's first commit turns out to be reachable through #428's transfer, I'll open that fix on its own.

@leodurandfr leodurandfr closed this Nov 8, 2026
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.

2 participants