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()) +}