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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +3 -0
- data/LICENSE +20 -0
- data/README.md +309 -0
- data/Rakefile +13 -0
- data/examples/playlists.rb +14 -0
- data/lib/spotify_web.rb +105 -0
- data/lib/spotify_web/album.rb +58 -0
- data/lib/spotify_web/artist.rb +54 -0
- data/lib/spotify_web/assertions.rb +36 -0
- data/lib/spotify_web/authorized_user.rb +119 -0
- data/lib/spotify_web/client.rb +369 -0
- data/lib/spotify_web/connection.rb +162 -0
- data/lib/spotify_web/error.rb +13 -0
- data/lib/spotify_web/event.rb +95 -0
- data/lib/spotify_web/handler.rb +74 -0
- data/lib/spotify_web/loggable.rb +13 -0
- data/lib/spotify_web/playlist.rb +48 -0
- data/lib/spotify_web/resource.rb +309 -0
- data/lib/spotify_web/resource_collection.rb +99 -0
- data/lib/spotify_web/restriction.rb +32 -0
- data/lib/spotify_web/schema.rb +120 -0
- data/lib/spotify_web/schema/core.pb.rb +31 -0
- data/lib/spotify_web/schema/mercury.pb.rb +66 -0
- data/lib/spotify_web/schema/metadata.pb.rb +257 -0
- data/lib/spotify_web/schema/playlist4.pb.rb +461 -0
- data/lib/spotify_web/schema/radio.pb.rb +110 -0
- data/lib/spotify_web/schema/socialgraph.pb.rb +106 -0
- data/lib/spotify_web/song.rb +58 -0
- data/lib/spotify_web/user.rb +7 -0
- data/lib/spotify_web/version.rb +9 -0
- data/lib/tasks/spotify_web.rake +1 -0
- data/lib/tasks/spotify_web.rb +9 -0
- data/spec/spec_helper.rb +7 -0
- data/spec/spotify_web_spec.rb +4 -0
- data/spotify_web.gemspec +27 -0
- metadata +216 -0
@@ -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
|