spotify_web 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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