Repository navigation
feat: report playback on other devices and take it over - #420
Closed
leodurandfr wants to merge 2 commits into
Closed
leodurandfr wants to merge 2 commits into
leodurandfr wants to merge 2 commits into
Conversation
leodurandfr
force-pushed
the
feat/remote-playback
branch
4 times, most recently
from
October 11, 2026 22:07
2dcbad2 to
2b5b7d2
Compare
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>
leodurandfr
force-pushed
the
feat/remote-playback
branch
from
October 16, 2026 08:30
2b5b7d2 to
d58073b
Compare
This was referenced Nov 7, 2026
Owner
|
I have merged #428, I feel like this PR duplicates most of that logic. You may want to double check. |
Contributor
Author
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.
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/playerwould answer the same question; with the daemon's token I observed it answering 429.API
GET /statusgainsremote: the active device (device_id,device_name,device_type),paused, and itstrackin the shape oftrack, with the position as of the request.nullwhen no device is active, this one is, or the active one has nothing loaded.pausedis 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).remoteevent on/eventscarries the same object on every change, andnullwhen this device becomes active. The cluster names no artist, so withmetadata.enabledthe track is resolved through the metadata fetcher and cache, once per track;trackisnulluntil then, and the event is raised again once it is resolved. Withmetadata.enabledoff the daemon makes no request playback does not need, andtrackstaysnull.POST /player/transfertakes 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 forrestore_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 samecommand_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
lastClusterTimestamp, which decides when this device was taken over: each device stamps its state with its own clock.remoteneeds 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. Thetransferhandler kept the playback'sTimestamp, 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
metadata.enabledoff;ErrForbiddenorErrNotFound;/statuswire format withremoteset andnull.command_idwas carried out twice: the player reloaded the session both times, as it did for a new id. Hence one attempt only;POST /player/transferplays here within a second (0.2 to 0.8 s over four transfers), at the other device's position, with its queue;remoteturnsnullwhen this device takes over, and comes back when the phone takes the session back;remoteturnsnull. A phone that loses its network while playing stays active until it reconnects.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.gofmt,go vet, build andgo 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.goand the generated code next to each other; the conflicts are trivial and I'll rebase whichever lands second.🤖 Generated with Claude Code