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
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,24 @@ matching URI, or canonical URL:

```sh
sptfy playlists list --max 10
sptfy playlists get spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
sptfy playlists get spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID
sptfy playlists items list spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID
```

Lists default to 10 and allow 1–50 results. Default fields are
`ID | PLAYLIST | OWNER_ID | OWNER | ITEM_COUNT | PUBLIC | COLLABORATIVE`.
`--extended` adds `URI | URL | SNAPSHOT_ID | DESCRIPTION`, and
`--include-artwork` adds Spotify-hosted artwork metadata. `--fields`, `--id`,
text sanitization, detail identity headers, and opaque stderr continuation
hints follow the resource-output rules above. Playlist items and mutations are
not implemented.
hints follow the resource-output rules above.

Playlist-item lists preserve Spotify order and print the playlist ID once,
followed by `POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID |
ALBUM | DURATION`. Positions are absolute and zero-based across continuation
pages. Track, episode, local, unavailable, and future item shapes remain
distinct; `--id` emits only entries with Spotify IDs. Playlist mutations are
not implemented. Spotify currently exposes playlist details and items only
when the current user owns or collaborates on the playlist.

## Saved tracks

Expand Down Expand Up @@ -190,14 +198,16 @@ an OS keychain. It is opt-in and is not part of ordinary CI:
SPOTIFY_CLI_LIVE=1 \
SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 \
SPOTIFY_CLI_LIVE_PLAYLIST_ID=your_playlist_id \
SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=first_item_id,second_item_id,third_item_id \
SPOTIFY_CLIENT_ID=your_client_id \
make live-smoke
```

The harness is interactive because Spotify authorization opens a browser. It
exercises setup, identity, refresh, search/pagination shapes, catalog gets,
relationship traversals, playlist list/get, replacement, clear, and
re-initialization without exporting the stored OAuth credential.
relationship traversals, playlist list/get, ordered playlist-item pagination,
replacement, clear, and re-initialization without exporting the stored OAuth
credential.

## License

Expand Down
171 changes: 171 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package client

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -139,6 +140,35 @@ type PlaylistPage struct {
HasNext bool
}

// PlaylistItem is one flattened mixed-media playlist entry.
type PlaylistItem struct {
Type string
ID string
Name string
Artists []Artist
AlbumID string
AlbumName string
DurationMS *int
URI string
URL string
AddedAt string
AddedByID string
DiscNumber int
TrackNumber int
Explicit *bool
Restriction string
Images []Image
}

// PlaylistItemPage is one validated playlist-item page.
type PlaylistItemPage struct {
Items []PlaylistItem
Offset int
Limit int
Total int
HasNext bool
}

// TrackPage is one validated Spotify search page.
type TrackPage struct {
Items []Track
Expand Down Expand Up @@ -305,6 +335,147 @@ func validPlaylist(playlist Playlist) bool {
return spotifyref.ValidID(playlist.ID) && playlist.ItemCount != nil && playlist.ItemCount.Total >= 0
}

type playlistItemWrapper struct {
AddedAt string `json:"added_at"`
AddedBy struct {
ID string `json:"id"`
} `json:"added_by"`
IsLocal bool `json:"is_local"`
Item json.RawMessage `json:"item"`
}

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

// ListPlaylistItems returns one playlist-item page without following provider pagination URLs.
func (client Client) ListPlaylistItems(ctx context.Context, id string, limit, offset int) (PlaylistItemPage, error) {
if !spotifyref.ValidID(id) || limit < 1 || limit > 50 || offset < 0 {
return PlaylistItemPage{}, ErrInvalidResponse
}
values := url.Values{
"additional_types": {"episode"},
"limit": {strconv.Itoa(limit)},
"offset": {strconv.Itoa(offset)},
}
var response playlistItemPageResponse
if err := client.getJSON(ctx, "/playlists/"+id+"/items?"+values.Encode(), &response); err != nil {
return PlaylistItemPage{}, err
}
if response.Offset != offset || response.Limit != limit || response.Items == nil ||
response.Total < 0 || len(*response.Items) > limit {
return PlaylistItemPage{}, ErrInvalidResponse
}
itemCount := len(*response.Items)
hasNext := response.Next != nil && *response.Next != ""
if itemCount > 0 && (itemCount > response.Total || response.Offset > response.Total-itemCount) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page validation accepts an underfilled non-final response (for example, limit=10, offset=0, total=20, five items, and a non-empty next). The command then encodes offset + page.Limit (10) for continuation, silently skipping positions 5–9 despite the feature’s ordered, absolute-position contract. Reject HasNext pages unless they contain exactly limit entries (and add a malformed-response test for this case), or otherwise derive and validate a continuation offset that cannot skip returned-provider positions.

Reply inline to this comment.

hasNext && itemCount != response.Limit ||
hasNext != (response.Offset < response.Total && itemCount < response.Total-response.Offset) {
return PlaylistItemPage{}, ErrInvalidResponse
}
items := make([]PlaylistItem, len(*response.Items))
for index, wrapper := range *response.Items {
item, err := decodePlaylistItem(wrapper)
if err != nil {
return PlaylistItemPage{}, err
}
items[index] = item
}
return PlaylistItemPage{
Items: items, Offset: response.Offset, Limit: response.Limit,
Total: response.Total, HasNext: hasNext,
}, nil
}

func decodePlaylistItem(wrapper playlistItemWrapper) (PlaylistItem, error) {
item := PlaylistItem{AddedAt: wrapper.AddedAt, AddedByID: wrapper.AddedBy.ID}
raw := bytes.TrimSpace(wrapper.Item)
if len(raw) == 0 {
return PlaylistItem{}, ErrInvalidResponse
}
if bytes.Equal(raw, []byte("null")) {
item.Type = "unavailable"
return item, nil
}
var base struct {
Type *string `json:"type"`
ID *string `json:"id"`
Name string `json:"name"`
DurationMS *int `json:"duration_ms"`
URI string `json:"uri"`
ExternalURLs ExternalURLs `json:"external_urls"`
Explicit *bool `json:"explicit"`
Restrictions Restriction `json:"restrictions"`
Images *[]Image `json:"images"`
}
if json.Unmarshal(raw, &base) != nil || base.DurationMS != nil && *base.DurationMS < 0 ||
base.ID != nil && !spotifyref.ValidID(*base.ID) {
return PlaylistItem{}, ErrInvalidResponse
}
item.Type = "unknown"
if base.Type != nil && strings.TrimSpace(*base.Type) != "" {
item.Type = *base.Type
}
if wrapper.IsLocal {
item.Type = "local"
}
if (item.Type == "track" || item.Type == "episode") && base.ID == nil {
return PlaylistItem{}, ErrInvalidResponse
}
if base.ID != nil {
item.ID = *base.ID
}
item.Name = base.Name
item.DurationMS = base.DurationMS
item.URI = base.URI
item.URL = base.ExternalURLs.Spotify
item.Explicit = base.Explicit
item.Restriction = base.Restrictions.Reason
if item.Type == "episode" {
if base.Images == nil {
return PlaylistItem{}, ErrInvalidResponse
}
item.Images = *base.Images
}
if item.Type == "track" {
var track struct {
Artists *[]Artist `json:"artists"`
DiscNumber int `json:"disc_number"`
TrackNumber int `json:"track_number"`
Album *struct {
ID string `json:"id"`
Name string `json:"name"`
Images *[]Image `json:"images"`
} `json:"album"`
}
if json.Unmarshal(raw, &track) != nil || track.Artists == nil || len(*track.Artists) == 0 ||
track.Album == nil || !spotifyref.ValidID(track.Album.ID) || track.Album.Images == nil {
return PlaylistItem{}, ErrInvalidResponse
}
for _, artist := range *track.Artists {
if !spotifyref.ValidID(artist.ID) {
return PlaylistItem{}, ErrInvalidResponse
}
}
item.Artists = *track.Artists
item.AlbumID = track.Album.ID
item.AlbumName = track.Album.Name
item.DiscNumber = track.DiscNumber
item.TrackNumber = track.TrackNumber
item.Images = *track.Album.Images
}
if item.Type != "track" && item.Type != "episode" {
item.Explicit = nil
item.Restriction = ""
item.Images = nil
}
return item, nil
}

type trackPageResponse struct {
Items *[]Track `json:"items"`
Limit int `json:"limit"`
Expand Down
130 changes: 130 additions & 0 deletions internal/client/playlist_items_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package client

import (
"context"
"errors"
"net/http"
"strconv"
"testing"
)

func TestListPlaylistItemsUsesCurrentFixedPathAndDecodesMixedItems(t *testing.T) {
const (
trackID = "abcdefghijklmnopqrstuv"
episodeID = "ZYXWVUTSRQPONMLKJIHGFE"
artistID = "1111111111111111111111"
albumID = "2222222222222222222222"
)
calls := 0
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
calls++
query := request.URL.Query()
if request.Method != http.MethodGet || request.URL.Host != "api.spotify.invalid" ||
request.URL.Path != "/v1/playlists/"+playlistID+"/items" || query.Get("additional_types") != "episode" ||
query.Get("limit") != "6" || query.Get("offset") != "20" || len(query) != 3 {
t.Fatalf("request=%s %s", request.Method, request.URL.String())
}
return response(http.StatusOK, `{"items":[
{"added_at":"2026-07-24T12:00:00Z","added_by":{"id":"owner-1"},"is_local":false,"item":{"type":"track","id":"`+trackID+`","name":"Song","artists":[{"id":"`+artistID+`","name":"Artist"}],"album":{"id":"`+albumID+`","name":"Album","images":[{"url":"https://track-image"}]},"duration_ms":61000,"uri":"spotify:track:`+trackID+`","external_urls":{"spotify":"https://open.spotify.com/track/`+trackID+`"},"disc_number":1,"track_number":2,"explicit":true,"restrictions":{"reason":"market"}}},
{"is_local":false,"item":{"type":"episode","id":"`+episodeID+`","name":"Episode","duration_ms":62000,"uri":"spotify:episode:`+episodeID+`","external_urls":{"spotify":"https://open.spotify.com/episode/`+episodeID+`"},"explicit":false,"images":[{"url":"https://episode-image"}]}},
{"is_local":true,"item":{"type":"track","name":"Local song","duration_ms":63000,"uri":"spotify:local:Artist:Album:Song:63"}},
{"is_local":false,"item":null},
{"is_local":false,"item":{"type":"audiobook","name":"Future | item\nline","duration_ms":64000}},
{"is_local":false,"item":{"name":"Untyped"}}
],"limit":6,"offset":20,"total":27,"next":"https://evil.invalid/follow"}`), nil
})}
page, err := (Client{HTTPClient: httpClient, BaseURL: "https://api.spotify.invalid/v1"}).ListPlaylistItems(context.Background(), playlistID, 6, 20)
if err != nil || calls != 1 || len(page.Items) != 6 || page.Offset != 20 || !page.HasNext {
t.Fatalf("page=%+v calls=%d error=%v", page, calls, err)
}
track, episode, local, unavailable, unknown, untyped := page.Items[0], page.Items[1], page.Items[2], page.Items[3], page.Items[4], page.Items[5]
if track.Type != "track" || track.ID != trackID || track.Name != "Song" || track.AlbumID != albumID ||
len(track.Artists) != 1 || track.Artists[0].ID != artistID || track.DurationMS == nil || *track.DurationMS != 61000 ||
track.AddedByID != "owner-1" || track.Explicit == nil || !*track.Explicit || track.Restriction != "market" || len(track.Images) != 1 {
t.Fatalf("track=%+v", track)
}
if episode.Type != "episode" || episode.ID != episodeID || episode.Name != "Episode" || len(episode.Images) != 1 {
t.Fatalf("episode=%+v", episode)
}
if local.Type != "local" || local.ID != "" || local.Name != "Local song" || local.DurationMS == nil || *local.DurationMS != 63000 {
t.Fatalf("local=%+v", local)
}
if unavailable.Type != "unavailable" || unavailable.ID != "" || unknown.Type != "audiobook" || unknown.Name != "Future | item\nline" ||
untyped.Type != "unknown" || untyped.Name != "Untyped" {
t.Fatalf("unavailable=%+v unknown=%+v untyped=%+v", unavailable, unknown, untyped)
}
}

func TestListPlaylistItemsAcceptsBoundsAndRejectsMalformedData(t *testing.T) {
for _, limit := range []int{1, 50} {
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusOK, `{"items":[],"limit":`+strconv.Itoa(limit)+`,"offset":0,"total":0,"next":null}`), nil
})}
if _, err := (Client{HTTPClient: httpClient}).ListPlaylistItems(context.Background(), playlistID, limit, 0); err != nil {
t.Fatalf("limit=%d error=%v", limit, err)
}
}
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusOK, `{"items":[],"limit":1,"offset":10,"total":3,"next":null}`), nil
})}
if _, err := (Client{HTTPClient: httpClient}).ListPlaylistItems(context.Background(), playlistID, 1, 10); err != nil {
t.Fatalf("empty page beyond total error=%v", err)
}

for _, body := range []string{
`{}`,
`{"items":null,"limit":1,"offset":0,"total":0}`,
`{"items":[],"limit":2,"offset":0,"total":0}`,
`{"items":[],"limit":1,"offset":1,"total":0}`,
`{"items":[],"limit":1,"offset":0,"total":-1}`,
`{"items":[{"item":null},{"item":null}],"limit":1,"offset":0,"total":2}`,
`{"items":[{"item":null}],"limit":1,"offset":0,"total":1,"next":"https://evil.invalid/follow"}`,
`{"items":[{"item":null}],"limit":1,"offset":0,"total":2,"next":null}`,
`{"items":[{"item":null}],"limit":1,"offset":1,"total":1,"next":null}`,
`{"items":[{}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"bad","duration_ms":1}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","artists":[],"album":{"images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"episode","images":[]}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"episode","id":"ZYXWVUTSRQPONMLKJIHGFE","duration_ms":-1}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":null,"album":{"images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":[],"album":{"id":"2222222222222222222222","images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":[{"id":"1111111111111111111111"}],"album":{"images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":[{"id":"1111111111111111111111"}],"album":{"id":"bad","images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":[{}],"album":{"id":"2222222222222222222222","images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":[{"id":"bad"}],"album":{"id":"2222222222222222222222","images":[]}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"track","id":"abcdefghijklmnopqrstuv","artists":[{"id":"1111111111111111111111"}],"album":{"id":"2222222222222222222222","images":null}}}],"limit":1,"offset":0,"total":1}`,
`{"items":[{"item":{"type":"episode","id":"ZYXWVUTSRQPONMLKJIHGFE","images":null}}],"limit":1,"offset":0,"total":1}`,
} {
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusOK, body), nil
})}
if _, err := (Client{HTTPClient: httpClient}).ListPlaylistItems(context.Background(), playlistID, 1, 0); !errors.Is(err, ErrInvalidResponse) {
t.Fatalf("body=%s error=%v", body, err)
}
}

httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusOK, `{"items":[{"item":null},{"item":null},{"item":null},{"item":null},{"item":null}],"limit":10,"offset":0,"total":20,"next":"https://evil.invalid/follow"}`), nil
})}
if _, err := (Client{HTTPClient: httpClient}).ListPlaylistItems(context.Background(), playlistID, 10, 0); !errors.Is(err, ErrInvalidResponse) {
t.Fatalf("short continuation page error=%v", err)
}
}

func TestListPlaylistItemsRejectsInvalidInputsBeforeRequest(t *testing.T) {
calls := 0
spotify := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return response(http.StatusOK, `{}`), nil
})}}
for _, call := range []func() error{
func() error { _, err := spotify.ListPlaylistItems(context.Background(), "bad", 1, 0); return err },
func() error { _, err := spotify.ListPlaylistItems(context.Background(), playlistID, 0, 0); return err },
func() error { _, err := spotify.ListPlaylistItems(context.Background(), playlistID, 51, 0); return err },
func() error { _, err := spotify.ListPlaylistItems(context.Background(), playlistID, 1, -1); return err },
} {
if err := call(); !errors.Is(err, ErrInvalidResponse) || calls != 0 {
t.Fatalf("calls=%d error=%v", calls, err)
}
}
}
Loading
Loading