Skip to content

Commit bfcaca5

Browse files
committed
feat: replace one playlist item in place
1 parent e6aba59 commit bfcaca5

12 files changed

Lines changed: 1048 additions & 105 deletions

File tree

‎README.md‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ sptfy playlists get spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID
130130
sptfy playlists items list spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID
131131
sptfy playlists items add spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID TRACK_ID --position 1
132132
sptfy playlists items remove spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID 1
133+
sptfy playlists items update spotify:playlist:YOUR_OWN_OR_COLLABORATIVE_PLAYLIST_ID 1 --item REPLACEMENT_TRACK_ID
133134
```
134135

135136
Lists default to 10 and allow 1–50 results. Default fields are
@@ -152,13 +153,17 @@ URLs. It preserves argument order and duplicates, appends by default, and
152153
accepts `--position` from zero through the current item count. Batches larger
153154
than 100 use ordered requests. Remove accepts one absolute zero-based position;
154155
it refuses non-track items and duplicate track URIs because Spotify's current
155-
removal operation is URI-based rather than position-based. Both commands
156+
removal operation is URI-based rather than position-based. Update replaces one
157+
unique track at an exact position by adding the replacement before removing the
158+
original. It refuses unsafe non-track, duplicate, same-item, and stale states.
159+
All three commands
156160
require the two read and two modify scopes and emit reversible, headerless tab
157161
records for applied mutations:
158162

159163
```text
160164
added<TAB>playlist-id<TAB>start-position<TAB>count<TAB>snapshot-id
161165
removed<TAB>playlist-id<TAB>position<TAB>track-id<TAB>snapshot-id
166+
updated<TAB>playlist-id<TAB>position<TAB>old-track-id<TAB>new-track-id<TAB>snapshot-id
162167
```
163168

164169
The add record retains the position and count needed for an inverse operation.
@@ -167,11 +172,26 @@ inserted track ID is unique in the playlist; intentional or preexisting
167172
duplicates must first be made unique by another Spotify client. The remove
168173
record can be inverted by adding `track-id` at `position`. If a later add chunk
169174
is definitely rejected, the confirmed-prefix record is emitted and the command
170-
exits nonzero; a first-chunk definite rejection emits no record. When transport
171-
or a successful but invalid response makes an add or remove outcome uncertain,
172-
the command emits no stdout record and its nonzero error says to inspect and
173-
reconcile before retrying. The uncertain change may have applied. Remove emits
174-
a stdout record only after confirmed success.
175+
exits nonzero; a first-chunk definite rejection emits no record. When transport,
176+
an ambiguous provider `5xx`, or a successful but invalid response makes an add
177+
or remove outcome uncertain, the command emits no stdout record and its nonzero
178+
error says to inspect and reconcile before retrying. The uncertain change may
179+
have applied. Remove emits a stdout record only after confirmed success.
180+
181+
The update record is inverted by updating the reported position with
182+
`old-track-id`. Update emits no record unless both steps are confirmed. A
183+
definite provider `4xx` removal rejection after a successful add reports that
184+
both tracks remain and includes the add snapshot for recovery. An uncertain add
185+
reports the playlist, position, and old/new IDs but has no confirmed post-write
186+
snapshot. An uncertain removal additionally reports the confirmed add snapshot.
187+
Provider `5xx` responses are uncertain; provider details remain hidden, so
188+
inspect before retrying because either write may have applied.
189+
Before removing the original, update verifies the confirmed add snapshot and
190+
item count around a complete identity-and-order read. A mismatch or read failure
191+
leaves the original untouched, emits no record, and reports the confirmed add
192+
snapshot for reconciliation. The final delete identifies the original's exact
193+
post-add position under that snapshot, so a concurrently added duplicate is not
194+
also removed.
175195

176196
## Saved tracks
177197

@@ -229,6 +249,7 @@ SPOTIFY_CLI_LIVE_DEDICATED_ACCOUNT=1 \
229249
SPOTIFY_CLI_LIVE_PLAYLIST_ID=your_playlist_id \
230250
SPOTIFY_CLI_LIVE_PLAYLIST_ITEM_IDS=first_item_id,second_item_id,third_item_id \
231251
SPOTIFY_CLI_LIVE_PLAYLIST_MUTATION_TRACK_ID=a_distinct_track_id \
252+
SPOTIFY_CLI_LIVE_PLAYLIST_MUTATION_SEARCH_QUERY='isrc:a_unique_isrc' \
232253
SPOTIFY_CLIENT_ID=your_client_id \
233254
make live-smoke
234255
```
@@ -237,6 +258,9 @@ make live-smoke
237258
entire playlist. Before mutating, the harness captures one complete runtime page
238259
of up to 50 IDs, requires that prefix and an absent mutation track, and uses the
239260
captured sequence for exact restoration.
261+
`SPOTIFY_CLI_LIVE_PLAYLIST_MUTATION_SEARCH_QUERY` must return that mutation
262+
track as its first ID-only result; the harness verifies the match before any
263+
playlist write.
240264

241265
The harness is interactive because Spotify authorization opens a browser. It
242266
exercises setup, identity, refresh, search/pagination shapes, catalog gets,

‎internal/client/client.go‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ func (err *MutationOutcomeUncertainError) Error() string {
5555
// Unwrap returns the post-send failure.
5656
func (err *MutationOutcomeUncertainError) Unwrap() error { return err.Cause }
5757

58+
// MutationRejectedError reports a failure known not to have applied the mutation.
59+
type MutationRejectedError struct {
60+
Cause error
61+
Method string
62+
Path string
63+
StatusCode int
64+
}
65+
66+
func (err *MutationRejectedError) Error() string { return err.Cause.Error() }
67+
68+
// Unwrap returns the sanitized provider failure.
69+
func (err *MutationRejectedError) Unwrap() error { return err.Cause }
70+
5871
// User is the stable identity returned by Spotify's current-user endpoint.
5972
type User struct {
6073
AccountID string `json:"account_id"`
@@ -410,7 +423,8 @@ type playlistSnapshotResponse struct {
410423
}
411424

412425
type playlistItemReference struct {
413-
URI string `json:"uri"`
426+
URI string `json:"uri"`
427+
Positions []int `json:"positions,omitempty"`
414428
}
415429

416430
// AddPlaylistItems adds one ordered batch of tracks and returns the new snapshot.
@@ -439,10 +453,22 @@ func (client Client) RemovePlaylistItemsByURI(ctx context.Context, id, uri, snap
439453
if !spotifyref.ValidID(id) || !validLibraryURIs(spotifyref.Track, []string{uri}) || strings.TrimSpace(snapshotID) == "" {
440454
return "", ErrInvalidResponse
441455
}
456+
return client.removePlaylistItems(ctx, id, []playlistItemReference{{URI: uri}}, snapshotID)
457+
}
458+
459+
// RemovePlaylistItemAtPosition removes one specific track occurrence against a known snapshot.
460+
func (client Client) RemovePlaylistItemAtPosition(ctx context.Context, id, uri string, position int, snapshotID string) (string, error) {
461+
if !spotifyref.ValidID(id) || !validLibraryURIs(spotifyref.Track, []string{uri}) || position < 0 || strings.TrimSpace(snapshotID) == "" {
462+
return "", ErrInvalidResponse
463+
}
464+
return client.removePlaylistItems(ctx, id, []playlistItemReference{{URI: uri, Positions: []int{position}}}, snapshotID)
465+
}
466+
467+
func (client Client) removePlaylistItems(ctx context.Context, id string, items []playlistItemReference, snapshotID string) (string, error) {
442468
body := struct {
443469
Items []playlistItemReference `json:"items"`
444470
SnapshotID string `json:"snapshot_id"`
445-
}{Items: []playlistItemReference{{URI: uri}}, SnapshotID: snapshotID}
471+
}{Items: items, SnapshotID: snapshotID}
446472
path := "/playlists/" + id + "/items"
447473
var response playlistSnapshotResponse
448474
if err := client.mutateJSON(ctx, http.MethodDelete, path, body, &response); err != nil {
@@ -861,7 +887,7 @@ func (client Client) requestJSON(ctx context.Context, method, path string, targe
861887
func (client Client) mutateJSON(ctx context.Context, method, path string, body, target any) error {
862888
encoded, err := json.Marshal(body)
863889
if err != nil {
864-
return ErrInvalidResponse
890+
return mutationRejected(method, path, 0, ErrInvalidResponse)
865891
}
866892
return client.doJSON(ctx, method, path, encoded, target)
867893
}
@@ -882,6 +908,9 @@ func (client Client) doJSON(ctx context.Context, method, path string, body []byt
882908
for attempt := 0; attempt < attempts; attempt++ {
883909
request, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
884910
if err != nil {
911+
if body != nil {
912+
return mutationRejected(method, path, 0, ErrUpstream)
913+
}
885914
return ErrUpstream
886915
}
887916
if body != nil {
@@ -898,6 +927,20 @@ func (client Client) doJSON(ctx context.Context, method, path string, body []byt
898927
}
899928
return cause
900929
}
930+
if body != nil && (response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices) {
931+
_ = response.Body.Close()
932+
cause := ErrUpstream
933+
switch response.StatusCode {
934+
case http.StatusUnauthorized:
935+
cause = ErrUnauthorized
936+
case http.StatusForbidden:
937+
cause = ErrForbidden
938+
}
939+
if response.StatusCode >= http.StatusBadRequest && response.StatusCode < http.StatusInternalServerError {
940+
return mutationRejected(method, path, response.StatusCode, cause)
941+
}
942+
return mutationOutcomeUncertain(method, path, cause)
943+
}
901944
if delay, retry, valid := retryDelay(response, attempt); retry {
902945
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes))
903946
_ = response.Body.Close()
@@ -988,3 +1031,7 @@ func (err transportError) Is(target error) bool {
9881031
func mutationOutcomeUncertain(method, path string, cause error) error {
9891032
return &MutationOutcomeUncertainError{Cause: cause, Method: method, Path: path}
9901033
}
1034+
1035+
func mutationRejected(method, path string, statusCode int, cause error) error {
1036+
return &MutationRejectedError{Cause: cause, Method: method, Path: path, StatusCode: statusCode}
1037+
}

‎internal/client/playlist_mutation_test.go‎

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -70,27 +70,49 @@ func TestAddPlaylistItemsAcceptsExact100URIRequestBody(t *testing.T) {
7070
}
7171
}
7272

73-
func TestAddPlaylistItemsOmitsPositionAndDoesNotRetry(t *testing.T) {
74-
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
75-
t.Run(http.StatusText(status), func(t *testing.T) {
76-
calls := 0
77-
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
78-
calls++
79-
body, err := io.ReadAll(request.Body)
80-
if err != nil {
81-
t.Fatal(err)
73+
func TestPlaylistMutationHTTPStatusesAreTypedAndNeverRetried(t *testing.T) {
74+
for _, method := range []string{http.MethodPost, http.MethodDelete} {
75+
for _, test := range []struct {
76+
status int
77+
uncertain bool
78+
}{
79+
{status: http.StatusBadRequest},
80+
{status: http.StatusTooManyRequests},
81+
{status: http.StatusInternalServerError, uncertain: true},
82+
{status: http.StatusBadGateway, uncertain: true},
83+
{status: http.StatusServiceUnavailable, uncertain: true},
84+
{status: http.StatusGatewayTimeout, uncertain: true},
85+
} {
86+
t.Run(method+" "+http.StatusText(test.status), func(t *testing.T) {
87+
calls := 0
88+
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
89+
calls++
90+
body, err := io.ReadAll(request.Body)
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
if method == http.MethodPost && (strings.Contains(string(body), "position") || strings.Contains(string(body), "tracks")) {
95+
t.Fatalf("body=%s", body)
96+
}
97+
return response(test.status, ""), nil
98+
})}
99+
spotify := Client{HTTPClient: httpClient}
100+
var err error
101+
if method == http.MethodPost {
102+
_, err = spotify.AddPlaylistItems(context.Background(), mutationPlaylistID, []string{"spotify:track:" + mutationTrackID}, nil)
103+
} else {
104+
_, err = spotify.RemovePlaylistItemAtPosition(context.Background(), mutationPlaylistID, "spotify:track:"+mutationTrackID, 2, "before")
82105
}
83-
if strings.Contains(string(body), "position") || strings.Contains(string(body), "tracks") {
84-
t.Fatalf("body=%s", body)
106+
if test.uncertain {
107+
assertMutationUncertain(t, err, method, ErrUpstream)
108+
} else {
109+
assertMutationRejected(t, err, method, test.status, ErrUpstream)
85110
}
86-
return response(status, ""), nil
87-
})}
88-
_, err := (Client{HTTPClient: httpClient}).AddPlaylistItems(context.Background(), mutationPlaylistID, []string{"spotify:track:" + mutationTrackID}, nil)
89-
var uncertain *MutationOutcomeUncertainError
90-
if !errors.Is(err, ErrUpstream) || errors.As(err, &uncertain) || calls != 1 {
91-
t.Fatalf("calls=%d error=%v", calls, err)
92-
}
93-
})
111+
if calls != 1 {
112+
t.Fatalf("calls=%d error=%v", calls, err)
113+
}
114+
})
115+
}
94116
}
95117
}
96118

@@ -120,18 +142,29 @@ func TestRemovePlaylistItemsByURIUsesCurrentItemsShapeAndSnapshot(t *testing.T)
120142
}
121143
}
122144

123-
func TestRemovePlaylistItemsByURIDoesNotRetry(t *testing.T) {
124-
calls := 0
125-
httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
145+
func TestRemovePlaylistItemAtPositionUsesSpecificOccurrenceAndSnapshot(t *testing.T) {
146+
var calls int
147+
httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
126148
calls++
127-
return response(http.StatusServiceUnavailable, ""), nil
149+
if request.Method != http.MethodDelete || request.URL.Path != "/v1/playlists/"+mutationPlaylistID+"/items" ||
150+
request.Header.Get("Content-Type") != "application/json" {
151+
t.Fatalf("request=%s %s", request.Method, request.URL.RequestURI())
152+
}
153+
var body map[string]json.RawMessage
154+
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
155+
t.Fatal(err)
156+
}
157+
if len(body) != 2 || string(body["snapshot_id"]) != `"after-add"` ||
158+
string(body["items"]) != `[{"uri":"spotify:track:`+mutationTrackID+`","positions":[2]}]` {
159+
t.Fatalf("body=%v", body)
160+
}
161+
return response(http.StatusOK, `{"snapshot_id":"after-remove"}`), nil
128162
})}
129-
_, err := (Client{HTTPClient: httpClient}).RemovePlaylistItemsByURI(
130-
context.Background(), mutationPlaylistID, "spotify:track:"+mutationTrackID, "before",
163+
snapshot, err := (Client{HTTPClient: httpClient, BaseURL: "https://api.spotify.invalid/v1"}).RemovePlaylistItemAtPosition(
164+
context.Background(), mutationPlaylistID, "spotify:track:"+mutationTrackID, 2, "after-add",
131165
)
132-
var uncertain *MutationOutcomeUncertainError
133-
if !errors.Is(err, ErrUpstream) || errors.As(err, &uncertain) || calls != 1 {
134-
t.Fatalf("calls=%d error=%v", calls, err)
166+
if err != nil || snapshot != "after-remove" || calls != 1 {
167+
t.Fatalf("snapshot=%q calls=%d error=%v", snapshot, calls, err)
135168
}
136169
}
137170

@@ -150,6 +183,10 @@ func TestPlaylistMutationsDoNotRetryTransportErrors(t *testing.T) {
150183
_, err := spotify.RemovePlaylistItemsByURI(context.Background(), mutationPlaylistID, "spotify:track:"+mutationTrackID, "before")
151184
return err
152185
}},
186+
{name: "remove position", method: http.MethodDelete, call: func(spotify Client) error {
187+
_, err := spotify.RemovePlaylistItemAtPosition(context.Background(), mutationPlaylistID, "spotify:track:"+mutationTrackID, 2, "before")
188+
return err
189+
}},
153190
} {
154191
t.Run(test.name, func(t *testing.T) {
155192
calls := 0
@@ -259,6 +296,10 @@ func TestPlaylistMutationsRejectInvalidInputsAndMarkMissingSnapshotsUncertain(t
259296
_, err := spotify.RemovePlaylistItemsByURI(context.Background(), mutationPlaylistID, validURI, " ")
260297
return err
261298
},
299+
func() error {
300+
_, err := spotify.RemovePlaylistItemAtPosition(context.Background(), mutationPlaylistID, validURI, -1, "snapshot")
301+
return err
302+
},
262303
} {
263304
err := call()
264305
var uncertain *MutationOutcomeUncertainError
@@ -267,10 +308,7 @@ func TestPlaylistMutationsRejectInvalidInputsAndMarkMissingSnapshotsUncertain(t
267308
}
268309
}
269310
_, err := (Client{BaseURL: "://"}).AddPlaylistItems(context.Background(), mutationPlaylistID, []string{validURI}, nil)
270-
var uncertain *MutationOutcomeUncertainError
271-
if !errors.Is(err, ErrUpstream) || errors.As(err, &uncertain) {
272-
t.Fatalf("request construction error=%v", err)
273-
}
311+
assertMutationRejected(t, err, http.MethodPost, 0, ErrUpstream)
274312
_, err = spotify.AddPlaylistItems(context.Background(), mutationPlaylistID, []string{validURI}, nil)
275313
assertMutationUncertain(t, err, http.MethodPost, ErrInvalidResponse)
276314
if calls != 1 {
@@ -293,6 +331,17 @@ func assertMutationUncertain(t *testing.T, err error, method string, cause error
293331
}
294332
}
295333

334+
func assertMutationRejected(t *testing.T, err error, method string, statusCode int, cause error) {
335+
t.Helper()
336+
var rejected *MutationRejectedError
337+
var uncertain *MutationOutcomeUncertainError
338+
if !errors.As(err, &rejected) || errors.As(err, &uncertain) || rejected.Method != method ||
339+
rejected.Path != "/playlists/"+mutationPlaylistID+"/items" || rejected.StatusCode != statusCode ||
340+
!errors.Is(err, cause) || err.Error() != cause.Error() {
341+
t.Fatalf("rejected=%+v error=%v", rejected, err)
342+
}
343+
}
344+
296345
type truncatedMutationBody struct{ sent bool }
297346

298347
func (body *truncatedMutationBody) Read(value []byte) (int, error) {

0 commit comments

Comments
 (0)