spotify-client 1.0.2 → 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: 8531ff35eb3c2054c9d4d99b6e97c5e22848e3805dae010d4be01841e3d535a6
4
- data.tar.gz: f4775af30bd3ad47662b06ba8ebaefdef6b0d30378773e2ec520c54a4c672a36
3
+ metadata.gz: 9fba94340f1fab59fe29af2fe56082fe7a957fe49cdd650dc689e681938fa7dc
4
+ data.tar.gz: 88842cffcde50db973f10b4ec60a1214fe4356203bf5c2ebe4826ca93789eaef
5
5
  SHA512:
6
- metadata.gz: 1892e985e4eff8f8f635e9438fc081832a65a6ac8717d73edaa17ee165219bdfcf29838e33b86ea6f06cf77489bedf418a90d06c2f114f1c5fc343e16615560a
7
- data.tar.gz: 2ff791f2c9c431305935f27345f1aa4e0001c59eaa132a7d4f1d839b3030ee8d8090aa4ee5be0b8aa75492ec113a243e996b6bd119b81340892e2c593c7cc106
6
+ metadata.gz: 7189b3aae25dd0b74d9035e2936adae1cd6bdd5fdc1147531566674b262fa9dacdb40f56f07b1ab65c3ba87934f0ec9a6f57fdf41f44528f3927ae4ddf101014
7
+ data.tar.gz: 3c806b5e89ddf72e7003a1fa30f18dabeb0720c9ac3c8a617a89c0e3677a929e4c5bb8e878a52e4b2216ff00363ba9bb730741ae7942fb5ba37ed925bdaf6a7c
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
 
@@ -47,33 +48,84 @@ client = Spotify::Client.new(config)
47
48
  ## Public API
48
49
 
49
50
  ```ruby
51
+ # User
50
52
  client.me
51
- client.me_tracks
53
+
54
+ # Library
55
+ client.me_albums(params = {})
56
+ client.me_audiobooks(params = {})
57
+ client.me_episodes(params = {})
52
58
  client.me_following
53
- client.user(user_id)
54
- client.user_playlists(user_id) # user_id kept for backward compatibility; requests /v1/me/playlists
55
- client.user_playlist(user_id, playlist_id)
56
- 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
57
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 = {})
58
75
  client.add_user_tracks_to_playlist(user_id, playlist_id, uris = [], position = nil)
59
76
  client.remove_user_tracks_from_playlist(user_id, playlist_id, tracks)
60
77
  client.replace_user_tracks_in_playlist(user_id, playlist_id, tracks)
61
78
  client.truncate_user_playlist(user_id, playlist_id)
79
+
80
+ # Metadata
62
81
  client.album(album_id)
63
82
  client.album_tracks(album_id)
64
83
  client.albums(album_ids)
65
- client.track(track_id)
66
- client.tracks(track_ids)
67
84
  client.artist(artist_id)
68
85
  client.artists(artist_ids)
69
86
  client.artist_albums(artist_id)
70
- client.search(entity, term, options = {})
71
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)
72
122
  client.related_artists(artist_id)
73
123
  client.follow(type, ids)
74
124
  client.follow_playlist(user_id, playlist_id, is_public = true)
75
- client.request(:get, '/v1/me') # generic helper for newer endpoints
76
- client.request!(:post, '/v1/some-endpoint', [201], payload, false)
125
+
126
+ # Generic helpers for forward compatibility
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
77
129
  ```
78
130
 
79
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.0.2'
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,294 +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
- # params:
48
- # - type: Required, The ID type, currently only 'artist' is supported
49
- # - limit: Optional. The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
50
- # - after: Optional. The last artist ID retrieved from the previous request.
51
- def me_following(params = {})
52
- params = params.merge(type: 'artist')
53
- run(:get, '/v1/me/following', [200], params)
54
- end
55
-
56
- def user(user_id)
57
- raise_endpoint_unavailable_in_development_mode!(
58
- endpoint: 'GET /v1/users/{id}',
59
- replacement: 'GET /v1/me'
60
- )
61
- run(:get, "/v1/users/#{user_id}", [200])
62
- end
63
-
64
- def user_playlists(_user_id = nil)
65
- run(:get, '/v1/me/playlists', [200])
66
- end
67
-
68
- def user_playlist(_user_id, playlist_id)
69
- run(:get, "/v1/playlists/#{playlist_id}", [200])
70
- end
71
-
72
- def user_playlist_tracks(_user_id, playlist_id, params = {})
73
- tracks = { 'items' => [] }
74
- path = "/v1/playlists/#{playlist_id}/items"
75
-
76
- while path
77
- response = run(:get, path, [200], params)
78
- tracks['items'].concat(response.delete('items'))
79
- tracks.merge!(response)
80
-
81
- path = response['next']&.gsub(BASE_URI, '')
82
- end
83
-
84
- tracks
85
- end
86
-
87
- # Create a playlist for a Spotify user. The playlist will be empty until you add tracks.
88
- #
89
- # Requires playlist-modify-public for a public playlist.
90
- # Requires playlist-modify-private for a private playlist.
91
- def create_user_playlist(_user_id, name, is_public = true)
92
- run(:post, '/v1/me/playlists', [201], JSON.dump(name: name, public: is_public), false)
93
- end
94
-
95
- # Add an Array of track uris to an existing playlist.
96
- #
97
- # Adding tracks to a user's public playlist requires authorization of the playlist-modify-public scope;
98
- # adding tracks to a private playlist requires the playlist-modify-private scope.
99
- #
100
- # client.add_user_tracks_to_playlist(
101
- # '1181346016', '7i3thJWDtmX04dJhFwYb0x', %w(spotify:track:... spotify:track:...)
102
- # )
103
- def add_user_tracks_to_playlist(_user_id, playlist_id, uris = [], position = nil)
104
- params = { uris: Array(uris)[0..99].join(',') }
105
- params.merge!(position: position) if position
106
- run(:post, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(params), false)
107
- end
108
-
109
- # Removes tracks from playlist
110
- #
111
- # client.remove_user_tracks_from_playlist(
112
- # '1181346016', '7i3thJWDtmX04dJhFwYb0x', [{ uri: 'spotify:track:...', positions: [0] }]
113
- # )
114
- def remove_user_tracks_from_playlist(_user_id, playlist_id, tracks)
115
- run(:delete, "/v1/playlists/#{playlist_id}/items", [200], JSON.dump(items: tracks))
116
- end
117
-
118
- # Replaces all occurrences of tracks with what's in the playlist
119
- #
120
- # client.replace_user_tracks_in_playlist(
121
- # '1181346016', '7i3thJWDtmX04dJhFwYb0x', %w(spotify:track:... spotify:track:...)
122
- # )
123
- def replace_user_tracks_in_playlist(_user_id, playlist_id, tracks)
124
- run(:put, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(uris: tracks))
125
- end
126
-
127
- # Removes all tracks in playlist
128
- #
129
- # client.truncate_user_playlist('1181346016', '7i3thJWDtmX04dJhFwYb0x')
130
- def truncate_user_playlist(user_id, playlist_id)
131
- replace_user_tracks_in_playlist(user_id, playlist_id, [])
132
- end
133
-
134
- def album(album_id)
135
- run(:get, "/v1/albums/#{album_id}", [200])
136
- end
137
-
138
- def album_tracks(album_id)
139
- run(:get, "/v1/albums/#{album_id}/tracks", [200])
140
- end
141
-
142
- def albums(album_ids)
143
- { 'albums' => Array(album_ids).map { |album_id| album(album_id) } }
144
- end
145
-
146
- def track(track_id)
147
- run(:get, "/v1/tracks/#{track_id}", [200])
148
- end
149
-
150
- def tracks(track_ids)
151
- { 'tracks' => Array(track_ids).map { |track_id| track(track_id) } }
152
- end
153
-
154
- def artist(artist_id)
155
- run(:get, "/v1/artists/#{artist_id}", [200])
156
- end
157
-
158
- def artists(artist_ids)
159
- { 'artists' => Array(artist_ids).map { |artist_id| artist(artist_id) } }
160
- end
161
-
162
- def artist_albums(artist_id)
163
- run(:get, "/v1/artists/#{artist_id}/albums", [200])
164
- end
165
-
166
- def search(entity, term, options = {})
167
- unless %i[artist album track].include?(entity.to_sym)
168
- raise(ImplementationError, "entity needs to be either artist, album or track, got: #{entity}")
169
- end
170
-
171
- options = options.dup
172
- options[:limit] = [options[:limit].to_i, 10].min if options.key?(:limit)
173
-
174
- params = {
175
- q: term.to_s,
176
- type: entity
177
- }.merge(options)
178
- run(:get, '/v1/search', [200], params)
179
- end
180
-
181
- # Get Spotify catalog information about an artist's top 10 tracks by country.
182
- #
183
- # +country_id+ is required. An ISO 3166-1 alpha-2 country code.
184
- def artist_top_tracks(artist_id, country_id)
185
- raise_endpoint_unavailable_in_development_mode!(endpoint: 'GET /v1/artists/{id}/top-tracks')
186
- run(:get, "/v1/artists/#{artist_id}/top-tracks", [200], country: country_id)
187
- end
188
-
189
- def related_artists(artist_id)
190
- run(:get, "/v1/artists/#{artist_id}/related-artists", [200])
191
- end
192
-
193
- # Follow artists or users
194
- #
195
- # client.follow('artist', ['0BvkDsjIUla7X0k6CSWh1I'])
196
- def follow(type, ids)
197
- entity_type = type.to_s.strip
198
- uris = Array(ids).map do |id|
199
- raw = id.to_s
200
- next raw if raw.start_with?('spotify:')
201
-
202
- raise(ImplementationError, 'type is required when ids are not full Spotify URIs') if entity_type.empty?
203
-
204
- "spotify:#{entity_type}:#{raw}"
205
- end
206
- run(:put, '/v1/me/library', [200, 204], JSON.dump(uris: uris), false)
207
- end
208
-
209
- # Follow a playlist
210
- #
211
- # client.follow_playlist('lukebryan', '0obRj9nNySESpFelMCLSya')
212
- def follow_playlist(_user_id, playlist_id, is_public = true)
213
- _is_public = is_public # kept for backward-compatible signature
214
- run(:put, '/v1/me/library', [200, 204], JSON.dump(uris: ["spotify:playlist:#{playlist_id}"]), false)
215
- end
216
-
217
- # Generic API helper for forward compatibility with newly added endpoints.
218
- def request(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
219
- run(verb.to_sym, path, Array(expected_status_codes), params_or_body, idempotent)
220
- end
221
-
222
- # Bang variant that propagates mapped API errors.
223
- def request!(verb, path, expected_status_codes = [200], params_or_body = {}, idempotent = true)
224
- run!(verb.to_sym, path, Array(expected_status_codes), params_or_body, idempotent)
225
- end
226
-
227
- protected
228
-
229
- def raise_endpoint_unavailable_in_development_mode!(endpoint:, replacement: nil)
230
- return unless development_mode?
231
-
232
- message = "#{endpoint} is unavailable for Spotify Development Mode apps as of March 9, 2026."
233
- message += " Use #{replacement} instead." if replacement
234
- raise(EndpointUnavailableInDevelopmentMode, message)
235
- end
236
-
237
- def development_mode?
238
- @app_mode == 'development' || @app_mode == 'development_mode'
239
- end
240
-
241
- def run(verb, path, expected_status_codes, params = {}, idempotent = true)
242
- run!(verb, path, expected_status_codes, params, idempotent)
243
- rescue Error => e
244
- raise e if @raise_errors
245
-
246
- false
247
- end
248
-
249
- def run!(verb, path, expected_status_codes, params_or_body = nil, idempotent = true)
250
- packet = {
251
- idempotent: idempotent,
252
- expects: expected_status_codes,
253
- method: verb,
254
- path: path,
255
- read_timeout: @read_timeout,
256
- write_timeout: @write_timeout,
257
- retry_limit: @retries,
258
- headers: {
259
- 'Content-Type' => 'application/json',
260
- 'User-Agent' => 'Spotify Ruby Client'
261
- }
262
- }
263
- if params_or_body.is_a?(Hash)
264
- packet.merge!(query: params_or_body)
265
- else
266
- packet.merge!(body: params_or_body)
267
- end
268
-
269
- packet[:headers].merge!('Authorization' => "Bearer #{@access_token}") if !@access_token.nil? && @access_token != ''
270
-
271
- # puts "\033[31m [Spotify] HTTP Request: #{verb.upcase} #{BASE_URI}#{path} #{packet[:headers].inspect} \e[0m"
272
- response = @connection.request(packet)
273
- return {} if response.body.nil? || response.body.empty?
274
-
275
- ::JSON.parse(response.body)
276
- rescue Excon::Errors::NotFound => e
277
- raise(ResourceNotFound, "Error: #{e.message}")
278
- rescue Excon::Errors::BadRequest => e
279
- raise(BadRequest, "Error: #{e.message}")
280
- rescue Excon::Errors::Forbidden => e
281
- raise(InsufficientClientScopeError, "Error: #{e.message}")
282
- rescue Excon::Errors::Unauthorized => e
283
- raise(AuthenticationError, "Error: #{e.message}")
284
- rescue Excon::Errors::Error => e
285
- # Catch all others errors. Samples:
286
- #
287
- # <Excon::Errors::SocketError: Connection refused - connect(2) (Errno::ECONNREFUSED)>
288
- # <Excon::Errors::InternalServerError: Expected([200, 204, 404]) <=> Actual(500 InternalServerError)>
289
- # <Excon::Errors::Timeout: read timeout reached>
290
- # <Excon::Errors::BadGateway: Expected([200]) <=> Actual(502 Bad Gateway)>
291
- raise(HTTPError, "Error: #{e.message}")
292
- end
293
- end
294
- 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.0.2
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