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