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,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
|