spotify_web 0.0.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.
@@ -0,0 +1,58 @@
1
+ require 'spotify_web/resource'
2
+ require 'spotify_web/artist'
3
+ require 'spotify_web/restriction'
4
+ require 'spotify_web/schema/metadata.pb'
5
+
6
+ module SpotifyWeb
7
+ # Represents an album on Spotify
8
+ class Album < Resource
9
+ self.metadata_schema = Schema::Metadata::Album
10
+
11
+ # The title of the album
12
+ # @return [String]
13
+ attribute :title, :name
14
+
15
+ # Info about the artist
16
+ # @return [String]
17
+ attribute :artist do |artist|
18
+ Artist.new(client, artist[0].to_hash)
19
+ end
20
+
21
+ # The label that released the album
22
+ # @return [String]
23
+ attribute :label
24
+
25
+ # The date the album was published on
26
+ # @return [Date]
27
+ attribute :published_on, :date do |date|
28
+ Date.new(date.year, date.month || 1, date.day || 1)
29
+ end
30
+
31
+ # The relative popularity of this artist on Spotify
32
+ # @return [Fixnum]
33
+ attribute :popularity
34
+
35
+ # The songs recorded on this album
36
+ # @return [Array<SpotifyWeb::Song>]
37
+ attribute :songs, :disc do |discs|
38
+ songs = []
39
+ discs.each do |disc|
40
+ disc_songs = disc.track.map {|track| Song.new(client, track.to_hash)}
41
+ songs.concat(disc_songs)
42
+ end
43
+ ResourceCollection.new(client, songs)
44
+ end
45
+
46
+ # The countries for which this album is permitted to be played
47
+ # @return [Array<SpotifyWeb::Restriction>]
48
+ attribute :restrictions, :restriction do |restrictions|
49
+ restrictions.map {|restriction| Restriction.new(client, restriction.to_hash)}
50
+ end
51
+
52
+ # Whether this album is available to the user
53
+ # @return [Boolean]
54
+ def available?
55
+ restrictions.all? {|restriction| restriction.permitted?}
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,54 @@
1
+ require 'spotify_web/resource'
2
+ require 'spotify_web/schema/metadata.pb'
3
+
4
+ module SpotifyWeb
5
+ # Represents an artist on Spotify
6
+ class Artist < Resource
7
+ self.metadata_schema = Schema::Metadata::Artist
8
+
9
+ # The title of the artist
10
+ # @return [String]
11
+ attribute :name
12
+
13
+ # The relative popularity of this artist on Spotify
14
+ # @return [Fixnum]
15
+ attribute :popularity
16
+
17
+ # The top songs for this artist
18
+ # @return [Array<SpotifyWeb::Song>]
19
+ attribute :top_songs, :top_track do |groups|
20
+ group = groups.detect {|group| group.country == 'US'}
21
+ songs = group.track.map {|song| Song.new(client, song.to_hash)}
22
+ ResourceCollection.new(client, songs)
23
+ end
24
+
25
+ # The albums this artist has recorded
26
+ # @return [Array<SpotifyWeb::Album>]
27
+ attribute :albums, :album_group do |groups|
28
+ # Track all available albums
29
+ albums = []
30
+ groups.each do |group|
31
+ group_albums = []
32
+ group.album.each do |album|
33
+ album = Album.new(client, album.to_hash)
34
+ group_albums << album if album.available?
35
+ end
36
+
37
+ albums.concat(group_albums)
38
+ end
39
+
40
+ # Load the data to completely determine what albums to give back
41
+ albums = ResourceCollection.new(client, albums)
42
+ albums.load
43
+
44
+ # Reject duplicate titles
45
+ albums_by_title = albums.inject({}) do |result, album|
46
+ result[album.title] = album
47
+ result
48
+ end
49
+ albums.reject! {|album| albums_by_title[album.title] != album}
50
+
51
+ albums
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,36 @@
1
+ module SpotifyWeb
2
+ # Provides a set of helper methods for making assertions about the content
3
+ # of various objects
4
+ # @api private
5
+ module Assertions
6
+ # Validates that the given hash *only* includes the specified valid keys.
7
+ #
8
+ # @return [nil]
9
+ # @raise [ArgumentError] if any invalid keys are found
10
+ # @example
11
+ # options = {:name => 'John Smith', :age => 30}
12
+ #
13
+ # assert_valid_keys(options, :name) # => ArgumentError: Invalid key(s): age
14
+ # assert_valid_keys(options, 'name', 'age') # => ArgumentError: Invalid key(s): age, name
15
+ # assert_valid_keys(options, :name, :age) # => nil
16
+ def assert_valid_keys(hash, *valid_keys)
17
+ invalid_keys = hash.keys - valid_keys
18
+ raise ArgumentError, "Invalid key(s): #{invalid_keys.join(', ')}" unless invalid_keys.empty?
19
+ end
20
+
21
+ # Validates that the given value *only* matches one of the specified valid
22
+ # values.
23
+ #
24
+ # @return [nil]
25
+ # @raise [ArgumentError] if the value is not found
26
+ # @example
27
+ # value = :age
28
+ #
29
+ # assert_valid_values(value, :name) # => ArgumentError: :age is an invalid value; must be one of: :name
30
+ # assert_valid_values(value, 'name', 'age') # => ArgumentError: :age is an invalid value; must be one of: :name, "age"
31
+ # assert_valid_values(value, :name, :age) # => nil
32
+ def assert_valid_values(value, *valid_values)
33
+ raise ArgumentError, "#{value} is an invalid value; must be one of: #{valid_values.join(', ')}" unless valid_values.include?(value)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,119 @@
1
+ require 'spotify_web/playlist'
2
+ require 'spotify_web/resource_collection'
3
+ require 'spotify_web/user'
4
+ require 'spotify_web/schema/playlist4.pb'
5
+
6
+ module SpotifyWeb
7
+ # Represents a user who has authorized with the Spotify service
8
+ class AuthorizedUser < User
9
+ # The username the user registered with on Spotify.
10
+ # @return [String]
11
+ attribute :username
12
+
13
+ # The password associated with the username registered with on Spotify.
14
+ # @return [String]
15
+ attribute :password
16
+
17
+ # Gets the authentication settings associated with this user for use with API
18
+ # services. This will log the user in via username / password if it's not already
19
+ # set.
20
+ #
21
+ # @return [String]
22
+ # @raise [SpotifyWeb::Error] if the command fails
23
+ def settings
24
+ login unless @settings
25
+ @settings
26
+ end
27
+
28
+ # Logs the user in using the associated e-mail address / password. This will
29
+ # generate a user id / auth token for authentication with the API services.
30
+ #
31
+ # @api private
32
+ # @return [true]
33
+ # @raise [SpotifyWeb::Error] if the command fails
34
+ def login
35
+ # Look up the init options
36
+ request = EventMachine::HttpRequest.new('https://play.spotify.com/')
37
+ response = request.get(:head => {'User-Agent' => USER_AGENT})
38
+
39
+ if response.response_header.successful?
40
+ json = response.response.match(/Spotify\.Web\.Login\(document, (\{.+\}),[^\}]+\);/)[1]
41
+ options = JSON.parse(json)
42
+
43
+ # Authenticate the user
44
+ request = EventMachine::HttpRequest.new('https://play.spotify.com/xhr/json/auth.php')
45
+ response = request.post(
46
+ :body => {
47
+ :username => username,
48
+ :password => password,
49
+ :type => 'sp',
50
+ :secret => options['csrftoken'],
51
+ :trackingId => options['trackingId'],
52
+ :landingURL => options['landingURL'],
53
+ :referrer => options['referrer'],
54
+ :cf => nil
55
+ },
56
+ :head => {'User-Agent' => USER_AGENT}
57
+ )
58
+
59
+ if response.response_header.successful?
60
+ data = JSON.parse(response.response)
61
+
62
+ if data['status'] == 'OK'
63
+ @settings = data['config']
64
+ else
65
+ error = "Unable to authenticate (#{data['message']})"
66
+ end
67
+ else
68
+ error = "Unable to authenticate (#{response.response_header.status})"
69
+ end
70
+ else
71
+ error = "Landing page unavailable (#{response.response_header.status})"
72
+ end
73
+
74
+ raise(ConnectionError, error) if error
75
+
76
+ true
77
+ end
78
+
79
+ # Gets the playlists managed by the user.
80
+ #
81
+ # @param [Hash] options The search options
82
+ # @option options [Fixnum] :limit (100) The total number of playlists to get
83
+ # @option options [Fixnum] :skip (0) The number of playlists to skip when loading the list
84
+ # @option options [Boolean] :include_starred (false) Whether to include the playlist for songs the user starred
85
+ # @return [Array<SpotifyWeb::Playlist>]
86
+ # @example
87
+ # user.playlists # => [#<SpotifyWeb::Playlist ...>, ...]
88
+ def playlists(options = {})
89
+ options = {:limit => 100, :skip => 0, :include_starred => false}.merge(options)
90
+
91
+ response = api('request',
92
+ :uri => "hm://playlist/user/#{username}/rootlist?from=#{options[:skip]}&length=#{options[:limit]}",
93
+ :response_schema => Schema::Playlist4::SelectedListContent
94
+ )
95
+
96
+ playlists = response['result'].contents.items.map do |item|
97
+ playlist(:uri => item.uri)
98
+ end
99
+ playlists << playlist(:starred) if options[:include_starred]
100
+
101
+ ResourceCollection.new(client, playlists)
102
+ end
103
+
104
+ # Builds a playlist with the given attributes.
105
+ #
106
+ # @param [Hash] attributes The attributes identifying the playlist
107
+ # @return [SpotifyWeb::Playlist]
108
+ # @example
109
+ # user.playlist(:starred) # => #<SpotifyWeb::Playlist ...>
110
+ # user.playlist(:uri => "spotify:user:benzelano:playlist:starred") # => #<SpotifyWeb::Playlist ...>
111
+ def playlist(attributes = {})
112
+ if attributes == :starred
113
+ attributes = {:uri_id => 'starred'}
114
+ end
115
+
116
+ Playlist.new(client, attributes.merge(:user => self))
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,369 @@
1
+ require 'fiber'
2
+ require 'execjs'
3
+
4
+ require 'spotify_web/album'
5
+ require 'spotify_web/artist'
6
+ require 'spotify_web/authorized_user'
7
+ require 'spotify_web/connection'
8
+ require 'spotify_web/error'
9
+ require 'spotify_web/event'
10
+ require 'spotify_web/handler'
11
+ require 'spotify_web/loggable'
12
+ require 'spotify_web/song'
13
+ require 'spotify_web/schema/mercury.pb'
14
+
15
+ module SpotifyWeb
16
+ # Provides access to the Spotify Web API
17
+ class Client
18
+ include Assertions
19
+ include Loggable
20
+
21
+ # The interval with which to send keepalives
22
+ KEEPALIVE_INTERVAL = 180
23
+
24
+ # The current authorized user
25
+ # @return [SpotifyWeb::User]
26
+ attr_reader :user
27
+
28
+ # The response timeout configured for the connection
29
+ # @return [Fixnum]
30
+ attr_reader :timeout
31
+
32
+ # Creates a new client for communicating with Spotify with the given
33
+ # username / password.
34
+ #
35
+ # @param [String] username The username to authenticate with
36
+ # @param [String] password The Spotify password associated with the username
37
+ # @param [Hash] options The configuration options for the client
38
+ # @option options [Fixnum] :timeout (10) The amount of seconds to allow to elapse for requests before timing out
39
+ # @option options [Boolean] :reconnect (false) Whether to allow the client to automatically reconnect when disconnected either by Spotify or by the network
40
+ # @option options [Fixnum] :reconnect_wait (5) The amount of seconds to wait before reconnecting
41
+ # @raise [SpotifyWeb::Error] if an invalid option is specified
42
+ # @yield Runs the given block within the context if the client (for DSL-type usage)
43
+ def initialize(username, password, options = {}, &block)
44
+ options = {
45
+ :timeout => 10,
46
+ :reconnect => false,
47
+ :reconnect_wait => 5
48
+ }.merge(options)
49
+ assert_valid_keys(options, :timeout, :reconnect, :reconnect_wait)
50
+
51
+ @user = AuthorizedUser.new(self, :username => username, :password => password)
52
+ @event_handlers = {}
53
+ @timeout = options[:timeout]
54
+ @reconnect = options[:reconnect]
55
+ @reconnect_wait = options[:reconnect_wait]
56
+
57
+ # Setup default event handlers
58
+ on(:work_requested) {|work| on_work_requested(work) }
59
+ on(:session_ended) { on_session_ended }
60
+
61
+ reconnect_from(ConnectionError, APIError) do
62
+ connect
63
+ end
64
+
65
+ instance_eval(&block) if block_given?
66
+ end
67
+
68
+ # Initiates a connection with the given url. Once a connection is started,
69
+ # this will also attempt to authenticate the user.
70
+ #
71
+ # @api private
72
+ # @note This will only open a new connection if the client isn't already connected to the given url
73
+ # @param [String] url The url to open a connection to
74
+ # @return [true]
75
+ # @raise [SpotifyWeb::Error] if the connection cannot be opened
76
+ def connect
77
+ if !@connection || !@connection.connected?
78
+ # Close any existing connection
79
+ close
80
+
81
+ # Create a new connection to the given url
82
+ @connection = Connection.new(access_point_url, :timeout => timeout)
83
+ @connection.handler = lambda {|data| trigger(data.delete('command'), data)}
84
+ @connection.start
85
+
86
+ # Wait for connection to open
87
+ wait do |&resume|
88
+ on(:session_started, :once => true) { resume.call }
89
+ end
90
+
91
+ # Send the user's credentials
92
+ creds = user.settings['credentials'][0].split(':')
93
+ message = [creds[0], creds[1], creds[2..-1] * ':']
94
+ api('connect', message)
95
+
96
+ wait do |&resume|
97
+ on(:session_authenticated, :once => true) { resume.call }
98
+ end
99
+
100
+ start_keepalives
101
+ end
102
+
103
+ true
104
+ end
105
+
106
+ # Closes the current connection to Spotify if one was previously opened.
107
+ #
108
+ # @return [true]
109
+ def close(allow_reconnect = false)
110
+ if @connection
111
+ # Disable reconnects if specified
112
+ reconnect = @reconnect
113
+ @reconnect = reconnect && allow_reconnect
114
+
115
+ # Clean up timers / connections
116
+ @keepalive_timer.cancel if @keepalive_timer
117
+ @keepalive_timer = nil
118
+ @connection.close
119
+
120
+ # Revert change to reconnect config once the final signal is received
121
+ wait do |&resume|
122
+ on(:session_ended, :once => true) { resume.call }
123
+ end
124
+ @reconnect = reconnect
125
+ end
126
+
127
+ true
128
+ end
129
+
130
+ # Runs the given API command.
131
+ #
132
+ # @api private
133
+ # @param [String] command The name of the command to execute
134
+ # @param [Object] args The arguments to pass into the command
135
+ # @return [Hash] The data returned from the Spotify service
136
+ # @raise [SpotifyWeb::Error] if the connection is not open or the command fails to execute
137
+ def api(command, args = nil)
138
+ raise(ConnectionError, 'Connection is not open') unless @connection && @connection.connected?
139
+
140
+ if command == 'request' && args.delete(:batch)
141
+ batch(command, args) do |batch_command, batch_args|
142
+ api(batch_command, batch_args)
143
+ end
144
+ else
145
+ # Process this as a mercury request
146
+ if command == 'request'
147
+ response_schema = args.delete(:response_schema)
148
+ end
149
+
150
+ message_id = @connection.publish(command, args)
151
+
152
+ # Wait until we get a response for the given message
153
+ data = wait do |&resume|
154
+ on(:response_received, :once => true, :if => {'id' => message_id}) {|data| resume.call(data)}
155
+ end
156
+
157
+ if command == 'request' && !data['error']
158
+ # Parse the response bsed on the schema provided
159
+ header, body = data['result']
160
+ request = Schema::Mercury::MercuryRequest.decode(Base64.decode64(header))
161
+
162
+ if (400..599).include?(request.status_code)
163
+ data['error'] = "Failed response: #{request.status_code}"
164
+ else
165
+ data['result'] = response_schema.decode(Base64.decode64(body))
166
+ end
167
+ end
168
+
169
+ if error = data['error']
170
+ raise APIError, "Command \"#{command}\" failed with message: \"#{error}\""
171
+ else
172
+ data
173
+ end
174
+ end
175
+ end
176
+
177
+ # Starts the keepalive timer for ensure the connection remains open.
178
+ # @api private
179
+ def start_keepalives
180
+ @keepalive_timer.cancel if @keepalive_timer
181
+ @keepalive_timer = EM::Synchrony.add_periodic_timer(KEEPALIVE_INTERVAL) do
182
+ SpotifyWeb.run { api('sp/echo', 'h') }
183
+ end
184
+ end
185
+
186
+ # Builds a new song bound to the given id.
187
+ #
188
+ # @param [String, Hash] attributes The id of the song to build or a hash of attributes
189
+ # @return [SpotifyWeb::Song]
190
+ # @example
191
+ # client.song("\x92\xA9...") # => #<SpotifyWeb::Song id="\x92\xA9..." ...>
192
+ def song(attributes)
193
+ attributes = {:gid => attributes} unless attributes.is_a?(Hash)
194
+ Song.new(self, attributes)
195
+ end
196
+
197
+ # Builds a new artist bound to the given id.
198
+ #
199
+ # @param [String, Hash] attributes The id of the artist to build or a hash of attributes
200
+ # @return [SpotifyWeb::Artist]
201
+ # @example
202
+ # client.artist("\xC1\x8Fr...") # => #<SpotifyWeb::Artist gid="\xC1\x8Fr..." ...>
203
+ def artist(attributes)
204
+ attributes = {:gid => attributes} unless attributes.is_a?(Hash)
205
+ Artist.new(self, attributes)
206
+ end
207
+
208
+ # Builds a new album bound to the given id / attributes.
209
+ #
210
+ # @param [String, Hash] attributes The id of the album to build or a hash of attributes
211
+ # @return [SpotifyWeb::Album]
212
+ # @example
213
+ # client.album("\x03\xC0...") # => #<SpotifyWeb::Album id="\x03\xC0..." ...>
214
+ def album(attributes)
215
+ attributes = {:gid => attributes} unless attributes.is_a?(Hash)
216
+ Album.new(self, attributes)
217
+ end
218
+
219
+ private
220
+ # Determines the web socket url to connect to
221
+ def access_point_url
222
+ resolver = user.settings['aps']['resolver']
223
+ query = {:client => "24:0:0:#{user.settings['version']}"}
224
+ query[:site] = resolver['site'] if resolver['site']
225
+
226
+ request = EventMachine::HttpRequest.new("http://#{resolver['hostname']}")
227
+ response = request.get(:query => query, :head => {'User-Agent' => USER_AGENT})
228
+
229
+ if response.response_header.successful?
230
+ data = JSON.parse(response.response)
231
+ data['ap_list'][0]
232
+ else
233
+ raise(ConnectionError, data['message'])
234
+ end
235
+ end
236
+
237
+ # Runs a batch command within an API call
238
+ def batch(command, options)
239
+ # Update payload to be a batch
240
+ requests = options[:payload].map do |attrs|
241
+ attrs[:method] ||= 'GET'
242
+ Schema::Mercury::MercuryRequest.new(attrs)
243
+ end
244
+ options[:payload] = Schema::Mercury::MercuryMultiGetRequest.new(:request => requests)
245
+
246
+ # Track the schema
247
+ response_schema = options[:response_schema]
248
+ options[:response_schema] = Schema::Mercury::MercuryMultiGetReply
249
+
250
+ response = yield(command, options)
251
+
252
+ # Process each reply
253
+ results = []
254
+ response['result'].reply.each_with_index do |reply, index|
255
+ if (400..599).include?(reply.status_code)
256
+ request = requests[index]
257
+ raise APIError, "Command \"#{command}\" for URI \"#{request.uri}\" failed with message: \"#{reply.status_code}\""
258
+ else
259
+ results << response_schema.decode(reply.body)
260
+ end
261
+ end
262
+
263
+ response['result'] = results
264
+ response
265
+ end
266
+
267
+ # Registers a handler to invoke when an event occurs in Spotify.
268
+ #
269
+ # @param [Symbol] event The event to register a handler for
270
+ # @param [Hash] options The configuration options for the handler
271
+ # @option options [Hash] :if Specifies a set of key-value pairs that must be matched in the event data in order to run the handler
272
+ # @option options [Boolean] :once (false) Whether to only run the handler once
273
+ # @return [true]
274
+ def on(event, options = {}, &block)
275
+ event = event.to_sym
276
+ @event_handlers[event] ||= []
277
+ @event_handlers[event] << Handler.new(event, options, &block)
278
+ true
279
+ end
280
+
281
+ # Triggers callback handlers for the given Spotify command. This should
282
+ # be invoked when responses are received for Spotify.
283
+ #
284
+ # @note If the command is unknown, it will simply get skipped and not raise an exception
285
+ # @param [Symbol] command The name of the command triggered. This is typically the same name as the event.
286
+ # @param [Array] args The arguments to be processed by the event
287
+ # @return [true]
288
+ def trigger(command, *args)
289
+ command = command.to_sym if command
290
+
291
+ if Event.command?(command)
292
+ event = Event.new(self, command, args)
293
+ handlers = @event_handlers[event.name] || []
294
+ handlers.each do |handler|
295
+ success = handler.run(event)
296
+ handlers.delete(handler) if success && handler.once
297
+ end
298
+ end
299
+
300
+ true
301
+ end
302
+
303
+ # Callback when Spotify has requested to evaluate javascript
304
+ def on_work_requested(work)
305
+ script = <<-eos
306
+ this.reply = function() {
307
+ this.result = Array.prototype.slice.call(arguments);
308
+ };
309
+ #{work['args'][0]}
310
+ eos
311
+ context = ExecJS.compile(script)
312
+ result = context.eval('this.result')
313
+
314
+ api('sp/work_done', result)
315
+ end
316
+
317
+ # Callback when the session has ended. This will automatically reconnect if
318
+ # allowed to do so.
319
+ def on_session_ended
320
+ @connection = nil
321
+
322
+ # Automatically reconnect to the server if allowed
323
+ if @reconnect
324
+ reconnect_from(Exception) do
325
+ connect
326
+ trigger(:reconnected)
327
+ end
328
+ end
329
+ end
330
+
331
+ # Runs a given block and retries that block after a certain period of time
332
+ # if any of the specified exceptions are raised. Note that there is no
333
+ # limit on the number of attempts to retry.
334
+ def reconnect_from(*exceptions)
335
+ begin
336
+ yield
337
+ rescue *exceptions => ex
338
+ if @reconnect
339
+ logger.debug "Connection failed: #{ex.message}"
340
+ EM::Synchrony.sleep(@reconnect_wait)
341
+ logger.debug 'Attempting to reconnect'
342
+ retry
343
+ else
344
+ raise
345
+ end
346
+ end
347
+ end
348
+
349
+ # Pauses the current fiber until it is resumed with response data. This
350
+ # can only get resumed explicitly by the provided block.
351
+ def wait(&block)
352
+ fiber = Fiber.current
353
+
354
+ # Resume the fiber when a response is received
355
+ allow_resume = true
356
+ block.call do |*args|
357
+ fiber.resume(*args) if allow_resume
358
+ end
359
+
360
+ # Attempt to pause the fiber until a response is received
361
+ begin
362
+ Fiber.yield
363
+ rescue FiberError => ex
364
+ allow_resume = false
365
+ raise Error, 'Spotify Web APIs cannot be called from root fiber; use SpotifyWeb.run { ... } instead'
366
+ end
367
+ end
368
+ end
369
+ end