hallon 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. data/.autotest +6 -0
  2. data/.gemtest +0 -0
  3. data/.gitignore +29 -0
  4. data/.rspec +7 -0
  5. data/.yardopts +8 -0
  6. data/CHANGELOG +20 -0
  7. data/Gemfile +2 -0
  8. data/LICENSE.txt +21 -0
  9. data/QUIRKS +11 -0
  10. data/README.markdown +58 -0
  11. data/Rakefile +75 -0
  12. data/Termfile +7 -0
  13. data/examples/logging_in.rb +26 -0
  14. data/examples/printing_link_information.rb +27 -0
  15. data/hallon.gemspec +31 -0
  16. data/lib/hallon.rb +34 -0
  17. data/lib/hallon/error.rb +54 -0
  18. data/lib/hallon/ext/ffi.rb +26 -0
  19. data/lib/hallon/ext/spotify.rb +101 -0
  20. data/lib/hallon/image.rb +70 -0
  21. data/lib/hallon/link.rb +101 -0
  22. data/lib/hallon/linkable.rb +50 -0
  23. data/lib/hallon/observable.rb +91 -0
  24. data/lib/hallon/session.rb +189 -0
  25. data/lib/hallon/synchronizable.rb +32 -0
  26. data/lib/hallon/user.rb +69 -0
  27. data/lib/hallon/version.rb +7 -0
  28. data/spec/fixtures/example_uris.rb +11 -0
  29. data/spec/fixtures/pink_cover.jpg +0 -0
  30. data/spec/hallon/error_spec.rb +30 -0
  31. data/spec/hallon/ffi_spec.rb +5 -0
  32. data/spec/hallon/hallon_spec.rb +16 -0
  33. data/spec/hallon/image_spec.rb +41 -0
  34. data/spec/hallon/link_spec.rb +84 -0
  35. data/spec/hallon/linkable_spec.rb +43 -0
  36. data/spec/hallon/observable_spec.rb +103 -0
  37. data/spec/hallon/session_spec.rb +61 -0
  38. data/spec/hallon/synchronizable_spec.rb +19 -0
  39. data/spec/hallon/user_spec.rb +73 -0
  40. data/spec/spec_helper.rb +71 -0
  41. data/spec/support/.gitkeep +0 -0
  42. data/spec/support/context_initialized_session.rb +3 -0
  43. data/spec/support/context_logged_in.rb +16 -0
  44. data/spec/support/cover_me.rb +5 -0
  45. data/spec/support/shared_for_loadable_objects.rb +7 -0
  46. metadata +271 -96
@@ -0,0 +1,101 @@
1
+ # coding: utf-8
2
+
3
+ # Extensions to the Spotify gem.
4
+ #
5
+ # @see https://github.com/Burgestrand/libspotify-ruby
6
+ module Spotify
7
+ extend FFI::Library
8
+ ffi_lib ['libspotify', '/Library/Frameworks/libspotify.framework/libspotify']
9
+
10
+ # The Pointer is a kind of AutoPointer specially tailored for Spotify
11
+ # objects. It will automatically release the inner pointer with the
12
+ # proper function, based on the given type to #initialize.
13
+ class Pointer < FFI::AutoPointer
14
+ # Initialize the Spotify::Pointer
15
+ #
16
+ # @param [FFI::Pointer] ptr
17
+ # @param [Symbol] type session, link, etc
18
+ # @param [Boolean[ add_ref increase reference count
19
+ # @return [FFI::AutoPointer]
20
+ def initialize(ptr, type, add_ref = false)
21
+ super ptr, releaser_for(@type = type)
22
+ Spotify::send(:"#{type}_add_ref", ptr) if add_ref
23
+ end
24
+
25
+ # Create a proc that will accept a pointer of a given type and
26
+ # release it with the correct function if it’s not null.
27
+ #
28
+ # @param [Symbol]
29
+ # @return [Proc]
30
+ def releaser_for(type)
31
+ lambda do |ptr|
32
+ unless ptr.null?
33
+ $stdout.puts "Spotify::#{type}_release(#{ptr})" if $DEBUG
34
+ Spotify::send(:"#{type}_release", ptr)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # Extensions to SessionCallbacks, making it easier to define callbacks.
41
+ class SessionCallbacks < FFI::Struct
42
+ # Assigns the callbacks to call the given target; the callback
43
+ # procs are stored in the `storage` parameter. **Make sure the
44
+ # storage does not get garbage collected as long as these callbacks
45
+ # are needed!**
46
+ #
47
+ # @param [Object] target
48
+ # @param [#&#91;&#93;&#61;] storage
49
+ def initialize(target, storage)
50
+ members.each do |member|
51
+ callback = lambda { |ptr, *args| target.trigger(member, *args) }
52
+ self[member] = storage[member] = callback
53
+ end
54
+ end
55
+ end
56
+
57
+ # Extensions to SessionConfig, allowing more sensible configuration names.
58
+ class SessionConfig < FFI::Struct
59
+ [:cache_location, :settings_location, :user_agent].each do |field|
60
+ method = field.to_s.gsub('location', 'path')
61
+ define_method(:"#{method}") { self[field].read_string }
62
+ define_method(:"#{method}=") do |string|
63
+ self[field] = FFI::MemoryPointer.from_string(string)
64
+ end
65
+ end
66
+
67
+ # Also sets application_key_size.
68
+ #
69
+ # @param [#to_s]
70
+ def application_key=(appkey)
71
+ self[:application_key] = FFI::MemoryPointer.from_string(appkey)
72
+ self[:application_key_size] = appkey.bytesize
73
+ end
74
+
75
+ # Allows setting compress_playlists using a boolean.
76
+ #
77
+ # @param [Boolean]
78
+ # @return [Boolean]
79
+ def compress_playlists=(bool)
80
+ self[:compress_playlists] = !! bool
81
+ end
82
+
83
+ # Allows setting initially_unload_playlists using a boolean.
84
+ #
85
+ # @note Set to the inverse of the requested value.
86
+ # @param [Boolean]
87
+ # @return [Boolean]
88
+ def load_playlists=(bool)
89
+ self[:initially_unload_playlists] = ! bool
90
+ end
91
+
92
+ # Allows setting dont_save_metadata_for_playlists using a boolean.
93
+ #
94
+ # @note Set to the inverse of the requested value.
95
+ # @param [Boolean]
96
+ # @return [Boolean]
97
+ def cache_playlist_metadata=(bool)
98
+ self[:dont_save_metadata_for_playlists] = ! bool
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,70 @@
1
+ # coding: utf-8
2
+ module Hallon
3
+ # Images are JPEG images that can be linked to and saved.
4
+ #
5
+ # @see http://developer.spotify.com/en/libspotify/docs/group__image.html
6
+ class Image
7
+ extend Linkable
8
+
9
+ from_link(:image) do |link, session|
10
+ Spotify::image_create_from_link(session.pointer, link)
11
+ end
12
+
13
+ to_link(:image)
14
+
15
+ # Image triggers `:load` when loaded
16
+ include Hallon::Observable
17
+
18
+ # Create a new instance of an Image.
19
+ #
20
+ # @param [String, Link, FFI::Pointer] link
21
+ # @param [Hallon::Session] session
22
+ def initialize(link, session = Session.instance)
23
+ @callback = proc { trigger(:load) }
24
+ @pointer = Spotify::Pointer.new from_link(link, session), :image
25
+ Spotify::image_add_load_callback(@pointer, @callback, nil)
26
+
27
+ # TODO: remove load_callback when @pointer is released
28
+ # TODO: this makes libspotify segfault, figure out why
29
+ # on(:load) { Spotify::image_remove_load_callback(@pointer, @callback, nil) }
30
+ end
31
+
32
+ # True if the image has been loaded.
33
+ #
34
+ # @return [Boolean]
35
+ def loaded?
36
+ Spotify::image_is_loaded(@pointer)
37
+ end
38
+
39
+ # Retrieve the current error status.
40
+ #
41
+ # @return [Symbol] error
42
+ def status
43
+ Spotify::image_error(@pointer)
44
+ end
45
+
46
+ # Retrieve image format.
47
+ #
48
+ # @return [Symbol] `:jpeg` or `:unknown`
49
+ def format
50
+ Spotify::image_format(@pointer)
51
+ end
52
+
53
+ # Retrieve image ID as a hexadecimal string.
54
+ #
55
+ # @return [String]
56
+ def id
57
+ Spotify::image_image_id(@pointer).read_string(20).unpack('H*')[0]
58
+ end
59
+
60
+ # Raw image data as a binary encoded string.
61
+ #
62
+ # @return [String]
63
+ def data
64
+ FFI::MemoryPointer.new(:size_t) do |size|
65
+ data = Spotify::image_data(@pointer, size)
66
+ return data.read_bytes(size.read_size_t)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,101 @@
1
+ # coding: utf-8
2
+ module Hallon
3
+ # Wraps Spotify URIs in a class, giving access to methods performable on them.
4
+ #
5
+ # @see http://developer.spotify.com/en/libspotify/docs/group__link.html
6
+ class Link
7
+ include Comparable
8
+
9
+ # True if the given Spotify URI is valid (parsable by libspotify).
10
+ #
11
+ # @param (see Hallon::Link#initialize)
12
+ # @return [Boolean]
13
+ def self.valid?(spotify_uri)
14
+ !! new(spotify_uri)
15
+ rescue ArgumentError
16
+ false
17
+ end
18
+
19
+ # Overloaded to short-circuit when given a Link.
20
+ #
21
+ # @return [Hallon::Link]
22
+ def self.new(uri)
23
+ uri.is_a?(Link) ? uri : super
24
+ end
25
+
26
+ # Parse the given Spotify URI into a Link.
27
+ #
28
+ # @note Unless you have a {Session} initialized, this will segfault!
29
+ # @param [#to_str] uri
30
+ # @raise [ArgumentError] link could not be parsed
31
+ def initialize(uri)
32
+ if (link = uri).respond_to? :to_str
33
+ link = Spotify::link_create_from_string(link.to_str)
34
+ end
35
+
36
+ @pointer = Spotify::Pointer.new(link, :link)
37
+
38
+ raise ArgumentError, "#{uri} is not a valid Spotify link" if @pointer.null?
39
+ end
40
+
41
+ # Link type as a symbol.
42
+ #
43
+ # @return [Symbol]
44
+ def type
45
+ Spotify::link_type(@pointer)
46
+ end
47
+
48
+ # Spotify URI length.
49
+ #
50
+ # @return [Fixnum]
51
+ def length
52
+ Spotify::link_as_string(@pointer, nil, 0)
53
+ end
54
+
55
+ # Get the Spotify URI this Link represents.
56
+ #
57
+ # @see #length
58
+ # @param [Fixnum] length truncate to this size
59
+ # @return [String]
60
+ def to_str(length = length)
61
+ FFI::Buffer.alloc_out(length + 1) do |b|
62
+ Spotify::link_as_string(@pointer, b, b.size)
63
+ return b.get_string(0)
64
+ end
65
+ end
66
+
67
+ # Retrieve the full Spotify HTTP URL for this Link.
68
+ #
69
+ # @return [String]
70
+ def to_url
71
+ "http://open.spotify.com/%s" % to_str[8..-1].gsub(':', '/')
72
+ end
73
+
74
+ # Compare this Link to another object
75
+ #
76
+ # @param [#to_str] other
77
+ # @return [Integer]
78
+ def <=>(other)
79
+ to_str <=> String.try_convert(other)
80
+ end
81
+
82
+ # String representation of the given Link.
83
+ #
84
+ # @return [String]
85
+ def to_s
86
+ "<#{self.class.name} #{to_str}>"
87
+ end
88
+
89
+ # Retrieve the underlying pointer.
90
+ #
91
+ # @param [Symbol] expected_type if given, makes sure the link is of this type
92
+ # @return [FFI::Pointer]
93
+ # @raise ArgumentError if `type` is given and does not match link {#type}
94
+ def pointer(expected_type = nil)
95
+ unless type == expected_type
96
+ raise ArgumentError, "expected #{expected_type} link, but it is of type #{type}"
97
+ end if expected_type
98
+ @pointer
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,50 @@
1
+ # coding: utf-8
2
+ module Hallon
3
+ # Methods shared between objects that can be created from Spotify URIs,
4
+ # or can be turned into Spotify URIs.
5
+ #
6
+ # @note Linkable is not part of Hallons’ public API.
7
+ # @private
8
+ module Linkable
9
+ # These are extended onto a class when {Linkable} is included.
10
+ include Forwardable
11
+
12
+ # Creates `from_link` class & instance method which’ll convert a link to a pointer
13
+ #
14
+ # @example
15
+ # # Creates instance method `from_link(link)`
16
+ # from_link(:playlist) { |link| Spotify::link_as_playlist(link) }
17
+ #
18
+ # @param [Symbol] type expected link type
19
+ # @yield [link, *args] called when conversion is needed from Link pointer
20
+ # @yieldparam [Hallon::Link] link
21
+ # @yieldparam *args any extra arguments given to `#from_link`
22
+ # @see Link#pointer
23
+ def from_link(type)
24
+ define_singleton_method(:from_link) do |link, *args|
25
+ if link.is_a? FFI::Pointer then link else
26
+ yield Link.new(link).pointer(type), *args
27
+ end
28
+ end
29
+
30
+ def_delegators 'self.class', :from_link
31
+ end
32
+
33
+ # Defines `to_link` class & instance method.
34
+ #
35
+ # @example
36
+ # to_link(:artist)
37
+ #
38
+ # @note Calls down to `Spotify::link_create_from_#{type}(@pointer)`
39
+ # @param [Symbol] type object kind
40
+ # @return [Link]
41
+ def to_link(type)
42
+ define_singleton_method(:to_link) do |ptr, *args|
43
+ link = Spotify.__send__(:"link_create_from_#{type}", ptr, *args)
44
+ Hallon::Link.new(link)
45
+ end
46
+
47
+ define_method(:to_link) { |*args, &block| self.class.to_link(@pointer, *args, &block) }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,91 @@
1
+ # coding: utf-8
2
+ module Hallon
3
+ # A module providing event capabilities to Hallon objects.
4
+ #
5
+ # @private
6
+ module Observable
7
+ # Required for maintaining thread-safety around #handlers
8
+ include Hallon::Synchronizable
9
+
10
+ # Defines a handler for the given event.
11
+ #
12
+ # @example defining a handler and triggering it
13
+ # on(:callback) do |message|
14
+ # puts message
15
+ # end
16
+ #
17
+ # trigger(:callback, "Moo!") # => prints "Moo!"
18
+ #
19
+ # @example multiple events with one handler
20
+ # on(:a, :b, :c) do |name, *args|
21
+ # puts "#{name} called with: #{args.inspect}"
22
+ # end
23
+ #
24
+ # trigger(:a) # => prints ":a called with: []"
25
+ # trigger(:b, :c) # => prints ":b called with: [:c]"
26
+ #
27
+ # @note when defining a handler for multiple events, the
28
+ # first argument passed to the handler is the name
29
+ # of the event that called it
30
+ # @param [#to_sym] event name of event to handle
31
+ # @yield (*args) event handler block
32
+ # @see #initialize
33
+ def on(*events, &block)
34
+ raise ArgumentError, "no block given" unless block
35
+ wrap = events.length > 1
36
+ events.each do |event|
37
+ block = proc { |*args| yield(event, *args) } if wrap
38
+ __handlers[event] = [] unless __handlers.has_key?(event)
39
+ __handlers[event] << block
40
+ end
41
+ end
42
+
43
+ # Trigger a handler for a given event.
44
+ #
45
+ # @param [#to_sym] event
46
+ # @param [Object, ...] params given to each handler
47
+ def trigger(event, *params, &block)
48
+ catch :return do
49
+ return_value = nil
50
+ __handlers[event.to_sym].each do |handler|
51
+ return_value = handler.call(*params, &block)
52
+ end
53
+ return_value
54
+ end
55
+ end
56
+
57
+ # Run the given block, protecting all previous event handlers.
58
+ #
59
+ # @example
60
+ # o = Object.new
61
+ # o.instance_eval { include Hallon::Base }
62
+ # o.on(:method) { "outside" }
63
+ #
64
+ # puts o.on_method # => "outside"
65
+ # o.protecting_handlers do
66
+ # o.on(:method) { "inside" }
67
+ # puts o.on_method # => "inside"
68
+ # end
69
+ # puts o.on_method # => "outside"
70
+ #
71
+ # @yield
72
+ # @return whatever the given block returns
73
+ def protecting_handlers
74
+ deep_copy = __handlers.dup.clear
75
+ __handlers.each do |k, v|
76
+ deep_copy[k] = v.dup
77
+ end
78
+ yield
79
+ ensure
80
+ __handlers.replace deep_copy
81
+ end
82
+
83
+ private
84
+ # Hash mapping events to handlers.
85
+ #
86
+ # @return [Hash]
87
+ def __handlers
88
+ @__handlers ||= Hash.new([])
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,189 @@
1
+ # coding: utf-8
2
+ require 'singleton'
3
+ require 'timeout'
4
+ require 'thread'
5
+
6
+ module Hallon
7
+ # The Session is fundamental for all communication with Spotify.
8
+ # Pretty much all API calls require you to have established a session
9
+ # with Spotify before using them.
10
+ #
11
+ # @see https://developer.spotify.com/en/libspotify/docs/group__session.html
12
+ class Session
13
+ # The options Hallon used at {Session#initialize}.
14
+ #
15
+ # @return [Hash]
16
+ attr_reader :options
17
+
18
+ # Underlying Spotify pointer.
19
+ #
20
+ # @return [FFI::Pointer]
21
+ attr_reader :pointer
22
+
23
+ # libspotify only allows one session per process.
24
+ include Singleton
25
+ class << self
26
+ undef :instance
27
+ end
28
+
29
+ # Session allows you to define your own callbacks.
30
+ include Hallon::Observable
31
+
32
+ # Allows you to create a Spotify session. Subsequent calls to this method
33
+ # will return the previous instance, ignoring any passed arguments.
34
+ #
35
+ # @param (see Session#initialize)
36
+ # @see Session#initialize
37
+ # @return [Session]
38
+ def Session.instance(*args, &block)
39
+ @__instance__ ||= new(*args, &block)
40
+ end
41
+
42
+ # Create a new Spotify session.
43
+ #
44
+ # @param [#to_s] appkey
45
+ # @param [Hash] options
46
+ # @option options [String] :user_agent ("Hallon") User-Agent to use (length < 256)
47
+ # @option options [String] :settings_path ("tmp") where to save settings and user-specific cache
48
+ # @option options [String] :cache_path ("") where to save cache files (set to "" to disable)
49
+ # @option options [Bool] :load_playlists (true) load playlists into RAM on startup
50
+ # @option options [Bool] :compress_playlists (true) compress local copies of playlists
51
+ # @option options [Bool] :cache_playlist_metadata (true) cache metadata for playlists locally
52
+ # @yield allows you to define handlers for events (see {Hallon::Base#on})
53
+ # @raise [ArgumentError] if `options[:user_agent]` is more than 256 characters long
54
+ # @raise [Hallon::Error] if `sp_session_create` fails
55
+ # @see http://developer.spotify.com/en/libspotify/docs/structsp__session__config.html
56
+ def initialize(appkey, options = {}, &block)
57
+ @appkey = appkey.to_s
58
+ @options = {
59
+ :user_agent => "Hallon",
60
+ :settings_path => "tmp",
61
+ :cache_path => "",
62
+ :load_playlists => true,
63
+ :compress_playlists => true,
64
+ :cache_playlist_metadata => true
65
+ }.merge(options)
66
+
67
+ if @options[:user_agent].bytesize > 255
68
+ raise ArgumentError, "User-agent must be less than 256 bytes long"
69
+ end
70
+
71
+ # Set configuration, as well as callbacks
72
+ config = Spotify::SessionConfig.new
73
+ config[:api_version] = Hallon::API_VERSION
74
+ config.application_key = @appkey
75
+ @options.each { |(key, value)| config.send(:"#{key}=", value) }
76
+ config[:callbacks] = Spotify::SessionCallbacks.new(self, @sp_callbacks = {})
77
+
78
+ instance_eval(&block) if block_given?
79
+
80
+ # You pass a pointer to the session pointer to libspotify >:)
81
+ FFI::MemoryPointer.new(:pointer) do |p|
82
+ Hallon::Error::maybe_raise Spotify::session_create(config, p)
83
+ @pointer = p.read_pointer
84
+ end
85
+ end
86
+
87
+ # Process pending Spotify events (might fire callbacks).
88
+ #
89
+ # @return [Fixnum] minimum time until it should be called again
90
+ def process_events
91
+ FFI::MemoryPointer.new(:int) do |p|
92
+ Spotify::session_process_events(@pointer, p)
93
+ return p.read_int
94
+ end
95
+ end
96
+
97
+ # Wait for the given callbacks to fire until the block returns true
98
+ #
99
+ # @param [Symbol, ...] *events list of events to wait for
100
+ # @yield [Symbol, *args] name of the callback that fired, and its’ arguments
101
+ # @return [Hash<Event, Arguments>]
102
+ def process_events_on(*events, &block)
103
+ channel = SizedQueue.new(1)
104
+
105
+ protecting_handlers do
106
+ on(*events) { |*args| channel << args }
107
+ on(:notify_main_thread) { channel << :notify }
108
+
109
+ loop do
110
+ begin
111
+ process_events
112
+ params = Timeout::timeout(0.25) { channel.pop }
113
+ redo if params == :notify
114
+ rescue Timeout::Error
115
+ params = nil
116
+ end
117
+
118
+ if result = block.call(*params)
119
+ return result
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # Log into Spotify using the given credentials.
126
+ #
127
+ # @param [String] username
128
+ # @param [String] password
129
+ # @return [self]
130
+ def login(username, password)
131
+ Spotify::session_login(@pointer, username, password)
132
+ self
133
+ end
134
+
135
+ # Logs out of Spotify. Does nothing if not logged in.
136
+ #
137
+ # @return [self]
138
+ def logout
139
+ Spotify::session_logout(@pointer) if logged_in?
140
+ self
141
+ end
142
+
143
+ # Retrieve the currently logged in {User}.
144
+ #
145
+ # @return [User]
146
+ def user
147
+ User.new Spotify::session_user(@pointer)
148
+ end
149
+
150
+ # Retrieve the relation type between logged in {User} and `user`.
151
+ #
152
+ # @return [Symbol] :unknown, :none, :unidirectional or :bidirectional
153
+ def relation_type?(user)
154
+ Spotify::user_relation_type(@pointer, user.pointer)
155
+ end
156
+
157
+ # Retrieve current connection status.
158
+ #
159
+ # @return [Symbol]
160
+ def status
161
+ Spotify::session_connectionstate(@pointer)
162
+ end
163
+
164
+ # True if currently logged in.
165
+ # @see #status
166
+ def logged_in?
167
+ status == :logged_in
168
+ end
169
+
170
+ # True if logged out.
171
+ # @see #status
172
+ def logged_out?
173
+ status == :logged_out
174
+ end
175
+
176
+ # True if session has been disconnected.
177
+ # @see #status
178
+ def disconnected?
179
+ status == :disconnected
180
+ end
181
+
182
+ # String representation of the Session.
183
+ #
184
+ # @return [String]
185
+ def to_s
186
+ "<#{self.class.name}>"
187
+ end
188
+ end
189
+ end