Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,13 @@ The websocket endpoint is available at `/events`. The following events are emitt
- `value`: Whether repeating context is enabled
- `repeat_track`: The player repeating track setting changed
- `value`: Whether repeating track is enabled
- `remote`: What the account plays on another Spotify Connect device changed: the `remote` object of `/status`, or
`null` when there is none (no device is active, this one is, or nothing is loaded there). Raised again once the
remote track is named, which takes `metadata.enabled`.
- `device_id`: The active device ID
- `device_name`: The active device name, as its app reports it
- `device_type`: The active device type, for example `SMARTPHONE` or `COMPUTER`
- `paused`: Whether the playhead stands still on the active device (paused, stopped or buffering); when false,
the position advances from the one given
- `track`: The track playing there, in the shape of the `track` of `/status` (its format fields empty), or `null`
until it is named
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,15 @@ cache:

Optionally, the daemon can cache track metadata (name, artists, cover art) in memory and fetch it — via the same
internal API playback uses, not the rate-limited public Web API — for the tracks around the playback position. This
enables a `next_track` field in `GET /status` and a `GET /context/tracks?uri=...` endpoint that lists any playable
enables a `next_track` field in `GET /status`, names the track of its `remote` (what another device of the account
plays), and enables a `GET /context/tracks?uri=...` endpoint that lists any playable
context (playlist, album, artist) in order with metadata, so a client can render a browsable song list and start any
entry via `POST /player/play` with `skip_to_uri`. Everything is opt-in and disabled by default: a headless speaker has
no use for metadata beyond the playing track and should not pay network requests for it.

```yaml
metadata:
enabled: false # Cache + fetch metadata around the playback position; enables next_track and /context/tracks
enabled: false # Cache + fetch metadata around the playback position; enables next_track, remote's track and /context/tracks
context_sweep: false # Also resolve metadata for the whole context when one starts playing (requires enabled)
max_tracks: 800 # Maximum number of tracks of a context to enumerate and sweep; the metadata cache grows to fit it
```
Expand Down
62 changes: 62 additions & 0 deletions api-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ components:
- repeat_track
- shuffle_context
- track
- remote
properties:
username:
description: Currently active account's username
Expand Down Expand Up @@ -208,6 +209,14 @@ components:
allOf:
- $ref: '#/components/schemas/track'
nullable: true
remote:
description: >-
What the account plays on another Spotify Connect device, null
when no device is active, this one is, or the active one has
nothing loaded
allOf:
- $ref: '#/components/schemas/remote'
nullable: true
next_track:
description: >-
The upcoming track when its metadata is cached (metadata.enabled),
Expand All @@ -217,6 +226,41 @@ components:
- $ref: '#/components/schemas/track'
nullable: true
x-omitempty: true
remote:
x-go-name: ApiRemote
description: >-
The playback of the device the account's session is active on, when it
is another device than this one. Kept current from Spotify's Connect
cluster updates, which also raise the remote event. It 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.
type: object
required: [ device_id, device_name, device_type, paused, track ]
properties:
device_id:
description: The active device ID
type: string
device_name:
description: The active device name, as its app reports it
type: string
device_type:
description: The active device type, for example SMARTPHONE or COMPUTER
type: string
paused:
description: >-
Whether the playhead stands still on the active device: paused,
stopped or buffering. When false, the position advances from the
one given at the time of the status.
type: boolean
track:
description: >-
The track playing there, its position as of the status or the
event that carries it. Null until its metadata is resolved, for good with metadata.enabled
off (the daemon makes no request playback does not need) and for
an item with no metadata (a local file, an ad).
allOf:
- $ref: '#/components/schemas/track'
nullable: true
contextTrackItem:
x-go-name: ApiContextTrackItem
description: One entry of a context listing
Expand Down Expand Up @@ -952,6 +996,24 @@ paths:
responses:
200:
description: Successful response
/player/transfer:
post:
operationId: playerTransfer
description: >-
Take over the session from whichever device of the account is active:
its context, track, position, queue and options carry over, and it
plays here even if it was paused there. Answers once Spotify accepted
the request; the transfer itself arrives afterwards like any other.
Does nothing when this device is already the active one.
responses:
200:
description: Spotify accepted the transfer, or this device is already active
204:
description: No active session
403:
description: Spotify refused the transfer
404:
description: Spotify answered the transfer with not found
/player/output:
post:
operationId: playerOutput
Expand Down
39 changes: 39 additions & 0 deletions daemon/api_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions daemon/api_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const (
ApiRequestTypeNext ApiRequestType = "next"
ApiRequestTypePlay ApiRequestType = "play"
ApiRequestTypeStop ApiRequestType = "stop"
ApiRequestTypeTransfer ApiRequestType = "transfer"
ApiRequestTypeGetVolume ApiRequestType = "get_volume"
ApiRequestTypeSetVolume ApiRequestType = "set_volume"
ApiRequestTypeSetRepeatingContext ApiRequestType = "repeating_context"
Expand Down Expand Up @@ -173,6 +174,7 @@ const (
ApiEventTypeShuffleContext ApiEventType = "shuffle_context"
ApiEventTypePlaybackReady ApiEventType = "playback_ready"
ApiEventTypePlaybackError ApiEventType = "playback_error"
ApiEventTypeRemote ApiEventType = "remote"
)

type ApiRequest struct {
Expand Down Expand Up @@ -685,6 +687,10 @@ func (s *ConcreteApiServer) PlayerStop(w http.ResponseWriter, _ *http.Request) {
s.handleRequest(ApiRequest{Type: ApiRequestTypeStop}, w)
}

func (s *ConcreteApiServer) PlayerTransfer(w http.ResponseWriter, _ *http.Request) {
s.handleRequest(ApiRequest{Type: ApiRequestTypeTransfer}, w)
}

func (s *ConcreteApiServer) PlayerPrev(w http.ResponseWriter, _ *http.Request) {
s.handleRequest(ApiRequest{Type: ApiRequestTypePrev}, w)
}
Expand Down
24 changes: 23 additions & 1 deletion daemon/api_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ func TestApiStatusWireFormat(t *testing.T) {
"repeat_context": false,
"repeat_track": false,
"shuffle_context": false,
"remote": null,
"track": {
"uri": "spotify:track:xxx",
"name": "Some Song",
Expand All @@ -340,6 +341,26 @@ func TestApiStatusWireFormat(t *testing.T) {
}`, body(t, resp))
}

// What plays on another device reaches clients with the device, whether its
// playhead stands still, and its track; a track not resolved yet is null.
func TestApiStatusWireFormatRemote(t *testing.T) {
ts := newTestServer(t, func(ApiRequest) (any, error) {
return &ApiStatus{Remote: &ApiRemote{DeviceId: "def", DeviceName: "iPhone", DeviceType: "SMARTPHONE", Paused: true}}, nil
})

resp := ts.do(http.MethodGet, "/status", nil)

var got map[string]any
require.NoError(t, json.Unmarshal([]byte(body(t, resp)), &got))
require.Equal(t, map[string]any{
"device_id": "def",
"device_name": "iPhone",
"device_type": "SMARTPHONE",
"paused": true,
"track": nil,
}, got["remote"])
}

// The nullable fields must serialise as null rather than disappear.
func TestApiStatusWireFormatNulls(t *testing.T) {
ts := newTestServer(t, func(ApiRequest) (any, error) {
Expand All @@ -351,7 +372,7 @@ func TestApiStatusWireFormatNulls(t *testing.T) {
var got map[string]any
require.NoError(t, json.Unmarshal([]byte(body(t, resp)), &got))

for _, field := range []string{"play_origin", "play_origin_device_id", "context_uri", "context_name"} {
for _, field := range []string{"play_origin", "play_origin_device_id", "context_uri", "context_name", "remote"} {
value, present := got[field]
require.True(t, present, "%s must be present", field)
require.Nil(t, value, "%s must be null", field)
Expand All @@ -373,6 +394,7 @@ func TestApiSimpleCommands(t *testing.T) {
"/player/pause": ApiRequestTypePause,
"/player/playpause": ApiRequestTypePlayPause,
"/player/stop": ApiRequestTypeStop,
"/player/transfer": ApiRequestTypeTransfer,
"/player/prev": ApiRequestTypePrev,
"/token": ApiRequestTypeToken,
} {
Expand Down
2 changes: 2 additions & 0 deletions daemon/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err
ctx: playerCtx,
cancel: playerCancel,
stop: make(chan struct{}, 1),
remoteMeta: make(chan remoteMetaResult, 1),
logout: app.logoutCh,
volumeUpdate: make(chan float32, 1),
playbackReadyCh: make(chan struct{}),
Expand Down Expand Up @@ -315,6 +316,7 @@ func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err
appPlayer.statePush = newStatePushLane(app.log, appPlayer.sess.Spclient(), app.deviceId)
if app.metaCache != nil {
appPlayer.meta = newMetaFetcher(app.log, app.metaCache, appPlayer.sess.Spclient())
appPlayer.fetchRemoteMedia = appPlayer.meta.fetchOne
}

if appPlayer.player, err = player.NewPlayer(&player.Options{
Expand Down
22 changes: 20 additions & 2 deletions daemon/controls.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,17 @@ func (p *AppPlayer) loadContext(spotCtx *connectpb.Context, skipTo skipToFunc, p
})
}

// transferAnchor is where a transferred playback stands. A paused position
// does not move: anchored at the pause, a transfer that resumes would count
// the paused time as played, so a paused one is anchored at the transfer.
func transferAnchor(playback *connectpb.Playback) (timestamp, position int64) {
timestamp = playback.GetTimestamp()
if playback.GetIsPaused() {
timestamp = time.Now().UnixMilli()
}
return timestamp, int64(playback.GetPositionAsOfTimestamp())
}

// transferContext takes over playback from another device. The transfer itself
// is claimed by the caller before this is reached; resolving the context and
// finding the track being handed over runs on the loader lane.
Expand Down Expand Up @@ -518,8 +529,7 @@ func (p *AppPlayer) transferContext(transferState *connectpb.TransferState, sent
if continuation && !keep {
// Loaded like any other transfer, so from where it says.
p.app.log.Debugf("stream changed while resolving the transfer, loading it instead")
p.state.player.Timestamp = transferState.Playback.GetTimestamp()
p.state.player.PositionAsOfTimestamp = int64(transferState.Playback.GetPositionAsOfTimestamp())
p.state.player.Timestamp, p.state.player.PositionAsOfTimestamp = transferAnchor(transferState.Playback)
p.state.setPaused(paused)
}
if continuation {
Expand Down Expand Up @@ -548,6 +558,14 @@ func (p *AppPlayer) transferContext(transferState *connectpb.TransferState, sent
return
}

// A paused session resumed here stood still while the
// context resolved: its position starts moving with the
// load, unless something moved it meanwhile (a seek).
if transferState.Playback.GetIsPaused() &&
p.state.player.PositionAsOfTimestamp == int64(transferState.Playback.GetPositionAsOfTimestamp()) {
p.state.player.Timestamp = time.Now().UnixMilli()
}

// skip forward if the transferred track is unplayable, so a
// cast onto a refused track does not freeze the player.
p.loadCurrentTrackOrSkip(paused, true, false, func(err error) {
Expand Down
Loading