spotify-client 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fba94340f1fab59fe29af2fe56082fe7a957fe49cdd650dc689e681938fa7dc
4
- data.tar.gz: 88842cffcde50db973f10b4ec60a1214fe4356203bf5c2ebe4826ca93789eaef
3
+ metadata.gz: de40fcc8655fc96d463e86606cfe0caa6191fc5c4920323fa4fa6b38c89aa936
4
+ data.tar.gz: 6048f1d14fe1ad56d500815a498176f1e6eed4e1449a0f873ea142404878f290
5
5
  SHA512:
6
- metadata.gz: 7189b3aae25dd0b74d9035e2936adae1cd6bdd5fdc1147531566674b262fa9dacdb40f56f07b1ab65c3ba87934f0ec9a6f57fdf41f44528f3927ae4ddf101014
7
- data.tar.gz: 3c806b5e89ddf72e7003a1fa30f18dabeb0720c9ac3c8a617a89c0e3677a929e4c5bb8e878a52e4b2216ff00363ba9bb730741ae7942fb5ba37ed925bdaf6a7c
6
+ metadata.gz: 7183df6b108f55a76a6e000928888b2f46dca1bcf9921c527e2150f7a5c75feceaf892539a49560d0d07efe40e8028c6fd5aa6513937d9815a38c04de12e256c
7
+ data.tar.gz: 24b8d34991865ea0b8d8b2c6111961cda0e5cdd8acead3105c130844075f3d6c0f283bfcdce429e58dec52f24c2572b2f41a0e0701982161be710458f8eb3c51
data/README.md CHANGED
@@ -27,14 +27,14 @@ gem install spotify-client
27
27
 
28
28
  ## Supported Ruby Versions
29
29
 
30
- The CI matrix runs this gem against Ruby `3.2`, `3.3`, `3.4`, `4.0`, and `ruby-head`.
30
+ Ruby `3.3` or newer is required. The CI matrix runs against Ruby `3.3`, `3.4`, `4.0`, and `ruby-head`, with both released Excon and its development branch.
31
31
 
32
32
  ## Usage
33
33
 
34
34
  ```ruby
35
35
  config = {
36
36
  access_token: 'tk',
37
- app_mode: :development, # optional; use :development to fail fast on dev-mode restricted endpoints
37
+ app_mode: :development, # optional; opt into guards for restricted Development Mode endpoints
38
38
  raise_errors: true,
39
39
  retries: 0,
40
40
  read_timeout: 10,
@@ -128,30 +128,53 @@ client.request(:get, '/v1/me', [200], { market: 'IT' }) # GET-style hash payload
128
128
  client.request!(:post, '/v1/some-endpoint', [201], payload, false) # non-GET hash payloads are JSON encoded
129
129
  ```
130
130
 
131
- ## Spotify API Migration Notes
131
+ ## Spotify API Compatibility
132
132
 
133
- Spotify's Web API changed and removed several legacy endpoints in 2026. This gem now uses current routes while keeping backward-compatible method signatures:
133
+ The API contracts were checked against Spotify's official documentation on November 15, 2026. The latest listed Web API changelog is July 2026.
134
134
 
135
- - Playlist reads/writes use `/v1/me/playlists` and `/v1/playlists/{playlist_id}/*`.
136
- - `follow(type, ids)` now targets `/v1/me/library` using Spotify URIs (`spotify:{type}:{id}`), while still accepting prebuilt URIs.
137
- - `artist_top_tracks` uses `/v1/artists/{id}/top-tracks`.
138
- - `user(user_id)` and `artist_top_tracks` rely on endpoints that Spotify marks unavailable for Development Mode apps (still usable for Extended Quota Mode apps).
139
- - If initialized with `app_mode: :development`, `user(user_id)` and `artist_top_tracks` raise `Spotify::EndpointUnavailableInDevelopmentMode` before making the HTTP request.
135
+ | Change | Client behavior |
136
+ | --- | --- |
137
+ | [February: Development Mode migration](https://developer.spotify.com/documentation/web-api/references/changes/february-2026) | Playlist helpers use `/v1/me/playlists` and `/v1/playlists/{id}/items`. Library helpers and legacy follow helpers use `/v1/me/library`. |
138
+ | [March: restored external IDs](https://developer.spotify.com/documentation/web-api/references/changes/march-2026) | Album and track responses retain `external_ids` unchanged. |
139
+ | [May: stable account identifier](https://developer.spotify.com/documentation/web-api/references/changes/may-2026) | `me` retains `account_id`. Use this immutable identifier when linking accounts to your service. |
140
+ | [June: refresh-token expiration](https://developer.spotify.com/blog/2026-06-18-refresh-token-expiration) | OAuth remains the caller's responsibility. Refresh tokens expire six months after the original authorization; refreshing does not extend their lifetime. Handle `invalid_grant` by asking the user to authorize again. The change applied to existing apps from July 20, 2026. |
141
+ | [July: shared development quotas](https://developer.spotify.com/documentation/web-api/references/changes/july-2026) | HTTP 429 raises `Spotify::RateLimitError` when errors are enabled, exposing `reason` and `retry_after`. Development Mode Client IDs share their developer account's quota. |
140
142
 
141
- - February 2026 changes: [Spotify Web API Changes](https://developer.spotify.com/documentation/web-api/references/changes/february-2026)
143
+ Response JSON is returned without filtering fields. Account permissions, quota mode, scopes, and playlist ownership still determine which data Spotify returns.
144
+
145
+ `app_mode: :development` opts into the restricted endpoint contract: `user`, `artist_top_tracks`, and `related_artists` fail before sending a request. Omit this option for integrations that retain access to those endpoints. Spotify [postponed the endpoint-access migration for existing Development Mode integrations](https://developer.spotify.com/blog/2026-02-06-update-on-developer-access-and-platform-security); the February changes do not apply to Extended Quota Mode. `related_artists` is deprecated and only remains usable by eligible integrations.
146
+
147
+ Library save/remove calls follow the [current endpoint reference](https://developer.spotify.com/documentation/web-api/reference/save-library-items): comma-separated `uris` in the query, with at most 40 items per call. Spotify's February migration guide also shows a JSON-body example. Playlist additions accept at most 100 URI strings as a JSON array. Oversized batches raise `Spotify::ImplementationError`; split them into multiple calls.
148
+
149
+ ### Rate and quota errors
150
+
151
+ Enable `raise_errors: true` or use `request!` to inspect errors. With the default `raise_errors: false`, non-bang methods continue to return `false` on HTTP errors.
152
+
153
+ ```ruby
154
+ begin
155
+ client.request!(:get, '/v1/me')
156
+ rescue Spotify::RateLimitError => error
157
+ error.reason # "QUOTA_EXCEEDED" for the July quota response, otherwise nil
158
+ error.retry_after # Spotify's Retry-After header as a string, or nil
159
+ end
160
+ ```
161
+
162
+ `RateLimitError` inherits from `HTTPError`, so existing rescues still work. With the default `retries: 0`, a 429 response is returned immediately. Configured Excon retries can retry idempotent requests before exposing the final error. The client does not schedule retries from `Retry-After`; callers decide how to handle the supplied reason and retry interval.
142
163
 
143
164
  ## Development
144
165
 
145
- Install dependencies and run checks:
166
+ Use the Ruby version in `.ruby-version`, install dependencies, and run checks:
146
167
 
147
168
  ```bash
148
169
  bundle install
149
170
  bundle exec rake
150
171
  ```
151
172
 
173
+ The suite enables Ruby warnings and requires 100% line coverage overall and per file.
174
+
152
175
  ## Release
153
176
 
154
- Pushing a tag matching `v*` triggers the release workflow that builds and publishes the gem.
177
+ The gem version is defined in `lib/spotify/version.rb`. Push a matching `v`-prefixed tag to trigger the release workflow and publish the gem.
155
178
 
156
179
  ## License
157
180
 
@@ -45,7 +45,10 @@ module Spotify
45
45
  end
46
46
 
47
47
  def add_user_tracks_to_playlist(_user_id, playlist_id, uris = [], position = nil)
48
- params = { uris: Array(uris)[0..99].join(',') }
48
+ items = Array(uris)
49
+ raise(ImplementationError, 'playlist additions accept at most 100 URIs') if items.length > 100
50
+
51
+ params = { uris: items }
49
52
  params.merge!(position: position) if position
50
53
  run(:post, "/v1/playlists/#{playlist_id}/items", [200, 201], JSON.dump(params), false)
51
54
  end
@@ -14,20 +14,21 @@ module Spotify
14
14
 
15
15
  protected
16
16
 
17
- def run(verb, path, expected_status_codes, params = {}, idempotent = true)
18
- run!(verb, path, expected_status_codes, params, idempotent)
17
+ def run(verb, path, expected_status_codes, params = {}, idempotent = true, headers: {})
18
+ run!(verb, path, expected_status_codes, params, idempotent, headers: headers)
19
19
  rescue Error => e
20
20
  handle_nonbang_error(e)
21
21
  end
22
22
 
23
- def run!(verb, path, expected_status_codes, params_or_body = nil, idempotent = true)
23
+ def run!(verb, path, expected_status_codes, params_or_body = nil, idempotent = true, headers: {})
24
24
  response = @connection.request(
25
25
  build_request_packet(
26
26
  verb: verb,
27
27
  path: path,
28
28
  expected_status_codes: expected_status_codes,
29
29
  params_or_body: params_or_body,
30
- idempotent: idempotent
30
+ idempotent: idempotent,
31
+ headers: headers
31
32
  )
32
33
  )
33
34
  parse_response_body(response)
@@ -35,7 +36,7 @@ module Spotify
35
36
  raise map_transport_error(e)
36
37
  end
37
38
 
38
- def build_request_packet(verb:, path:, expected_status_codes:, params_or_body:, idempotent:)
39
+ def build_request_packet(verb:, path:, expected_status_codes:, params_or_body:, idempotent:, headers: {})
39
40
  packet = {
40
41
  idempotent: idempotent,
41
42
  expects: expected_status_codes,
@@ -46,8 +47,8 @@ module Spotify
46
47
  retry_limit: @retries,
47
48
  headers: {
48
49
  'Content-Type' => 'application/json',
49
- 'User-Agent' => 'Spotify Ruby Client'
50
- }
50
+ 'User-Agent' => "spotify-client/#{Spotify::VERSION} (Ruby)"
51
+ }.merge(headers)
51
52
  }
52
53
  apply_request_payload(packet, params_or_body)
53
54
 
@@ -85,12 +86,29 @@ module Spotify
85
86
  end
86
87
 
87
88
  def map_transport_error(error)
89
+ if error.is_a?(Excon::Errors::TooManyRequests)
90
+ return RateLimitError.new("Error: #{error.message}",
91
+ reason: rate_limit_reason(error.response.body),
92
+ retry_after: error.response.headers['Retry-After'])
93
+ end
94
+
88
95
  error_class = EXCON_ERROR_MAP.find do |transport_error, _client_error|
89
96
  error.is_a?(transport_error)
90
97
  end&.last || HTTPError
91
98
 
92
99
  error_class.new("Error: #{error.message}")
93
100
  end
101
+
102
+ def rate_limit_reason(body)
103
+ case JSON.parse(body.to_s, symbolize_names: true)
104
+ in { error: { reason: String => reason } }
105
+ reason
106
+ else
107
+ nil
108
+ end
109
+ rescue JSON::ParserError
110
+ nil
111
+ end
94
112
  end
95
113
  end
96
114
  end
@@ -4,6 +4,7 @@ require 'excon'
4
4
  require 'json'
5
5
 
6
6
  require 'spotify/exceptions'
7
+ require 'spotify/version'
7
8
  require 'spotify/client/compatibility_api'
8
9
  require 'spotify/client/transport'
9
10
 
@@ -85,7 +86,8 @@ module Spotify
85
86
  end
86
87
 
87
88
  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
+ run(:put, "/v1/playlists/#{playlist_id}/images", [200, 202, 204], image_base64_jpeg.to_s, false,
90
+ headers: { 'Content-Type' => 'image/jpeg' })
89
91
  end
90
92
 
91
93
  def album(album_id)
@@ -164,10 +166,11 @@ module Spotify
164
166
  # +country_id+ is required. An ISO 3166-1 alpha-2 country code.
165
167
  def artist_top_tracks(artist_id, country_id)
166
168
  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)
169
+ run(:get, "/v1/artists/#{artist_id}/top-tracks", [200], { market: country_id })
168
170
  end
169
171
 
170
172
  def related_artists(artist_id)
173
+ raise_endpoint_unavailable_in_development_mode!(endpoint: 'GET /v1/artists/{id}/related-artists')
171
174
  run(:get, "/v1/artists/#{artist_id}/related-artists", [200])
172
175
  end
173
176
 
@@ -190,7 +193,7 @@ module Spotify
190
193
  end
191
194
 
192
195
  def playback_state(params = {})
193
- run(:get, '/v1/me/player', [200], params)
196
+ run(:get, '/v1/me/player', [200, 204], params)
194
197
  end
195
198
 
196
199
  def available_devices
@@ -244,11 +247,11 @@ module Spotify
244
247
  end
245
248
 
246
249
  def add_to_library(uris)
247
- run(:put, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
250
+ run(:put, '/v1/me/library', [200, 204], library_query(uris), false)
248
251
  end
249
252
 
250
253
  def remove_from_library(uris)
251
- run(:delete, '/v1/me/library', [200, 204], JSON.dump(uris: Array(uris)), false)
254
+ run(:delete, '/v1/me/library', [200, 204], library_query(uris), false)
252
255
  end
253
256
 
254
257
  # Generic API helper for forward compatibility with newly added endpoints.
@@ -275,10 +278,17 @@ module Spotify
275
278
 
276
279
  protected
277
280
 
281
+ def library_query(uris)
282
+ items = Array(uris)
283
+ raise(ImplementationError, 'library requests accept at most 40 URIs') if items.length > 40
284
+
285
+ { uris: items.join(',') }
286
+ end
287
+
278
288
  def raise_endpoint_unavailable_in_development_mode!(endpoint:, replacement: nil)
279
289
  return unless development_mode?
280
290
 
281
- message = "#{endpoint} is unavailable for Spotify Development Mode apps as of March 9, 2026."
291
+ message = "#{endpoint} is unavailable under the configured Spotify Development Mode restrictions."
282
292
  message += " Use #{replacement} instead." if replacement
283
293
  raise(EndpointUnavailableInDevelopmentMode, message)
284
294
  end
@@ -4,6 +4,17 @@ module Spotify
4
4
  class Error < StandardError; end
5
5
  class AuthenticationError < Error; end
6
6
  class HTTPError < Error; end
7
+
8
+ class RateLimitError < HTTPError
9
+ attr_reader :reason, :retry_after
10
+
11
+ def initialize(message, reason: nil, retry_after: nil)
12
+ super(message)
13
+ @reason = reason
14
+ @retry_after = retry_after
15
+ end
16
+ end
17
+
7
18
  class InsufficientClientScopeError < Error; end
8
19
  class BadRequest < Error; end
9
20
  class ResourceNotFound < Error; end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spotify
4
- VERSION = '1.1.1'
4
+ VERSION = '2.0.0'
5
5
  end
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
16
16
  spec.description = 'Lightweight Ruby client for the Spotify Web API with playlist and catalog helpers.'
17
17
  spec.homepage = 'https://github.com/icoretech/spotify-client'
18
18
  spec.license = 'MIT'
19
- spec.required_ruby_version = '>= 3.2'
19
+ spec.required_ruby_version = '>= 3.3'
20
20
 
21
21
  spec.metadata['source_code_uri'] = 'https://github.com/icoretech/spotify-client'
22
22
  spec.metadata['bug_tracker_uri'] = 'https://github.com/icoretech/spotify-client/issues'
@@ -31,5 +31,5 @@ Gem::Specification.new do |spec|
31
31
  ]
32
32
  spec.require_paths = ['lib']
33
33
 
34
- spec.add_dependency 'excon', '>= 1.4', '< 2.0'
34
+ spec.add_dependency 'excon', '>= 1.12', '< 2.0'
35
35
  end
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.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Poli
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.4'
18
+ version: '1.12'
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '2.0'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: '1.4'
28
+ version: '1.12'
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '2.0'
@@ -62,14 +62,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
62
62
  requirements:
63
63
  - - ">="
64
64
  - !ruby/object:Gem::Version
65
- version: '3.2'
65
+ version: '3.3'
66
66
  required_rubygems_version: !ruby/object:Gem::Requirement
67
67
  requirements:
68
68
  - - ">="
69
69
  - !ruby/object:Gem::Version
70
70
  version: '0'
71
71
  requirements: []
72
- rubygems_version: 4.0.3
72
+ rubygems_version: 4.0.20
73
73
  specification_version: 4
74
74
  summary: Ruby client for the Spotify Web API
75
75
  test_files: []