From a17b61cc7be77df96d4049c7863368aa9cc8aeaa Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 24 Jul 2026 13:39:39 -0400 Subject: [PATCH] feat: list ordered playlist items Add current-schema playlist item reads with absolute positions, mixed-media-safe text output, playlist-bound continuation tokens, and ordered live-smoke verification. Closes #23 --- README.md | 20 ++- internal/client/client.go | 171 ++++++++++++++++++++++ internal/client/playlist_items_test.go | 130 ++++++++++++++++ internal/cmd/playlistcmd/playlist.go | 77 +++++++++- internal/cmd/playlistcmd/playlist_test.go | 96 +++++++++++- internal/cmd/root/root_test.go | 12 +- internal/output/playlist.go | 170 +++++++++++++++++++++ internal/output/playlist_test.go | 52 +++++++ internal/session/session.go | 5 + internal/session/session_test.go | 8 +- scripts/live-smoke-test.sh | 49 ++++++- scripts/live-smoke.sh | 28 ++++ spotify-cli-behavior.md | 47 +++++- 13 files changed, 849 insertions(+), 16 deletions(-) create mode 100644 internal/client/playlist_items_test.go diff --git a/README.md b/README.md index 5ddfc01..1e192d3 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ 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 @@ -133,8 +134,15 @@ Lists default to 10 and allow 1–50 results. Default fields are `--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 @@ -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 diff --git a/internal/client/client.go b/internal/client/client.go index b854262..4d4a7c2 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2,6 +2,7 @@ package client import ( + "bytes" "context" "encoding/json" "errors" @@ -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 @@ -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) || + 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"` diff --git a/internal/client/playlist_items_test.go b/internal/client/playlist_items_test.go new file mode 100644 index 0000000..2287549 --- /dev/null +++ b/internal/client/playlist_items_test.go @@ -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) + } + } +} diff --git a/internal/cmd/playlistcmd/playlist.go b/internal/cmd/playlistcmd/playlist.go index 990dd9d..a67ee20 100644 --- a/internal/cmd/playlistcmd/playlist.go +++ b/internal/cmd/playlistcmd/playlist.go @@ -29,6 +29,7 @@ type Session interface { Scopes() []string ListCurrentUserPlaylists(context.Context, int, int) (client.PlaylistPage, error) GetPlaylist(context.Context, string) (client.Playlist, error) + ListPlaylistItems(context.Context, string, int, int) (client.PlaylistItemPage, error) } // SessionOpener opens the authenticated capability required by playlist commands. @@ -59,10 +60,84 @@ func New(deps Dependencies) *cobra.Command { Use: "playlists", Aliases: []string{"playlist"}, Short: "Read Spotify playlists", Args: noArgs("playlists"), RunE: func(command *cobra.Command, _ []string) error { return command.Help() }, } - command.AddCommand(newList(deps), newGet(deps)) + command.AddCommand(newList(deps), newGet(deps), newItems(deps)) return command } +func newItems(deps Dependencies) *cobra.Command { + command := &cobra.Command{ + Use: "items", Short: "Read ordered Spotify playlist items", Args: noArgs("items"), + RunE: func(command *cobra.Command, _ []string) error { return command.Help() }, + } + command.AddCommand(newItemsList(deps)) + return command +} + +func newItemsList(deps Dependencies) *cobra.Command { + opts := listOptions{max: 10} + command := &cobra.Command{ + Use: "list ", Short: "List ordered Spotify playlist items", + Args: func(command *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(command, args); err != nil { + return exitcode.New(exitcode.Usage, err) + } + return nil + }, + RunE: func(command *cobra.Command, args []string) error { + id, err := spotifyref.Parse(args[0], spotifyref.Playlist) + if err != nil { + return exitcode.New(exitcode.Usage, err) + } + if opts.max < 1 || opts.max > 50 { + return exitcode.New(exitcode.Usage, errors.New("--max must be between 1 and 50")) + } + var fields []output.PlaylistItemField + if !opts.id { + fields, err = output.SelectPlaylistItemFields(opts.fields, opts.extended, opts.artwork) + if err != nil { + return exitcode.New(exitcode.Usage, err) + } + } + scope := playlistItemPageScope(id) + offset, err := pagetoken.Decode(scope, opts.nextPageToken, math.MaxInt-50) + if err != nil { + return exitcode.New(exitcode.Usage, errors.New("invalid --next-page-token")) + } + authenticated, err := openSession(command, deps) + if err != nil { + return err + } + defer func() { _ = authenticated.Close() }() + page, err := authenticated.ListPlaylistItems(command.Context(), id, opts.max, offset) + if err != nil { + return classify(err) + } + rendered := output.RenderPlaylistItemIDs(page.Items) + if !opts.id { + rendered = "Playlist ID: " + id + "\n" + output.RenderPlaylistItems(page.Items, page.Offset, fields) + } + if err := writeOutput(command, rendered); err != nil { + return err + } + if page.HasNext { + if _, err := fmt.Fprintf(command.ErrOrStderr(), "More results available (next: %s)\n", pagetoken.Encode(scope, page.Offset+page.Limit)); err != nil { + return exitcode.New(exitcode.Generic, errors.New("writing pagination notice failed")) + } + } + return nil + }, + } + flags := command.Flags() + flags.IntVarP(&opts.max, "max", "m", 10, "Maximum results (1-50)") + flags.StringVar(&opts.nextPageToken, "next-page-token", "", "Opaque continuation token") + addOutputFlags(command, &opts.options, "item IDs") + return command +} + +func playlistItemPageScope(id string) string { + return "playlist-items:" + id +} + func newList(deps Dependencies) *cobra.Command { opts := listOptions{max: 10} command := &cobra.Command{ diff --git a/internal/cmd/playlistcmd/playlist_test.go b/internal/cmd/playlistcmd/playlist_test.go index 948d463..90a6231 100644 --- a/internal/cmd/playlistcmd/playlist_test.go +++ b/internal/cmd/playlistcmd/playlist_test.go @@ -59,6 +59,24 @@ func (session *fakeSession) GetPlaylist(_ context.Context, id string) (client.Pl ItemCount: &client.PlaylistItemCount{Total: 3}, Collaborative: true, }, nil } +func (session *fakeSession) ListPlaylistItems(_ context.Context, id string, limit, offset int) (client.PlaylistItemPage, error) { + session.calls = append(session.calls, fmt.Sprintf("items:%s:%d:%d", id, limit, offset)) + if session.readErr != nil { + return client.PlaylistItemPage{}, session.readErr + } + if session.empty { + return client.PlaylistItemPage{Limit: limit, Offset: offset}, nil + } + duration := 61000 + explicit := true + return client.PlaylistItemPage{ + Items: []client.PlaylistItem{ + {Type: "track", ID: "abcdefghijklmnopqrstuv", Name: "Song", Artists: []client.Artist{{ID: "artist-1", Name: "Ada"}}, AlbumID: "album-1", AlbumName: "Album", DurationMS: &duration, URI: "spotify:track:abcdefghijklmnopqrstuv", URL: "https://track", AddedAt: "2026-07-24T12:00:00Z", AddedByID: "owner-1", DiscNumber: 1, TrackNumber: 2, Explicit: &explicit, Restriction: "market", Images: []client.Image{{URL: "https://image"}}}, + {Type: "unavailable"}, + }, + Limit: limit, Offset: offset, HasNext: session.hasNext, + }, nil +} func TestPlaylistListShapeFlags(t *testing.T) { for _, test := range []struct { @@ -157,7 +175,7 @@ func TestPlaylistCommandsValidateBeforeSession(t *testing.T) { func TestPlaylistScopeGuardRequiresBothScopesWithOverwriteHint(t *testing.T) { for _, scopes := range [][]string{nil, {auth.ScopePlaylistReadCollaborative}, {auth.ScopePlaylistReadPrivate}} { - for _, args := range [][]string{{"playlists", "list"}, {"playlists", "get", playlistID}} { + for _, args := range [][]string{{"playlists", "list"}, {"playlists", "get", playlistID}, {"playlists", "items", "list", playlistID}} { session := &fakeSession{scopes: scopes} stdout, stderr, opens, err := execute(session, args...) if exitcode.Code(err) != exitcode.Config || stdout != "" || stderr != "" || opens != 1 || len(session.calls) != 0 || !session.closed || @@ -183,6 +201,82 @@ func TestPlaylistReadErrorsAreClassified(t *testing.T) { } } +func TestPlaylistItemsListAcceptedReferencesAndPagination(t *testing.T) { + for _, reference := range []string{ + playlistID, + "spotify:playlist:" + playlistID, + "https://open.spotify.com/playlist/" + playlistID, + } { + session := &fakeSession{scopes: playlistScopes(), hasNext: true} + stdout, stderr, opens, err := execute(session, "playlists", "items", "list", reference, "--max", "2", "--next-page-token", pagetoken.Encode(playlistItemPageScope(playlistID), 10)) + want := "Playlist ID: " + playlistID + "\n" + + "POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n" + + "10 | track | abcdefghijklmnopqrstuv | Song | artist-1 | Ada | album-1 | Album | 1:01\n" + + "11 | unavailable | - | - | - | - | - | - | -\n" + wantErr := "More results available (next: " + pagetoken.Encode(playlistItemPageScope(playlistID), 12) + ")\n" + if err != nil || stdout != want || stderr != wantErr || opens != 1 || fmt.Sprint(session.calls) != "[items:"+playlistID+":2:10]" { + t.Fatalf("reference=%q stdout=%q stderr=%q opens=%d calls=%v error=%v", reference, stdout, stderr, opens, session.calls, err) + } + } +} + +func TestPlaylistItemsListShapeFlagsAndEmptyOutput(t *testing.T) { + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "selected", args: []string{"--fields", "item,type,item"}, want: "Playlist ID: " + playlistID + "\nITEM | TYPE\nSong | track\n- | unavailable\n"}, + {name: "artwork", args: []string{"--include-artwork", "--fields", "position,artwork"}, want: "Playlist ID: " + playlistID + "\nPOSITION | ARTWORK\n0 | -x- https://image\n1 | -\n"}, + {name: "extended", args: []string{"--extended"}, want: "Playlist ID: " + playlistID + "\nPOSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION | URI | URL | ADDED_AT | ADDED_BY_ID | DISC_NUMBER | TRACK_NUMBER | EXPLICIT | RESTRICTION\n0 | track | abcdefghijklmnopqrstuv | Song | artist-1 | Ada | album-1 | Album | 1:01 | spotify:track:abcdefghijklmnopqrstuv | https://track | 2026-07-24T12:00:00Z | owner-1 | 1 | 2 | true | market\n1 | unavailable | - | - | - | - | - | - | - | - | - | - | - | - | - | - | -\n"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout, stderr, opens, err := execute(&fakeSession{scopes: playlistScopes()}, append([]string{"playlists", "items", "list", playlistID}, test.args...)...) + if err != nil || stdout != test.want || stderr != "" || opens != 1 { + t.Fatalf("stdout=%q stderr=%q opens=%d error=%v", stdout, stderr, opens, err) + } + }) + } + + stdout, stderr, _, err := execute(&fakeSession{scopes: playlistScopes()}, "playlists", "items", "list", playlistID, "--id", "--fields", "invalid", "--extended", "--include-artwork") + if err != nil || stdout != "abcdefghijklmnopqrstuv\n" || stderr != "" { + t.Fatalf("id stdout=%q stderr=%q error=%v", stdout, stderr, err) + } + stdout, stderr, _, err = execute(&fakeSession{scopes: playlistScopes(), empty: true}, "playlists", "items", "list", playlistID) + wantEmpty := "Playlist ID: " + playlistID + "\nPOSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n" + if err != nil || stdout != wantEmpty || stderr != "" { + t.Fatalf("empty stdout=%q stderr=%q error=%v", stdout, stderr, err) + } +} + +func TestPlaylistItemsListValidatesBeforeSession(t *testing.T) { + otherPlaylistID := "ZYXWVUTSRQPONMLKJIHGFE" + for _, args := range [][]string{ + {"playlists", "items", "extra"}, + {"playlists", "items", "list"}, + {"playlists", "items", "list", "bad"}, + {"playlists", "items", "list", "spotify:album:" + playlistID}, + {"playlists", "items", "list", playlistID, "--max", "0"}, + {"playlists", "items", "list", playlistID, "--max", "51"}, + {"playlists", "items", "list", playlistID, "--fields", "invalid"}, + {"playlists", "items", "list", playlistID, "--next-page-token", "invalid"}, + {"playlists", "items", "list", playlistID, "--next-page-token", pagetoken.Encode(playlistPageScope, 10)}, + {"playlists", "items", "list", playlistID, "--next-page-token", pagetoken.Encode(playlistItemPageScope(otherPlaylistID), 10)}, + } { + _, _, opens, err := execute(&fakeSession{}, args...) + if exitcode.Code(err) != exitcode.Usage || opens != 0 { + t.Fatalf("args=%v opens=%d code=%d error=%v", args, opens, exitcode.Code(err), err) + } + } + for _, max := range []string{"1", "50"} { + session := &fakeSession{scopes: playlistScopes()} + _, _, opens, err := execute(session, "playlists", "items", "list", playlistID, "--max", max) + if err != nil || opens != 1 || fmt.Sprint(session.calls) != "[items:"+playlistID+":"+max+":0]" { + t.Fatalf("max=%s opens=%d calls=%v error=%v", max, opens, session.calls, err) + } + } +} + func execute(session *fakeSession, args ...string) (string, string, int, error) { opens := 0 command := &cobra.Command{Use: "sptfy"} diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 99e3570..f748af6 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -334,6 +334,8 @@ func TestPlaylistCommandsUseRealSessionAndClientPath(t *testing.T) { _, _ = io.WriteString(writer, `{"items":[{"id":"`+id+`","name":"Mix","owner":{"id":"owner-1","display_name":"Ada"},"items":{"total":3},"public":null,"collaborative":true}],"limit":10,"offset":0,"total":1,"next":null}`) case "/v1/playlists/" + id: _, _ = io.WriteString(writer, `{"id":"`+id+`","name":"Mix","owner":{"id":"owner-1","display_name":"Ada"},"items":{"total":3},"public":null,"collaborative":true}`) + case "/v1/playlists/" + id + "/items": + _, _ = io.WriteString(writer, `{"items":[{"added_at":"2026-07-24T12:00:00Z","added_by":{"id":"owner-1"},"is_local":false,"item":{"type":"track","id":"abcdefghijklmnopqrstuv","name":"Song","artists":[{"id":"1111111111111111111111","name":"Artist"}],"album":{"id":"2222222222222222222222","name":"Album","images":[]},"duration_ms":61000}}],"limit":10,"offset":0,"total":1,"next":null}`) default: http.NotFound(writer, request) } @@ -356,7 +358,15 @@ func TestPlaylistCommandsUseRealSessionAndClientPath(t *testing.T) { if h.out.String() != id+" Mix\nOwner ID: owner-1\n" || h.errOut.Len() != 0 { t.Fatalf("get stdout=%q stderr=%q", h.out.String(), h.errOut.String()) } - if strings.Join(paths, ",") != "/v1/me/playlists?limit=10&offset=0,/v1/playlists/"+id { + h.out.Reset() + if err := h.execute("playlists", "items", "list", "https://open.spotify.com/playlist/"+id); err != nil { + t.Fatal(err) + } + wantItems := "Playlist ID: " + id + "\nPOSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n0 | track | abcdefghijklmnopqrstuv | Song | 1111111111111111111111 | Artist | 2222222222222222222222 | Album | 1:01\n" + if h.out.String() != wantItems || h.errOut.Len() != 0 { + t.Fatalf("items stdout=%q stderr=%q", h.out.String(), h.errOut.String()) + } + if strings.Join(paths, ",") != "/v1/me/playlists?limit=10&offset=0,/v1/playlists/"+id+",/v1/playlists/"+id+"/items?additional_types=episode&limit=10&offset=0" { t.Fatalf("paths=%v", paths) } } diff --git a/internal/output/playlist.go b/internal/output/playlist.go index acf2597..dc85f36 100644 --- a/internal/output/playlist.go +++ b/internal/output/playlist.go @@ -162,3 +162,173 @@ func playlistFieldNames() string { } return strings.Join(values, ", ") } + +// PlaylistItemField identifies one stable playlist-item table column. +type PlaylistItemField string + +// Stable playlist-item output fields. +const ( + PlaylistItemPosition PlaylistItemField = "POSITION" + PlaylistItemType PlaylistItemField = "TYPE" + PlaylistItemID PlaylistItemField = "ID" + PlaylistItemName PlaylistItemField = "ITEM" + PlaylistItemArtistIDs PlaylistItemField = "ARTIST_IDS" + PlaylistItemArtists PlaylistItemField = "ARTISTS" + PlaylistItemAlbumID PlaylistItemField = "ALBUM_ID" + PlaylistItemAlbum PlaylistItemField = "ALBUM" + PlaylistItemDuration PlaylistItemField = "DURATION" + PlaylistItemURI PlaylistItemField = "URI" + PlaylistItemURL PlaylistItemField = "URL" + PlaylistItemAddedAt PlaylistItemField = "ADDED_AT" + PlaylistItemAddedByID PlaylistItemField = "ADDED_BY_ID" + PlaylistItemDiscNumber PlaylistItemField = "DISC_NUMBER" + PlaylistItemTrackNumber PlaylistItemField = "TRACK_NUMBER" + PlaylistItemExplicit PlaylistItemField = "EXPLICIT" + PlaylistItemRestriction PlaylistItemField = "RESTRICTION" + PlaylistItemArtwork PlaylistItemField = "ARTWORK" +) + +var ( + defaultPlaylistItemFields = []PlaylistItemField{ + PlaylistItemPosition, PlaylistItemType, PlaylistItemID, PlaylistItemName, + PlaylistItemArtistIDs, PlaylistItemArtists, PlaylistItemAlbumID, PlaylistItemAlbum, PlaylistItemDuration, + } + extendedPlaylistItemFields = []PlaylistItemField{ + PlaylistItemURI, PlaylistItemURL, PlaylistItemAddedAt, PlaylistItemAddedByID, + PlaylistItemDiscNumber, PlaylistItemTrackNumber, PlaylistItemExplicit, PlaylistItemRestriction, + } + allPlaylistItemFields = append(append(append([]PlaylistItemField(nil), defaultPlaylistItemFields...), extendedPlaylistItemFields...), PlaylistItemArtwork) +) + +// SelectPlaylistItemFields applies default, widening, artwork, then explicit-field precedence. +func SelectPlaylistItemFields(csv string, extended, includeArtwork bool) ([]PlaylistItemField, error) { + fields := append([]PlaylistItemField(nil), defaultPlaylistItemFields...) + if extended { + fields = append(fields, extendedPlaylistItemFields...) + } + if includeArtwork { + fields = append(fields, PlaylistItemArtwork) + } + if strings.TrimSpace(csv) == "" { + return fields, nil + } + defaults := fields + fields = nil + seen := map[PlaylistItemField]bool{} + for _, raw := range strings.Split(csv, ",") { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + field := PlaylistItemField(strings.ToUpper(trimmed)) + if !containsPlaylistItemField(field) { + return nil, fmt.Errorf("unknown playlist item field %q; valid fields: %s", trimmed, playlistItemFieldNames()) + } + if !seen[field] { + fields = append(fields, field) + seen[field] = true + } + } + if len(fields) == 0 { + return defaults, nil + } + return fields, nil +} + +// RenderPlaylistItems renders one ordered pipe-delimited table. +func RenderPlaylistItems(items []client.PlaylistItem, offset int, fields []PlaylistItemField) string { + var rendered strings.Builder + headers := make([]string, len(fields)) + for index, field := range fields { + headers[index] = string(field) + } + rendered.WriteString(strings.Join(headers, " | ")) + rendered.WriteByte('\n') + for index, item := range items { + cells := make([]string, len(fields)) + for fieldIndex, field := range fields { + cells[fieldIndex] = playlistItemCell(item, offset+index, field) + } + rendered.WriteString(strings.Join(cells, " | ")) + rendered.WriteByte('\n') + } + return rendered.String() +} + +// RenderPlaylistItemIDs renders only present Spotify item IDs. +func RenderPlaylistItemIDs(items []client.PlaylistItem) string { + var rendered strings.Builder + for _, item := range items { + if item.ID != "" { + rendered.WriteString(cell(item.ID)) + rendered.WriteByte('\n') + } + } + return rendered.String() +} + +func playlistItemCell(item client.PlaylistItem, position int, field PlaylistItemField) string { + switch field { + case PlaylistItemPosition: + return strconv.Itoa(position) + case PlaylistItemType: + return cell(item.Type) + case PlaylistItemID: + return cell(item.ID) + case PlaylistItemName: + return cell(item.Name) + case PlaylistItemArtistIDs: + return cell(joinArtists(item.Artists, func(artist client.Artist) string { return artist.ID })) + case PlaylistItemArtists: + return cell(joinArtists(item.Artists, func(artist client.Artist) string { return artist.Name })) + case PlaylistItemAlbumID: + return cell(item.AlbumID) + case PlaylistItemAlbum: + return cell(item.AlbumName) + case PlaylistItemDuration: + if item.DurationMS == nil { + return "-" + } + return duration(*item.DurationMS) + case PlaylistItemURI: + return cell(item.URI) + case PlaylistItemURL: + return cell(item.URL) + case PlaylistItemAddedAt: + return cell(item.AddedAt) + case PlaylistItemAddedByID: + return cell(item.AddedByID) + case PlaylistItemDiscNumber: + return positiveInt(item.DiscNumber) + case PlaylistItemTrackNumber: + return positiveInt(item.TrackNumber) + case PlaylistItemExplicit: + if item.Explicit == nil { + return "-" + } + return strconv.FormatBool(*item.Explicit) + case PlaylistItemRestriction: + return cell(item.Restriction) + case PlaylistItemArtwork: + return renderArtwork(item.Images) + default: + return "-" + } +} + +func containsPlaylistItemField(field PlaylistItemField) bool { + for _, candidate := range allPlaylistItemFields { + if candidate == field { + return true + } + } + return false +} + +func playlistItemFieldNames() string { + values := make([]string, len(allPlaylistItemFields)) + for index, field := range allPlaylistItemFields { + values[index] = string(field) + } + return strings.Join(values, ", ") +} diff --git a/internal/output/playlist_test.go b/internal/output/playlist_test.go index 1f1d999..295fbb2 100644 --- a/internal/output/playlist_test.go +++ b/internal/output/playlist_test.go @@ -77,3 +77,55 @@ func TestRenderPlaylistDetailKeepsIdentityOnlyInHeader(t *testing.T) { t.Fatalf("detail=%q want=%q", got, want) } } + +func TestPlaylistItemFieldsAndMixedRendering(t *testing.T) { + explicit := true + items := []client.PlaylistItem{ + {Type: "track", ID: "track-1", Name: "Song | live\ncut", Artists: []client.Artist{{ID: "artist-1", Name: "Ada"}}, AlbumID: "album-1", AlbumName: "Album", DurationMS: intPointer(61000), URI: "spotify:track:track-1", URL: "https://track", AddedAt: "2026-07-24T12:00:00Z", AddedByID: "owner-1", DiscNumber: 1, TrackNumber: 2, Explicit: &explicit, Restriction: "market", Images: []client.Image{{URL: "https://track-image"}}}, + {Type: "episode", ID: "episode-1", Name: "Episode", DurationMS: intPointer(62000), Images: []client.Image{{URL: "https://episode-image"}}}, + {Type: "local", Name: "Local", DurationMS: intPointer(63000)}, + {Type: "unavailable"}, + {Type: "future | type\nline", Name: "Future", DurationMS: intPointer(64000)}, + {Type: "unknown", Name: "Untyped"}, + } + fields, err := SelectPlaylistItemFields("", false, false) + if err != nil { + t.Fatal(err) + } + want := "POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n" + + "20 | track | track-1 | Song live cut | artist-1 | Ada | album-1 | Album | 1:01\n" + + "21 | episode | episode-1 | Episode | - | - | - | - | 1:02\n" + + "22 | local | - | Local | - | - | - | - | 1:03\n" + + "23 | unavailable | - | - | - | - | - | - | -\n" + + "24 | future type line | - | Future | - | - | - | - | 1:04\n" + + "25 | unknown | - | Untyped | - | - | - | - | -\n" + if got := RenderPlaylistItems(items, 20, fields); got != want { + t.Fatalf("rendered=%q want=%q", got, want) + } + + fields, err = SelectPlaylistItemFields("item,TYPE,item,artwork", true, true) + if err != nil { + t.Fatal(err) + } + want = "ITEM | TYPE | ARTWORK\nSong live cut | track | -x- https://track-image\nEpisode | episode | -x- https://episode-image\nLocal | local | -\n- | unavailable | -\nFuture | future type line | -\nUntyped | unknown | -\n" + if got := RenderPlaylistItems(items, 0, fields); got != want { + t.Fatalf("selected=%q want=%q", got, want) + } + if got := RenderPlaylistItemIDs(items); got != "track-1\nepisode-1\n" { + t.Fatalf("ids=%q", got) + } +} + +func TestPlaylistItemExtendedAndEmptyRendering(t *testing.T) { + fields, err := SelectPlaylistItemFields("", true, true) + if err != nil { + t.Fatal(err) + } + wantHeader := "POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION | URI | URL | ADDED_AT | ADDED_BY_ID | DISC_NUMBER | TRACK_NUMBER | EXPLICIT | RESTRICTION | ARTWORK\n" + if got := RenderPlaylistItems(nil, 0, fields); got != wantHeader { + t.Fatalf("empty=%q", got) + } + if _, err := SelectPlaylistItemFields("nope", false, false); err == nil || !strings.Contains(err.Error(), "valid fields: POSITION, TYPE") { + t.Fatalf("error=%v", err) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 2cfaaee..b9c9790 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -93,6 +93,11 @@ func (session *Session) GetPlaylist(ctx context.Context, id string) (client.Play return session.client.GetPlaylist(ctx, id) } +// ListPlaylistItems lists one ordered playlist-item page with the authenticated Spotify client. +func (session *Session) ListPlaylistItems(ctx context.Context, id string, limit, offset int) (client.PlaylistItemPage, error) { + return session.client.ListPlaylistItems(ctx, id, limit, offset) +} + // ListSavedTracks lists saved tracks with the authenticated Spotify client. func (session *Session) ListSavedTracks(ctx context.Context, limit, offset int) (client.SavedTrackPage, error) { return session.client.ListSavedTracks(ctx, limit, offset) diff --git a/internal/session/session_test.go b/internal/session/session_test.go index a912535..ee34f3c 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -169,6 +169,9 @@ func TestSessionDelegatesPlaylistReads(t *testing.T) { if request.URL.Path == "/v1/me/playlists" { return response(http.StatusOK, `{"items":[],"limit":1,"offset":0,"total":0,"next":null}`), nil } + if request.URL.Path == "/v1/playlists/"+id+"/items" { + return response(http.StatusOK, `{"items":[],"limit":1,"offset":0,"total":0,"next":null}`), nil + } return response(http.StatusOK, `{"id":"`+id+`","items":{"total":0}}`), nil })} authenticated := New(client.Client{HTTPClient: httpClient, BaseURL: "https://api.spotify.invalid/v1"}, nil, nil) @@ -178,7 +181,10 @@ func TestSessionDelegatesPlaylistReads(t *testing.T) { if _, err := authenticated.GetPlaylist(context.Background(), id); err != nil { t.Fatal(err) } - want := "/v1/me/playlists?limit=1&offset=0,/v1/playlists/" + id + if _, err := authenticated.ListPlaylistItems(context.Background(), id, 1, 0); err != nil { + t.Fatal(err) + } + want := "/v1/me/playlists?limit=1&offset=0,/v1/playlists/" + id + ",/v1/playlists/" + id + "/items?additional_types=episode&limit=1&offset=0" if strings.Join(paths, ",") != want { t.Fatalf("paths=%v want=%s", paths, want) } diff --git a/scripts/live-smoke-test.sh b/scripts/live-smoke-test.sh index 27af30b..8deb9a8 100755 --- a/scripts/live-smoke-test.sh +++ b/scripts/live-smoke-test.sh @@ -49,9 +49,30 @@ elif [[ $args == *" playlists list "* ]]; then printf 'More results available (next: playlist-token)\n' >&2 fi elif [[ $args == *" playlists get "* ]]; then - [[ -f $XDG_DATA_HOME/playlist-fixture-listed ]] || exit 17 - [[ $args == *" playlists get ${SPOTIFY_CLI_LIVE_PLAYLIST_ID:?} --id "* ]] || exit 16 - printf '%s\n' "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" + [[ -f $XDG_DATA_HOME/playlist-fixture-listed ]] || exit 17 + [[ $args == *" playlists get ${SPOTIFY_CLI_LIVE_PLAYLIST_ID:?} --id "* ]] || exit 16 + printf '%s\n' "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" +elif [[ $args == *" playlists items list "* ]]; then + IFS=, read -r -a item_ids <<<"${SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS:?}" + playlist_items_base=" --backend file playlists items list ${SPOTIFY_CLI_LIVE_PLAYLIST_ID:?} " + if [[ $args == "${playlist_items_base}--id --max 3 " ]]; then + printf '%s\n' "${item_ids[0]}" "${item_ids[1]}" "${item_ids[2]}" + elif [[ $args == "${playlist_items_base}--max 2 --next-page-token playlist-items-token " ]]; then + printf 'Playlist ID: %s\n' "${SPOTIFY_CLI_LIVE_PLAYLIST_ID:?}" + printf 'POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n' + printf '2 | track | %s | Three | artist-1 | Artist | album-1 | Album | 1:02\n' "${item_ids[2]}" + if [[ ${SPOTIFY_CLI_LIVE_FAKE_SPURIOUS_ITEM_NEXT:-0} == 1 ]]; then + printf 'More results available (next: bogus-final-token)\n' >&2 + fi + elif [[ $args == "${playlist_items_base}--max 2 " ]]; then + printf 'Playlist ID: %s\n' "${SPOTIFY_CLI_LIVE_PLAYLIST_ID:?}" + printf 'POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n' + printf '0 | track | %s | One | artist-1 | Artist | album-1 | Album | 1:00\n' "${item_ids[0]}" + printf '1 | track | %s | Two | artist-1 | Artist | album-1 | Album | 1:01\n' "${item_ids[1]}" + printf 'More results available (next: playlist-items-token)\n' >&2 + else + exit 18 + fi elif [[ $args == *" library tracks list "* ]]; then printf 'ADDED_AT | ID | TRACK | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION\n' printf '2026-07-23T12:00:00Z | 11dFghVXANMlKmJXsNCbNl | Song | artist-1 | Artist | album-1 | Album | 1:00\n' @@ -167,6 +188,9 @@ expect_guard_failure env -u SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT SPOTIFY_CLI_LIVE= expect_guard_failure env -u SPOTIFY_CLIENT_ID SPOTIFY_CLI_LIVE=1 SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 SPOTIFY_CLI_LIVE_DRY_RUN=1 SPOTIFY_CLI_LIVE_BINARY="$fake" ./scripts/live-smoke.sh expect_guard_failure env -u SPOTIFY_CLI_LIVE_PLAYLIST_ID SPOTIFY_CLI_LIVE=1 SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 SPOTIFY_CLI_LIVE_DRY_RUN=1 SPOTIFY_CLI_LIVE_BINARY="$fake" SPOTIFY_CLIENT_ID=test ./scripts/live-smoke.sh expect_guard_failure env SPOTIFY_CLI_LIVE=1 SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 SPOTIFY_CLI_LIVE_DRY_RUN=1 SPOTIFY_CLI_LIVE_BINARY="$fake" SPOTIFY_CLI_LIVE_PLAYLIST_ID=bad SPOTIFY_CLIENT_ID=test ./scripts/live-smoke.sh +expect_guard_failure env -u SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS SPOTIFY_CLI_LIVE=1 SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 SPOTIFY_CLI_LIVE_DRY_RUN=1 SPOTIFY_CLI_LIVE_BINARY="$fake" SPOTIFY_CLI_LIVE_PLAYLIST_ID=0123456789ABCDEFGHIJKL SPOTIFY_CLIENT_ID=test ./scripts/live-smoke.sh +expect_guard_failure env SPOTIFY_CLI_LIVE=1 SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 SPOTIFY_CLI_LIVE_DRY_RUN=1 SPOTIFY_CLI_LIVE_BINARY="$fake" SPOTIFY_CLI_LIVE_PLAYLIST_ID=0123456789ABCDEFGHIJKL SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=abcdefghijklmnopqrstuv SPOTIFY_CLIENT_ID=test ./scripts/live-smoke.sh +expect_guard_failure env SPOTIFY_CLI_LIVE=1 SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 SPOTIFY_CLI_LIVE_DRY_RUN=1 SPOTIFY_CLI_LIVE_BINARY="$fake" SPOTIFY_CLI_LIVE_PLAYLIST_ID=0123456789ABCDEFGHIJKL SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=abcdefghijklmnopqrstuv,ZYXWVUTSRQPONMLKJIHGFE,1111111111111111111111,2222222222222222222222 SPOTIFY_CLIENT_ID=test ./scripts/live-smoke.sh for initially_saved in 0 1; do smoke_err="$test_root/smoke-$initially_saved.err" @@ -178,6 +202,7 @@ for initially_saved in 0 1; do SPOTIFY_CLI_LIVE_FAKE_INITIAL_SAVED="$initially_saved" \ SPOTIFY_CLI_LIVE_FAKE_INITIAL_ALBUM_SAVED="$initially_saved" \ SPOTIFY_CLI_LIVE_PLAYLIST_ID=0123456789ABCDEFGHIJKL \ + SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=abcdefghijklmnopqrstuv,ZYXWVUTSRQPONMLKJIHGFE,1111111111111111111111 \ SPOTIFY_CLIENT_ID=test \ ./scripts/live-smoke.sh >/dev/null 2>"$smoke_err"; then sed -n '1,120p' "$smoke_err" >&2 @@ -186,6 +211,23 @@ for initially_saved in 0 1; do rm -f "$smoke_err" done +spurious_err="$test_root/spurious-item-next.err" +if TMPDIR="$test_root" \ + SPOTIFY_CLI_LIVE=1 \ + SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 \ + SPOTIFY_CLI_LIVE_DRY_RUN=1 \ + SPOTIFY_CLI_LIVE_BINARY="$fake" \ + SPOTIFY_CLI_LIVE_FAKE_SPURIOUS_ITEM_NEXT=1 \ + SPOTIFY_CLI_LIVE_PLAYLIST_ID=0123456789ABCDEFGHIJKL \ + SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=abcdefghijklmnopqrstuv,ZYXWVUTSRQPONMLKJIHGFE,1111111111111111111111 \ + SPOTIFY_CLIENT_ID=test \ + ./scripts/live-smoke.sh >/dev/null 2>"$spurious_err"; then + printf '%s\n' 'live harness unexpectedly accepted a final playlist-item continuation marker' >&2 + exit 1 +fi +grep -Fq 'playlist item continuation emitted unexpected stderr' "$spurious_err" +rm -f "$spurious_err" + for initially_saved in 0 1; do restore_err="$test_root/restore-$initially_saved.err" if TMPDIR="$test_root" \ @@ -196,6 +238,7 @@ for initially_saved in 0 1; do SPOTIFY_CLI_LIVE_FAKE_INITIAL_SAVED="$initially_saved" \ SPOTIFY_CLI_LIVE_FAKE_FAIL_RESTORE=1 \ SPOTIFY_CLI_LIVE_PLAYLIST_ID=0123456789ABCDEFGHIJKL \ + SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=abcdefghijklmnopqrstuv,ZYXWVUTSRQPONMLKJIHGFE,1111111111111111111111 \ SPOTIFY_CLIENT_ID=test \ ./scripts/live-smoke.sh >/dev/null 2>"$restore_err"; then printf '%s\n' 'live harness unexpectedly ignored a restoration failure' >&2 diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index cb308b8..4257674 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -6,6 +6,12 @@ set -euo pipefail [[ -n ${SPOTIFY_CLIENT_ID:-} ]] || { printf '%s\n' 'SPOTIFY_CLIENT_ID is required' >&2; exit 2; } [[ -n ${SPOTIFY_CLI_LIVE_PLAYLIST_ID:-} ]] || { printf '%s\n' 'SPOTIFY_CLI_LIVE_PLAYLIST_ID is required' >&2; exit 2; } [[ $SPOTIFY_CLI_LIVE_PLAYLIST_ID =~ ^[A-Za-z0-9]{22}$ ]] || { printf '%s\n' 'SPOTIFY_CLI_LIVE_PLAYLIST_ID must be a 22-character Spotify ID' >&2; exit 2; } +[[ -n ${SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS:-} ]] || { printf '%s\n' 'SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS is required' >&2; exit 2; } +IFS=, read -r -a playlist_expected_item_ids <<<"$SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS" +[[ ${#playlist_expected_item_ids[@]} -eq 3 ]] || { printf '%s\n' 'SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS requires exactly three comma-separated IDs' >&2; exit 2; } +for playlist_item_id in "${playlist_expected_item_ids[@]}"; do + [[ $playlist_item_id =~ ^[A-Za-z0-9]{22}$ ]] || { printf '%s\n' 'SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS must contain only 22-character Spotify IDs' >&2; exit 2; } +done live_dry=${SPOTIFY_CLI_LIVE_DRY_RUN:-0} if [[ $live_dry != 1 ]]; then [[ -t 0 ]] || { printf '%s\n' 'the live smoke requires an interactive terminal' >&2; exit 2; } @@ -134,6 +140,28 @@ playlist_fixture_ids=$("$SPTFY" --backend file playlists list --id --max 50) grep -Fxq "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" <<<"$playlist_fixture_ids" || { printf '%s\n' 'configured playlist fixture was not listed' >&2; exit 1; } [[ $("$SPTFY" --backend file playlists get "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" --id) == "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" ]] || { printf '%s\n' 'playlist get returned an unexpected ID' >&2; exit 1; } +playlist_items_page_out="$SPOTIFY_CLI_LIVE_ROOT/playlist-items-page.out" +playlist_items_page_err="$SPOTIFY_CLI_LIVE_ROOT/playlist-items-page.err" +"$SPTFY" --backend file playlists items list "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" --max 2 >"$playlist_items_page_out" 2>"$playlist_items_page_err" +[[ $(wc -l <"$playlist_items_page_out") -eq 4 ]] || { printf '%s\n' 'playlist items first page did not return exactly two rows' >&2; exit 1; } +[[ $(sed -n '1p' "$playlist_items_page_out") == "Playlist ID: $SPOTIFY_CLI_LIVE_PLAYLIST_ID" ]] || { printf '%s\n' 'playlist items returned an unexpected parent' >&2; exit 1; } +[[ $(sed -n '2p' "$playlist_items_page_out") == 'POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION' ]] || { printf '%s\n' 'playlist items returned an unexpected shape' >&2; exit 1; } +[[ $(awk -F ' \\| ' 'NR == 3 {print $1":"$3}' "$playlist_items_page_out") == "0:${playlist_expected_item_ids[0]}" ]] || { printf '%s\n' 'playlist item zero did not match the fixture' >&2; exit 1; } +[[ $(awk -F ' \\| ' 'NR == 4 {print $1":"$3}' "$playlist_items_page_out") == "1:${playlist_expected_item_ids[1]}" ]] || { printf '%s\n' 'playlist item one did not match the fixture' >&2; exit 1; } +playlist_items_token=$(sed -n 's/^More results available (next: \(.*\))$/\1/p' "$playlist_items_page_err") +[[ -n $playlist_items_token ]] || { printf '%s\n' 'playlist items did not return a continuation token' >&2; exit 1; } +playlist_items_next_out="$SPOTIFY_CLI_LIVE_ROOT/playlist-items-next.out" +playlist_items_next_err="$SPOTIFY_CLI_LIVE_ROOT/playlist-items-next.err" +"$SPTFY" --backend file playlists items list "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" --max 2 --next-page-token "$playlist_items_token" >"$playlist_items_next_out" 2>"$playlist_items_next_err" +[[ ! -s $playlist_items_next_err ]] || { printf '%s\n' 'playlist item continuation emitted unexpected stderr' >&2; exit 1; } +[[ $(wc -l <"$playlist_items_next_out") -eq 3 ]] || { printf '%s\n' 'playlist item continuation did not return exactly one row' >&2; exit 1; } +[[ $(sed -n '1p' "$playlist_items_next_out") == "Playlist ID: $SPOTIFY_CLI_LIVE_PLAYLIST_ID" ]] || { printf '%s\n' 'playlist item continuation returned an unexpected parent' >&2; exit 1; } +[[ $(sed -n '2p' "$playlist_items_next_out") == 'POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION' ]] || { printf '%s\n' 'playlist item continuation returned an unexpected shape' >&2; exit 1; } +[[ $(awk -F ' \\| ' 'NR == 3 {print $1":"$3}' "$playlist_items_next_out") == "2:${playlist_expected_item_ids[2]}" ]] || { printf '%s\n' 'playlist item continuation did not resume at position two' >&2; exit 1; } +playlist_items_ids=$("$SPTFY" --backend file playlists items list "$SPOTIFY_CLI_LIVE_PLAYLIST_ID" --id --max 3) +printf -v playlist_expected_ids '%s\n%s\n%s' "${playlist_expected_item_ids[0]}" "${playlist_expected_item_ids[1]}" "${playlist_expected_item_ids[2]}" +[[ $playlist_items_ids == "$playlist_expected_ids" ]] || { printf '%s\n' 'playlist item ID-only order did not match normal output' >&2; exit 1; } + track_id=11dFghVXANMlKmJXsNCbNl album_id=4aawyAB9vmqN3uQ7FjRGTy artist_id=0TnOYISbd1XYRBk9myaseg diff --git a/spotify-cli-behavior.md b/spotify-cli-behavior.md index 416bd58..afbf5c7 100644 --- a/spotify-cli-behavior.md +++ b/spotify-cli-behavior.md @@ -32,6 +32,7 @@ The currently implemented surface contains exactly these commands: - `sptfy library albums remove ...` - `sptfy playlists list` - `sptfy playlists get ` +- `sptfy playlists items list ` The product exclusions under [Future boundaries](#future-boundaries) remain directional and non-normative until promoted into command sections. @@ -339,7 +340,36 @@ The command accepts one raw 22-character ID, matching playlist URI, or canonical playlist URL under the same strict validation used by catalog gets. It uses one fixed-origin `GET /playlists/{id}` request. Detail output uses the stable identity header, and selected `ID` or `PLAYLIST` fields are not repeated -below it. Playlist items and every playlist mutation are outside this surface. +below it. Spotify currently permits this read only for playlists the current +user owns or collaborates on. Every playlist mutation is outside this surface. + +### `sptfy playlists items list ` + +The command accepts the same playlist reference forms as `playlists get` and +uses the current fixed-origin `GET /playlists/{id}/items` endpoint with +`additional_types=episode`. It defaults to 10 items and accepts 1–50. Its +opaque continuation token is bound to the playlist-items surface and parsed +playlist ID; provider response URLs are never followed. The same current +provider owner-or-collaborator restriction applies. + +Normal output renders `Playlist ID: ` once, followed by: + +```text +POSITION | TYPE | ID | ITEM | ARTIST_IDS | ARTISTS | ALBUM_ID | ALBUM | DURATION +``` + +`POSITION` is the absolute zero-based page offset plus row index. `TYPE` is +`track`, `episode`, `local`, or `unavailable` for known wrapper shapes; a +future provider type is sanitized and preserved, while a non-null item without +a type renders `unknown`. Only track rows populate artist and album +breadcrumbs. Episode, local, unavailable, and unknown rows do not fabricate +track metadata. + +`--extended` adds `URI`, `URL`, `ADDED_AT`, `ADDED_BY_ID`, `DISC_NUMBER`, +`TRACK_NUMBER`, `EXPLICIT`, and `RESTRICTION`. `--include-artwork` uses album +artwork for tracks and item artwork for episodes. `--fields` follows the +standard selection precedence. `--id` overrides every other shape flag, +omits the parent line, and emits only entries with a Spotify ID. ## Request behavior @@ -446,6 +476,15 @@ overwrite hint before provider I/O. Provider fixtures verify fixed paths, requested page metadata, non-negative totals/item counts, item bounds, response shape, and that provider continuation URLs are never followed. +Playlist-item tests cover accepted parent references, 1/50 bounds, absolute +positions across resumed pages, every known mixed-item shape plus future and +untyped items, exact parent/default/selected/extended/artwork/ID-only/empty +output, and validation before authentication. Provider fixtures prove the +current `/items` path and `item` member, exact page metadata, nonnegative +durations, required arrays, present item IDs, fixed origin, and ignored +provider pagination URLs. The static and opt-in live smokes prove the first +three configured item IDs, their positions, continuation, and ID-only order. + Live tests are opt-in and use a dedicated Spotify application/account plus a hermetic state directory and an explicitly selected encrypted-file credential backend rooted there. They never run in ordinary CI or mutate a developer's @@ -455,6 +494,6 @@ normal Spotify configuration or OS keychain. Future paginated commands use `-m/--max`, `--next-page-token`, and stderr continuation hints. Future resource reads remain text-only and carry the -relationship breadcrumbs defined above. Playlist items and mutations, -playback, recommendations, podcasts, audiobooks, raw HTTP access, and local -media-library synchronization remain out of scope until separately specified. +relationship breadcrumbs defined above. Playlist mutations, playback, +recommendations, podcasts, audiobooks, raw HTTP access, and local media-library +synchronization remain out of scope until separately specified.