From bdcc7398ff9cdc620e0636f21c88a4c8b5620138 Mon Sep 17 00:00:00 2001 From: Leo Durand Date: Wed, 7 Oct 2026 17:27:01 +0200 Subject: [PATCH 1/3] fix: describe incomplete metadata instead of panicking newApiResponseStatusMedia dereferenced every metadata field. The playing track always has them, but next_track and /context/tracks describe whatever the metadata cache holds, and a cached track missing its album, an artist's name or its gid panicked the daemon on a /status read. GET /player/queue, later in this series, describes up to 65 of them. Read the fields through the generated nil-safe getters, so that missing metadata is described as empty, a missing album date included. An episode without a named show lists no artist instead of one blank name. A track keeps a nameless artist's "" so that artist_names stays paired with artist_uris, and a track without artists lists [] instead of null. Co-Authored-By: Claude Opus 5.5 --- daemon/api_server.go | 59 +++++++++++++++++++++++------------ daemon/api_track_uris_test.go | 37 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/daemon/api_server.go b/daemon/api_server.go index 4af4288d..f7c51cc7 100644 --- a/daemon/api_server.go +++ b/daemon/api_server.go @@ -286,18 +286,22 @@ func (p *AppPlayer) newApiResponseStatusTrack(stream *player.Stream, position in return resp } +// newApiResponseStatusMedia describes cached or playing media. Fields are read +// through the getters, as the service may leave any of them out. func (p *AppPlayer) newApiResponseStatusMedia(media *librespot.Media, position int64) *ApiTrack { if media.IsTrack() { track := media.Track() - var artists []string + artists := make([]string, 0, len(track.Artist)) for _, a := range track.Artist { - artists = append(artists, *a.Name) + // A nameless artist's "" is kept: artist_uris pair with + // artist_names by index. + artists = append(artists, a.GetName()) } - albumCoverId := getBestImageIdForSize(track.Album.Cover, p.app.cfg.ImageSize) - if albumCoverId == nil && track.Album.CoverGroup != nil { - albumCoverId = getBestImageIdForSize(track.Album.CoverGroup.Image, p.app.cfg.ImageSize) + albumCoverId := getBestImageIdForSize(track.GetAlbum().GetCover(), p.app.cfg.ImageSize) + if albumCoverId == nil { + albumCoverId = getBestImageIdForSize(track.GetAlbum().GetCoverGroup().GetImage(), p.app.cfg.ImageSize) } artistUris := make([]string, 0, len(track.Artist)) @@ -306,34 +310,40 @@ func (p *AppPlayer) newApiResponseStatusMedia(media *librespot.Media, position i } return &ApiTrack{ - Uri: librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeTrack, track.Gid).Uri(), - Name: *track.Name, + Uri: gidUri(librespot.SpotifyIdTypeTrack, track.GetGid()), + Name: track.GetName(), ArtistNames: artists, ArtistUris: artistUris, - AlbumName: *track.Album.Name, - AlbumUri: gidUri(librespot.SpotifyIdTypeAlbum, track.Album.GetGid()), + AlbumName: track.GetAlbum().GetName(), + AlbumUri: gidUri(librespot.SpotifyIdTypeAlbum, track.GetAlbum().GetGid()), AlbumCoverUrl: p.prodInfo.ImageUrl(albumCoverId), Position: position, - Duration: int(*track.Duration), - ReleaseDate: track.Album.Date.String(), - TrackNumber: int(*track.Number), - DiscNumber: int(*track.DiscNumber), + Duration: int(track.GetDuration()), + ReleaseDate: releaseDate(track.GetAlbum().GetDate()), + TrackNumber: int(track.GetNumber()), + DiscNumber: int(track.GetDiscNumber()), } } else { episode := media.Episode() - albumCoverId := getBestImageIdForSize(episode.CoverImage.Image, p.app.cfg.ImageSize) + albumCoverId := getBestImageIdForSize(episode.GetCoverImage().GetImage(), p.app.cfg.ImageSize) + + // The show stands for the artist. + artists := []string{} + if show := episode.GetShow().GetName(); show != "" { + artists = append(artists, show) + } return &ApiTrack{ - Uri: librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeEpisode, episode.Gid).Uri(), - Name: *episode.Name, - ArtistNames: []string{*episode.Show.Name}, + Uri: gidUri(librespot.SpotifyIdTypeEpisode, episode.GetGid()), + Name: episode.GetName(), + ArtistNames: artists, ArtistUris: []string{}, - AlbumName: *episode.Show.Name, - AlbumUri: gidUri(librespot.SpotifyIdTypeShow, episode.Show.GetGid()), + AlbumName: episode.GetShow().GetName(), + AlbumUri: gidUri(librespot.SpotifyIdTypeShow, episode.GetShow().GetGid()), AlbumCoverUrl: p.prodInfo.ImageUrl(albumCoverId), Position: position, - Duration: int(*episode.Duration), + Duration: int(episode.GetDuration()), ReleaseDate: "", TrackNumber: 0, DiscNumber: 0, @@ -341,6 +351,15 @@ func (p *AppPlayer) newApiResponseStatusMedia(media *librespot.Media, position i } } +// releaseDate describes an album's date, or "" when the metadata has none +// (the message's String would say ""). +func releaseDate(date *metadatapb.Date) string { + if date == nil { + return "" + } + return date.String() +} + // gidUri turns a metadata gid into a URI of the given type, or "" when the // gid is missing or malformed. func gidUri(typ librespot.SpotifyIdType, gid []byte) string { diff --git a/daemon/api_track_uris_test.go b/daemon/api_track_uris_test.go index 6ad70c4f..61c5ea9d 100644 --- a/daemon/api_track_uris_test.go +++ b/daemon/api_track_uris_test.go @@ -30,6 +30,43 @@ func TestApiTrackCarriesAlbumAndArtistUris(t *testing.T) { require.Empty(t, episode.ArtistUris) } +// Cached metadata is not guaranteed complete: what it lacks is described empty, +// never dereferenced, so a gap in one track cannot take the status down. +func TestApiTrackToleratesIncompleteMetadata(t *testing.T) { + p := &AppPlayer{app: &App{cfg: &Config{}}, prodInfo: &ProductInfo{}} + + incomplete := metaTrack(nil, "Track") + incomplete.Album = nil + incomplete.Artist = []*metadatapb.Artist{{Gid: gid(0xa2)}} + incomplete.Number = nil + track := p.newApiResponseStatusMedia(librespot.NewMediaFromTrack(incomplete), 0) + require.Equal(t, "Track", track.Name) + require.Equal(t, "", track.Uri) + require.Equal(t, "", track.AlbumName) + require.Equal(t, "", track.ReleaseDate) + require.Equal(t, []string{""}, track.ArtistNames, "kept, in step with artist_uris") + require.Equal(t, 0, track.TrackNumber) + + episode := p.newApiResponseStatusMedia(librespot.NewMediaFromEpisode(&metadatapb.Episode{ + Name: proto.String("Episode"), + }), 0) + require.Equal(t, "Episode", episode.Name) + require.Equal(t, "", episode.Uri) + require.Equal(t, []string{}, episode.ArtistNames, "no show, no artist rather than a blank one") + + nameless := p.newApiResponseStatusMedia(librespot.NewMediaFromEpisode(&metadatapb.Episode{ + Name: proto.String("Episode"), + Show: &metadatapb.Show{Gid: gid(0x04)}, + }), 0) + require.Equal(t, []string{}, nameless.ArtistNames) + require.Equal(t, librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeShow, gid(0x04)).Uri(), nameless.AlbumUri) + + // No artists: still an array, as the spec declares artist_names. + alone := metaTrack(gid(0x02), "Alone") + alone.Artist = nil + require.Equal(t, []string{}, p.newApiResponseStatusMedia(librespot.NewMediaFromTrack(alone), 0).ArtistNames) +} + func TestGidUriToleratesMalformedGids(t *testing.T) { require.Equal(t, "", gidUri(librespot.SpotifyIdTypeAlbum, nil)) require.Equal(t, "", gidUri(librespot.SpotifyIdTypeAlbum, []byte{1, 2, 3})) From 19de8e9fa4e83b49427679ee4328b62c0a7e8ddb Mon Sep 17 00:00:00 2001 From: Leo Durand Date: Wed, 7 Oct 2026 17:27:34 +0200 Subject: [PATCH 2/3] feat: let POST /player/prev skip back without rewinding POST /player/prev rewinds a track played past three seconds. That suits a previous button, but not a client that already shows the previous track and wants to go to it, such as a swipe over a carousel. Spotify Connect's skip_prev command has an option for this, which the daemon already honors. Accept an optional {"allow_seeking": bool} body, defaulting to true. With false, prev goes to the previous track, or restarts the current one when there is none. Prev used to ignore its body, so a body that does not parse gets the default rather than a 400. Co-Authored-By: Claude Opus 5.5 --- api-spec.yml | 22 ++++++++++++++++++++- daemon/api_gen.go | 9 +++++++++ daemon/api_server.go | 11 +++++++++-- daemon/api_server_test.go | 40 ++++++++++++++++++++++++++++++++++++++- daemon/player.go | 3 ++- 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/api-spec.yml b/api-spec.yml index 2cbb3e15..b6d33932 100644 --- a/api-spec.yml +++ b/api-spec.yml @@ -701,6 +701,17 @@ components: The track URI to skip to. When omitted the next track in the context is played. type: string + prev: + x-go-name: ApiPrev + description: A skip to previous payload + type: object + properties: + allow_seeking: + description: >- + Whether to rewind a track played past three seconds instead of + skipping to the previous one + type: boolean + default: true seek: x-go-name: ApiSeek description: A seek payload @@ -1258,7 +1269,16 @@ paths: /player/prev: post: operationId: playerPrev - description: Skip to previous track (or rewind current track) + description: >- + Skip to previous track (or rewind current track); with none before it, + the current track restarts. From a queued track it returns to the + context, to the track before the one the queue was entered from (or + that one if it is the context's first); the queued track stays queued + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/prev' responses: 200: description: Successful response diff --git a/daemon/api_gen.go b/daemon/api_gen.go index 2b20f445..7237b063 100644 --- a/daemon/api_gen.go +++ b/daemon/api_gen.go @@ -332,6 +332,12 @@ type ApiPlaylistRemoveTrack struct { Uri string `json:"uri"` } +// ApiPrev A skip to previous payload +type ApiPrev struct { + // AllowSeeking Whether to rewind a track played past three seconds instead of skipping to the previous one + AllowSeeking *bool `json:"allow_seeking,omitempty"` +} + // ApiRepeatContext A toggle repeating context payload type ApiRepeatContext struct { // RepeatContext Whether repeating context should be enabled @@ -591,6 +597,9 @@ type PlayerOutputJSONRequestBody = ApiOutput // PlayerPlayJSONRequestBody defines body for PlayerPlay for application/json ContentType. type PlayerPlayJSONRequestBody = ApiPlay +// PlayerPrevJSONRequestBody defines body for PlayerPrev for application/json ContentType. +type PlayerPrevJSONRequestBody = ApiPrev + // PlayerRepeatContextJSONRequestBody defines body for PlayerRepeatContext for application/json ContentType. type PlayerRepeatContextJSONRequestBody = ApiRepeatContext diff --git a/daemon/api_server.go b/daemon/api_server.go index f7c51cc7..cc867442 100644 --- a/daemon/api_server.go +++ b/daemon/api_server.go @@ -751,8 +751,15 @@ func (s *ConcreteApiServer) PlayerStop(w http.ResponseWriter, _ *http.Request) { s.handleRequest(ApiRequest{Type: ApiRequestTypeStop}, w) } -func (s *ConcreteApiServer) PlayerPrev(w http.ResponseWriter, _ *http.Request) { - s.handleRequest(ApiRequest{Type: ApiRequestTypePrev}, w) +func (s *ConcreteApiServer) PlayerPrev(w http.ResponseWriter, r *http.Request) { + // The body used to be ignored: one that does not parse gets the default + // rather than a 400. + var data ApiPrev + if err := jsonDecode(r, &data); err != nil { + data = ApiPrev{} + } + + s.handleRequest(ApiRequest{Type: ApiRequestTypePrev, Data: data}, w) } func (s *ConcreteApiServer) PlayerGetVolume(w http.ResponseWriter, _ *http.Request) { diff --git a/daemon/api_server_test.go b/daemon/api_server_test.go index ddd58933..9da03e0e 100644 --- a/daemon/api_server_test.go +++ b/daemon/api_server_test.go @@ -376,7 +376,6 @@ func TestApiSimpleCommands(t *testing.T) { "/player/pause": ApiRequestTypePause, "/player/playpause": ApiRequestTypePlayPause, "/player/stop": ApiRequestTypeStop, - "/player/prev": ApiRequestTypePrev, "/token": ApiRequestTypeToken, } { t.Run(path, func(t *testing.T) { @@ -456,6 +455,45 @@ func TestApiNext(t *testing.T) { }) } +func TestApiPrev(t *testing.T) { + t.Run("without seeking", func(t *testing.T) { + ts := newTestServer(t, okReply) + + ts.do(http.MethodPost, "/player/prev", map[string]any{"allow_seeking": false}) + + req := ts.request() + require.Equal(t, ApiRequestTypePrev, req.Type) + data := req.Data.(ApiPrev) + require.NotNil(t, data.AllowSeeking) + require.False(t, *data.AllowSeeking) + }) + + t.Run("without a body", func(t *testing.T) { + ts := newTestServer(t, okReply) + + resp := ts.do(http.MethodPost, "/player/prev", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Nil(t, ts.request().Data.(ApiPrev).AllowSeeking, "absent: seeking allowed, as before") + }) + + // Prev used to ignore its body, so one that does not parse gets the default. + for name, body := range map[string]string{ + "invalid json": "{not json", + "form data": "allow_seeking=false", + "mistyped": `{"allow_seeking":"true"}`, + } { + t.Run("falls back to the default on "+name, func(t *testing.T) { + ts := newTestServer(t, okReply) + + resp := ts.do(http.MethodPost, "/player/prev", body) + require.Equal(t, http.StatusOK, resp.StatusCode) + req := ts.request() + require.Equal(t, ApiRequestTypePrev, req.Type) + require.Nil(t, req.Data.(ApiPrev).AllowSeeking) + }) + } +} + func TestApiSeek(t *testing.T) { t.Run("absolute", func(t *testing.T) { ts := newTestServer(t, okReply) diff --git a/daemon/player.go b/daemon/player.go index 9804553a..16dc8a6c 100644 --- a/daemon/player.go +++ b/daemon/player.go @@ -768,7 +768,8 @@ func (p *AppPlayer) handleApiRequest(req ApiRequest) (any, error) { _ = p.seek(position) return nil, nil case ApiRequestTypePrev: - _ = p.skipPrev(true) + data := req.Data.(ApiPrev) + _ = p.skipPrev(data.AllowSeeking == nil || *data.AllowSeeking) return nil, nil case ApiRequestTypeNext: data := req.Data.(ApiNext) From 150b9c0a335f5cbbcbe8ee644b78502e2009ec35 Mon Sep 17 00:00:00 2001 From: Leo Durand Date: Wed, 7 Oct 2026 19:49:31 +0200 Subject: [PATCH 3/3] feat: list the play order in GET /player/queue, with a queue event A client that shows what comes before and after the current track has only next_track to go on, while the daemon already holds the window Spotify Connect publishes: up to 32 tracks on each side in play order, with the user's queue and shuffling applied. GET /player/queue returns {prev_tracks, track, next_tracks}. Each entry is {uri, uid, provider, track}, where track is the cached metadata or null, and the current entry carries the playing position. A queue event without data is raised where the answer can change: when the window is published or cleared, when its metadata is cached or the product info arrives, and when a session that listed entries ends. Each site compares a signature of the answer, position aside, with the last one announced. The signature reads the window from the metadata cache with get, and /context/tracks marks it used again after a listing, so that sweeping other contexts evicts around it. Run's loop moves into serve so that tests can drive it without a session. Co-Authored-By: Claude Opus 5.5 --- API.md | 1 + README.md | 8 +- api-spec.yml | 74 ++++++ config_schema.json | 4 +- daemon/api_gen.go | 55 +++++ daemon/api_server.go | 6 + daemon/api_server_test.go | 23 ++ daemon/config.go | 13 +- daemon/controls.go | 5 + daemon/player.go | 40 +++- daemon/player_queue.go | 149 ++++++++++++ daemon/player_queue_test.go | 404 ++++++++++++++++++++++++++++++++ daemon/player_state.go | 4 +- daemon/track_meta_cache.go | 22 +- daemon/track_meta_cache_test.go | 8 +- 15 files changed, 794 insertions(+), 22 deletions(-) create mode 100644 daemon/player_queue.go create mode 100644 daemon/player_queue_test.go diff --git a/API.md b/API.md index 58e6eaca..75bd8d7c 100644 --- a/API.md +++ b/API.md @@ -72,3 +72,4 @@ 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 +- `queue`: The `GET /player/queue` response changed, or the session ended and the endpoint now answers 204 diff --git a/README.md b/README.md index 9a7d39b1..a82eecb2 100644 --- a/README.md +++ b/README.md @@ -233,10 +233,10 @@ 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 -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. +enables a `next_track` field in `GET /status`, track metadata in `GET /player/queue` and 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: diff --git a/api-spec.yml b/api-spec.yml index b6d33932..369d25d6 100644 --- a/api-spec.yml +++ b/api-spec.yml @@ -233,6 +233,61 @@ components: allOf: - $ref: '#/components/schemas/track' nullable: true + queueEntry: + x-go-name: ApiQueueEntry + description: One entry of the play order + type: object + required: [ uri, uid, provider, track ] + properties: + uri: + description: The entry URI, usually a track or an episode + type: string + uid: + description: >- + The entry's uid in the play order, which tells two copies of a + track apart; null when it has none, as for album tracks + type: string + nullable: true + provider: + description: >- + Where the entry comes from: the user's queue, the context, or + autoplay once the context has ended + type: string + enum: [ queue, context, autoplay ] + track: + description: >- + Track metadata (metadata.enabled), null until cached or for entries + without any, such as local files. Its position is the playback + position for the current entry and 0 for the others + allOf: + - $ref: '#/components/schemas/track' + nullable: true + queue: + x-go-name: ApiQueue + description: The play order around the current track + type: object + required: [ prev_tracks, track, next_tracks ] + properties: + prev_tracks: + description: >- + Entries before the current one in the play order, farthest first, + up to 32. Not a play history: a context started from one of its + tracks lists the tracks before it + type: array + items: + $ref: '#/components/schemas/queueEntry' + track: + description: The current entry, null when nothing is loaded + allOf: + - $ref: '#/components/schemas/queueEntry' + nullable: true + next_tracks: + description: >- + Entries after the current one, nearest first, the user's queue + before the rest, up to 32 + type: array + items: + $ref: '#/components/schemas/queueEntry' contextTracks: x-go-name: ApiContextTracks description: A context's track listing @@ -1368,6 +1423,25 @@ paths: responses: 200: description: Successful response + /player/queue: + get: + operationId: playerGetQueue + description: >- + List the play order around the current track, as Spotify Connect holds + it. With metadata.enabled, missing metadata is fetched in the background + and the response never waits for it. The queue event is raised whenever + the response changes, the current entry's position aside; a context + start can report the cleared order before the resolved one. A 204 means + the same as an empty order + responses: + 200: + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/queue' + 204: + description: No active session /player/add_to_queue: post: operationId: playerAddToQueue diff --git a/config_schema.json b/config_schema.json index 0e009b95..53dc4ae7 100644 --- a/config_schema.json +++ b/config_schema.json @@ -369,11 +369,11 @@ }, "metadata": { "type": "object", - "description": "Opt-in in-memory track metadata cache behind the next_track status field and the GET /context/tracks listing", + "description": "Opt-in in-memory track metadata cache behind the next_track status field, the tracks of GET /player/queue and the GET /context/tracks listing", "properties": { "enabled": { "type": "boolean", - "description": "Whether to cache track metadata and batch-fetch it for the tracks around the playback position, enabling the next_track status field and the /context/tracks endpoint. Disabled, the daemon performs no metadata request playback does not need", + "description": "Whether to cache track metadata and batch-fetch it for the tracks around the playback position, enabling the next_track status field, the tracks of GET /player/queue and the /context/tracks endpoint. Disabled, the daemon performs no metadata request playback does not need", "default": false }, "context_sweep": { diff --git a/daemon/api_gen.go b/daemon/api_gen.go index 7237b063..f46b291c 100644 --- a/daemon/api_gen.go +++ b/daemon/api_gen.go @@ -24,6 +24,13 @@ const ( ConnectCommandEndpointSkipPrev ConnectCommandEndpoint = "skip_prev" ) +// Defines values for QueueEntryProvider. +const ( + QueueEntryProviderAutoplay QueueEntryProvider = "autoplay" + QueueEntryProviderContext QueueEntryProvider = "context" + QueueEntryProviderQueue QueueEntryProvider = "queue" +) + // Defines values for TrackCodec. const ( TrackCodecAac TrackCodec = "aac" @@ -338,6 +345,36 @@ type ApiPrev struct { AllowSeeking *bool `json:"allow_seeking,omitempty"` } +// ApiQueue The play order around the current track +type ApiQueue struct { + // NextTracks Entries after the current one, nearest first, the user's queue before the rest, up to 32 + NextTracks []ApiQueueEntry `json:"next_tracks"` + + // PrevTracks Entries before the current one in the play order, farthest first, up to 32. Not a play history: a context started from one of its tracks lists the tracks before it + PrevTracks []ApiQueueEntry `json:"prev_tracks"` + + // Track The current entry, null when nothing is loaded + Track *ApiQueueEntry `json:"track"` +} + +// ApiQueueEntry One entry of the play order +type ApiQueueEntry struct { + // Provider Where the entry comes from: the user's queue, the context, or autoplay once the context has ended + Provider QueueEntryProvider `json:"provider"` + + // Track Track metadata (metadata.enabled), null until cached or for entries without any, such as local files. Its position is the playback position for the current entry and 0 for the others + Track *ApiTrack `json:"track"` + + // Uid The entry's uid in the play order, which tells two copies of a track apart; null when it has none, as for album tracks + Uid *string `json:"uid"` + + // Uri The entry URI, usually a track or an episode + Uri string `json:"uri"` +} + +// QueueEntryProvider Where the entry comes from: the user's queue, the context, or autoplay once the context has ended +type QueueEntryProvider string + // ApiRepeatContext A toggle repeating context payload type ApiRepeatContext struct { // RepeatContext Whether repeating context should be enabled @@ -687,6 +724,9 @@ type ServerInterface interface { // (POST /player/prev) PlayerPrev(w http.ResponseWriter, r *http.Request) + // (GET /player/queue) + PlayerGetQueue(w http.ResponseWriter, r *http.Request) + // (POST /player/repeat_context) PlayerRepeatContext(w http.ResponseWriter, r *http.Request) @@ -1176,6 +1216,20 @@ func (siw *ServerInterfaceWrapper) PlayerPrev(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } +// PlayerGetQueue operation middleware +func (siw *ServerInterfaceWrapper) PlayerGetQueue(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PlayerGetQueue(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // PlayerRepeatContext operation middleware func (siw *ServerInterfaceWrapper) PlayerRepeatContext(w http.ResponseWriter, r *http.Request) { @@ -1472,6 +1526,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("POST "+options.BaseURL+"/player/play", wrapper.PlayerPlay) m.HandleFunc("POST "+options.BaseURL+"/player/playpause", wrapper.PlayerPlayPause) m.HandleFunc("POST "+options.BaseURL+"/player/prev", wrapper.PlayerPrev) + m.HandleFunc("GET "+options.BaseURL+"/player/queue", wrapper.PlayerGetQueue) m.HandleFunc("POST "+options.BaseURL+"/player/repeat_context", wrapper.PlayerRepeatContext) m.HandleFunc("POST "+options.BaseURL+"/player/repeat_track", wrapper.PlayerRepeatTrack) m.HandleFunc("POST "+options.BaseURL+"/player/resume", wrapper.PlayerResume) diff --git a/daemon/api_server.go b/daemon/api_server.go index cc867442..dab589bb 100644 --- a/daemon/api_server.go +++ b/daemon/api_server.go @@ -134,6 +134,7 @@ const ( ApiRequestSetDeviceName ApiRequestType = "set_device_name" ApiRequestTypeReopenOutput ApiRequestType = "reopen_output" ApiRequestTypeContextTracks ApiRequestType = "context_tracks" + ApiRequestTypeGetQueue ApiRequestType = "get_queue" ApiRequestTypeLibraryPlaylists ApiRequestType = "library_playlists" ApiRequestTypeLibraryAlbums ApiRequestType = "library_albums" ApiRequestTypeLibraryArtists ApiRequestType = "library_artists" @@ -179,6 +180,7 @@ const ( ApiEventTypePlaybackReady ApiEventType = "playback_ready" ApiEventTypePlaybackError ApiEventType = "playback_error" ApiEventTypeCluster ApiEventType = "cluster" + ApiEventTypeQueue ApiEventType = "queue" ) type ApiRequest struct { @@ -604,6 +606,10 @@ func (s *ConcreteApiServer) GetToken(w http.ResponseWriter, _ *http.Request) { s.handleRequest(ApiRequest{Type: ApiRequestTypeToken}, w) } +func (s *ConcreteApiServer) PlayerGetQueue(w http.ResponseWriter, _ *http.Request) { + s.handleRequest(ApiRequest{Type: ApiRequestTypeGetQueue}, w) +} + func (s *ConcreteApiServer) GetContextTracks(w http.ResponseWriter, _ *http.Request, params GetContextTracksParams) { s.handleRequest(ApiRequest{Type: ApiRequestTypeContextTracks, Data: ApiRequestDataContextTracks{Uri: params.Uri}}, w) } diff --git a/daemon/api_server_test.go b/daemon/api_server_test.go index 9da03e0e..6589f745 100644 --- a/daemon/api_server_test.go +++ b/daemon/api_server_test.go @@ -151,6 +151,7 @@ var endpointMethods = map[string][]string{ "/player/repeat_track": {http.MethodPost}, "/player/shuffle_context": {http.MethodPost}, "/player/add_to_queue": {http.MethodPost}, + "/player/queue": {http.MethodGet}, "/player/output": {http.MethodPost}, "/context/tracks": {http.MethodGet}, "/library/playlists": {http.MethodGet}, @@ -343,6 +344,28 @@ func TestApiStatusWireFormat(t *testing.T) { }`, body(t, resp)) } +// GET /player/queue says null where an entry has no uid or no metadata yet, +// and [] where nothing is listed, rather than leaving the field out. +func TestApiPlayerQueueWireFormat(t *testing.T) { + ts := newTestServer(t, func(ApiRequest) (any, error) { + return &ApiQueue{ + PrevTracks: []ApiQueueEntry{}, + Track: &ApiQueueEntry{Uri: "spotify:track:xxx", Provider: QueueEntryProviderContext}, + NextTracks: []ApiQueueEntry{}, + }, nil + }) + + resp := ts.do(http.MethodGet, "/player/queue", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, ApiRequestTypeGetQueue, ts.request().Type) + + require.JSONEq(t, `{ + "prev_tracks": [], + "track": {"uri": "spotify:track:xxx", "uid": null, "provider": "context", "track": null}, + "next_tracks": [] + }`, body(t, resp)) +} + // The nullable fields must serialise as null rather than disappear. func TestApiStatusWireFormatNulls(t *testing.T) { ts := newTestServer(t, func(ApiRequest) (any, error) { diff --git a/daemon/config.go b/daemon/config.go index aeea9e3e..86254b7c 100644 --- a/daemon/config.go +++ b/daemon/config.go @@ -65,14 +65,15 @@ type Config struct { } // MetadataConfig configures the in-memory track metadata cache behind the -// next_track status field and the /context/tracks listing. Everything here is -// opt-in: a headless speaker has no use for metadata beyond the playing track -// and should not pay network requests for it. +// next_track status field, the tracks of /player/queue and the +// /context/tracks listing. Everything here is opt-in: a headless speaker has +// no use for metadata beyond the playing track and should not pay network +// requests for it. type MetadataConfig struct { // Enabled turns on the metadata cache, the batched fetch of metadata for - // the tracks around the playback position, and the /context/tracks - // endpoint. Off, the daemon performs no metadata request playback does not - // need. + // the tracks around the playback position, the track metadata of + // /player/queue, and the /context/tracks endpoint. Off, the daemon performs + // no metadata request playback does not need. Enabled bool // ContextSweep additionally resolves metadata for the whole context when // one starts playing, so every track is known before the user skips diff --git a/daemon/controls.go b/daemon/controls.go index 619125f6..95d7d700 100644 --- a/daemon/controls.go +++ b/daemon/controls.go @@ -131,6 +131,7 @@ func (p *AppPlayer) commitPrefetch(stream *player.Stream, source librespot.Audio p.secondarySource = source p.player.SetSecondaryStream(source) p.app.metaCache.putStream(stream.RequestedId.Uri(), stream.Media) + p.emitQueue() p.app.log.WithField("uri", stream.RequestedId.Uri()). Infof("prefetched %s %s (duration: %dms)", stream.RequestedId.Type(), @@ -364,6 +365,7 @@ func (p *AppPlayer) loadContext(spotCtx *connectpb.Context, skipTo skipToFunc, p p.state.player.IsBuffering = true p.state.player.PlaybackSpeed = 0 p.updateState() + p.emitQueue() shuffle := p.state.player.Options.ShufflingContext p.loadGen++ @@ -666,6 +668,7 @@ func (p *AppPlayer) abandonTransfer() { p.state.player.PositionAsOfTimestamp = 0 p.flushState() + p.emitQueue() } // playingQueuedTrack reports whether a transfer hands over a queued track as @@ -1027,6 +1030,7 @@ func (p *AppPlayer) commitLoad(stream *player.Stream, uri string, paused bool, t // Track wholesale with a fresh ProvidedTrack from the track list. enrichTrackMetadata(p.state.player.Track, stream.Media) p.app.metaCache.putStream(uri, stream.Media) + p.emitQueue() p.state.player.Timestamp = time.Now().UnixMilli() p.state.player.PositionAsOfTimestamp = trackPosition @@ -1734,6 +1738,7 @@ func (p *AppPlayer) stopPlayback() { p.state.reset() p.pushState(connectpb.PutStateReason_BECAME_INACTIVE) + p.emitQueue() p.schedulePrefetchNext() diff --git a/daemon/player.go b/daemon/player.go index 16dc8a6c..cd9150c0 100644 --- a/daemon/player.go +++ b/daemon/player.go @@ -135,6 +135,10 @@ type AppPlayer struct { // until a burst of skips has settled, so the burst costs one request. metaPrefetchTimer *time.Timer + // lastQueue is the play order the last queue event announced. Run + // goroutine only. + lastQueue queueSignature + // lastFullMetaContext is the context uri the last full sweep ran for, so // replaying the same playlist does not re-sweep it. Run goroutine only. lastFullMetaContext string @@ -217,6 +221,7 @@ func (p *AppPlayer) handleAccesspointPacket(pktType ap.PacketType, payload []byt } p.prodInfo = &prod + p.emitQueue() return nil case ap.PacketTypeCountryCode: p.countryCode.Store(pointer(string(payload))) @@ -493,6 +498,7 @@ func (p *AppPlayer) handlePlayerCommand(req dealer.RequestPayload) error { p.state.player.IsBuffering = true p.state.player.PlaybackSpeed = 0 // not progressing while buffering p.flushState() + p.emitQueue() p.app.server.Emit(&ApiEvent{ Type: ApiEventTypeActive, @@ -714,6 +720,8 @@ func (p *AppPlayer) handleApiRequest(req ApiRequest) (any, error) { resp.NextTrack = p.apiNextTrack() return resp, nil + case ApiRequestTypeGetQueue: + return p.apiQueue(), nil case ApiRequestTypeContextTracks: if p.meta == nil { return nil, ErrNotFound @@ -1127,10 +1135,32 @@ func (p *AppPlayer) Run(apiRecv <-chan ApiRequest, mprisRecv <-chan mpris.MediaP return } - apRecv := p.sess.Accesspoint().Receive(ap.PacketTypeProductInfo, ap.PacketTypeCountryCode) - msgRecv := p.sess.Dealer().ReceiveMessage("hm://pusher/v1/connections/", "hm://connect-state/v1/") - reqRecv := p.sess.Dealer().ReceiveRequest("hm://connect-state/v1/player/command") - playerRecv := p.player.Receive() + p.serve( + p.sess.Accesspoint().Receive(ap.PacketTypeProductInfo, ap.PacketTypeCountryCode), + p.sess.Dealer().ReceiveMessage("hm://pusher/v1/connections/", "hm://connect-state/v1/"), + p.sess.Dealer().ReceiveRequest("hm://connect-state/v1/player/command"), + p.player.Receive(), + apiRecv, mprisRecv, + ) +} + +// serve is Run's loop, split out so that tests can drive it without a session. +func (p *AppPlayer) serve( + apRecv <-chan ap.Packet, + msgRecv <-chan dealer.Message, + reqRecv <-chan dealer.Request, + playerRecv <-chan player.Event, + apiRecv <-chan ApiRequest, + mprisRecv <-chan mpris.MediaPlayer2PlayerCommand, +) { + defer p.emitQueueGone() + + // Metadata fetches run detached and cannot read the play order, so they + // wake the loop to do it. Nil, never ready, with metadata disabled. + var metaCached <-chan struct{} + if p.meta != nil { + metaCached = p.meta.cached + } volumeTimer := time.NewTimer(time.Minute) volumeTimer.Stop() // don't emit a volume change event at start @@ -1247,6 +1277,8 @@ func (p *AppPlayer) Run(apiRecv <-chan ApiRequest, mprisRecv <-chan mpris.MediaP p.prefetchNext() case <-p.metaPrefetchTimer.C: p.prefetchWindowMetadata() + case <-metaCached: + p.emitQueue() case <-p.sleepTimer.C: // Cleared before pause(), whose own updateState call picks this // up - so the app stops showing the timer as active in the same diff --git a/daemon/player_queue.go b/daemon/player_queue.go new file mode 100644 index 00000000..f3af6606 --- /dev/null +++ b/daemon/player_queue.go @@ -0,0 +1,149 @@ +package daemon + +import ( + "slices" + + librespot "github.com/devgianlu/go-librespot" + connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate" + "google.golang.org/protobuf/proto" +) + +// delimiterUri marks the end of the context's tracks in the window. +const delimiterUri = "spotify:delimiter" + +func isQueueEntry(t *connectpb.ProvidedTrack) bool { + return t != nil && t.Uri != "" && t.Uri != delimiterUri +} + +// apiQueue describes the window around the current track, as published to +// Spotify Connect. Runs on the Run goroutine. +func (p *AppPlayer) apiQueue() *ApiQueue { + sig := p.queueSignature() + + entry := func(k queueKey, position int64) ApiQueueEntry { + // Entries without metadata, such as local files, have a null track. + e := ApiQueueEntry{Uri: k.uri, Uid: nonEmpty(k.uid), Provider: QueueEntryProvider(k.provider)} + if k.media != nil { + e.Track = p.newApiResponseStatusMedia(k.media, position) + } + return e + } + entries := func(keys []queueKey) []ApiQueueEntry { + out := make([]ApiQueueEntry, 0, len(keys)) + for _, k := range keys { + out = append(out, entry(k, 0)) + } + return out + } + + queue := &ApiQueue{PrevTracks: entries(sig.prev), NextTracks: entries(sig.next)} + if sig.current.uri != "" { + // A loading track is marked playing while buffering, and + // trackPosition would run on from its anchor. + position := p.state.player.PositionAsOfTimestamp + if !p.state.player.IsBuffering { + position = p.state.trackPosition() + } + current := entry(sig.current, position) + // A track that ended stays current until the next one loads. + if t := current.Track; t != nil && t.Duration > 0 { + t.Position = min(t.Position, int64(t.Duration)) + } + queue.Track = ¤t + } + return queue +} + +// queueKey is an entry of GET /player/queue minus the playing position, so +// that a change can be detected without describing every track. +type queueKey struct { + uri, uid, provider string + media *librespot.Media +} + +// equal compares media by content: a load caches a new copy of metadata that +// may already be cached. +func (k queueKey) equal(o queueKey) bool { + if k.uri != o.uri || k.uid != o.uid || k.provider != o.provider { + return false + } + + switch a, b := k.media, o.media; { + case a == b: + return true + case a == nil || b == nil || a.IsTrack() != b.IsTrack(): + return false + case a.IsTrack(): + return proto.Equal(a.Track(), b.Track()) + default: + return proto.Equal(a.Episode(), b.Episode()) + } +} + +// queueSignature is the keys of a whole GET /player/queue answer. +type queueSignature struct { + current queueKey + prev, next []queueKey +} + +func (s queueSignature) equal(o queueSignature) bool { + return s.current.equal(o.current) && + slices.EqualFunc(s.prev, o.prev, queueKey.equal) && + slices.EqualFunc(s.next, o.next, queueKey.equal) +} + +func (s queueSignature) empty() bool { + return s.current.uri == "" && len(s.prev) == 0 && len(s.next) == 0 +} + +func (p *AppPlayer) queueSignature() queueSignature { + // Anything that is not an entry is the zero key. + key := func(t *connectpb.ProvidedTrack) queueKey { + if !isQueueEntry(t) { + return queueKey{} + } + + k := queueKey{uri: t.Uri, uid: t.Uid, provider: t.Provider} + // get marks the entries used: this runs after every cached batch, so + // sweeping another context evicts around the window. Cover URLs need + // the product info. + if p.prodInfo != nil { + k.media = p.app.metaCache.get(t.Uri) + } + return k + } + keys := func(window []*connectpb.ProvidedTrack) []queueKey { + out := make([]queueKey, 0, len(window)) + for _, t := range window { + if k := key(t); k.uri != "" { + out = append(out, k) + } + } + return out + } + return queueSignature{ + current: key(p.state.player.Track), + prev: keys(p.state.player.PrevTracks), + next: keys(p.state.player.NextTracks), + } +} + +// emitQueue raises the queue event if GET /player/queue would answer +// differently than when it was last raised, the playing position aside. +func (p *AppPlayer) emitQueue() { + sig := p.queueSignature() + if sig.equal(p.lastQueue) { + return + } + p.lastQueue = sig + p.app.server.Emit(&ApiEvent{Type: ApiEventTypeQueue}) +} + +// emitQueueGone raises the queue event when the session ends with entries +// listed, so that clients do not keep showing them. +func (p *AppPlayer) emitQueueGone() { + if p.lastQueue.empty() { + return + } + p.app.server.Emit(&ApiEvent{Type: ApiEventTypeQueue}) +} diff --git a/daemon/player_queue_test.go b/daemon/player_queue_test.go new file mode 100644 index 00000000..759a37bd --- /dev/null +++ b/daemon/player_queue_test.go @@ -0,0 +1,404 @@ +//go:build test_unit + +package daemon + +import ( + "encoding/binary" + "math" + "sync" + "testing" + "time" + + librespot "github.com/devgianlu/go-librespot" + "github.com/devgianlu/go-librespot/ap" + "github.com/devgianlu/go-librespot/player" + connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate" + "github.com/devgianlu/go-librespot/session" + "github.com/devgianlu/go-librespot/tracks" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func entry(uri, uid, provider string) *connectpb.ProvidedTrack { + return &connectpb.ProvidedTrack{Uri: uri, Uid: uid, Provider: provider} +} + +func playerQueue(t *testing.T, p *AppPlayer) *ApiQueue { + t.Helper() + + resp, err := p.handleApiRequest(ApiRequest{Type: ApiRequestTypeGetQueue}) + require.NoError(t, err) + return resp.(*ApiQueue) +} + +func queueEntryUris(entries []ApiQueueEntry) []string { + uris := make([]string, 0, len(entries)) + for _, e := range entries { + uris = append(uris, e.Uri) + } + return uris +} + +func queueEvents(p *AppPlayer) int { + n := 0 + for _, ev := range apiEvents(p) { + if ev == ApiEventTypeQueue { + n++ + } + } + return n +} + +// newQueueTestPlayer is a player with the metadata cache on, over a session +// with nothing behind it: contexts that carry their tracks resolve without +// it, and nothing else reaches it. +func newQueueTestPlayer(t *testing.T) *AppPlayer { + t.Helper() + + p := newTestAppPlayer(t) + p.ctx = t.Context() + p.sess = &session.Session{} + p.app.cfg = &Config{Metadata: MetadataConfig{Enabled: true}} + p.app.metaCache = newTrackMetaCache(trackMetaCacheLimit) + p.app.contextLists = newContextListCache() + p.meta = newMetaFetcher(p.app.log, p.app.metaCache, nil) + p.meta.fetch = echoMetadata + p.metaPrefetchTimer = time.NewTimer(math.MaxInt64) + p.metaPrefetchTimer.Stop() + p.prodInfo = &ProductInfo{} + + return p +} + +func streamOf(t *testing.T, uri, name string) *player.Stream { + t.Helper() + + id, err := librespot.SpotifyIdFromUri(uri) + require.NoError(t, err) + return &player.Stream{RequestedId: *id, Media: librespot.NewMediaFromTrack(metaTrack(id.Id(), name))} +} + +// The queue lists the play order around the current track as Spotify's queue +// view shows it: where each entry comes from, the uid that tells two copies of +// a track apart, and the metadata of the entries already cached. +func TestPlayerQueue(t *testing.T) { + p := newMetaTestPlayer(t, echoMetadata, nil) + require.Equal(t, &ApiQueue{PrevTracks: []ApiQueueEntry{}, NextTracks: []ApiQueueEntry{}}, playerQueue(t, p)) + + // The shapes measured on a live session: album tracks carry no uid, the + // user's queue numbers its entries, the same track queued twice included. + p.state.player.PrevTracks = []*connectpb.ProvidedTrack{entry(trackUri(0x01), "", "context")} + p.state.player.Track = entry(trackUri(0x02), "", "context") + p.state.player.NextTracks = []*connectpb.ProvidedTrack{ + entry(trackUri(0x03), "q1", "queue"), + entry(trackUri(0x03), "q2", "queue"), + entry(trackUri(0x04), "", "context"), + entry("spotify:local:Artist:Album:Title:200", "", "context"), + entry("spotify:delimiter", "", "context"), + } + p.app.metaCache.put(trackUri(0x02), librespot.NewMediaFromTrack(metaTrack(gid(0x02), "current"))) + p.app.metaCache.put(trackUri(0x03), librespot.NewMediaFromTrack(metaTrack(gid(0x03), "queued"))) + + queue := playerQueue(t, p) + require.Equal(t, []string{trackUri(0x01)}, queueEntryUris(queue.PrevTracks)) + require.Nil(t, queue.PrevTracks[0].Uid) + require.Equal(t, QueueEntryProviderContext, queue.PrevTracks[0].Provider) + require.Nil(t, queue.PrevTracks[0].Track, "not cached") + + require.Equal(t, trackUri(0x02), queue.Track.Uri) + require.Equal(t, "current", queue.Track.Track.Name) + + require.Equal(t, []string{trackUri(0x03), trackUri(0x03), trackUri(0x04), "spotify:local:Artist:Album:Title:200"}, + queueEntryUris(queue.NextTracks), "a local file listed, no delimiter") + require.Nil(t, queue.NextTracks[3].Track) + require.Equal(t, "q1", *queue.NextTracks[0].Uid) + require.Equal(t, "q2", *queue.NextTracks[1].Uid) + require.Equal(t, QueueEntryProviderQueue, queue.NextTracks[1].Provider) + require.Equal(t, "queued", queue.NextTracks[1].Track.Name) + require.Equal(t, QueueEntryProviderContext, queue.NextTracks[2].Provider) + require.Nil(t, queue.NextTracks[2].Track) +} + +// Without the metadata cache the play order is still listed, every track null. +func TestPlayerQueueWithoutMetadata(t *testing.T) { + p := newTestAppPlayer(t) + p.app.cfg = &Config{} + p.prodInfo = &ProductInfo{} + p.state.player.Track = entry(trackUri(0x01), "", "autoplay") + p.state.player.NextTracks = []*connectpb.ProvidedTrack{entry(trackUri(0x02), "e58e6bb1e268e0bb", "autoplay")} + + queue := playerQueue(t, p) + require.Equal(t, trackUri(0x01), queue.Track.Uri) + require.Nil(t, queue.Track.Track) + require.Equal(t, []string{trackUri(0x02)}, queueEntryUris(queue.NextTracks)) + require.Equal(t, QueueEntryProviderAutoplay, queue.NextTracks[0].Provider) + require.Nil(t, queue.NextTracks[0].Track) +} + +// The current entry carries the playing position: it stands still while the +// track loads and at the end of a track that ended but is still current. The +// other entries have not started. +func TestPlayerQueueCurrentEntryPosition(t *testing.T) { + for name, tc := range map[string]struct { + paused, buffering bool + duration int32 + want int64 + }{ + "playing": {duration: 300_000, want: 45_000}, + "paused": {paused: true, duration: 300_000, want: 42_000}, + "loading": {buffering: true, duration: 300_000, want: 42_000}, + "ended": {duration: 1000, want: 1000}, + } { + t.Run(name, func(t *testing.T) { + p := newMetaTestPlayer(t, echoMetadata, nil) + p.publishSnapshot(&tracks.Snapshot{ + Current: provided(trackUri(0x02)), + Next: []*connectpb.ProvidedTrack{provided(trackUri(0x03))}, + }) + current := metaTrack(gid(0x02), "current") + current.Duration = proto.Int32(tc.duration) + p.app.metaCache.put(trackUri(0x02), librespot.NewMediaFromTrack(current)) + p.app.metaCache.put(trackUri(0x03), librespot.NewMediaFromTrack(metaTrack(gid(0x03), "next"))) + + p.state.player.IsPlaying = true + p.state.player.IsPaused = tc.paused + p.state.player.IsBuffering = tc.buffering + p.state.player.Timestamp = time.Now().Add(-3 * time.Second).UnixMilli() + p.state.player.PositionAsOfTimestamp = 42_000 + + queue := playerQueue(t, p) + require.InDelta(t, tc.want, queue.Track.Track.Position, 500) + require.Zero(t, queue.NextTracks[0].Track.Position) + }) + } +} + +// A queue event is raised once each time what GET /player/queue returns +// changes, and never for a change it would not show. +func TestQueueEvent(t *testing.T) { + p := newMetaTestPlayer(t, echoMetadata, nil) + + window := &tracks.Snapshot{ + Current: provided(trackUri(0x02)), + Prev: []*connectpb.ProvidedTrack{provided(trackUri(0x01))}, + Next: []*connectpb.ProvidedTrack{provided(trackUri(0x03))}, + } + p.publishSnapshot(window) + require.Equal(t, 1, queueEvents(p)) + p.publishSnapshot(window) + require.Equal(t, 1, queueEvents(p), "the same window") + + queued := func(uid, provider string) *tracks.Snapshot { + return &tracks.Snapshot{ + Current: provided(trackUri(0x02)), + Prev: []*connectpb.ProvidedTrack{provided(trackUri(0x01))}, + Next: []*connectpb.ProvidedTrack{entry(trackUri(0x04), uid, provider), provided(trackUri(0x03))}, + } + } + p.publishSnapshot(queued("q1", "queue")) + require.Equal(t, 2, queueEvents(p), "a track queued") + p.publishSnapshot(queued("q1", "context")) + require.Equal(t, 3, queueEvents(p), "the provider alone") + p.publishSnapshot(queued("q2", "context")) + require.Equal(t, 4, queueEvents(p), "the uid alone") + p.publishUpcoming(&tracks.Snapshot{Next: []*connectpb.ProvidedTrack{provided(trackUri(0x03))}}) + require.Equal(t, 5, queueEvents(p), "the surroundings alone") + + p.state.player.PositionAsOfTimestamp = 42_000 + p.emitQueue() + require.Equal(t, 5, queueEvents(p), "the playhead alone") + + // A load caches its stream's description again, as a new copy. + describe := func(name string) { + p.app.metaCache.put(trackUri(0x03), librespot.NewMediaFromTrack(metaTrack(gid(0x03), name))) + p.emitQueue() + } + describe("next") + require.Equal(t, 6, queueEvents(p), "described") + describe("next") + require.Equal(t, 6, queueEvents(p), "the same description") + describe("renamed") + require.Equal(t, 7, queueEvents(p), "a new description") +} + +// Changes that move the play order or describe its entries outside a +// published window raise the event too. +func TestQueueEventOutsideAWindow(t *testing.T) { + transfer := func(t *testing.T, p *AppPlayer) { + require.NoError(t, p.handlePlayerCommand(transferCommand(t, &connectpb.TransferState{ + Options: &connectpb.ContextPlayerOptions{}, + CurrentSession: &connectpb.Session{Context: &connectpb.Context{Uri: "spotify:playlist:37i9dQZF1DWVKDF4ycOESi"}}, + Playback: &connectpb.Playback{CurrentTrack: &connectpb.ContextTrack{Uri: jamTrackUri}}, + }))) + } + + for name, tc := range map[string]struct { + change func(t *testing.T, p *AppPlayer) + events int + }{ + "stopped": {func(t *testing.T, p *AppPlayer) { p.stopPlayback() }, 2}, + "context started": {func(t *testing.T, p *AppPlayer) { + p.loadContext(&connectpb.Context{Uri: "spotify:unsupported:37i9dQZF1DX8vwRmUsEIMT"}, nil, false, true, func(error) {}) + runQueuedJob(t, p) + }, 2}, + // The track handed over, then nothing once the context fails. + "transfer given up": {func(t *testing.T, p *AppPlayer) { + p.resolveTrackList = failResolve + transfer(t, p) + runQueuedJob(t, p) + }, 3}, + "taken over idle": {func(t *testing.T, p *AppPlayer) { + req := transferCommand(t, &connectpb.TransferState{ + Options: &connectpb.ContextPlayerOptions{}, + CurrentSession: &connectpb.Session{}, + Playback: &connectpb.Playback{Timestamp: 1790340000000}, + }) + req.Command.Options.RestoreTrack = "always_play_something" + require.NoError(t, p.handlePlayerCommand(req)) + }, 2}, + "next track prefetched": {func(t *testing.T, p *AppPlayer) { + p.commitPrefetch(streamOf(t, trackUri(0x02), "next"), nil) + }, 2}, + "current track loaded": {func(t *testing.T, p *AppPlayer) { + p.commitLoad(streamOf(t, trackUri(0x01), "current"), trackUri(0x01), false, 0, false) + }, 2}, + } { + t.Run(name, func(t *testing.T) { + p := newQueueTestPlayer(t) + playingTrack(t, p, trackUri(0x01), "u1") + p.publishSnapshot(&tracks.Snapshot{Current: p.state.player.Track, Next: []*connectpb.ProvidedTrack{provided(trackUri(0x02))}}) + require.Equal(t, 1, queueEvents(p)) + + tc.change(t, p) + require.Equal(t, tc.events, queueEvents(p)) + }) + } +} + +// Tracks are described only once the product info is known, and its arrival +// names the entries already cached. +func TestQueueEventWhenTheProductInfoArrives(t *testing.T) { + p := newQueueTestPlayer(t) + p.prodInfo = nil + p.app.metaCache.put(trackUri(0x01), librespot.NewMediaFromTrack(metaTrack(gid(0x01), "current"))) + p.publishSnapshot(&tracks.Snapshot{Current: provided(trackUri(0x01))}) + require.Equal(t, 1, queueEvents(p)) + require.Nil(t, playerQueue(t, p).Track.Track) + + products := `premiumhttps://i.scdn.co/image/{file_id}` + require.NoError(t, p.handleAccesspointPacket(ap.PacketTypeProductInfo, []byte(products))) + require.Equal(t, 2, queueEvents(p)) + require.Equal(t, "current", playerQueue(t, p).Track.Track.Name) +} + +// serveLoop runs p's loop without a session until the test ends, and returns +// a func that stops it. Requests sent to api are served by the loop. +func serveLoop(t *testing.T, p *AppPlayer, api chan ApiRequest) (stop func()) { + t.Helper() + + p.stop = make(chan struct{}, 1) + p.sleepTimer = time.NewTimer(math.MaxInt64) + p.sleepTimer.Stop() + + done := make(chan struct{}) + go func() { + defer close(done) + p.serve(nil, nil, nil, nil, api, nil) + }() + + var once sync.Once + stop = func() { + once.Do(func() { + p.stop <- struct{}{} + <-done + }) + } + t.Cleanup(stop) + return stop +} + +// Metadata fetched off the loop wakes it, and clients are told the window's +// entries are named; a fetch that names nothing in the window says nothing. +func TestQueueEventWhenMetadataIsFetched(t *testing.T) { + p := newQueueTestPlayer(t) + p.publishSnapshot(&tracks.Snapshot{Current: provided(trackUri(0x02))}) + require.Equal(t, 1, queueEvents(p)) + + api := make(chan ApiRequest) + serveLoop(t, p, api) + fetch := func(uri string) *ApiQueue { + _, err := p.meta.fetchBatch(t.Context(), []string{uri}) + require.NoError(t, err) + require.Eventually(t, func() bool { return len(p.meta.cached) == 0 }, 5*time.Second, time.Millisecond) + + req, wait := NewApiRequest(ApiRequestTypeGetQueue, nil) + api <- req + resp, err := wait(t.Context()) + require.NoError(t, err) + return resp.(*ApiQueue) + } + + require.NotNil(t, fetch(trackUri(0x02)).Track.Track) + require.Equal(t, 2, queueEvents(p)) + fetch(trackUri(0x05)) + require.Equal(t, 2, queueEvents(p), "not in the window") +} + +// A session that ends takes its play order with it, and clients are told. +func TestQueueEventWhenTheSessionEnds(t *testing.T) { + quiet := newQueueTestPlayer(t) + serveLoop(t, quiet, nil)() + require.Zero(t, queueEvents(quiet), "nothing was listed") + + p := newQueueTestPlayer(t) + p.publishSnapshot(&tracks.Snapshot{Current: provided(trackUri(0x01))}) + require.Equal(t, 1, queueEvents(p)) + serveLoop(t, p, nil)() + require.Equal(t, 2, queueEvents(p)) +} + +// otherContextUris names n tracks of another context, none of them in the +// window. +func otherContextUris(base uint32, n int) []string { + uris := make([]string, 0, n) + for i := 0; i < n; i++ { + id := make([]byte, 16) + id[0] = 0xff + binary.BigEndian.PutUint32(id[12:], base+uint32(i)) + uris = append(uris, librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeTrack, id).Uri()) + } + return uris +} + +// The window's entries stay the most recently used part of the metadata cache, +// so that sweeping or listing other contexts evicts around them: each wake +// marks them, and so does a listing after its own tracks. +func TestQueueKeepsItsEntriesNamed(t *testing.T) { + p := newMetaTestPlayer(t, echoMetadata, nil) + p.publishSnapshot(&tracks.Snapshot{ + Current: provided(trackUri(0x02)), + Prev: []*connectpb.ProvidedTrack{provided(trackUri(0x01))}, + Next: []*connectpb.ProvidedTrack{provided(trackUri(0x03))}, + }) + fetch := func(uris []string) { + for len(uris) > 0 { + batch := uris[:min(len(uris), maxMetaBatch)] + uris = uris[len(batch):] + _, err := p.meta.fetchBatch(t.Context(), batch) + require.NoError(t, err) + p.emitQueue() // as the loop does when the batch wakes it + } + } + fetch(p.windowMetaUris()) + + swept := otherContextUris(0, trackMetaCacheLimit) + fetch(swept) + _ = p.contextTracksResponse("spotify:playlist:37i9dQZF1DX8vwRmUsEIMT", swept[3:], true) + fetch(otherContextUris(trackMetaCacheLimit, maxMetaBatch)) + + queue := p.apiQueue() + require.NotNil(t, queue.PrevTracks[0].Track) + require.NotNil(t, queue.Track.Track) + require.NotNil(t, queue.NextTracks[0].Track) + require.Equal(t, 2, queueEvents(p), "named once, never unnamed") +} diff --git a/daemon/player_state.go b/daemon/player_state.go index 5cf8d3aa..52a236f1 100644 --- a/daemon/player_state.go +++ b/daemon/player_state.go @@ -308,8 +308,10 @@ func (p *AppPlayer) publishUpcoming(snap *tracks.Snapshot) { p.state.player.NextTracks = snap.Next // Every path that moves the window — load, transfer, skip, queue edit, - // shuffle — lands here, so this is where its metadata is asked for. + // shuffle — lands here, so this is where its metadata is asked for and + // where the move is announced. p.scheduleMetaPrefetch() + p.emitQueue() } // statePutMinInterval is the minimum spacing between connect-state PUTs. diff --git a/daemon/track_meta_cache.go b/daemon/track_meta_cache.go index 2cfc0dbd..1da597d2 100644 --- a/daemon/track_meta_cache.go +++ b/daemon/track_meta_cache.go @@ -268,6 +268,9 @@ type metaFetcher struct { fetch func(ctx context.Context, req *extmetadatapb.BatchedEntityRequest) (*extmetadatapb.BatchedExtensionResponse, error) resolve func(ctx context.Context, uri string) (tracks.ContextResolver, error) + // cached is signalled each time a batch cached tracks. + cached chan struct{} + // inFlight single-flights the window fetch: a timer that fires while one // runs re-arms itself rather than starting another. inFlight atomic.Bool @@ -277,9 +280,10 @@ type metaFetcher struct { func newMetaFetcher(log librespot.Logger, cache *trackMetaCache, sp *spclient.Spclient) *metaFetcher { return &metaFetcher{ - log: log, - cache: cache, - fetch: sp.ExtendedMetadata, + log: log, + cache: cache, + cached: make(chan struct{}, 1), + fetch: sp.ExtendedMetadata, resolve: func(ctx context.Context, uri string) (tracks.ContextResolver, error) { // A context of its own, never the playing one: the resolver behind // a track list mutates its pages as it walks, and is reachable only @@ -346,6 +350,12 @@ func (f *metaFetcher) fetchBatch(ctx context.Context, uris []string) (int, error } } + if cached > 0 { + select { + case f.cached <- struct{}{}: + default: // one already pending says the same + } + } return cached, nil } @@ -692,5 +702,11 @@ func (p *AppPlayer) contextTracksResponse(contextUri string, uris []string, read } resp.Tracks = append(resp.Tracks, entry) } + + // Keep the window the most recently used part of the cache, ahead of the + // tracks just listed. + for _, uri := range p.windowMetaUris() { + p.meta.cache.get(uri) + } return resp } diff --git a/daemon/track_meta_cache_test.go b/daemon/track_meta_cache_test.go index 270bbd93..c67c7268 100644 --- a/daemon/track_meta_cache_test.go +++ b/daemon/track_meta_cache_test.go @@ -104,9 +104,13 @@ func recordingFetch(fetch fetchFunc) (fetchFunc, func() [][]string) { func newMetaTestPlayer(t *testing.T, fetch fetchFunc, resolve func(context.Context, string) (tracks.ContextResolver, error)) *AppPlayer { t.Helper() + server, err := NewStubApiServer(&librespot.NullLogger{}) + require.NoError(t, err) + app := &App{ log: &librespot.NullLogger{}, cfg: &Config{Metadata: MetadataConfig{Enabled: true}}, + server: &recordingApiServer{ApiServer: server}, metaCache: newTrackMetaCache(trackMetaCacheLimit), contextLists: newContextListCache(), } @@ -381,8 +385,8 @@ func TestMetadataDisabledIsNoop(t *testing.T) { require.False(t, claimed(cl, "spotify:playlist:a"), "a nil cache never claims an enumeration") // A disabled player: no timer is armed and no session is touched. - p := &AppPlayer{app: &App{cfg: &Config{}}, state: &State{}} - p.state.reset() + p := newTestAppPlayer(t) + p.app.cfg = &Config{} p.publishSnapshot(&tracks.Snapshot{Current: provided(trackUri(0x01)), Next: []*connectpb.ProvidedTrack{provided(trackUri(0x02))}}) p.scheduleContextEnumerate("spotify:playlist:a") p.scheduleMetaSweep([]string{"spotify:track:a"}, "test")