spotify-client 1.1.0 → 1.1.1

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: 9fba94340f1fab59fe29af2fe56082fe7a957fe49cdd650dc689e681938fa7dc
4
+ data.tar.gz: 88842cffcde50db973f10b4ec60a1214fe4356203bf5c2ebe4826ca93789eaef
5
5
  SHA512:
6
- metadata.gz: bd4aa7ea96354bdbb0d0dd5b9f9fc93840e67005419bc1ed8c5dada3405798db188c945a56b08e16872b3ab04829a87cdd163e987c72bb1b688c1ee1e77083fe
7
- data.tar.gz: ae3a9d60e844e7677131c0c3425001d2495fd63c56f28b5d20d4bbc6d364734bb90804a6cd21a41f868460469895b17865de5ac79342d818fa3dc9f5c1f33489
6
+ metadata.gz: 7189b3aae25dd0b74d9035e2936adae1cd6bdd5fdc1147531566674b262fa9dacdb40f56f07b1ab65c3ba87934f0ec9a6f57fdf41f44528f3927ae4ddf101014
7
+ data.tar.gz: 3c806b5e89ddf72e7003a1fa30f18dabeb0720c9ac3c8a617a89c0e3677a929e4c5bb8e878a52e4b2216ff00363ba9bb730741ae7942fb5ba37ed925bdaf6a7c
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 Ruby Client'
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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'excon'
4
+ require 'json'
5
+
6
+ require 'spotify/exceptions'
7
+ require 'spotify/client/compatibility_api'
8
+ require 'spotify/client/transport'
9
+
10
+ module Spotify
11
+ class Client
12
+ include CompatibilityAPI
13
+ include Transport
14
+
15
+ BASE_URI = 'https://api.spotify.com'
16
+
17
+ attr_accessor :access_token
18
+
19
+ # Initialize the client.
20
+ #
21
+ # @example
22
+ # client = Spotify::Client.new(:access_token => 'longtoken', retries: 0, raise_errors: true)
23
+ #
24
+ # @param [Hash] configuration.
25
+ def initialize(config = {})
26
+ @access_token = config[:access_token]
27
+ @raise_errors = config[:raise_errors] || false
28
+ @retries = config[:retries] || 0
29
+ @read_timeout = config[:read_timeout] || 10
30
+ @write_timeout = config[:write_timeout] || 10
31
+ @app_mode = config[:app_mode].to_s.strip.downcase
32
+ @connection = Excon.new(BASE_URI, persistent: config[:persistent] || false)
33
+ end
34
+
35
+ def inspect
36
+ vars = instance_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(', ')
37
+ "<#{self.class}: #{vars}>"
38
+ end
39
+
40
+ # Closes the connection underlying socket.
41
+ # Use when you employ persistent connections and are done with your requests.
42
+ def close_connection
43
+ @connection.reset
44
+ end
45
+
46
+ def me
47
+ run(:get, '/v1/me', [200])
48
+ end
49
+
50
+ def me_tracks
51
+ run(:get, '/v1/me/tracks', [200])
52
+ end
53
+
54
+ def me_albums(params = {})
55
+ run(:get, '/v1/me/albums', [200], params)
56
+ end
57
+
58
+ def me_audiobooks(params = {})
59
+ run(:get, '/v1/me/audiobooks', [200], params)
60
+ end
61
+
62
+ def me_episodes(params = {})
63
+ run(:get, '/v1/me/episodes', [200], params)
64
+ end
65
+
66
+ def me_shows(params = {})
67
+ run(:get, '/v1/me/shows', [200], params)
68
+ end
69
+
70
+ # params:
71
+ # - type: Required, The ID type, currently only 'artist' is supported
72
+ # - limit: Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
73
+ # - after: Optional. The last artist ID retrieved from the previous request.
74
+ def me_following(params = {})
75
+ params = params.merge(type: 'artist')
76
+ run(:get, '/v1/me/following', [200], params)
77
+ end
78
+
79
+ def playlist(playlist_id)
80
+ run(:get, "/v1/playlists/#{playlist_id}", [200])
81
+ end
82
+
83
+ def playlist_cover_image(playlist_id)
84
+ run(:get, "/v1/playlists/#{playlist_id}/images", [200], {})
85
+ end
86
+
87
+ def upload_playlist_cover_image(playlist_id, image_base64_jpeg)
88
+ run(:put, "/v1/playlists/#{playlist_id}/images", [200, 202, 204], image_base64_jpeg.to_s, false)
89
+ end
90
+
91
+ def album(album_id)
92
+ run(:get, "/v1/albums/#{album_id}", [200])
93
+ end
94
+
95
+ def album_tracks(album_id)
96
+ run(:get, "/v1/albums/#{album_id}/tracks", [200])
97
+ end
98
+
99
+ def albums(album_ids)
100
+ { 'albums' => Array(album_ids).map { |album_id| album(album_id) } }
101
+ end
102
+
103
+ def track(track_id)
104
+ run(:get, "/v1/tracks/#{track_id}", [200])
105
+ end
106
+
107
+ def tracks(track_ids)
108
+ { 'tracks' => Array(track_ids).map { |track_id| track(track_id) } }
109
+ end
110
+
111
+ def artist(artist_id)
112
+ run(:get, "/v1/artists/#{artist_id}", [200])
113
+ end
114
+
115
+ def artists(artist_ids)
116
+ { 'artists' => Array(artist_ids).map { |artist_id| artist(artist_id) } }
117
+ end
118
+
119
+ def artist_albums(artist_id)
120
+ run(:get, "/v1/artists/#{artist_id}/albums", [200])
121
+ end
122
+
123
+ def audiobook(audiobook_id, params = {})
124
+ run(:get, "/v1/audiobooks/#{audiobook_id}", [200], params)
125
+ end
126
+
127
+ def audiobook_chapters(audiobook_id, params = {})
128
+ run(:get, "/v1/audiobooks/#{audiobook_id}/chapters", [200], params)
129
+ end
130
+
131
+ def chapter(chapter_id, params = {})
132
+ run(:get, "/v1/chapters/#{chapter_id}", [200], params)
133
+ end
134
+
135
+ def episode(episode_id, params = {})
136
+ run(:get, "/v1/episodes/#{episode_id}", [200], params)
137
+ end
138
+
139
+ def show(show_id, params = {})
140
+ run(:get, "/v1/shows/#{show_id}", [200], params)
141
+ end
142
+
143
+ def show_episodes(show_id, params = {})
144
+ run(:get, "/v1/shows/#{show_id}/episodes", [200], params)
145
+ end
146
+
147
+ def search(entity, term, options = {})
148
+ unless %i[artist album track].include?(entity.to_sym)
149
+ raise(ImplementationError, "entity needs to be either artist, album or track, got: #{entity}")
150
+ end
151
+
152
+ options = options.dup
153
+ options[:limit] = [options[:limit].to_i, 10].min if options.key?(:limit)
154
+
155
+ params = {
156
+ q: term.to_s,
157
+ type: entity
158
+ }.merge(options)
159
+ run(:get, '/v1/search', [200], params)
160
+ end
161
+
162
+ # Get Spotify catalog information about an artist's top 10 tracks by country.
163
+ #
164
+ # +country_id+ is required. An ISO 3166-1 alpha-2 country code.
165
+ def artist_top_tracks(artist_id, country_id)
166
+ raise_endpoint_unavailable_in_development_mode!(endpoint: 'GET /v1/artists/{id}/top-tracks')
167
+ run(:get, "/v1/artists/#{artist_id}/top-tracks", [200], country: country_id)
168
+ end
169
+
170
+ def related_artists(artist_id)
171
+ run(:get, "/v1/artists/#{artist_id}/related-artists", [200])
172
+ end
173
+
174
+ def me_top(type, params = {})
175
+ valid_types = %w[artists tracks]
176
+ normalized_type = type.to_s
177
+ unless valid_types.include?(normalized_type)
178
+ raise(ImplementationError, "type needs to be one of #{valid_types.join(', ')}, got: #{type}")
179
+ end
180
+
181
+ run(:get, "/v1/me/top/#{normalized_type}", [200], params)
182
+ end
183
+
184
+ def currently_playing(params = {})
185
+ run(:get, '/v1/me/player/currently-playing', [200], params)
186
+ end
187
+
188
+ def recently_played(params = {})
189
+ run(:get, '/v1/me/player/recently-played', [200], params)
190
+ end
191
+
192
+ def playback_state(params = {})
193
+ run(:get, '/v1/me/player', [200], params)
194
+ end
195
+
196
+ def available_devices
197
+ run(:get, '/v1/me/player/devices', [200], {})
198
+ end
199
+
200
+ def transfer_playback(device_ids, play = nil)
201
+ body = { device_ids: Array(device_ids) }
202
+ body[:play] = play unless play.nil?
203
+ run(:put, '/v1/me/player', [200, 204], JSON.dump(body), false)
204
+ end
205
+
206
+ def start_or_resume_playback(payload = {})
207
+ run(:put, '/v1/me/player/play', [200, 204], JSON.dump(payload), false)
208
+ end
209
+
210
+ def pause_playback(params = {})
211
+ run(:put, '/v1/me/player/pause', [200, 204], params, false)
212
+ end
213
+
214
+ def skip_to_next(params = {})
215
+ run(:post, '/v1/me/player/next', [200, 204], params, false)
216
+ end
217
+
218
+ def skip_to_previous(params = {})
219
+ run(:post, '/v1/me/player/previous', [200, 204], params, false)
220
+ end
221
+
222
+ def seek_to_position(position_ms, params = {})
223
+ run(:put, '/v1/me/player/seek', [200, 204], params.merge(position_ms: position_ms), false)
224
+ end
225
+
226
+ def set_repeat_mode(state, params = {})
227
+ run(:put, '/v1/me/player/repeat', [200, 204], params.merge(state: state), false)
228
+ end
229
+
230
+ def set_playback_volume(volume_percent, params = {})
231
+ run(:put, '/v1/me/player/volume', [200, 204], params.merge(volume_percent: volume_percent), false)
232
+ end
233
+
234
+ def set_shuffle(state, params = {})
235
+ run(:put, '/v1/me/player/shuffle', [200, 204], params.merge(state: state), false)
236
+ end
237
+
238
+ def playback_queue(params = {})
239
+ run(:get, '/v1/me/player/queue', [200], params)
240
+ end
241
+
242
+ def add_to_playback_queue(uri, params = {})
243
+ run(:post, '/v1/me/player/queue', [200, 204], params.merge(uri: uri), false)
244
+ end
245
+
246
+ def add_to_library(uris)
247
+ run(:put, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
248
+ end
249
+
250
+ def remove_from_library(uris)
251
+ run(:delete, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
252
+ end
253
+
254
+ # Generic API helper for forward compatibility with newly added endpoints.
255
+ def request(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
256
+ run(
257
+ verb.to_sym,
258
+ path,
259
+ Array(expected_status_codes),
260
+ normalize_generic_request_payload(verb, params_or_body),
261
+ idempotent
262
+ )
263
+ end
264
+
265
+ # Bang variant that propagates mapped API errors.
266
+ def request!(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
267
+ run!(
268
+ verb.to_sym,
269
+ path,
270
+ Array(expected_status_codes),
271
+ normalize_generic_request_payload(verb, params_or_body),
272
+ idempotent
273
+ )
274
+ end
275
+
276
+ protected
277
+
278
+ def raise_endpoint_unavailable_in_development_mode!(endpoint:, replacement: nil)
279
+ return unless development_mode?
280
+
281
+ message = "#{endpoint} is unavailable for Spotify Development Mode apps as of March 9, 2026."
282
+ message += " Use #{replacement} instead." if replacement
283
+ raise(EndpointUnavailableInDevelopmentMode, message)
284
+ end
285
+
286
+ def development_mode?
287
+ @app_mode == 'development' || @app_mode == 'development_mode'
288
+ end
289
+
290
+ def normalize_generic_request_payload(verb, params_or_body)
291
+ return params_or_body unless params_or_body.is_a?(Hash)
292
+
293
+ query_verbs = %i[get head options]
294
+ return params_or_body if query_verbs.include?(verb.to_sym)
295
+
296
+ JSON.dump(params_or_body)
297
+ end
298
+ end
299
+ 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.1'
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.1
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