hallon 0.0.0 → 0.1.0

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