From d670a72ac3e81a97f0067ad54754ef27dc49136a Mon Sep 17 00:00:00 2001 From: jmt Date: Sun, 27 Sep 2026 11:51:31 +0200 Subject: [PATCH] feat: add GET /library/albums and /library/artists List the albums saved in the user's library and the artists the user follows, most recent first. The lists come from the collection service (saved albums share the "collection" set with Liked Songs, followed artists are the "artist" set) and names, years and images from batched extended metadata, so neither goes through the rate-limited Web API. Like /library/playlists, every request reads the list and pages it in memory; only the requested page's entities are looked up. The sets are read with the new Spclient.CollectionPage, which pages through a collection set (PageRequest/PageResponse). Co-Authored-By: Claude Opus 5.5 --- api-spec.yml | 157 ++++++++++++++ daemon/api_gen.go | 154 +++++++++++++ daemon/api_server.go | 29 ++- daemon/api_server_test.go | 27 ++- daemon/library_collections.go | 215 +++++++++++++++++++ daemon/library_collections_test.go | 79 +++++++ daemon/player.go | 26 ++- proto/spotify/collection/v2/collection.pb.go | 157 +++++++++++++- proto/spotify/collection/v2/collection.proto | 13 ++ spclient/library_write.go | 47 +++- spclient/library_write_test.go | 26 +++ 11 files changed, 911 insertions(+), 19 deletions(-) create mode 100644 daemon/library_collections.go create mode 100644 daemon/library_collections_test.go diff --git a/api-spec.yml b/api-spec.yml index 9c5b9c6f..d781949d 100644 --- a/api-spec.yml +++ b/api-spec.yml @@ -329,6 +329,84 @@ components: type: array items: $ref: '#/components/schemas/libraryPlaylist' + libraryAlbum: + x-go-name: ApiLibraryAlbum + description: An album saved in the user's library + type: object + required: [ uri, name, artist_names, year, image_url ] + properties: + uri: + description: Album URI + type: string + name: + description: Album name, empty when its metadata could not be read + type: string + artist_names: + description: Names of the album artists + type: array + items: + type: string + year: + description: Release year, zero when unknown + type: integer + image_url: + description: Cover image URL, null when unknown + type: string + nullable: true + libraryAlbums: + x-go-name: ApiLibraryAlbums + description: A page of the user's saved albums + type: object + required: [ total, offset, limit, items ] + properties: + total: + description: Number of saved albums + type: integer + offset: + description: Index of the first returned album + type: integer + limit: + description: Maximum number of albums requested + type: integer + items: + type: array + items: + $ref: '#/components/schemas/libraryAlbum' + libraryArtist: + x-go-name: ApiLibraryArtist + description: An artist the user follows + type: object + required: [ uri, name, image_url ] + properties: + uri: + description: Artist URI + type: string + name: + description: Artist name, empty when its metadata could not be read + type: string + image_url: + description: Portrait image URL, null when unknown + type: string + nullable: true + libraryArtists: + x-go-name: ApiLibraryArtists + description: A page of the artists the user follows + type: object + required: [ total, offset, limit, items ] + properties: + total: + description: Number of followed artists + type: integer + offset: + description: Index of the first returned artist + type: integer + limit: + description: Maximum number of artists requested + type: integer + items: + type: array + items: + $ref: '#/components/schemas/libraryArtist' likedState: x-go-name: ApiLikedState description: Whether a track is in the user's Liked Songs @@ -760,6 +838,85 @@ paths: description: No active session 400: description: The offset or limit is out of range + /library/albums: + get: + operationId: getLibraryAlbums + description: >- + List the albums saved in the user's library, most recently saved + first. The list comes from the internal collection service and the + album details from extended metadata, not the rate-limited public Web + API. + parameters: + - name: offset + in: query + required: false + description: Index of the first item to return + schema: + type: integer + minimum: 0 + default: 0 + x-go-type-skip-optional-pointer: true + - name: limit + in: query + required: false + description: Maximum number of items to return, from 1 to 500 + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + x-go-type-skip-optional-pointer: true + responses: + 200: + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/libraryAlbums' + 204: + description: No active session + 400: + description: The offset or limit is out of range + /library/artists: + get: + operationId: getLibraryArtists + description: >- + List the artists the user follows, most recently followed first. The + list comes from the internal collection service and the artist + details from extended metadata, not the rate-limited public Web API. + Play an artist, or list its tracks through /context/tracks, with its + URI. + parameters: + - name: offset + in: query + required: false + description: Index of the first item to return + schema: + type: integer + minimum: 0 + default: 0 + x-go-type-skip-optional-pointer: true + - name: limit + in: query + required: false + description: Maximum number of items to return, from 1 to 500 + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + x-go-type-skip-optional-pointer: true + responses: + 200: + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/libraryArtists' + 204: + description: No active session + 400: + description: The offset or limit is out of range /library/liked: get: operationId: getLiked diff --git a/daemon/api_gen.go b/daemon/api_gen.go index 3cfbcda9..2e75c4f5 100644 --- a/daemon/api_gen.go +++ b/daemon/api_gen.go @@ -66,6 +66,64 @@ type ApiDeviceAuth struct { Url string `json:"url"` } +// ApiLibraryAlbum An album saved in the user's library +type ApiLibraryAlbum struct { + // ArtistNames Names of the album artists + ArtistNames []string `json:"artist_names"` + + // ImageUrl Cover image URL, null when unknown + ImageUrl *string `json:"image_url"` + + // Name Album name, empty when its metadata could not be read + Name string `json:"name"` + + // Uri Album URI + Uri string `json:"uri"` + + // Year Release year, zero when unknown + Year int `json:"year"` +} + +// ApiLibraryAlbums A page of the user's saved albums +type ApiLibraryAlbums struct { + Items []ApiLibraryAlbum `json:"items"` + + // Limit Maximum number of albums requested + Limit int `json:"limit"` + + // Offset Index of the first returned album + Offset int `json:"offset"` + + // Total Number of saved albums + Total int `json:"total"` +} + +// ApiLibraryArtist An artist the user follows +type ApiLibraryArtist struct { + // ImageUrl Portrait image URL, null when unknown + ImageUrl *string `json:"image_url"` + + // Name Artist name, empty when its metadata could not be read + Name string `json:"name"` + + // Uri Artist URI + Uri string `json:"uri"` +} + +// ApiLibraryArtists A page of the artists the user follows +type ApiLibraryArtists struct { + Items []ApiLibraryArtist `json:"items"` + + // Limit Maximum number of artists requested + Limit int `json:"limit"` + + // Offset Index of the first returned artist + Offset int `json:"offset"` + + // Total Number of followed artists + Total int `json:"total"` +} + // ApiLibraryPlaylist A playlist in the user's library type ApiLibraryPlaylist struct { // CanEdit Whether the user may add items to the playlist, true for their own and for collaborative playlists @@ -378,6 +436,24 @@ type GetContextTracksParams struct { Uri string `form:"uri" json:"uri"` } +// GetLibraryAlbumsParams defines parameters for GetLibraryAlbums. +type GetLibraryAlbumsParams struct { + // Offset Index of the first item to return + Offset int `form:"offset,omitempty" json:"offset,omitempty"` + + // Limit Maximum number of items to return, from 1 to 500 + Limit int `form:"limit,omitempty" json:"limit,omitempty"` +} + +// GetLibraryArtistsParams defines parameters for GetLibraryArtists. +type GetLibraryArtistsParams struct { + // Offset Index of the first item to return + Offset int `form:"offset,omitempty" json:"offset,omitempty"` + + // Limit Maximum number of items to return, from 1 to 500 + Limit int `form:"limit,omitempty" json:"limit,omitempty"` +} + // GetLikedParams defines parameters for GetLiked. type GetLikedParams struct { // Uris Comma-separated track URIs, 1 to 50 @@ -456,6 +532,12 @@ type ServerInterface interface { // (GET /events) GetEvents(w http.ResponseWriter, r *http.Request) + // (GET /library/albums) + GetLibraryAlbums(w http.ResponseWriter, r *http.Request, params GetLibraryAlbumsParams) + + // (GET /library/artists) + GetLibraryArtists(w http.ResponseWriter, r *http.Request, params GetLibraryArtistsParams) + // (GET /library/liked) GetLiked(w http.ResponseWriter, r *http.Request, params GetLikedParams) @@ -614,6 +696,76 @@ func (siw *ServerInterfaceWrapper) GetEvents(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// GetLibraryAlbums operation middleware +func (siw *ServerInterfaceWrapper) GetLibraryAlbums(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetLibraryAlbumsParams + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", r.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetLibraryAlbums(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetLibraryArtists operation middleware +func (siw *ServerInterfaceWrapper) GetLibraryArtists(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetLibraryArtistsParams + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", r.URL.Query(), ¶ms.Offset) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "offset", Err: err}) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetLibraryArtists(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetLiked operation middleware func (siw *ServerInterfaceWrapper) GetLiked(w http.ResponseWriter, r *http.Request) { @@ -1150,6 +1302,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("GET "+options.BaseURL+"/auth/code", wrapper.GetAuthCode) m.HandleFunc("GET "+options.BaseURL+"/context/tracks", wrapper.GetContextTracks) m.HandleFunc("GET "+options.BaseURL+"/events", wrapper.GetEvents) + m.HandleFunc("GET "+options.BaseURL+"/library/albums", wrapper.GetLibraryAlbums) + m.HandleFunc("GET "+options.BaseURL+"/library/artists", wrapper.GetLibraryArtists) m.HandleFunc("GET "+options.BaseURL+"/library/liked", wrapper.GetLiked) m.HandleFunc("POST "+options.BaseURL+"/library/liked", wrapper.SetLiked) m.HandleFunc("GET "+options.BaseURL+"/library/playlists", wrapper.GetLibraryPlaylists) diff --git a/daemon/api_server.go b/daemon/api_server.go index 3c868363..c4afe251 100644 --- a/daemon/api_server.go +++ b/daemon/api_server.go @@ -135,6 +135,8 @@ const ( ApiRequestTypeReopenOutput ApiRequestType = "reopen_output" ApiRequestTypeContextTracks ApiRequestType = "context_tracks" ApiRequestTypeLibraryPlaylists ApiRequestType = "library_playlists" + ApiRequestTypeLibraryAlbums ApiRequestType = "library_albums" + ApiRequestTypeLibraryArtists ApiRequestType = "library_artists" ApiRequestTypeSetLiked ApiRequestType = "set_liked" ApiRequestTypeGetLiked ApiRequestType = "get_liked" ApiRequestTypePlaylistAddTracks ApiRequestType = "playlist_add_tracks" @@ -148,9 +150,9 @@ type ApiRequestDataContextTracks struct { Uri string } -// ApiRequestDataLibraryPlaylists carries the paging query parameters of the -// library playlists request. -type ApiRequestDataLibraryPlaylists struct { +// ApiRequestDataLibraryPage carries the paging query parameters of the +// library listing requests. +type ApiRequestDataLibraryPage struct { Offset int Limit int } @@ -561,18 +563,31 @@ const ( libraryPlaylistsMaxLimit = 500 ) -func (s *ConcreteApiServer) GetLibraryPlaylists(w http.ResponseWriter, r *http.Request, params GetLibraryPlaylistsParams) { +// handleLibraryPage validates the paging of a library listing and forwards it. +func (s *ConcreteApiServer) handleLibraryPage(w http.ResponseWriter, r *http.Request, typ ApiRequestType, offset, limit int) { // The generated params cannot tell an absent limit from limit=0. if !r.URL.Query().Has("limit") { - params.Limit = libraryPlaylistsDefaultLimit + limit = libraryPlaylistsDefaultLimit } - if params.Offset < 0 || params.Limit < 1 || params.Limit > libraryPlaylistsMaxLimit { + if offset < 0 || limit < 1 || limit > libraryPlaylistsMaxLimit { w.WriteHeader(http.StatusBadRequest) return } - s.handleRequest(ApiRequest{Type: ApiRequestTypeLibraryPlaylists, Data: ApiRequestDataLibraryPlaylists{Offset: params.Offset, Limit: params.Limit}}, w) + s.handleRequest(ApiRequest{Type: typ, Data: ApiRequestDataLibraryPage{Offset: offset, Limit: limit}}, w) +} + +func (s *ConcreteApiServer) GetLibraryPlaylists(w http.ResponseWriter, r *http.Request, params GetLibraryPlaylistsParams) { + s.handleLibraryPage(w, r, ApiRequestTypeLibraryPlaylists, params.Offset, params.Limit) +} + +func (s *ConcreteApiServer) GetLibraryAlbums(w http.ResponseWriter, r *http.Request, params GetLibraryAlbumsParams) { + s.handleLibraryPage(w, r, ApiRequestTypeLibraryAlbums, params.Offset, params.Limit) +} + +func (s *ConcreteApiServer) GetLibraryArtists(w http.ResponseWriter, r *http.Request, params GetLibraryArtistsParams) { + s.handleLibraryPage(w, r, ApiRequestTypeLibraryArtists, params.Offset, params.Limit) } // maxLibraryUris caps how many items one library request may name. diff --git a/daemon/api_server_test.go b/daemon/api_server_test.go index 9749fe25..ddd58933 100644 --- a/daemon/api_server_test.go +++ b/daemon/api_server_test.go @@ -154,6 +154,8 @@ var endpointMethods = map[string][]string{ "/player/output": {http.MethodPost}, "/context/tracks": {http.MethodGet}, "/library/playlists": {http.MethodGet}, + "/library/albums": {http.MethodGet}, + "/library/artists": {http.MethodGet}, "/library/liked": {http.MethodGet, http.MethodPost}, "/library/playlists/add_tracks": {http.MethodPost}, "/library/playlists/remove_track": {http.MethodPost}, @@ -584,7 +586,7 @@ func TestApiLibraryPlaylists(t *testing.T) { req := ts.request() require.Equal(t, ApiRequestTypeLibraryPlaylists, req.Type) - require.Equal(t, ApiRequestDataLibraryPlaylists{Offset: 0, Limit: 50}, req.Data) + require.Equal(t, ApiRequestDataLibraryPage{Offset: 0, Limit: 50}, req.Data) }) t.Run("forwards the paging", func(t *testing.T) { @@ -592,7 +594,7 @@ func TestApiLibraryPlaylists(t *testing.T) { resp := ts.do(http.MethodGet, "/library/playlists?offset=20&limit=500", nil) require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, ApiRequestDataLibraryPlaylists{Offset: 20, Limit: 500}, ts.request().Data) + require.Equal(t, ApiRequestDataLibraryPage{Offset: 20, Limit: 500}, ts.request().Data) }) for _, query := range []string{"limit=0", "limit=501", "offset=-1", "limit=abc"} { @@ -622,6 +624,27 @@ func TestApiLibraryPlaylists(t *testing.T) { }) } +func TestApiLibraryAlbumsAndArtists(t *testing.T) { + for path, typ := range map[string]ApiRequestType{ + "/library/albums": ApiRequestTypeLibraryAlbums, + "/library/artists": ApiRequestTypeLibraryArtists, + } { + t.Run(path, func(t *testing.T) { + ts := newTestServer(t, okReply) + + resp := ts.do(http.MethodGet, path+"?offset=5&limit=10", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + req := ts.request() + require.Equal(t, typ, req.Type) + require.Equal(t, ApiRequestDataLibraryPage{Offset: 5, Limit: 10}, req.Data) + + resp = ts.do(http.MethodGet, path+"?limit=0", nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + ts.requireNoRequest() + }) + } +} + func TestApiGetLiked(t *testing.T) { t.Run("forwards comma-separated uris", func(t *testing.T) { ts := newTestServer(t, func(ApiRequest) (any, error) { diff --git a/daemon/library_collections.go b/daemon/library_collections.go new file mode 100644 index 00000000..b31c6469 --- /dev/null +++ b/daemon/library_collections.go @@ -0,0 +1,215 @@ +package daemon + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + + collectionpb "github.com/devgianlu/go-librespot/proto/spotify/collection/v2" + extmetadatapb "github.com/devgianlu/go-librespot/proto/spotify/extendedmetadata" + metadatapb "github.com/devgianlu/go-librespot/proto/spotify/metadata" + "github.com/devgianlu/go-librespot/spclient" + "google.golang.org/protobuf/types/known/anypb" +) + +// extendedMetadataBatch caps how many entities one extended metadata request +// asks for. +const extendedMetadataBatch = 100 + +// collectionPageSize is how many items are asked for per collection page. +const collectionPageSize = 500 + +// collectionMaxPages caps the pages read for one set, in case the service +// keeps handing out page tokens. +const collectionMaxPages = 100 + +// pageBounds clamps a page of limit items at offset to a list of n items. +func pageBounds(n, offset, limit int) (start, end int) { + start = min(offset, n) + return start, min(start+limit, n) +} + +// fetchCollectionUris pages through a collection set and returns the URIs of +// the given type, most recently added first. +func fetchCollectionUris(ctx context.Context, spc *spclient.Spclient, username, set, prefix string) ([]string, error) { + var items []*collectionpb.CollectionItem + token := "" + for page := 0; ; page++ { + if page == collectionMaxPages { + return nil, fmt.Errorf("collection set %s still paging after %d pages", set, collectionMaxPages) + } + + resp, err := spc.CollectionPage(ctx, username, set, token, collectionPageSize) + if err != nil { + return nil, err + } + + items = append(items, resp.GetItems()...) + token = resp.GetNextPageToken() + if token == "" { + return newestUris(items, prefix), nil + } + } +} + +// newestUris returns the URIs of the items with the given prefix that are not +// removed, most recently added first. +func newestUris(items []*collectionpb.CollectionItem, prefix string) []string { + var kept []*collectionpb.CollectionItem + for _, item := range items { + if !item.GetIsRemoved() && strings.HasPrefix(item.GetUri(), prefix) { + kept = append(kept, item) + } + } + + slices.SortStableFunc(kept, func(a, b *collectionpb.CollectionItem) int { + return cmp.Compare(b.GetAddedAt(), a.GetAddedAt()) + }) + + uris := make([]string, len(kept)) + for i, item := range kept { + uris[i] = item.GetUri() + } + return uris +} + +// fetchEntityMetadata reads extended metadata of one kind for uris in batches +// and hands every entity that resolved to decode. +func fetchEntityMetadata(ctx context.Context, spc *spclient.Spclient, uris []string, kind extmetadatapb.ExtensionKind, decode func(uri string, data *anypb.Any)) error { + for start := 0; start < len(uris); start += extendedMetadataBatch { + req := &extmetadatapb.BatchedEntityRequest{} + for _, uri := range uris[start:min(start+extendedMetadataBatch, len(uris))] { + req.EntityRequest = append(req.EntityRequest, &extmetadatapb.EntityRequest{ + EntityUri: uri, + Query: []*extmetadatapb.ExtensionQuery{{ExtensionKind: kind}}, + }) + } + + resp, err := spc.ExtendedMetadata(ctx, req) + if err != nil { + return err + } + + for _, item := range resp.GetExtendedMetadata() { + if item.GetExtensionKind() != kind { + continue + } + for _, ext := range item.GetExtensionData() { + if ext.GetHeader().GetStatusCode() == 200 && ext.GetExtensionData() != nil { + decode(ext.GetEntityUri(), ext.GetExtensionData()) + } + } + } + } + return nil +} + +// imageUrlFunc turns a list of image variants into the URL of the configured +// size, or nil. +type imageUrlFunc func(images []*metadatapb.Image) *string + +// imageUrlFunc returns how this player turns image variants into a URL: the +// configured size, through the product info's image URL template. +func (p *AppPlayer) imageUrlFunc() imageUrlFunc { + prodInfo, size := p.prodInfo, p.app.cfg.ImageSize + return func(images []*metadatapb.Image) *string { + if prodInfo == nil { + return nil + } + return prodInfo.ImageUrl(getBestImageIdForSize(images, size)) + } +} + +// fetchLibraryAlbums lists a page of the user's saved albums with their +// details, and how many albums there are. Only the page's albums are looked +// up; those whose metadata does not resolve are still listed, by URI. +func fetchLibraryAlbums(ctx context.Context, spc *spclient.Spclient, username string, imageUrl imageUrlFunc, offset, limit int) (*ApiLibraryAlbums, error) { + all, err := fetchCollectionUris(ctx, spc, username, spclient.CollectionSetLikedSongs, "spotify:album:") + if err != nil { + return nil, err + } + start, end := pageBounds(len(all), offset, limit) + uris := all[start:end] + + byUri := make(map[string]*metadatapb.Album, len(uris)) + err = fetchEntityMetadata(ctx, spc, uris, extmetadatapb.ExtensionKind_ALBUM_V4, func(uri string, data *anypb.Any) { + var album metadatapb.Album + if data.UnmarshalTo(&album) == nil { + byUri[uri] = &album + } + }) + if err != nil { + return nil, err + } + + albums := make([]ApiLibraryAlbum, len(uris)) + for i, uri := range uris { + albums[i] = libraryAlbum(uri, byUri[uri], imageUrl) + } + return &ApiLibraryAlbums{Total: len(all), Offset: offset, Limit: limit, Items: albums}, nil +} + +func libraryAlbum(uri string, album *metadatapb.Album, imageUrl imageUrlFunc) ApiLibraryAlbum { + out := ApiLibraryAlbum{Uri: uri, ArtistNames: []string{}} + if album == nil { + return out + } + + out.Name = album.GetName() + for _, artist := range album.GetArtist() { + out.ArtistNames = append(out.ArtistNames, artist.GetName()) + } + out.Year = int(album.GetDate().GetYear()) + images := album.GetCover() + if len(images) == 0 { + images = album.GetCoverGroup().GetImage() + } + out.ImageUrl = imageUrl(images) + return out +} + +// fetchLibraryArtists lists a page of the artists the user follows with their +// details, and how many artists there are. Only the page's artists are looked +// up; those whose metadata does not resolve are still listed, by URI. +func fetchLibraryArtists(ctx context.Context, spc *spclient.Spclient, username string, imageUrl imageUrlFunc, offset, limit int) (*ApiLibraryArtists, error) { + all, err := fetchCollectionUris(ctx, spc, username, spclient.CollectionSetArtists, "spotify:artist:") + if err != nil { + return nil, err + } + start, end := pageBounds(len(all), offset, limit) + uris := all[start:end] + + byUri := make(map[string]*metadatapb.Artist, len(uris)) + err = fetchEntityMetadata(ctx, spc, uris, extmetadatapb.ExtensionKind_ARTIST_V4, func(uri string, data *anypb.Any) { + var artist metadatapb.Artist + if data.UnmarshalTo(&artist) == nil { + byUri[uri] = &artist + } + }) + if err != nil { + return nil, err + } + + artists := make([]ApiLibraryArtist, len(uris)) + for i, uri := range uris { + artists[i] = libraryArtist(uri, byUri[uri], imageUrl) + } + return &ApiLibraryArtists{Total: len(all), Offset: offset, Limit: limit, Items: artists}, nil +} + +func libraryArtist(uri string, artist *metadatapb.Artist, imageUrl imageUrlFunc) ApiLibraryArtist { + out := ApiLibraryArtist{Uri: uri} + if artist == nil { + return out + } + + out.Name = artist.GetName() + images := artist.GetPortrait() + if len(images) == 0 { + images = artist.GetPortraitGroup().GetImage() + } + out.ImageUrl = imageUrl(images) + return out +} diff --git a/daemon/library_collections_test.go b/daemon/library_collections_test.go new file mode 100644 index 00000000..5492273a --- /dev/null +++ b/daemon/library_collections_test.go @@ -0,0 +1,79 @@ +//go:build test_unit + +package daemon + +import ( + "testing" + + collectionpb "github.com/devgianlu/go-librespot/proto/spotify/collection/v2" + metadatapb "github.com/devgianlu/go-librespot/proto/spotify/metadata" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestNewestUris(t *testing.T) { + items := []*collectionpb.CollectionItem{ + {Uri: "spotify:album:old", AddedAt: 100}, + {Uri: "spotify:track:t", AddedAt: 500}, + {Uri: "spotify:album:new", AddedAt: 300}, + {Uri: "spotify:album:gone", AddedAt: 400, IsRemoved: true}, + {Uri: "spotify:album:tie", AddedAt: 100}, + } + + require.Equal(t, []string{"spotify:album:new", "spotify:album:old", "spotify:album:tie"}, newestUris(items, "spotify:album:")) +} + +func TestPageBounds(t *testing.T) { + for _, tc := range []struct{ n, offset, limit, start, end int }{ + {10, 0, 50, 0, 10}, + {10, 4, 3, 4, 7}, + {10, 10, 5, 10, 10}, + {10, 20, 5, 10, 10}, + } { + start, end := pageBounds(tc.n, tc.offset, tc.limit) + require.Equal(t, [2]int{tc.start, tc.end}, [2]int{start, end}, "%+v", tc) + } +} + +func fakeImageUrl(images []*metadatapb.Image) *string { + if len(images) == 0 { + return nil + } + url := "img:" + string(images[0].GetFileId()) + return &url +} + +func TestLibraryAlbum(t *testing.T) { + album := &metadatapb.Album{ + Name: proto.String("Discovery"), + Artist: []*metadatapb.Artist{{Name: proto.String("Daft Punk")}}, + Date: &metadatapb.Date{Year: proto.Int32(2001)}, + CoverGroup: &metadatapb.ImageGroup{Image: []*metadatapb.Image{ + {FileId: []byte("c")}, + }}, + } + + got := libraryAlbum("spotify:album:a", album, fakeImageUrl) + require.Equal(t, "Discovery", got.Name) + require.Equal(t, []string{"Daft Punk"}, got.ArtistNames) + require.Equal(t, 2001, got.Year) + require.Equal(t, "img:c", *got.ImageUrl, "the cover group stands in for a missing cover") + + missing := libraryAlbum("spotify:album:b", nil, fakeImageUrl) + require.Equal(t, "spotify:album:b", missing.Uri) + require.NotNil(t, missing.ArtistNames, "artist_names must serialise as []") + require.Nil(t, missing.ImageUrl) +} + +func TestLibraryArtist(t *testing.T) { + artist := &metadatapb.Artist{ + Name: proto.String("Moloko"), + Portrait: []*metadatapb.Image{{FileId: []byte("p")}}, + } + + got := libraryArtist("spotify:artist:a", artist, fakeImageUrl) + require.Equal(t, "Moloko", got.Name) + require.Equal(t, "img:p", *got.ImageUrl) + + require.Equal(t, ApiLibraryArtist{Uri: "spotify:artist:b"}, libraryArtist("spotify:artist:b", nil, fakeImageUrl)) +} diff --git a/daemon/player.go b/daemon/player.go index 12e2af9f..7b3b1333 100644 --- a/daemon/player.go +++ b/daemon/player.go @@ -877,7 +877,7 @@ func (p *AppPlayer) handleApiRequest(req ApiRequest) (any, error) { case ApiRequestTypeLibraryPlaylists: // Like the token, the rootlist touches no player state, so the loop // does not wait on the playlist service for it. - data := req.Data.(ApiRequestDataLibraryPlaylists) + data := req.Data.(ApiRequestDataLibraryPage) reply := apiReply(req) spc, username := p.sess.Spclient(), p.sess.Username() p.goDetached(libraryPlaylistsTimeout, func(ctx context.Context) { @@ -890,6 +890,30 @@ func (p *AppPlayer) handleApiRequest(req ApiRequest) (any, error) { reply.done(pageLibraryPlaylists(playlists, data.Offset, data.Limit), nil) }) + return nil, errReplyDeferred + case ApiRequestTypeLibraryAlbums, ApiRequestTypeLibraryArtists: + data := req.Data.(ApiRequestDataLibraryPage) + reply := apiReply(req) + spc, username, imageUrl := p.sess.Spclient(), p.sess.Username(), p.imageUrlFunc() + p.goDetached(libraryPlaylistsTimeout, func(ctx context.Context) { + if req.Type == ApiRequestTypeLibraryAlbums { + albums, err := fetchLibraryAlbums(ctx, spc, username, imageUrl, data.Offset, data.Limit) + if err != nil { + reply.done(nil, fmt.Errorf("failed listing saved albums: %w", err)) + return + } + reply.done(albums, nil) + return + } + + artists, err := fetchLibraryArtists(ctx, spc, username, imageUrl, data.Offset, data.Limit) + if err != nil { + reply.done(nil, fmt.Errorf("failed listing followed artists: %w", err)) + return + } + reply.done(artists, nil) + }) + return nil, errReplyDeferred case ApiRequestTypeSetLiked: data := req.Data.(ApiSetLiked) diff --git a/proto/spotify/collection/v2/collection.pb.go b/proto/spotify/collection/v2/collection.pb.go index c8afb8ee..92d862c9 100644 --- a/proto/spotify/collection/v2/collection.pb.go +++ b/proto/spotify/collection/v2/collection.pb.go @@ -245,6 +245,134 @@ func (x *ContainsResponse) GetFound() []bool { return nil } +type PageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Set string `protobuf:"bytes,2,opt,name=set,proto3" json:"set,omitempty"` + PaginationToken string `protobuf:"bytes,3,opt,name=pagination_token,json=paginationToken,proto3" json:"pagination_token,omitempty"` + Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PageRequest) Reset() { + *x = PageRequest{} + mi := &file_spotify_collection_v2_collection_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PageRequest) ProtoMessage() {} + +func (x *PageRequest) ProtoReflect() protoreflect.Message { + mi := &file_spotify_collection_v2_collection_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PageRequest.ProtoReflect.Descriptor instead. +func (*PageRequest) Descriptor() ([]byte, []int) { + return file_spotify_collection_v2_collection_proto_rawDescGZIP(), []int{4} +} + +func (x *PageRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *PageRequest) GetSet() string { + if x != nil { + return x.Set + } + return "" +} + +func (x *PageRequest) GetPaginationToken() string { + if x != nil { + return x.PaginationToken + } + return "" +} + +func (x *PageRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type PageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*CollectionItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + SyncToken string `protobuf:"bytes,3,opt,name=sync_token,json=syncToken,proto3" json:"sync_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PageResponse) Reset() { + *x = PageResponse{} + mi := &file_spotify_collection_v2_collection_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PageResponse) ProtoMessage() {} + +func (x *PageResponse) ProtoReflect() protoreflect.Message { + mi := &file_spotify_collection_v2_collection_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PageResponse.ProtoReflect.Descriptor instead. +func (*PageResponse) Descriptor() ([]byte, []int) { + return file_spotify_collection_v2_collection_proto_rawDescGZIP(), []int{5} +} + +func (x *PageResponse) GetItems() []*CollectionItem { + if x != nil { + return x.Items + } + return nil +} + +func (x *PageResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +func (x *PageResponse) GetSyncToken() string { + if x != nil { + return x.SyncToken + } + return "" +} + var File_spotify_collection_v2_collection_proto protoreflect.FileDescriptor const file_spotify_collection_v2_collection_proto_rawDesc = "" + @@ -264,7 +392,17 @@ const file_spotify_collection_v2_collection_proto_rawDesc = "" + "\x03set\x18\x02 \x01(\tR\x03set\x12;\n" + "\x05items\x18\x03 \x03(\v2%.spotify.collection.v2.CollectionItemR\x05items\"(\n" + "\x10ContainsResponse\x12\x14\n" + - "\x05found\x18\x01 \x03(\bR\x05foundB\xee\x01\n" + + "\x05found\x18\x01 \x03(\bR\x05found\"|\n" + + "\vPageRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x10\n" + + "\x03set\x18\x02 \x01(\tR\x03set\x12)\n" + + "\x10pagination_token\x18\x03 \x01(\tR\x0fpaginationToken\x12\x14\n" + + "\x05limit\x18\x04 \x01(\x05R\x05limit\"\x92\x01\n" + + "\fPageResponse\x12;\n" + + "\x05items\x18\x01 \x03(\v2%.spotify.collection.v2.CollectionItemR\x05items\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1d\n" + + "\n" + + "sync_token\x18\x03 \x01(\tR\tsyncTokenB\xee\x01\n" + "\x19com.spotify.collection.v2B\x0fCollectionProtoP\x01ZJgithub.com/devgianlu/go-librespot/proto/spotify/collection/v2;collectionv2\xa2\x02\x03SCX\xaa\x02\x15Spotify.Collection.V2\xca\x02\x15Spotify\\Collection\\V2\xe2\x02!Spotify\\Collection\\V2\\GPBMetadata\xea\x02\x17Spotify::Collection::V2b\x06proto3" var ( @@ -279,21 +417,24 @@ func file_spotify_collection_v2_collection_proto_rawDescGZIP() []byte { return file_spotify_collection_v2_collection_proto_rawDescData } -var file_spotify_collection_v2_collection_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_spotify_collection_v2_collection_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_spotify_collection_v2_collection_proto_goTypes = []any{ (*CollectionItem)(nil), // 0: spotify.collection.v2.CollectionItem (*WriteRequest)(nil), // 1: spotify.collection.v2.WriteRequest (*ContainsRequest)(nil), // 2: spotify.collection.v2.ContainsRequest (*ContainsResponse)(nil), // 3: spotify.collection.v2.ContainsResponse + (*PageRequest)(nil), // 4: spotify.collection.v2.PageRequest + (*PageResponse)(nil), // 5: spotify.collection.v2.PageResponse } var file_spotify_collection_v2_collection_proto_depIdxs = []int32{ 0, // 0: spotify.collection.v2.WriteRequest.items:type_name -> spotify.collection.v2.CollectionItem 0, // 1: spotify.collection.v2.ContainsRequest.items:type_name -> spotify.collection.v2.CollectionItem - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 0, // 2: spotify.collection.v2.PageResponse.items:type_name -> spotify.collection.v2.CollectionItem + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_spotify_collection_v2_collection_proto_init() } @@ -307,7 +448,7 @@ func file_spotify_collection_v2_collection_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_spotify_collection_v2_collection_proto_rawDesc), len(file_spotify_collection_v2_collection_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/spotify/collection/v2/collection.proto b/proto/spotify/collection/v2/collection.proto index 3a5ea32f..b43906ea 100644 --- a/proto/spotify/collection/v2/collection.proto +++ b/proto/spotify/collection/v2/collection.proto @@ -26,3 +26,16 @@ message ContainsRequest { message ContainsResponse { repeated bool found = 1; } + +message PageRequest { + string username = 1; + string set = 2; + string pagination_token = 3; + int32 limit = 4; +} + +message PageResponse { + repeated CollectionItem items = 1; + string next_page_token = 2; + string sync_token = 3; +} diff --git a/spclient/library_write.go b/spclient/library_write.go index 7472cfdc..95b44459 100644 --- a/spclient/library_write.go +++ b/spclient/library_write.go @@ -20,7 +20,8 @@ import ( // collectionContentType is the media type the collection service speaks. const collectionContentType = "application/vnd.collection-v2.spotify.proto" -// CollectionSetLikedSongs is the collection set holding the user's Liked Songs. +// CollectionSetLikedSongs is the collection set holding the user's Liked Songs +// and saved albums. const CollectionSetLikedSongs = "collection" // StatusError reports a status the collection or playlist service answered @@ -35,6 +36,10 @@ func (e *StatusError) Error() string { return fmt.Sprintf("invalid status code from %s: %d", e.Op, e.StatusCode) } +// CollectionSetArtists is the collection set holding the artists the user +// follows. +const CollectionSetArtists = "artist" + // ErrPlaylistConflict reports that a playlist changed between reading its // revision and writing to it. var ErrPlaylistConflict = errors.New("playlist changed concurrently") @@ -74,6 +79,46 @@ func (c *Spclient) CollectionWrite(ctx context.Context, username, set string, ur return nil } +// CollectionPage reads one page of a collection set; an empty token asks for +// the first page. The response names the next page, empty on the last one. +func (c *Spclient) CollectionPage(ctx context.Context, username, set, token string, limit int) (*collectionpb.PageResponse, error) { + body, err := proto.Marshal(&collectionpb.PageRequest{ + Username: username, + Set: set, + PaginationToken: token, + Limit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("failed marshalling PageRequest: %w", err) + } + + resp, err := c.Request(ctx, "POST", "/collection/v2/paging", nil, http.Header{ + "Content-Type": {collectionContentType}, + "Accept": {collectionContentType}, + }, body) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("invalid status code from collection paging: %d", resp.StatusCode) + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed reading response body: %w", err) + } + + var page collectionpb.PageResponse + if err := proto.Unmarshal(respBytes, &page); err != nil { + return nil, fmt.Errorf("failed unmarshalling PageResponse: %w", err) + } + + return &page, nil +} + // CollectionContains tells for each of uris whether it is in one of the // user's collection sets, in one round trip. func (c *Spclient) CollectionContains(ctx context.Context, username, set string, uris []string) ([]bool, error) { diff --git a/spclient/library_write_test.go b/spclient/library_write_test.go index ab2d9822..956322ab 100644 --- a/spclient/library_write_test.go +++ b/spclient/library_write_test.go @@ -204,3 +204,29 @@ func (suite *RequestSuite) TestPlaylistRemoveSendsAnIndexRange() { suite.False(op.GetRem().GetItemsAsKey(), "keyed by the item, Spotify would remove every duplicate of it") suite.Equal("spotify:track:b", op.GetRem().GetItems()[0].GetUri()) } + +func (suite *RequestSuite) TestCollectionPageReadsPage() { + suite.handler = func(_ int, w http.ResponseWriter) { + body, _ := proto.Marshal(&collectionpb.PageResponse{ + Items: []*collectionpb.CollectionItem{{Uri: "spotify:track:a", AddedAt: 1}}, + NextPageToken: "next", + }) + _, _ = w.Write(body) + } + + page, err := suite.spclient.CollectionPage(suite.T().Context(), "user", spclient.CollectionSetLikedSongs, "tok", 300) + suite.Require().NoError(err) + suite.Equal("next", page.GetNextPageToken()) + suite.Require().Len(page.GetItems(), 1) + + got := suite.requests()[0] + suite.Equal("/collection/v2/paging", got.path) + suite.Equal("application/vnd.collection-v2.spotify.proto", got.header.Get("Content-Type")) + + var req collectionpb.PageRequest + suite.Require().NoError(proto.Unmarshal(got.body, &req)) + suite.Equal("user", req.GetUsername()) + suite.Equal("collection", req.GetSet()) + suite.Equal("tok", req.GetPaginationToken()) + suite.Equal(int32(300), req.GetLimit()) +}