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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ Lists default to 10 and allow 1–50 results. Normal output begins with
deduplicate the complete batch before making requests, then print only
`added<TAB>N` or `removed<TAB>N` after every chunk succeeds.

## Saved albums

```sh
sptfy library albums list
sptfy library albums check 4aawyAB9vmqN3uQ7FjRGTy spotify:album:2up3OPMp9Tb4dAKM2erWXQ
sptfy library albums add https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy
sptfy library albums remove 4aawyAB9vmqN3uQ7FjRGTy
```

Lists default to 10 and allow 1–50 results. Normal output begins with
`ADDED_AT` and preserves every credited artist ID and name; `--id` emits only
album IDs. Album checks and mutations follow the same complete-batch
validation, first-seen deduplication, compact output, and 40-item request
chunking as saved tracks.

## Development

Requires Go 1.26 or newer.
Expand Down
90 changes: 83 additions & 7 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ type SavedTrackPage struct {
HasNext bool
}

// SavedAlbum is one album and its library timestamp.
type SavedAlbum struct {
AddedAt string `json:"added_at"`
Album Album `json:"album"`
}

// SavedAlbumPage is one validated saved-album page.
type SavedAlbumPage struct {
Items []SavedAlbum
Offset int
Limit int
Total int
HasNext bool
}

// AlbumPage is one validated Spotify album-search page.
type AlbumPage struct {
Items []Album
Expand Down Expand Up @@ -305,9 +320,59 @@ func (client Client) ListSavedTracks(ctx context.Context, limit, offset int) (Sa
}, nil
}

type savedAlbumPageResponse struct {
Items *[]SavedAlbum `json:"items"`
Limit int `json:"limit"`
Next *string `json:"next"`
Offset int `json:"offset"`
Total int `json:"total"`
}

// ListSavedAlbums returns one saved-album page without following provider pagination URLs.
func (client Client) ListSavedAlbums(ctx context.Context, limit, offset int) (SavedAlbumPage, error) {
if limit < 1 || limit > 50 || offset < 0 {
return SavedAlbumPage{}, ErrInvalidResponse
}
values := url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}}
var response savedAlbumPageResponse
if err := client.requestJSON(ctx, http.MethodGet, "/me/albums?"+values.Encode(), &response); err != nil {
return SavedAlbumPage{}, err
}
if response.Offset != offset || response.Limit != limit || response.Items == nil ||
response.Total < 0 || len(*response.Items) > limit {
return SavedAlbumPage{}, ErrInvalidResponse
}
for _, item := range *response.Items {
if !spotifyref.ValidID(item.Album.ID) || len(item.Album.Artists) == 0 {
return SavedAlbumPage{}, ErrInvalidResponse
}
for _, artist := range item.Album.Artists {
if !spotifyref.ValidID(artist.ID) {
return SavedAlbumPage{}, ErrInvalidResponse
}
}
if _, err := time.Parse(time.RFC3339, item.AddedAt); err != nil {
return SavedAlbumPage{}, ErrInvalidResponse
}
}
return SavedAlbumPage{
Items: *response.Items, Offset: response.Offset, Limit: response.Limit,
Total: response.Total, HasNext: response.Next != nil && *response.Next != "",
}, nil
}

// CheckSavedTracks reports saved membership in input order.
func (client Client) CheckSavedTracks(ctx context.Context, uris []string) ([]bool, error) {
if !validTrackURIs(uris) {
return client.checkSavedItems(ctx, spotifyref.Track, uris)
}

// CheckSavedAlbums reports saved membership in input order.
func (client Client) CheckSavedAlbums(ctx context.Context, uris []string) ([]bool, error) {
return client.checkSavedItems(ctx, spotifyref.Album, uris)
}

func (client Client) checkSavedItems(ctx context.Context, kind spotifyref.Kind, uris []string) ([]bool, error) {
if !validLibraryURIs(kind, uris) {
return nil, ErrInvalidResponse
}
result := make([]bool, 0, len(uris))
Expand All @@ -328,16 +393,26 @@ func (client Client) CheckSavedTracks(ctx context.Context, uris []string) ([]boo

// SaveSavedTracks adds tracks to the current user's library.
func (client Client) SaveSavedTracks(ctx context.Context, uris []string) error {
return client.mutateSavedTracks(ctx, http.MethodPut, uris)
return client.mutateSavedItems(ctx, http.MethodPut, spotifyref.Track, uris)
}

// RemoveSavedTracks removes tracks from the current user's library.
func (client Client) RemoveSavedTracks(ctx context.Context, uris []string) error {
return client.mutateSavedTracks(ctx, http.MethodDelete, uris)
return client.mutateSavedItems(ctx, http.MethodDelete, spotifyref.Track, uris)
}

// SaveSavedAlbums adds albums to the current user's library.
func (client Client) SaveSavedAlbums(ctx context.Context, uris []string) error {
return client.mutateSavedItems(ctx, http.MethodPut, spotifyref.Album, uris)
}

// RemoveSavedAlbums removes albums from the current user's library.
func (client Client) RemoveSavedAlbums(ctx context.Context, uris []string) error {
return client.mutateSavedItems(ctx, http.MethodDelete, spotifyref.Album, uris)
}

func (client Client) mutateSavedTracks(ctx context.Context, method string, uris []string) error {
if !validTrackURIs(uris) {
func (client Client) mutateSavedItems(ctx context.Context, method string, kind spotifyref.Kind, uris []string) error {
if !validLibraryURIs(kind, uris) {
return ErrInvalidResponse
}
for start := 0; start < len(uris); start += 40 {
Expand All @@ -350,12 +425,13 @@ func (client Client) mutateSavedTracks(ctx context.Context, method string, uris
return nil
}

func validTrackURIs(uris []string) bool {
func validLibraryURIs(kind spotifyref.Kind, uris []string) bool {
if len(uris) == 0 {
return false
}
prefix := "spotify:" + string(kind) + ":"
for _, uri := range uris {
if !strings.HasPrefix(uri, "spotify:track:") || !spotifyref.ValidID(strings.TrimPrefix(uri, "spotify:track:")) {
if !strings.HasPrefix(uri, prefix) || !spotifyref.ValidID(strings.TrimPrefix(uri, prefix)) {
return false
}
}
Expand Down
142 changes: 142 additions & 0 deletions internal/client/library_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,152 @@ func TestSavedTrackOperationsRejectInvalidInputBeforeRequest(t *testing.T) {
}
}

func TestSavedAlbumListUsesFixedPathAndValidatesPage(t *testing.T) {
const (
album = "0123456789ABCDEFGHIJKL"
artist = "abcdefghijklmnopqrstuv"
)
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Method != http.MethodGet || request.URL.Host != "api.spotify.invalid" ||
request.URL.Path != "/v1/me/albums" || request.URL.Query().Get("limit") != "10" ||
request.URL.Query().Get("offset") != "20" || len(request.URL.Query()) != 2 {
t.Fatalf("request=%s %s", request.Method, request.URL.String())
}
return response(http.StatusOK, `{"items":[{"added_at":"2026-07-23T12:00:00Z","album":{"id":"`+album+`","artists":[{"id":"`+artist+`"}]}}],"limit":10,"offset":20,"total":31,"next":"https://evil.invalid/follow"}`), nil
})}
page, err := (Client{HTTPClient: httpClient, BaseURL: "https://api.spotify.invalid/v1"}).ListSavedAlbums(context.Background(), 10, 20)
if err != nil || len(page.Items) != 1 || page.Items[0].Album.ID != album || !page.HasNext {
t.Fatalf("page=%+v error=%v", page, err)
}

for _, body := range []string{
`{}`,
`{"items":[],"limit":11,"offset":20,"total":0}`,
`{"items":[],"limit":10,"offset":21,"total":0}`,
`{"items":[],"limit":10,"offset":20,"total":-1}`,
`{"items":[{"added_at":"2026-07-23T12:00:00Z","album":{"id":"bad","artists":[{"id":"abcdefghijklmnopqrstuv"}]}}],"limit":10,"offset":20,"total":1}`,
`{"items":[{"added_at":"2026-07-23T12:00:00Z","album":{"id":"0123456789ABCDEFGHIJKL","artists":[]}}],"limit":10,"offset":20,"total":1}`,
`{"items":[{"added_at":"2026-07-23T12:00:00Z","album":{"id":"0123456789ABCDEFGHIJKL","artists":[{"id":"bad"}]}}],"limit":10,"offset":20,"total":1}`,
`{"items":[{"album":{"id":"0123456789ABCDEFGHIJKL","artists":[{"id":"abcdefghijklmnopqrstuv"}]}}],"limit":10,"offset":20,"total":1}`,
} {
httpClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusOK, body), nil
})
if _, err := (Client{HTTPClient: httpClient}).ListSavedAlbums(context.Background(), 10, 20); !errors.Is(err, ErrInvalidResponse) {
t.Fatalf("body=%s error=%v", body, err)
}
}
}

func TestSavedAlbumOperationsUseGenericLibraryChunks(t *testing.T) {
for _, count := range []int{40, 41, 80, 81} {
t.Run(fmt.Sprint(count), func(t *testing.T) {
uris := albumLibraryURIs(count)
isSaved := func(uri string) bool {
index, _ := strconv.Atoi(strings.TrimPrefix(uri, "spotify:album:"))
return index%5 == 0 || index%13 == 2
}
var checkSizes []int
checkClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Method != http.MethodGet || request.URL.Path != "/v1/me/library/contains" {
t.Fatalf("request=%s %s", request.Method, request.URL.String())
}
chunk := strings.Split(request.URL.Query().Get("uris"), ",")
checkSizes = append(checkSizes, len(chunk))
values := make([]string, len(chunk))
for index, uri := range chunk {
values[index] = strconv.FormatBool(isSaved(uri))
}
return response(http.StatusOK, "["+strings.Join(values, ",")+"]"), nil
})}
got, err := (Client{HTTPClient: checkClient}).CheckSavedAlbums(context.Background(), uris)
want := make([]bool, count)
for index, uri := range uris {
want[index] = isSaved(uri)
}
if err != nil || !slices.Equal(got, want) || len(checkSizes) != (count+39)/40 {
t.Fatalf("check sizes=%v results=%v want=%v error=%v", checkSizes, got, want, err)
}

for _, method := range []string{http.MethodPut, http.MethodDelete} {
var sizes []int
var seen []string
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Method != method || request.URL.Path != "/v1/me/library" {
t.Fatalf("request=%s %s", request.Method, request.URL.String())
}
chunk := strings.Split(request.URL.Query().Get("uris"), ",")
sizes = append(sizes, len(chunk))
seen = append(seen, chunk...)
return response(http.StatusNoContent, ""), nil
})}
spotify := Client{HTTPClient: httpClient}
if method == http.MethodPut {
err = spotify.SaveSavedAlbums(context.Background(), uris)
} else {
err = spotify.RemoveSavedAlbums(context.Background(), uris)
}
if err != nil || len(sizes) != (count+39)/40 || !slices.Equal(seen, uris) {
t.Fatalf("method=%s sizes=%v seen=%v error=%v", method, sizes, seen, err)
}
for index, size := range sizes {
if size != min(40, count-index*40) {
t.Fatalf("method=%s sizes=%v", method, sizes)
}
}
}
})
}
}

func TestSavedAlbumOperationsRejectWrongKindsAndMalformedResponsesBeforeContinuing(t *testing.T) {
calls := 0
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
calls++
if request.Method == http.MethodGet {
return response(http.StatusOK, `[true]`), nil
}
if calls == 2 {
return response(http.StatusInternalServerError, "secret"), nil
}
return response(http.StatusNoContent, ""), nil
})}
spotify := Client{HTTPClient: httpClient}
for _, call := range []func() error{
func() error { _, err := spotify.CheckSavedAlbums(context.Background(), nil); return err },
func() error {
return spotify.SaveSavedAlbums(context.Background(), []string{"spotify:track:0123456789ABCDEFGHIJKL"})
},
func() error { return spotify.RemoveSavedAlbums(context.Background(), []string{"spotify:album:bad"}) },
} {
if err := call(); !errors.Is(err, ErrInvalidResponse) || calls != 0 {
t.Fatalf("calls=%d error=%v", calls, err)
}
}

if _, err := spotify.CheckSavedAlbums(context.Background(), albumLibraryURIs(41)); !errors.Is(err, ErrInvalidResponse) || calls != 1 {
t.Fatalf("check calls=%d error=%v", calls, err)
}
for _, mutate := range []func(context.Context, []string) error{spotify.SaveSavedAlbums, spotify.RemoveSavedAlbums} {
calls = 0
if err := mutate(context.Background(), albumLibraryURIs(81)); !errors.Is(err, ErrUpstream) || calls != 2 {
t.Fatalf("mutation calls=%d error=%v", calls, err)
}
}
}

func libraryURIs(count int) []string {
result := make([]string, count)
for index := range result {
result[index] = fmt.Sprintf("spotify:track:%022d", index)
}
return result
}

func albumLibraryURIs(count int) []string {
result := make([]string, count)
for index := range result {
result[index] = fmt.Sprintf("spotify:album:%022d", index)
}
return result
}
Loading
Loading