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,162 @@
1
+ require 'faye/websocket'
2
+ require 'em-http'
3
+ require 'json'
4
+
5
+ require 'spotify_web/assertions'
6
+ require 'spotify_web/loggable'
7
+ require 'spotify_web/schema/mercury.pb'
8
+
9
+ module SpotifyWeb
10
+ # Represents the interface for sending and receiving data in Spotify
11
+ # @api private
12
+ class Connection
13
+ include Assertions
14
+ include Loggable
15
+
16
+ # Maps method actions to their Spotify identifier
17
+ METHODS = {'SUB' => 1, 'UNSUB' => 2}
18
+
19
+ # The host that this connection is bound to
20
+ # @return [String]
21
+ attr_reader :host
22
+
23
+ # The callback to run when a message is received from the underlying socket.
24
+ # The data passed to the callback will always be a hash.
25
+ # @return [Proc]
26
+ attr_accessor :handler
27
+
28
+ # Builds a new connection for sending / receiving data via the given host.
29
+ #
30
+ # @note This will *not* open the connection -- #start must be explicitly called in order to do so.
31
+ # @param [String] host The host to open a conection to
32
+ # @param [Hash] options The connection options
33
+ # @option options [Fixnum] :timeout The amount of time to allow to elapse for requests before timing out
34
+ # @raise [ArgumentError] if an invalid option is specified
35
+ def initialize(host, options = {})
36
+ assert_valid_keys(options, :timeout)
37
+
38
+ @host = host
39
+ @message_id = 0
40
+ @timeout = options[:timeout]
41
+ end
42
+
43
+ # Initiates the connection with Spotify
44
+ #
45
+ # @return [true]
46
+ def start
47
+ uri = URI.parse("ws://#{host}")
48
+ scheme = uri.port == 443 ? 'wss' : 'ws'
49
+ @socket = Faye::WebSocket::Client.new("#{scheme}://#{uri.host}")
50
+ @socket.onopen = lambda {|event| on_open(event)}
51
+ @socket.onclose = lambda {|event| on_close(event)}
52
+ @socket.onmessage = lambda {|event| on_message(event)}
53
+ true
54
+ end
55
+
56
+ # Closes the connection (if one was previously opened)
57
+ #
58
+ # @return [true]
59
+ def close
60
+ if @socket
61
+ @socket.close
62
+
63
+ # Spotify doesn't send the disconnect frame quickly, so the callback
64
+ # gets run immediately
65
+ EventMachine.add_timer(0.1) { on_close(nil) }
66
+ end
67
+ true
68
+ end
69
+
70
+ # Whether this connection's socket is currently open
71
+ #
72
+ # @return [Boolean] +true+ if the connection is open, otherwise +false+
73
+ def connected?
74
+ @connected
75
+ end
76
+
77
+ # Publishes the given params to the underlying web socket. The defaults
78
+ # initially configured as part of the connection will also be included in
79
+ # the message.
80
+ #
81
+ # @param [Hash] params The parameters to include in the message sent
82
+ # @return [Fixnum] The id of the message delivered
83
+ def publish(command, options)
84
+ if command == 'request'
85
+ options = {:uri => '', :method => 'GET', :source => ''}.merge(options)
86
+ options[:content_type] = 'vnd.spotify/mercury-mget-request' if options[:payload].is_a?(Schema::Mercury::MercuryMultiGetRequest)
87
+ payload = options.delete(:payload)
88
+
89
+ # Generate arguments for the request
90
+ args = [
91
+ METHODS[options[:method]] || 0,
92
+ Base64.encode64(Schema::Mercury::MercuryRequest.new(options).encode)
93
+ ]
94
+ args << Base64.encode64(payload.encode) if payload
95
+
96
+ # Update the command to what Spotify expects
97
+ command = 'sp/hm_b64'
98
+ else
99
+ args = options
100
+ end
101
+
102
+ message = {
103
+ :id => next_message_id,
104
+ :name => command,
105
+ :args => args || []
106
+ }
107
+
108
+ logger.debug "Message sent: #{message.inspect}"
109
+ @socket.send(message.to_json)
110
+
111
+ # Add timeout handler
112
+ EventMachine.add_timer(@timeout) do
113
+ dispatch('id' => message[:id], 'command' => 'response_received', 'error' => 'timed out')
114
+ end if @timeout
115
+
116
+ message[:id]
117
+ end
118
+
119
+ private
120
+ # Runs the configured handler with the given message
121
+ def dispatch(message)
122
+ SpotifyWeb.run { @handler.call(message) } if @handler
123
+ end
124
+
125
+ # Callback when the socket is opened.
126
+ def on_open(event)
127
+ logger.debug 'Socket opened'
128
+ @connected = true
129
+ dispatch('command' => 'session_started')
130
+ end
131
+
132
+ # Callback when the socket is closed. This will mark the connection as no
133
+ # longer connected.
134
+ def on_close(event)
135
+ logger.debug 'Socket closed'
136
+ @connected = false
137
+ @socket = nil
138
+ dispatch('command' => 'session_ended')
139
+ end
140
+
141
+ # Callback when a message has been received from the remote server on the
142
+ # open socket.
143
+ def on_message(event)
144
+ message = JSON.parse(event.data)
145
+
146
+ if message['id']
147
+ message['command'] = 'response_received'
148
+ elsif message['message']
149
+ command, *args = *message['message']
150
+ message = {'command' => command, 'args' => args}
151
+ end
152
+
153
+ logger.debug "Message received: #{message.inspect}"
154
+ dispatch(message)
155
+ end
156
+
157
+ # Calculates what the next message id should be sent to Spotify
158
+ def next_message_id
159
+ @message_id += 1
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,13 @@
1
+ module SpotifyWeb
2
+ # Represents an error within the library
3
+ class Error < StandardError
4
+ end
5
+
6
+ # Represents an error that occurred while connecting to the Spotify Web API
7
+ class ConnectionError < Error
8
+ end
9
+
10
+ # Represents an error that occurred while interacting with the Spotify Web API
11
+ class APIError < Error
12
+ end
13
+ end
@@ -0,0 +1,95 @@
1
+ module SpotifyWeb
2
+ # Provides access to all of the events that get triggered by incoming messages
3
+ # from the Spotify Web API
4
+ # @api private
5
+ class Event
6
+ class << self
7
+ # Maps Spotify command => event name
8
+ # @return [Hash<String, String>]
9
+ attr_reader :commands
10
+
11
+ # Defines a new event that maps to the given Spotify command. The
12
+ # block defines how to typecast the data that is received from Spotify.
13
+ #
14
+ # @param [String] name The name of the event exposed to the rest of the library
15
+ # @param [String] command The Spotify command that this event name maps to
16
+ # @yield [data] Gives the data to typecast to the block
17
+ # @yieldparam [Hash] data The data received from Spotify
18
+ # @yieldreturn The typecasted data that should be passed into any handlers bound to the event
19
+ # @return [nil]
20
+ def handle(name, command = name, &block)
21
+ block ||= lambda { [args] }
22
+ commands[command] = name
23
+
24
+ define_method("typecast_#{command}_event", &block)
25
+ protected :"typecast_#{command}_event"
26
+ end
27
+
28
+ # Determines whether the given command is handled.
29
+ #
30
+ # @param [String] command The command to check for the existence of
31
+ # @return [Boolean] +true+ if the command exists, otherwise +false+
32
+ def command?(command)
33
+ commands.include?(command)
34
+ end
35
+ end
36
+
37
+ @commands = {}
38
+
39
+ # The client's connection has opened
40
+ handle :session_started
41
+
42
+ # The client's connection has closed
43
+ handle :session_ended
44
+
45
+ # The user is successfully logged in
46
+ handle :session_authenticated, :login_complete
47
+
48
+ # The client re-connected after previously being disconnected
49
+ handle :reconnected
50
+
51
+ # A response was receivied from a prior command sent to Spotify
52
+ handle :response_received do
53
+ data
54
+ end
55
+
56
+ # A request was made to evaluate javascript on the client
57
+ handle :work_requested, :do_work do
58
+ data
59
+ end
60
+
61
+ # The name of the event that was triggered
62
+ # @return [String]
63
+ attr_reader :name
64
+
65
+ # The raw arguments list from the event
66
+ # @return [Array<Object>]
67
+ attr_reader :args
68
+
69
+ # The raw hash of data parsed from the event
70
+ # @return [Hash<String, Object>]
71
+ attr_reader :data
72
+
73
+ # The typecasted results args parsed from the event
74
+ # @return [Array<Array<Object>>]
75
+ attr_reader :results
76
+
77
+ # Creates a new event triggered with the given data
78
+ #
79
+ # @param [SpotifyWeb::Client] client The client that this event is bound to
80
+ # @param [Symbol] command The name of the command that fired the event
81
+ # @param [Array] args The raw argument data from the event
82
+ def initialize(client, command, args)
83
+ @client = client
84
+ @args = args
85
+ @data = args[0]
86
+ @name = self.class.commands[command]
87
+ @results = __send__("typecast_#{command}_event")
88
+ @results = [[@results].compact] unless @results.is_a?(Array)
89
+ end
90
+
91
+ private
92
+ # The client that all APIs filter through
93
+ attr_reader :client
94
+ end
95
+ end
@@ -0,0 +1,74 @@
1
+ require 'spotify_web/assertions'
2
+ require 'spotify_web/event'
3
+ require 'spotify_web/loggable'
4
+
5
+ module SpotifyWeb
6
+ # Represents a callback that's been bound to a particular event
7
+ # @api private
8
+ class Handler
9
+ include Assertions
10
+ include Loggable
11
+
12
+ # The event this handler is bound to
13
+ # @return [String]
14
+ attr_reader :event
15
+
16
+ # Whether to only call the handler once and then never again
17
+ # @return [Boolean] +true+ if only called once, otherwise +false+
18
+ attr_reader :once
19
+
20
+ # The data that must be matched in order for the handler to run
21
+ # @return [Hash<String, Object>]
22
+ attr_reader :conditions
23
+
24
+ # Builds a new handler bound to the given event.
25
+ #
26
+ # @param [String] event The name of the event to bind to
27
+ # @param [Hash] options The configuration options
28
+ # @option options [Boolean] :once (false) Whether to only call the handler once
29
+ # @option options [Hash] :if (nil) Data that must be matched to run
30
+ # @raise [ArgumentError] if an invalid option is specified
31
+ def initialize(event, options = {}, &block)
32
+ assert_valid_values(event, *Event.commands.values)
33
+ assert_valid_keys(options, :once, :if)
34
+ options = {:once => false, :if => nil}.merge(options)
35
+
36
+ @event = event
37
+ @once = options[:once]
38
+ @conditions = options[:if]
39
+ @block = block
40
+ end
41
+
42
+ # Runs this handler for each result from the given event.
43
+ #
44
+ # @param [SpotifyWeb::Event] event The event being triggered
45
+ # @return [Boolean] +true+ if conditions were matched to run the handler, otherwise +false+
46
+ def run(event)
47
+ if conditions_match?(event.data)
48
+ # Run the block for each individual result
49
+ event.results.each do |args|
50
+ begin
51
+ @block.call(*args)
52
+ rescue StandardError => ex
53
+ logger.error(([ex.message] + ex.backtrace) * "\n")
54
+ end
55
+ end
56
+
57
+ true
58
+ else
59
+ false
60
+ end
61
+ end
62
+
63
+ private
64
+ # Determines whether the conditions configured for this handler match the
65
+ # event data
66
+ def conditions_match?(data)
67
+ if conditions
68
+ conditions.all? {|(key, value)| data[key] == value}
69
+ else
70
+ true
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,13 @@
1
+ module SpotifyWeb
2
+ # Provides a set of helper methods for logging
3
+ # @api private
4
+ module Loggable
5
+ private
6
+ # Delegates access to the logger to SpotifyWeb.logger
7
+ #
8
+ # @return [Logger] The logger configured for this library
9
+ def logger
10
+ SpotifyWeb.logger
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ require 'spotify_web/resource'
2
+ require 'spotify_web/resource_collection'
3
+ require 'spotify_web/song'
4
+ require 'spotify_web/schema/mercury.pb'
5
+ require 'spotify_web/schema/playlist4.pb'
6
+
7
+ module SpotifyWeb
8
+ # Represents a collection of songs
9
+ class Playlist < Resource
10
+ # The user this playlist is managed by
11
+ # @return [SpotifyWeb::User]
12
+ attribute :user, :load => false
13
+
14
+ # The human-readable name for the playlist
15
+ # @return [String]
16
+ attribute :name
17
+
18
+ # The songs that have been added to this playlist
19
+ # @return [Array<SpotifyWeb::Song>]
20
+ attribute :songs do |songs|
21
+ ResourceCollection.new(client, songs.map {|song| Song.new(client, :uri => song.uri)})
22
+ end
23
+
24
+ def name #:nodoc:
25
+ uri_id == 'starred' ? 'Starred' : @name
26
+ end
27
+
28
+ def uri_id #:nodoc:
29
+ @uri_id ||= @uri ? @uri.split(':')[4] : super
30
+ end
31
+
32
+ # Loads the attributes for this playlist
33
+ def load
34
+ path = uri_id == 'starred' ? uri_id : "playlist/#{uri_id}"
35
+ response = api('request',
36
+ :uri => "hm://playlist/user/#{user.username}/#{path}?from=0&length=100",
37
+ :response_schema => Schema::Playlist4::SelectedListContent
38
+ )
39
+ result = response['result']
40
+
41
+ attributes = result.attributes.to_hash
42
+ attributes[:songs] = result.contents.items
43
+ self.attributes = attributes
44
+
45
+ super
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,309 @@
1
+ require 'pp'
2
+ require 'radix'
3
+ require 'spotify_web/assertions'
4
+ require 'spotify_web/error'
5
+
6
+ module SpotifyWeb
7
+ # Represents an object that's been created using content from Spotify. This
8
+ # encapsulates responsibilities such as reading and writing attributes.
9
+ class Resource
10
+ include Assertions
11
+
12
+ class << self
13
+ include Assertions
14
+
15
+ # Defines a new Spotify attribute on this class. By default, the name
16
+ # of the attribute is assumed to be the same name that Spotify specifies
17
+ # in its API. If the names are different, this can be overridden on a
18
+ # per-attribute basis.
19
+ #
20
+ # @api private
21
+ # @param [String] name The name for the attribute
22
+ # @param [Hash] options The configuration options
23
+ # @option options [Boolean] :load (true) Whether the resource should be loaded remotely from Spotify in order to access the attribute
24
+ # @raise [ArgumentError] if an invalid option is specified
25
+ # @example
26
+ # # Define a "name" attribute that maps to a Spotify "name" attribute
27
+ # attribute :name
28
+ #
29
+ # # Define an "id" attribute that maps to a Spotify "_id" attribute
30
+ # attribute :id, :_id
31
+ #
32
+ # # Define an "user_id" attribute that maps to both a Spotify "user_id" and "userid" attribute
33
+ # attribute :user_id, :user_id, :userid
34
+ #
35
+ # # Define a "time" attribute that maps to a Spotify "time" attribute
36
+ # # and converts the value to a Time object
37
+ # attribute :time do |value|
38
+ # Time.at(value)
39
+ # end
40
+ #
41
+ # # Define a "created_at" attribute that maps to a Spotify "time" attribute
42
+ # # and converts the value to a Time object
43
+ # attribute :created_at, :time do |value|
44
+ # Time.at(value)
45
+ # end
46
+ #
47
+ # # Define a "songs" attribute that does *not* get loaded from Spotify
48
+ # # when accessed
49
+ # attribute :songs, :load => false
50
+ #
51
+ # @!macro [attach] attribute
52
+ # @!attribute [r] $1
53
+ def attribute(name, *spotify_names, &block)
54
+ options = spotify_names.last.is_a?(Hash) ? spotify_names.pop : {}
55
+ assert_valid_keys(options, :load)
56
+ options = {:load => true}.merge(options)
57
+
58
+ # Reader
59
+ define_method(name) do
60
+ load if instance_variable_get("@#{name}").nil? && !loaded? && options[:load]
61
+ instance_variable_get("@#{name}")
62
+ end
63
+
64
+ # Query
65
+ define_method("#{name}?") do
66
+ !!__send__(name)
67
+ end
68
+
69
+ # Typecasting
70
+ block ||= lambda do |value|
71
+ value.force_encoding('UTF-8') if value.is_a?(String)
72
+ value
73
+ end
74
+ define_method("typecast_#{name}", &block)
75
+ protected :"typecast_#{name}"
76
+
77
+ # Attribute name conversion
78
+ spotify_names = [name] if spotify_names.empty?
79
+ spotify_names.each do |spotify_name|
80
+ define_method("#{spotify_name}=") do |value|
81
+ instance_variable_set("@#{name}", value.nil? ? nil : __send__("typecast_#{name}", value))
82
+ end
83
+ protected :"#{spotify_name}="
84
+ end
85
+ end
86
+
87
+ # The Spotify type name for this resouce
88
+ # @return [String]
89
+ attr_accessor :resource_name
90
+
91
+ # The metadata schema to use for loading data for this resource
92
+ # @return [Class]
93
+ attr_accessor :metadata_schema
94
+
95
+ # Provides a default resource name if one isn't specified
96
+ # @return [String]
97
+ def resource_name
98
+ @resource_name ||= begin
99
+ result = name.split('::').last.downcase
100
+ result.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
101
+ result.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
102
+ result
103
+ end
104
+ end
105
+ end
106
+
107
+ # The characters to encoding / decoding in base62
108
+ BASE62_CHARS = ('0'..'9').to_a + ('a'..'z').to_a + ('A'..'Z').to_a
109
+
110
+ # The unique id for this resource on Spotify
111
+ # "e1987c10dbc34f4d8be1b11ddfd6bb31"
112
+ # @return [String]
113
+ attribute :id, :load => false
114
+
115
+ # The global unique id for this resource on Spotify
116
+ # "\xE1\x98|\x10\xDB\xC3OM\x8B\xE1\xB1\x1D\xDF\xD6\xBB1"
117
+ # @return [String]
118
+ attribute :gid, :load => false
119
+
120
+ # The URI for loading information about this resource
121
+ # "spotify:track:6RGXtkDeWNP2gyASlfjzTr"
122
+ # @return [String]
123
+ attribute :uri, :load => false
124
+
125
+ # The id used within the URI for this resource
126
+ # "6RGXtkDeWNP2gyASlfjzTr"
127
+ # @return [String]
128
+ attribute :uri_id, :load => false
129
+
130
+ # Initializes this resources with the given attributes. This will continue
131
+ # to call the superclass's constructor with any additional arguments that
132
+ # get specified.
133
+ #
134
+ # @api private
135
+ def initialize(client, attributes = {}, *args)
136
+ @loaded = false
137
+ @metadata_loaded = false
138
+ @client = client
139
+ self.attributes = attributes
140
+ super(*args)
141
+ end
142
+
143
+ # The unique identifier, represented in base16
144
+ #
145
+ # @return [String] The Spotify ID for the resource
146
+ def id
147
+ @id ||= begin
148
+ if @gid
149
+ Radix::Base.new(Radix::BASE::HEX).encode(gid).rjust(32, '0')
150
+ elsif @uri
151
+ Radix::Base.new(Radix::BASE::HEX).convert(uri_id, BASE62_CHARS).rjust(32, '0')
152
+ end
153
+ end
154
+ end
155
+
156
+ # The unique group identifier, represented as bytes in base 16
157
+ #
158
+ # @example "\xE1\x98|\x10\xDB\xC3OM\x8B\xE1\xB1\x1D\xDF\xD6\xBB1"
159
+ # @return [String] The group id
160
+ def gid
161
+ @gid ||= id && Radix::Base.new(Radix::BASE::HEX).decode(id)
162
+ end
163
+
164
+ # The unique URI representing this resource in Spotify
165
+ #
166
+ # @example "spotify:track:6RGXtkDeWNP2gyASlfjzTr"
167
+ # @return [String] The URI for the resource
168
+ def uri
169
+ @uri ||= uri_id && "spotify:#{self.class.resource_name}:#{uri_id}"
170
+ end
171
+
172
+ # The id used within the resource's URI, represented in base62.
173
+ #
174
+ # @example "6RGXtkDeWNP2gyASlfjzTr"
175
+ # @return [String] The URI's id
176
+ def uri_id
177
+ @uri_id ||= begin
178
+ if @uri
179
+ @uri.split(':')[2]
180
+ elsif @gid || @id
181
+ Radix::Base.new(BASE62_CHARS).convert(id, Radix::BASE::HEX).rjust(22, '0')
182
+ end
183
+ end
184
+ end
185
+
186
+ # Loads the attributes for this resource from Spotify. By default this is
187
+ # a no-op and just marks the resource as loaded.
188
+ #
189
+ # @return [true]
190
+ def load
191
+ load_metadata
192
+ @loaded = true
193
+ end
194
+ alias :reload :load
195
+
196
+ # Determines whether the current resource has been loaded from Spotify.
197
+ #
198
+ # @return [Boolean] +true+ if the resource has been loaded, otherwise +false+
199
+ def loaded?
200
+ @loaded
201
+ end
202
+
203
+ # Looks up the metadata associated with this resource.
204
+ #
205
+ # @api private
206
+ # @return [Object, nil] +nil+ if there is no metadata schema associated, otherwise the result of the request
207
+ def load_metadata
208
+ if self.class.metadata_schema && !@metadata_loaded
209
+ if @metadata_loader
210
+ # Load the metadata externally
211
+ @metadata_loader.call
212
+ @metadata_loader = nil
213
+ else
214
+ # Load the metadata just for this single resource
215
+ response = api('request',
216
+ :uri => metadata_uri,
217
+ :response_schema => self.class.metadata_schema
218
+ )
219
+ self.metadata = response['result']
220
+ end
221
+ end
222
+ end
223
+
224
+ # Updates this resource based on the given metadata
225
+ #
226
+ # @api private
227
+ # @param [Beefcake::Message, Proc] metadata The metadata to use or a proc for loading it
228
+ def metadata=(metadata)
229
+ if !metadata || metadata.is_a?(Proc)
230
+ @metadata_loader = metadata
231
+ else
232
+ @metadata_loaded = true
233
+ self.attributes = metadata.to_hash
234
+ end
235
+ end
236
+
237
+ # The URI for looking up the resource's metadata
238
+ #
239
+ # @api private
240
+ # @return [String, nil] +nil+ if there is no metadata schema associated, otherwise the uri
241
+ def metadata_uri
242
+ if self.class.metadata_schema
243
+ "hm://metadata/#{self.class.resource_name}/#{id}"
244
+ end
245
+ end
246
+
247
+ # Attempts to set attributes on the object only if they've been explicitly
248
+ # defined by the class.
249
+ #
250
+ # @api private
251
+ # @param [Hash] attributes The updated attributes for the resource
252
+ def attributes=(attributes)
253
+ if attributes
254
+ attributes.each do |attribute, value|
255
+ attribute = attribute.to_s
256
+ __send__("#{attribute}=", value) if respond_to?("#{attribute}=", true)
257
+ end
258
+ end
259
+ end
260
+
261
+ # Forces this object to use PP's implementation of inspection.
262
+ #
263
+ # @api private
264
+ # @return [String]
265
+ def pretty_print(q)
266
+ q.pp_object(self)
267
+ end
268
+ alias inspect pretty_print_inspect
269
+
270
+ # Defines the instance variables that should be printed when inspecting this
271
+ # object. This ignores the +@client+ and +@loaded+ variables.
272
+ #
273
+ # @api private
274
+ # @return [Array<Symbol>]
275
+ def pretty_print_instance_variables
276
+ (instance_variables - [:'@client', :'@loaded', :'@metadata_loaded', :'@metadata_loader']).sort
277
+ end
278
+
279
+ # Determines whether this resource is equal to another based on their
280
+ # unique identifiers.
281
+ #
282
+ # @param [Object] other The object this resource is being compared against
283
+ # @return [Boolean] +true+ if the resource ids are equal, otherwise +false+
284
+ def ==(other)
285
+ if other && other.respond_to?(:id) && other.id
286
+ other.id == id
287
+ else
288
+ false
289
+ end
290
+ end
291
+ alias :eql? :==
292
+
293
+ # Generates a hash for this resource based on the unique identifier
294
+ #
295
+ # @return [Fixnum]
296
+ def hash
297
+ id.hash
298
+ end
299
+
300
+ private
301
+ # The client that all APIs filter through
302
+ attr_reader :client
303
+
304
+ # Runs the given API command on the client.
305
+ def api(command, options = {})
306
+ client.api(command, options)
307
+ end
308
+ end
309
+ end