spotify-client 1.1.0 → 1.1.2

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: 1a6a776c3c8bbec1fba9afe39bf5d91da04789a313496873bd256b41564b0180
4
- data.tar.gz: '09b9987a2dc0f5dbb7437701d4edc69d58abae3f11b2a3f9cb7ab2c330a88be2'
3
+ metadata.gz: dab0062dc15c5a8556c3b8ecea7ad6cf26c45172a87103389c77e885009beb1a
4
+ data.tar.gz: 6d51094f6f0fe8be0acb6e4da39b79f5edbb8e648782b909c54fe185b5928897
5
5
  SHA512:
6
- metadata.gz: bd4aa7ea96354bdbb0d0dd5b9f9fc93840e67005419bc1ed8c5dada3405798db188c945a56b08e16872b3ab04829a87cdd163e987c72bb1b688c1ee1e77083fe
7
- data.tar.gz: ae3a9d60e844e7677131c0c3425001d2495fd63c56f28b5d20d4bbc6d364734bb90804a6cd21a41f868460469895b17865de5ac79342d818fa3dc9f5c1f33489
6
+ metadata.gz: fe8fe55119d744c77e70a493cd82ff8eb77fd15a5106cbdace3b81e9424201b02f42ebbeffd19cf326bd0f50f0fe3b68ab6f615e2eaa0ecf5db572396768910a
7
+ data.tar.gz: f4032ff231d6a624da1ed989277698ca2dcb45dad853832d24f8eec1b885bb7e76b17368ad657ac4912a643135aa0a3db9e203ea7de7dfb3320e633125b33dfe
data/README.md CHANGED
@@ -124,8 +124,8 @@ client.follow(type, ids)
124
124
  client.follow_playlist(user_id, playlist_id, is_public = true)
125
125
 
126
126
  # Generic helpers for forward compatibility
127
- client.request(:get, '/v1/me') # generic helper for newer endpoints
128
- client.request!(:post, '/v1/some-endpoint', [201], payload, false)
127
+ client.request(:get, '/v1/me', [200], { market: 'IT' }) # GET-style hash payloads become query params
128
+ client.request!(:post, '/v1/some-endpoint', [201], payload, false) # non-GET hash payloads are JSON encoded
129
129
  ```
130
130
 
131
131
  ## Spotify API Migration Notes
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spotify
4
+ class Client
5
+ module CompatibilityAPI
6
+ def user(user_id)
7
+ raise_endpoint_unavailable_in_development_mode!(
8
+ endpoint: 'GET /v1/users/{id}',
9
+ replacement: 'GET /v1/me'
10
+ )
11
+ run(:get, "/v1/users/#{user_id}", [200])
12
+ end
13
+
14
+ def user_playlists(_user_id = nil)
15
+ run(:get, '/v1/me/playlists', [200])
16
+ end
17
+
18
+ def user_playlist(_user_id, playlist_id)
19
+ playlist(playlist_id)
20
+ end
21
+
22
+ def user_playlist_tracks(_user_id, playlist_id, params = {})
23
+ tracks = { 'items' => [] }
24
+ path = "/v1/playlists/#{playlist_id}/items"
25
+ query_params = params.dup
26
+
27
+ while path
28
+ response = run(:get, path, [200], query_params)
29
+ return false unless response
30
+
31
+ tracks['items'].concat(response.fetch('items', []))
32
+ tracks.merge!(response.reject { |key, _value| key == 'items' })
33
+ path, query_params = next_page_request(response['next'])
34
+ end
35
+
36
+ tracks
37
+ end
38
+
39
+ def create_user_playlist(_user_id, name, is_public = true)
40
+ run(:post, '/v1/me/playlists', [201], JSON.dump(name: name, public: is_public), false)
41
+ end
42
+
43
+ def change_playlist_details(_user_id, playlist_id, attributes = {})
44
+ run(:put, "/v1/playlists/#{playlist_id}", [200, 204], JSON.dump(attributes), false)
45
+ end
46
+
47
+ def add_user_tracks_to_playlist(_user_id, playlist_id, uris = [], position = nil)
48
+ params = { uris: Array(uris)[0..99].join(',') }
49
+ params.merge!(position: position) if position
50
+ run(:post, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(params), false)
51
+ end
52
+
53
+ def remove_user_tracks_from_playlist(_user_id, playlist_id, tracks)
54
+ run(:delete, "/v1/playlists/#{playlist_id}/items", [200], JSON.dump(items: tracks))
55
+ end
56
+
57
+ def replace_user_tracks_in_playlist(_user_id, playlist_id, tracks)
58
+ run(:put, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(uris: tracks))
59
+ end
60
+
61
+ def truncate_user_playlist(user_id, playlist_id)
62
+ replace_user_tracks_in_playlist(user_id, playlist_id, [])
63
+ end
64
+
65
+ def follow(type, ids)
66
+ entity_type = type.to_s.strip
67
+ uris = Array(ids).map do |id|
68
+ raw = id.to_s
69
+ next raw if raw.start_with?('spotify:')
70
+
71
+ raise(ImplementationError, 'type is required when ids are not full Spotify URIs') if entity_type.empty?
72
+
73
+ "spotify:#{entity_type}:#{raw}"
74
+ end
75
+ add_to_library(uris)
76
+ end
77
+
78
+ def follow_playlist(_user_id, playlist_id, _is_public = true)
79
+ add_to_library(["spotify:playlist:#{playlist_id}"])
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Spotify
6
+ class Client
7
+ module Transport
8
+ EXCON_ERROR_MAP = {
9
+ Excon::Errors::NotFound => ResourceNotFound,
10
+ Excon::Errors::BadRequest => BadRequest,
11
+ Excon::Errors::Forbidden => InsufficientClientScopeError,
12
+ Excon::Errors::Unauthorized => AuthenticationError
13
+ }.freeze
14
+
15
+ protected
16
+
17
+ def run(verb, path, expected_status_codes, params = {}, idempotent = true)
18
+ run!(verb, path, expected_status_codes, params, idempotent)
19
+ rescue Error => e
20
+ handle_nonbang_error(e)
21
+ end
22
+
23
+ def run!(verb, path, expected_status_codes, params_or_body = nil, idempotent = true)
24
+ response = @connection.request(
25
+ build_request_packet(
26
+ verb: verb,
27
+ path: path,
28
+ expected_status_codes: expected_status_codes,
29
+ params_or_body: params_or_body,
30
+ idempotent: idempotent
31
+ )
32
+ )
33
+ parse_response_body(response)
34
+ rescue Excon::Errors::Error => e
35
+ raise map_transport_error(e)
36
+ end
37
+
38
+ def build_request_packet(verb:, path:, expected_status_codes:, params_or_body:, idempotent:)
39
+ packet = {
40
+ idempotent: idempotent,
41
+ expects: expected_status_codes,
42
+ method: verb,
43
+ path: path,
44
+ read_timeout: @read_timeout,
45
+ write_timeout: @write_timeout,
46
+ retry_limit: @retries,
47
+ headers: {
48
+ 'Content-Type' => 'application/json',
49
+ 'User-Agent' => "spotify-client/#{Spotify::VERSION} (Ruby)"
50
+ }
51
+ }
52
+ apply_request_payload(packet, params_or_body)
53
+
54
+ packet[:headers].merge!('Authorization' => "Bearer #{@access_token}") if !@access_token.nil? && @access_token != ''
55
+ packet
56
+ end
57
+
58
+ def apply_request_payload(packet, params_or_body)
59
+ if params_or_body.is_a?(Hash)
60
+ packet[:query] = params_or_body
61
+ else
62
+ packet[:body] = params_or_body
63
+ end
64
+ end
65
+
66
+ def parse_response_body(response)
67
+ return {} if response.body.nil? || response.body.empty?
68
+
69
+ ::JSON.parse(response.body)
70
+ rescue JSON::ParserError => e
71
+ raise(HTTPError, "Error: #{e.message}")
72
+ end
73
+
74
+ def next_page_request(next_url)
75
+ return [nil, {}] if next_url.nil? || next_url.empty?
76
+
77
+ uri = URI.parse(next_url)
78
+ [uri.query ? "#{uri.path}?#{uri.query}" : uri.path, {}]
79
+ end
80
+
81
+ def handle_nonbang_error(error)
82
+ raise error if @raise_errors
83
+
84
+ false
85
+ end
86
+
87
+ def map_transport_error(error)
88
+ error_class = EXCON_ERROR_MAP.find do |transport_error, _client_error|
89
+ error.is_a?(transport_error)
90
+ end&.last || HTTPError
91
+
92
+ error_class.new("Error: #{error.message}")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'excon'
4
+ require 'json'
5
+
6
+ require 'spotify/exceptions'
7
+ require 'spotify/version'
8
+ require 'spotify/client/compatibility_api'
9
+ require 'spotify/client/transport'
10
+
11
+ module Spotify
12
+ class Client
13
+ include CompatibilityAPI
14
+ include Transport
15
+
16
+ BASE_URI = 'https://api.spotify.com'
17
+
18
+ attr_accessor :access_token
19
+
20
+ # Initialize the client.
21
+ #
22
+ # @example
23
+ # client = Spotify::Client.new(:access_token => 'longtoken', retries: 0, raise_errors: true)
24
+ #
25
+ # @param [Hash] configuration.
26
+ def initialize(config = {})
27
+ @access_token = config[:access_token]
28
+ @raise_errors = config[:raise_errors] || false
29
+ @retries = config[:retries] || 0
30
+ @read_timeout = config[:read_timeout] || 10
31
+ @write_timeout = config[:write_timeout] || 10
32
+ @app_mode = config[:app_mode].to_s.strip.downcase
33
+ @connection = Excon.new(BASE_URI, persistent: config[:persistent] || false)
34
+ end
35
+
36
+ def inspect
37
+ vars = instance_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(', ')
38
+ "<#{self.class}: #{vars}>"
39
+ end
40
+
41
+ # Closes the connection underlying socket.
42
+ # Use when you employ persistent connections and are done with your requests.
43
+ def close_connection
44
+ @connection.reset
45
+ end
46
+
47
+ def me
48
+ run(:get, '/v1/me', [200])
49
+ end
50
+
51
+ def me_tracks
52
+ run(:get, '/v1/me/tracks', [200])
53
+ end
54
+
55
+ def me_albums(params = {})
56
+ run(:get, '/v1/me/albums', [200], params)
57
+ end
58
+
59
+ def me_audiobooks(params = {})
60
+ run(:get, '/v1/me/audiobooks', [200], params)
61
+ end
62
+
63
+ def me_episodes(params = {})
64
+ run(:get, '/v1/me/episodes', [200], params)
65
+ end
66
+
67
+ def me_shows(params = {})
68
+ run(:get, '/v1/me/shows', [200], params)
69
+ end
70
+
71
+ # params:
72
+ # - type: Required, The ID type, currently only 'artist' is supported
73
+ # - limit: Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
74
+ # - after: Optional. The last artist ID retrieved from the previous request.
75
+ def me_following(params = {})
76
+ params = params.merge(type: 'artist')
77
+ run(:get, '/v1/me/following', [200], params)
78
+ end
79
+
80
+ def playlist(playlist_id)
81
+ run(:get, "/v1/playlists/#{playlist_id}", [200])
82
+ end
83
+
84
+ def playlist_cover_image(playlist_id)
85
+ run(:get, "/v1/playlists/#{playlist_id}/images", [200], {})
86
+ end
87
+
88
+ def upload_playlist_cover_image(playlist_id, image_base64_jpeg)
89
+ run(:put, "/v1/playlists/#{playlist_id}/images", [200, 202, 204], image_base64_jpeg.to_s, false)
90
+ end
91
+
92
+ def album(album_id)
93
+ run(:get, "/v1/albums/#{album_id}", [200])
94
+ end
95
+
96
+ def album_tracks(album_id)
97
+ run(:get, "/v1/albums/#{album_id}/tracks", [200])
98
+ end
99
+
100
+ def albums(album_ids)
101
+ { 'albums' => Array(album_ids).map { |album_id| album(album_id) } }
102
+ end
103
+
104
+ def track(track_id)
105
+ run(:get, "/v1/tracks/#{track_id}", [200])
106
+ end
107
+
108
+ def tracks(track_ids)
109
+ { 'tracks' => Array(track_ids).map { |track_id| track(track_id) } }
110
+ end
111
+
112
+ def artist(artist_id)
113
+ run(:get, "/v1/artists/#{artist_id}", [200])
114
+ end
115
+
116
+ def artists(artist_ids)
117
+ { 'artists' => Array(artist_ids).map { |artist_id| artist(artist_id) } }
118
+ end
119
+
120
+ def artist_albums(artist_id)
121
+ run(:get, "/v1/artists/#{artist_id}/albums", [200])
122
+ end
123
+
124
+ def audiobook(audiobook_id, params = {})
125
+ run(:get, "/v1/audiobooks/#{audiobook_id}", [200], params)
126
+ end
127
+
128
+ def audiobook_chapters(audiobook_id, params = {})
129
+ run(:get, "/v1/audiobooks/#{audiobook_id}/chapters", [200], params)
130
+ end
131
+
132
+ def chapter(chapter_id, params = {})
133
+ run(:get, "/v1/chapters/#{chapter_id}", [200], params)
134
+ end
135
+
136
+ def episode(episode_id, params = {})
137
+ run(:get, "/v1/episodes/#{episode_id}", [200], params)
138
+ end
139
+
140
+ def show(show_id, params = {})
141
+ run(:get, "/v1/shows/#{show_id}", [200], params)
142
+ end
143
+
144
+ def show_episodes(show_id, params = {})
145
+ run(:get, "/v1/shows/#{show_id}/episodes", [200], params)
146
+ end
147
+
148
+ def search(entity, term, options = {})
149
+ unless %i[artist album track].include?(entity.to_sym)
150
+ raise(ImplementationError, "entity needs to be either artist, album or track, got: #{entity}")
151
+ end
152
+
153
+ options = options.dup
154
+ options[:limit] = [options[:limit].to_i, 10].min if options.key?(:limit)
155
+
156
+ params = {
157
+ q: term.to_s,
158
+ type: entity
159
+ }.merge(options)
160
+ run(:get, '/v1/search', [200], params)
161
+ end
162
+
163
+ # Get Spotify catalog information about an artist's top 10 tracks by country.
164
+ #
165
+ # +country_id+ is required. An ISO 3166-1 alpha-2 country code.
166
+ def artist_top_tracks(artist_id, country_id)
167
+ raise_endpoint_unavailable_in_development_mode!(endpoint: 'GET /v1/artists/{id}/top-tracks')
168
+ run(:get, "/v1/artists/#{artist_id}/top-tracks", [200], country: country_id)
169
+ end
170
+
171
+ def related_artists(artist_id)
172
+ run(:get, "/v1/artists/#{artist_id}/related-artists", [200])
173
+ end
174
+
175
+ def me_top(type, params = {})
176
+ valid_types = %w[artists tracks]
177
+ normalized_type = type.to_s
178
+ unless valid_types.include?(normalized_type)
179
+ raise(ImplementationError, "type needs to be one of #{valid_types.join(', ')}, got: #{type}")
180
+ end
181
+
182
+ run(:get, "/v1/me/top/#{normalized_type}", [200], params)
183
+ end
184
+
185
+ def currently_playing(params = {})
186
+ run(:get, '/v1/me/player/currently-playing', [200], params)
187
+ end
188
+
189
+ def recently_played(params = {})
190
+ run(:get, '/v1/me/player/recently-played', [200], params)
191
+ end
192
+
193
+ def playback_state(params = {})
194
+ run(:get, '/v1/me/player', [200], params)
195
+ end
196
+
197
+ def available_devices
198
+ run(:get, '/v1/me/player/devices', [200], {})
199
+ end
200
+
201
+ def transfer_playback(device_ids, play = nil)
202
+ body = { device_ids: Array(device_ids) }
203
+ body[:play] = play unless play.nil?
204
+ run(:put, '/v1/me/player', [200, 204], JSON.dump(body), false)
205
+ end
206
+
207
+ def start_or_resume_playback(payload = {})
208
+ run(:put, '/v1/me/player/play', [200, 204], JSON.dump(payload), false)
209
+ end
210
+
211
+ def pause_playback(params = {})
212
+ run(:put, '/v1/me/player/pause', [200, 204], params, false)
213
+ end
214
+
215
+ def skip_to_next(params = {})
216
+ run(:post, '/v1/me/player/next', [200, 204], params, false)
217
+ end
218
+
219
+ def skip_to_previous(params = {})
220
+ run(:post, '/v1/me/player/previous', [200, 204], params, false)
221
+ end
222
+
223
+ def seek_to_position(position_ms, params = {})
224
+ run(:put, '/v1/me/player/seek', [200, 204], params.merge(position_ms: position_ms), false)
225
+ end
226
+
227
+ def set_repeat_mode(state, params = {})
228
+ run(:put, '/v1/me/player/repeat', [200, 204], params.merge(state: state), false)
229
+ end
230
+
231
+ def set_playback_volume(volume_percent, params = {})
232
+ run(:put, '/v1/me/player/volume', [200, 204], params.merge(volume_percent: volume_percent), false)
233
+ end
234
+
235
+ def set_shuffle(state, params = {})
236
+ run(:put, '/v1/me/player/shuffle', [200, 204], params.merge(state: state), false)
237
+ end
238
+
239
+ def playback_queue(params = {})
240
+ run(:get, '/v1/me/player/queue', [200], params)
241
+ end
242
+
243
+ def add_to_playback_queue(uri, params = {})
244
+ run(:post, '/v1/me/player/queue', [200, 204], params.merge(uri: uri), false)
245
+ end
246
+
247
+ def add_to_library(uris)
248
+ run(:put, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
249
+ end
250
+
251
+ def remove_from_library(uris)
252
+ run(:delete, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
253
+ end
254
+
255
+ # Generic API helper for forward compatibility with newly added endpoints.
256
+ def request(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
257
+ run(
258
+ verb.to_sym,
259
+ path,
260
+ Array(expected_status_codes),
261
+ normalize_generic_request_payload(verb, params_or_body),
262
+ idempotent
263
+ )
264
+ end
265
+
266
+ # Bang variant that propagates mapped API errors.
267
+ def request!(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
268
+ run!(
269
+ verb.to_sym,
270
+ path,
271
+ Array(expected_status_codes),
272
+ normalize_generic_request_payload(verb, params_or_body),
273
+ idempotent
274
+ )
275
+ end
276
+
277
+ protected
278
+
279
+ def raise_endpoint_unavailable_in_development_mode!(endpoint:, replacement: nil)
280
+ return unless development_mode?
281
+
282
+ message = "#{endpoint} is unavailable for Spotify Development Mode apps as of March 9, 2026."
283
+ message += " Use #{replacement} instead." if replacement
284
+ raise(EndpointUnavailableInDevelopmentMode, message)
285
+ end
286
+
287
+ def development_mode?
288
+ @app_mode == 'development' || @app_mode == 'development_mode'
289
+ end
290
+
291
+ def normalize_generic_request_payload(verb, params_or_body)
292
+ return params_or_body unless params_or_body.is_a?(Hash)
293
+
294
+ query_verbs = %i[get head options]
295
+ return params_or_body if query_verbs.include?(verb.to_sym)
296
+
297
+ JSON.dump(params_or_body)
298
+ end
299
+ end
300
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spotify
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.2'
5
5
  end
@@ -1 +1,4 @@
1
- require "#{File.dirname(__FILE__)}/spotify_client"
1
+ lib_dir = __dir__
2
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
3
+
4
+ require 'spotify/client'
@@ -1,430 +1,6 @@
1
- require 'excon'
2
- require 'json'
1
+ # frozen_string_literal: true
3
2
 
4
- require "#{File.dirname(__FILE__)}/spotify/exceptions"
3
+ lib_dir = __dir__
4
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
5
5
 
6
- module Spotify
7
- class Client
8
- BASE_URI = 'https://api.spotify.com'.freeze
9
-
10
- attr_accessor :access_token
11
-
12
- # Initialize the client.
13
- #
14
- # @example
15
- # client = Spotify::Client.new(:access_token => 'longtoken', retries: 0, raise_errors: true)
16
- #
17
- # @param [Hash] configuration.
18
- def initialize(config = {})
19
- @access_token = config[:access_token]
20
- @raise_errors = config[:raise_errors] || false
21
- @retries = config[:retries] || 0
22
- @read_timeout = config[:read_timeout] || 10
23
- @write_timeout = config[:write_timeout] || 10
24
- @app_mode = config[:app_mode].to_s.strip.downcase
25
- @connection = Excon.new(BASE_URI, persistent: config[:persistent] || false)
26
- end
27
-
28
- def inspect
29
- vars = instance_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(', ')
30
- "<#{self.class}: #{vars}>"
31
- end
32
-
33
- # Closes the connection underlying socket.
34
- # Use when you employ persistent connections and are done with your requests.
35
- def close_connection
36
- @connection.reset
37
- end
38
-
39
- def me
40
- run(:get, '/v1/me', [200])
41
- end
42
-
43
- def me_tracks
44
- run(:get, '/v1/me/tracks', [200])
45
- end
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
-
63
- # params:
64
- # - type: Required, The ID type, currently only 'artist' is supported
65
- # - limit: Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
66
- # - after: Optional. The last artist ID retrieved from the previous request.
67
- def me_following(params = {})
68
- params = params.merge(type: 'artist')
69
- run(:get, '/v1/me/following', [200], params)
70
- end
71
-
72
- def user(user_id)
73
- raise_endpoint_unavailable_in_development_mode!(
74
- endpoint: 'GET /v1/users/{id}',
75
- replacement: 'GET /v1/me'
76
- )
77
- run(:get, "/v1/users/#{user_id}", [200])
78
- end
79
-
80
- def user_playlists(_user_id = nil)
81
- run(:get, '/v1/me/playlists', [200])
82
- end
83
-
84
- def user_playlist(_user_id, playlist_id)
85
- playlist(playlist_id)
86
- end
87
-
88
- def user_playlist_tracks(_user_id, playlist_id, params = {})
89
- tracks = { 'items' => [] }
90
- path = "/v1/playlists/#{playlist_id}/items"
91
-
92
- while path
93
- response = run(:get, path, [200], params)
94
- tracks['items'].concat(response.delete('items'))
95
- tracks.merge!(response)
96
-
97
- path = response['next']&.gsub(BASE_URI, '')
98
- end
99
-
100
- tracks
101
- end
102
-
103
- # Create a playlist for a Spotify user. The playlist will be empty until you add tracks.
104
- #
105
- # Requires playlist-modify-public for a public playlist.
106
- # Requires playlist-modify-private for a private playlist.
107
- def create_user_playlist(_user_id, name, is_public = true)
108
- run(:post, '/v1/me/playlists', [201], JSON.dump(name: name, public: is_public), false)
109
- end
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
-
115
- # Add an Array of track uris to an existing playlist.
116
- #
117
- # Adding tracks to a user's public playlist requires authorization of the playlist-modify-public scope;
118
- # adding tracks to a private playlist requires the playlist-modify-private scope.
119
- #
120
- # client.add_user_tracks_to_playlist(
121
- # '1181346016', '7i3thJWDtmX04dJhFwYb0x', %w(spotify:track:... spotify:track:...)
122
- # )
123
- def add_user_tracks_to_playlist(_user_id, playlist_id, uris = [], position = nil)
124
- params = { uris: Array(uris)[0..99].join(',') }
125
- params.merge!(position: position) if position
126
- run(:post, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(params), false)
127
- end
128
-
129
- # Removes tracks from playlist
130
- #
131
- # client.remove_user_tracks_from_playlist(
132
- # '1181346016', '7i3thJWDtmX04dJhFwYb0x', [{ uri: 'spotify:track:...', positions: [0] }]
133
- # )
134
- def remove_user_tracks_from_playlist(_user_id, playlist_id, tracks)
135
- run(:delete, "/v1/playlists/#{playlist_id}/items", [200], JSON.dump(items: tracks))
136
- end
137
-
138
- # Replaces all occurrences of tracks with what's in the playlist
139
- #
140
- # client.replace_user_tracks_in_playlist(
141
- # '1181346016', '7i3thJWDtmX04dJhFwYb0x', %w(spotify:track:... spotify:track:...)
142
- # )
143
- def replace_user_tracks_in_playlist(_user_id, playlist_id, tracks)
144
- run(:put, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(uris: tracks))
145
- end
146
-
147
- # Removes all tracks in playlist
148
- #
149
- # client.truncate_user_playlist('1181346016', '7i3thJWDtmX04dJhFwYb0x')
150
- def truncate_user_playlist(user_id, playlist_id)
151
- replace_user_tracks_in_playlist(user_id, playlist_id, [])
152
- end
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
-
166
- def album(album_id)
167
- run(:get, "/v1/albums/#{album_id}", [200])
168
- end
169
-
170
- def album_tracks(album_id)
171
- run(:get, "/v1/albums/#{album_id}/tracks", [200])
172
- end
173
-
174
- def albums(album_ids)
175
- { 'albums' => Array(album_ids).map { |album_id| album(album_id) } }
176
- end
177
-
178
- def track(track_id)
179
- run(:get, "/v1/tracks/#{track_id}", [200])
180
- end
181
-
182
- def tracks(track_ids)
183
- { 'tracks' => Array(track_ids).map { |track_id| track(track_id) } }
184
- end
185
-
186
- def artist(artist_id)
187
- run(:get, "/v1/artists/#{artist_id}", [200])
188
- end
189
-
190
- def artists(artist_ids)
191
- { 'artists' => Array(artist_ids).map { |artist_id| artist(artist_id) } }
192
- end
193
-
194
- def artist_albums(artist_id)
195
- run(:get, "/v1/artists/#{artist_id}/albums", [200])
196
- end
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
-
222
- def search(entity, term, options = {})
223
- unless %i[artist album track].include?(entity.to_sym)
224
- raise(ImplementationError, "entity needs to be either artist, album or track, got: #{entity}")
225
- end
226
-
227
- options = options.dup
228
- options[:limit] = [options[:limit].to_i, 10].min if options.key?(:limit)
229
-
230
- params = {
231
- q: term.to_s,
232
- type: entity
233
- }.merge(options)
234
- run(:get, '/v1/search', [200], params)
235
- end
236
-
237
- # Get Spotify catalog information about an artist's top 10 tracks by country.
238
- #
239
- # +country_id+ is required. An ISO 3166-1 alpha-2 country code.
240
- def artist_top_tracks(artist_id, 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)
243
- end
244
-
245
- def related_artists(artist_id)
246
- run(:get, "/v1/artists/#{artist_id}/related-artists", [200])
247
- end
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
-
329
- # Follow artists or users
330
- #
331
- # client.follow('artist', ['0BvkDsjIUla7X0k6CSWh1I'])
332
- def follow(type, ids)
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)
343
- end
344
-
345
- # Follow a playlist
346
- #
347
- # client.follow_playlist('lukebryan', '0obRj9nNySESpFelMCLSya')
348
- def follow_playlist(_user_id, playlist_id, is_public = true)
349
- _is_public = is_public # kept for backward-compatible signature
350
- add_to_library(["spotify:playlist:#{playlist_id}"])
351
- end
352
-
353
- # Generic API helper for forward compatibility with newly added endpoints.
354
- def request(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
355
- run(verb.to_sym, path, Array(expected_status_codes), params_or_body, idempotent)
356
- end
357
-
358
- # Bang variant that propagates mapped API errors.
359
- def request!(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
360
- run!(verb.to_sym, path, Array(expected_status_codes), params_or_body, idempotent)
361
- end
362
-
363
- protected
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
-
377
- def run(verb, path, expected_status_codes, params = {}, idempotent = true)
378
- run!(verb, path, expected_status_codes, params, idempotent)
379
- rescue Error => e
380
- raise e if @raise_errors
381
-
382
- false
383
- end
384
-
385
- def run!(verb, path, expected_status_codes, params_or_body = nil, idempotent = true)
386
- packet = {
387
- idempotent: idempotent,
388
- expects: expected_status_codes,
389
- method: verb,
390
- path: path,
391
- read_timeout: @read_timeout,
392
- write_timeout: @write_timeout,
393
- retry_limit: @retries,
394
- headers: {
395
- 'Content-Type' => 'application/json',
396
- 'User-Agent' => 'Spotify Ruby Client'
397
- }
398
- }
399
- if params_or_body.is_a?(Hash)
400
- packet.merge!(query: params_or_body)
401
- else
402
- packet.merge!(body: params_or_body)
403
- end
404
-
405
- packet[:headers].merge!('Authorization' => "Bearer #{@access_token}") if !@access_token.nil? && @access_token != ''
406
-
407
- # puts "\033[31m [Spotify] HTTP Request: #{verb.upcase} #{BASE_URI}#{path} #{packet[:headers].inspect} \e[0m"
408
- response = @connection.request(packet)
409
- return {} if response.body.nil? || response.body.empty?
410
-
411
- ::JSON.parse(response.body)
412
- rescue Excon::Errors::NotFound => e
413
- raise(ResourceNotFound, "Error: #{e.message}")
414
- rescue Excon::Errors::BadRequest => e
415
- raise(BadRequest, "Error: #{e.message}")
416
- rescue Excon::Errors::Forbidden => e
417
- raise(InsufficientClientScopeError, "Error: #{e.message}")
418
- rescue Excon::Errors::Unauthorized => e
419
- raise(AuthenticationError, "Error: #{e.message}")
420
- rescue Excon::Errors::Error => e
421
- # Catch all others errors. Samples:
422
- #
423
- # <Excon::Errors::SocketError: Connection refused - connect(2) (Errno::ECONNREFUSED)>
424
- # <Excon::Errors::InternalServerError: Expected([200, 204, 404]) <=> Actual(500 InternalServerError)>
425
- # <Excon::Errors::Timeout: read timeout reached>
426
- # <Excon::Errors::BadGateway: Expected([200]) <=> Actual(502 Bad Gateway)>
427
- raise(HTTPError, "Error: #{e.message}")
428
- end
429
- end
430
- end
6
+ require 'spotify/client'
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.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Poli
@@ -40,6 +40,9 @@ files:
40
40
  - LICENSE
41
41
  - README.md
42
42
  - lib/spotify-client.rb
43
+ - lib/spotify/client.rb
44
+ - lib/spotify/client/compatibility_api.rb
45
+ - lib/spotify/client/transport.rb
43
46
  - lib/spotify/exceptions.rb
44
47
  - lib/spotify/version.rb
45
48
  - lib/spotify_client.rb