spotify-client 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa7eb1f393af143301f5f029d8b707c20c314752c8095c069e85980650dcde29
4
- data.tar.gz: 23e2e9669afae1cc5af7459493c14880028b963e11f8689a2d26502f15c852ef
3
+ metadata.gz: 1a6a776c3c8bbec1fba9afe39bf5d91da04789a313496873bd256b41564b0180
4
+ data.tar.gz: '09b9987a2dc0f5dbb7437701d4edc69d58abae3f11b2a3f9cb7ab2c330a88be2'
5
5
  SHA512:
6
- metadata.gz: 6e5ee46bee109d75e9fc34898d711f171d0ef2f2c5bf87bf0a1f07480eb1d14d6ff5edd4dc3d6e82e635fad778c595b501c3165ab5df339351fc444141b18bf4
7
- data.tar.gz: 2296b443e46a7a481a3dfd264909722b9848145f7c21aba107fccd421089ce77cf231b55adcdb1c1beb2539c01b6a660a1370d2dbb50e05fc49d35f4e9a5023c
6
+ metadata.gz: bd4aa7ea96354bdbb0d0dd5b9f9fc93840e67005419bc1ed8c5dada3405798db188c945a56b08e16872b3ab04829a87cdd163e987c72bb1b688c1ee1e77083fe
7
+ data.tar.gz: ae3a9d60e844e7677131c0c3425001d2495fd63c56f28b5d20d4bbc6d364734bb90804a6cd21a41f868460469895b17865de5ac79342d818fa3dc9f5c1f33489
data/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Ruby client for the [Spotify Web API](https://developer.spotify.com/documentation/web-api).
4
4
 
5
- [![Gem Version](https://badge.fury.io/rb/spotify-client.svg)](https://rubygems.org/gems/spotify-client)
5
+ [![Test](https://github.com/icoretech/spotify-client/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/icoretech/spotify-client/actions/workflows/test.yml?query=branch%3Amain)
6
+ [![Gem Version](https://badge.fury.io/rb/spotify-client.svg)](https://badge.fury.io/rb/spotify-client)
6
7
 
7
8
  ## Installation
8
9
 
@@ -33,6 +34,7 @@ The CI matrix runs this gem against Ruby `3.2`, `3.3`, `3.4`, `4.0`, and `ruby-h
33
34
  ```ruby
34
35
  config = {
35
36
  access_token: 'tk',
37
+ app_mode: :development, # optional; use :development to fail fast on dev-mode restricted endpoints
36
38
  raise_errors: true,
37
39
  retries: 0,
38
40
  read_timeout: 10,
@@ -46,31 +48,82 @@ client = Spotify::Client.new(config)
46
48
  ## Public API
47
49
 
48
50
  ```ruby
51
+ # User
49
52
  client.me
50
- client.me_tracks
53
+
54
+ # Library
55
+ client.me_albums(params = {})
56
+ client.me_audiobooks(params = {})
57
+ client.me_episodes(params = {})
51
58
  client.me_following
52
- client.user(user_id)
53
- client.user_playlists(user_id) # user_id kept for backward compatibility; requests /v1/me/playlists
54
- client.user_playlist(user_id, playlist_id)
55
- client.user_playlist_tracks(user_id, playlist_id, params = {})
59
+ client.me_shows(params = {})
60
+ client.me_tracks
61
+ client.add_to_library(uris)
62
+ client.remove_from_library(uris)
63
+ client.user_playlists(user_id = nil) # backward-compatible signature; requests /v1/me/playlists
56
64
  client.create_user_playlist(user_id, name, is_public = true)
65
+ client.change_playlist_details(user_id, playlist_id, attributes = {})
66
+
67
+ # Playlist
68
+ client.playlist(playlist_id)
69
+ client.playlist_cover_image(playlist_id)
70
+ client.upload_playlist_cover_image(playlist_id, image_base64_jpeg)
71
+
72
+ # Backward-compatible playlist helpers
73
+ client.user_playlist(user_id, playlist_id) # delegates to playlist(playlist_id)
74
+ client.user_playlist_tracks(user_id, playlist_id, params = {})
57
75
  client.add_user_tracks_to_playlist(user_id, playlist_id, uris = [], position = nil)
58
76
  client.remove_user_tracks_from_playlist(user_id, playlist_id, tracks)
59
77
  client.replace_user_tracks_in_playlist(user_id, playlist_id, tracks)
60
78
  client.truncate_user_playlist(user_id, playlist_id)
79
+
80
+ # Metadata
61
81
  client.album(album_id)
62
82
  client.album_tracks(album_id)
63
83
  client.albums(album_ids)
64
- client.track(track_id)
65
- client.tracks(track_ids)
66
84
  client.artist(artist_id)
67
85
  client.artists(artist_ids)
68
86
  client.artist_albums(artist_id)
69
- client.search(entity, term, options = {})
70
87
  client.artist_top_tracks(artist_id, country_id)
88
+ client.audiobook(audiobook_id, params = {})
89
+ client.audiobook_chapters(audiobook_id, params = {})
90
+ client.chapter(chapter_id, params = {})
91
+ client.episode(episode_id, params = {})
92
+ client.show(show_id, params = {})
93
+ client.show_episodes(show_id, params = {})
94
+ client.track(track_id)
95
+ client.tracks(track_ids)
96
+
97
+ # Personalisation
98
+ client.me_top(type, params = {}) # type: "artists" or "tracks"
99
+
100
+ # Player
101
+ client.currently_playing(params = {})
102
+ client.recently_played(params = {})
103
+ client.playback_state(params = {})
104
+ client.available_devices
105
+ client.transfer_playback(device_ids, play = nil)
106
+ client.start_or_resume_playback(payload = {})
107
+ client.pause_playback(params = {})
108
+ client.skip_to_next(params = {})
109
+ client.skip_to_previous(params = {})
110
+ client.seek_to_position(position_ms, params = {})
111
+ client.set_repeat_mode(state, params = {})
112
+ client.set_playback_volume(volume_percent, params = {})
113
+ client.set_shuffle(state, params = {})
114
+ client.playback_queue(params = {})
115
+ client.add_to_playback_queue(uri, params = {})
116
+
117
+ # Search
118
+ client.search(entity, term, options = {}) # entity: :artist, :album, :track
119
+
120
+ # Legacy helpers kept for compatibility
121
+ client.user(user_id)
71
122
  client.related_artists(artist_id)
72
123
  client.follow(type, ids)
73
124
  client.follow_playlist(user_id, playlist_id, is_public = true)
125
+
126
+ # Generic helpers for forward compatibility
74
127
  client.request(:get, '/v1/me') # generic helper for newer endpoints
75
128
  client.request!(:post, '/v1/some-endpoint', [201], payload, false)
76
129
  ```
@@ -80,11 +133,12 @@ client.request!(:post, '/v1/some-endpoint', [201], payload, false)
80
133
  Spotify's Web API changed and removed several legacy endpoints in 2026. This gem now uses current routes while keeping backward-compatible method signatures:
81
134
 
82
135
  - Playlist reads/writes use `/v1/me/playlists` and `/v1/playlists/{playlist_id}/*`.
83
- - `follow(type, ids)` keeps the same signature but now targets `/v1/me/library` (the `type` argument is ignored for compatibility).
84
- - `artist_top_tracks` now uses the top-songs route.
136
+ - `follow(type, ids)` now targets `/v1/me/library` using Spotify URIs (`spotify:{type}:{id}`), while still accepting prebuilt URIs.
137
+ - `artist_top_tracks` uses `/v1/artists/{id}/top-tracks`.
138
+ - `user(user_id)` and `artist_top_tracks` rely on endpoints that Spotify marks unavailable for Development Mode apps (still usable for Extended Quota Mode apps).
139
+ - If initialized with `app_mode: :development`, `user(user_id)` and `artist_top_tracks` raise `Spotify::EndpointUnavailableInDevelopmentMode` before making the HTTP request.
85
140
 
86
- - Changelog: [Spotify Web API Changelog](https://developer.spotify.com/documentation/web-api/concepts/changelog)
87
- - Migration guide: [Spotify Web API Migration Guide](https://developer.spotify.com/documentation/web-api/concepts/migration-guide)
141
+ - February 2026 changes: [Spotify Web API Changes](https://developer.spotify.com/documentation/web-api/references/changes/february-2026)
88
142
 
89
143
  ## Development
90
144
 
@@ -1,5 +1,6 @@
1
1
  module Spotify
2
2
  class ImplementationError < StandardError; end
3
+ class EndpointUnavailableInDevelopmentMode < ImplementationError; end
3
4
  class Error < StandardError; end
4
5
  class AuthenticationError < Error; end
5
6
  class HTTPError < Error; end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spotify
4
- VERSION = '1.0.1'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -21,6 +21,7 @@ module Spotify
21
21
  @retries = config[:retries] || 0
22
22
  @read_timeout = config[:read_timeout] || 10
23
23
  @write_timeout = config[:write_timeout] || 10
24
+ @app_mode = config[:app_mode].to_s.strip.downcase
24
25
  @connection = Excon.new(BASE_URI, persistent: config[:persistent] || false)
25
26
  end
26
27
 
@@ -43,6 +44,22 @@ module Spotify
43
44
  run(:get, '/v1/me/tracks', [200])
44
45
  end
45
46
 
47
+ def me_albums(params = {})
48
+ run(:get, '/v1/me/albums', [200], params)
49
+ end
50
+
51
+ def me_audiobooks(params = {})
52
+ run(:get, '/v1/me/audiobooks', [200], params)
53
+ end
54
+
55
+ def me_episodes(params = {})
56
+ run(:get, '/v1/me/episodes', [200], params)
57
+ end
58
+
59
+ def me_shows(params = {})
60
+ run(:get, '/v1/me/shows', [200], params)
61
+ end
62
+
46
63
  # params:
47
64
  # - type: Required, The ID type, currently only 'artist' is supported
48
65
  # - limit: Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
@@ -53,6 +70,10 @@ module Spotify
53
70
  end
54
71
 
55
72
  def user(user_id)
73
+ raise_endpoint_unavailable_in_development_mode!(
74
+ endpoint: 'GET /v1/users/{id}',
75
+ replacement: 'GET /v1/me'
76
+ )
56
77
  run(:get, "/v1/users/#{user_id}", [200])
57
78
  end
58
79
 
@@ -61,12 +82,12 @@ module Spotify
61
82
  end
62
83
 
63
84
  def user_playlist(_user_id, playlist_id)
64
- run(:get, "/v1/playlists/#{playlist_id}", [200])
85
+ playlist(playlist_id)
65
86
  end
66
87
 
67
88
  def user_playlist_tracks(_user_id, playlist_id, params = {})
68
89
  tracks = { 'items' => [] }
69
- path = "/v1/playlists/#{playlist_id}/tracks"
90
+ path = "/v1/playlists/#{playlist_id}/items"
70
91
 
71
92
  while path
72
93
  response = run(:get, path, [200], params)
@@ -87,6 +108,10 @@ module Spotify
87
108
  run(:post, '/v1/me/playlists', [201], JSON.dump(name: name, public: is_public), false)
88
109
  end
89
110
 
111
+ def change_playlist_details(_user_id, playlist_id, attributes = {})
112
+ run(:put, "/v1/playlists/#{playlist_id}", [200, 204], JSON.dump(attributes), false)
113
+ end
114
+
90
115
  # Add an Array of track uris to an existing playlist.
91
116
  #
92
117
  # Adding tracks to a user's public playlist requires authorization of the playlist-modify-public scope;
@@ -107,7 +132,7 @@ module Spotify
107
132
  # '1181346016', '7i3thJWDtmX04dJhFwYb0x', [{ uri: 'spotify:track:...', positions: [0] }]
108
133
  # )
109
134
  def remove_user_tracks_from_playlist(_user_id, playlist_id, tracks)
110
- run(:delete, "/v1/playlists/#{playlist_id}/tracks", [200], JSON.dump(tracks: tracks))
135
+ run(:delete, "/v1/playlists/#{playlist_id}/items", [200], JSON.dump(items: tracks))
111
136
  end
112
137
 
113
138
  # Replaces all occurrences of tracks with what's in the playlist
@@ -116,7 +141,7 @@ module Spotify
116
141
  # '1181346016', '7i3thJWDtmX04dJhFwYb0x', %w(spotify:track:... spotify:track:...)
117
142
  # )
118
143
  def replace_user_tracks_in_playlist(_user_id, playlist_id, tracks)
119
- run(:put, "/v1/playlists/#{playlist_id}/tracks", [200, 201], JSON.dump(uris: tracks))
144
+ run(:put, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(uris: tracks))
120
145
  end
121
146
 
122
147
  # Removes all tracks in playlist
@@ -126,6 +151,18 @@ module Spotify
126
151
  replace_user_tracks_in_playlist(user_id, playlist_id, [])
127
152
  end
128
153
 
154
+ def playlist(playlist_id)
155
+ run(:get, "/v1/playlists/#{playlist_id}", [200])
156
+ end
157
+
158
+ def playlist_cover_image(playlist_id)
159
+ run(:get, "/v1/playlists/#{playlist_id}/images", [200], {})
160
+ end
161
+
162
+ def upload_playlist_cover_image(playlist_id, image_base64_jpeg)
163
+ run(:put, "/v1/playlists/#{playlist_id}/images", [200, 202, 204], image_base64_jpeg.to_s, false)
164
+ end
165
+
129
166
  def album(album_id)
130
167
  run(:get, "/v1/albums/#{album_id}", [200])
131
168
  end
@@ -135,8 +172,7 @@ module Spotify
135
172
  end
136
173
 
137
174
  def albums(album_ids)
138
- params = { ids: Array(album_ids).join(',') }
139
- run(:get, '/v1/albums', [200], params)
175
+ { 'albums' => Array(album_ids).map { |album_id| album(album_id) } }
140
176
  end
141
177
 
142
178
  def track(track_id)
@@ -144,8 +180,7 @@ module Spotify
144
180
  end
145
181
 
146
182
  def tracks(track_ids)
147
- params = { ids: Array(track_ids).join(',') }
148
- run(:get, '/v1/tracks', [200], params)
183
+ { 'tracks' => Array(track_ids).map { |track_id| track(track_id) } }
149
184
  end
150
185
 
151
186
  def artist(artist_id)
@@ -153,19 +188,45 @@ module Spotify
153
188
  end
154
189
 
155
190
  def artists(artist_ids)
156
- params = { ids: Array(artist_ids).join(',') }
157
- run(:get, '/v1/artists', [200], params)
191
+ { 'artists' => Array(artist_ids).map { |artist_id| artist(artist_id) } }
158
192
  end
159
193
 
160
194
  def artist_albums(artist_id)
161
195
  run(:get, "/v1/artists/#{artist_id}/albums", [200])
162
196
  end
163
197
 
198
+ def audiobook(audiobook_id, params = {})
199
+ run(:get, "/v1/audiobooks/#{audiobook_id}", [200], params)
200
+ end
201
+
202
+ def audiobook_chapters(audiobook_id, params = {})
203
+ run(:get, "/v1/audiobooks/#{audiobook_id}/chapters", [200], params)
204
+ end
205
+
206
+ def chapter(chapter_id, params = {})
207
+ run(:get, "/v1/chapters/#{chapter_id}", [200], params)
208
+ end
209
+
210
+ def episode(episode_id, params = {})
211
+ run(:get, "/v1/episodes/#{episode_id}", [200], params)
212
+ end
213
+
214
+ def show(show_id, params = {})
215
+ run(:get, "/v1/shows/#{show_id}", [200], params)
216
+ end
217
+
218
+ def show_episodes(show_id, params = {})
219
+ run(:get, "/v1/shows/#{show_id}/episodes", [200], params)
220
+ end
221
+
164
222
  def search(entity, term, options = {})
165
223
  unless %i[artist album track].include?(entity.to_sym)
166
224
  raise(ImplementationError, "entity needs to be either artist, album or track, got: #{entity}")
167
225
  end
168
226
 
227
+ options = options.dup
228
+ options[:limit] = [options[:limit].to_i, 10].min if options.key?(:limit)
229
+
169
230
  params = {
170
231
  q: term.to_s,
171
232
  type: entity
@@ -177,27 +238,116 @@ module Spotify
177
238
  #
178
239
  # +country_id+ is required. An ISO 3166-1 alpha-2 country code.
179
240
  def artist_top_tracks(artist_id, country_id)
180
- run(:get, "/v1/artists/#{artist_id}/top-songs", [200], market: country_id)
241
+ raise_endpoint_unavailable_in_development_mode!(endpoint: 'GET /v1/artists/{id}/top-tracks')
242
+ run(:get, "/v1/artists/#{artist_id}/top-tracks", [200], country: country_id)
181
243
  end
182
244
 
183
245
  def related_artists(artist_id)
184
246
  run(:get, "/v1/artists/#{artist_id}/related-artists", [200])
185
247
  end
186
248
 
249
+ def me_top(type, params = {})
250
+ valid_types = %w[artists tracks]
251
+ normalized_type = type.to_s
252
+ unless valid_types.include?(normalized_type)
253
+ raise(ImplementationError, "type needs to be one of #{valid_types.join(', ')}, got: #{type}")
254
+ end
255
+
256
+ run(:get, "/v1/me/top/#{normalized_type}", [200], params)
257
+ end
258
+
259
+ def currently_playing(params = {})
260
+ run(:get, '/v1/me/player/currently-playing', [200], params)
261
+ end
262
+
263
+ def recently_played(params = {})
264
+ run(:get, '/v1/me/player/recently-played', [200], params)
265
+ end
266
+
267
+ def playback_state(params = {})
268
+ run(:get, '/v1/me/player', [200], params)
269
+ end
270
+
271
+ def available_devices
272
+ run(:get, '/v1/me/player/devices', [200], {})
273
+ end
274
+
275
+ def transfer_playback(device_ids, play = nil)
276
+ body = { device_ids: Array(device_ids) }
277
+ body[:play] = play unless play.nil?
278
+ run(:put, '/v1/me/player', [200, 204], JSON.dump(body), false)
279
+ end
280
+
281
+ def start_or_resume_playback(payload = {})
282
+ run(:put, '/v1/me/player/play', [200, 204], JSON.dump(payload), false)
283
+ end
284
+
285
+ def pause_playback(params = {})
286
+ run(:put, '/v1/me/player/pause', [200, 204], params, false)
287
+ end
288
+
289
+ def skip_to_next(params = {})
290
+ run(:post, '/v1/me/player/next', [200, 204], params, false)
291
+ end
292
+
293
+ def skip_to_previous(params = {})
294
+ run(:post, '/v1/me/player/previous', [200, 204], params, false)
295
+ end
296
+
297
+ def seek_to_position(position_ms, params = {})
298
+ run(:put, '/v1/me/player/seek', [200, 204], params.merge(position_ms: position_ms), false)
299
+ end
300
+
301
+ def set_repeat_mode(state, params = {})
302
+ run(:put, '/v1/me/player/repeat', [200, 204], params.merge(state: state), false)
303
+ end
304
+
305
+ def set_playback_volume(volume_percent, params = {})
306
+ run(:put, '/v1/me/player/volume', [200, 204], params.merge(volume_percent: volume_percent), false)
307
+ end
308
+
309
+ def set_shuffle(state, params = {})
310
+ run(:put, '/v1/me/player/shuffle', [200, 204], params.merge(state: state), false)
311
+ end
312
+
313
+ def playback_queue(params = {})
314
+ run(:get, '/v1/me/player/queue', [200], params)
315
+ end
316
+
317
+ def add_to_playback_queue(uri, params = {})
318
+ run(:post, '/v1/me/player/queue', [200, 204], params.merge(uri: uri), false)
319
+ end
320
+
321
+ def add_to_library(uris)
322
+ run(:put, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
323
+ end
324
+
325
+ def remove_from_library(uris)
326
+ run(:delete, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
327
+ end
328
+
187
329
  # Follow artists or users
188
330
  #
189
331
  # client.follow('artist', ['0BvkDsjIUla7X0k6CSWh1I'])
190
332
  def follow(type, ids)
191
- _type = type # kept for backward-compatible signature
192
- params = { ids: Array(ids).join(',') }
193
- run(:put, '/v1/me/library', [200, 204], params)
333
+ entity_type = type.to_s.strip
334
+ uris = Array(ids).map do |id|
335
+ raw = id.to_s
336
+ next raw if raw.start_with?('spotify:')
337
+
338
+ raise(ImplementationError, 'type is required when ids are not full Spotify URIs') if entity_type.empty?
339
+
340
+ "spotify:#{entity_type}:#{raw}"
341
+ end
342
+ add_to_library(uris)
194
343
  end
195
344
 
196
345
  # Follow a playlist
197
346
  #
198
347
  # client.follow_playlist('lukebryan', '0obRj9nNySESpFelMCLSya')
199
348
  def follow_playlist(_user_id, playlist_id, is_public = true)
200
- run(:put, "/v1/playlists/#{playlist_id}/followers", [200, 204], { public: is_public })
349
+ _is_public = is_public # kept for backward-compatible signature
350
+ add_to_library(["spotify:playlist:#{playlist_id}"])
201
351
  end
202
352
 
203
353
  # Generic API helper for forward compatibility with newly added endpoints.
@@ -212,6 +362,18 @@ module Spotify
212
362
 
213
363
  protected
214
364
 
365
+ def raise_endpoint_unavailable_in_development_mode!(endpoint:, replacement: nil)
366
+ return unless development_mode?
367
+
368
+ message = "#{endpoint} is unavailable for Spotify Development Mode apps as of March 9, 2026."
369
+ message += " Use #{replacement} instead." if replacement
370
+ raise(EndpointUnavailableInDevelopmentMode, message)
371
+ end
372
+
373
+ def development_mode?
374
+ @app_mode == 'development' || @app_mode == 'development_mode'
375
+ end
376
+
215
377
  def run(verb, path, expected_status_codes, params = {}, idempotent = true)
216
378
  run!(verb, path, expected_status_codes, params, idempotent)
217
379
  rescue Error => e
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spotify-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Poli