ruby-mpd 0.1.4

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.
data/lib/ruby-mpd.rb ADDED
@@ -0,0 +1,310 @@
1
+ require 'socket'
2
+ require 'thread'
3
+
4
+ require 'ruby-mpd/song'
5
+ require 'ruby-mpd/parser'
6
+ require 'ruby-mpd/playlist'
7
+
8
+ require 'ruby-mpd/plugins/information'
9
+ require 'ruby-mpd/plugins/playback_options'
10
+ require 'ruby-mpd/plugins/controls'
11
+ require 'ruby-mpd/plugins/queue'
12
+ require 'ruby-mpd/plugins/playlists'
13
+ require 'ruby-mpd/plugins/database'
14
+ require 'ruby-mpd/plugins/stickers'
15
+ require 'ruby-mpd/plugins/outputs'
16
+ require 'ruby-mpd/plugins/reflection'
17
+ require 'ruby-mpd/plugins/channels'
18
+
19
+ # TODO: object oriented: song commands and dir commands
20
+ # in MPD::Song, MPD::Directory.
21
+ # Make stickers a mixin for Playlist, Song, Directory...
22
+ # TODO: Namespace queue?
23
+ # TODO: fix parser to use build_group also
24
+
25
+ # command list as a do block
26
+ # mpd.command_list do
27
+ # volume 10
28
+ # play xyz
29
+ # end
30
+
31
+ # Playlist#rename -> Playlist#name= ?
32
+ # MPD::Playlist.new(mpd, 'name') no mpd?
33
+
34
+ # make it possible to use MPD::Song objects instead of filepath strings
35
+
36
+ # error codes stored in ack.h
37
+
38
+ # @!macro [new] error_raise
39
+ # @raise (see #send_command)
40
+ # @!macro [new] returnraise
41
+ # @return [Boolean] returns true if successful.
42
+ # @macro error_raise
43
+
44
+ # The main class/namespace of the MPD client.
45
+ class MPD
46
+
47
+ # Standard MPD error.
48
+ class MPDError < StandardError; end
49
+
50
+ include Parser
51
+
52
+ include Plugins::Information
53
+ include Plugins::PlaybackOptions
54
+ include Plugins::Controls
55
+ include Plugins::Queue
56
+ include Plugins::Playlists
57
+ include Plugins::Database
58
+ include Plugins::Stickers
59
+ include Plugins::Outputs
60
+ include Plugins::Reflection
61
+ include Plugins::Channels
62
+
63
+ # The version of the MPD protocol the server is using.
64
+ attr_reader :version
65
+ # A list of tags MPD accepts.
66
+ attr_reader :tags
67
+
68
+ # Initialize an MPD object with the specified hostname and port.
69
+ # When called without arguments, 'localhost' and 6600 are used.
70
+ def initialize(hostname = 'localhost', port = 6600)
71
+ @hostname = hostname
72
+ @port = port
73
+ @socket = nil
74
+ @version = nil
75
+
76
+ @stop_cb_thread = false
77
+ @mutex = Mutex.new
78
+ @cb_thread = nil
79
+ @callbacks = {}
80
+ end
81
+
82
+ # This will register a block callback that will trigger whenever
83
+ # that specific event happens.
84
+ #
85
+ # mpd.on :volume do |volume|
86
+ # puts "Volume was set to #{volume}"!
87
+ # end
88
+ #
89
+ # One can also define separate methods or Procs and whatnot,
90
+ # just pass them in as a parameter.
91
+ #
92
+ # method = Proc.new {|volume| puts "Volume was set to #{volume}"! }
93
+ # mpd.on :volume, &method
94
+ #
95
+ def on(event, &block)
96
+ @callbacks[event] ||= []
97
+ @callbacks[event].push block
98
+ end
99
+
100
+ # Triggers an event, running it's callbacks.
101
+ # @param [Symbol] event The event that happened.
102
+ def emit(event, *args)
103
+ @callbacks[event] ||= []
104
+ @callbacks[event].each do |handle|
105
+ handle.call *args
106
+ end
107
+ end
108
+
109
+ # Connect to the daemon.
110
+ #
111
+ # When called without any arguments, this will just connect to the server
112
+ # and wait for your commands.
113
+ #
114
+ # When called with true as an argument, this will enable callbacks by starting
115
+ # a seperate polling thread, which will also automatically reconnect if disconnected
116
+ # for whatever reason.
117
+ #
118
+ # @return [true] Successfully connected.
119
+ # @raise [MPDError] If connect is called on an already connected instance.
120
+ def connect(callbacks = false)
121
+ raise MPDError, 'Already Connected!' if self.connected?
122
+
123
+ @socket = File.exists?(@hostname) ? UNIXSocket.new(@hostname) : TCPSocket.new(@hostname, @port)
124
+ @version = @socket.gets.chomp.gsub('OK MPD ', '') # Read the version
125
+
126
+ if callbacks and (@cb_thread.nil? or !@cb_thread.alive?)
127
+ @stop_cb_thread = false
128
+ @cb_thread = Thread.new(self) { |mpd|
129
+ old_status = {}
130
+ connected = ''
131
+ while !@stop_cb_thread
132
+ status = mpd.status rescue {}
133
+ c = mpd.connected?
134
+
135
+ # @todo Move into status[:connection]?
136
+ if connected != c
137
+ connected = c
138
+ emit(:connection, connected)
139
+ end
140
+
141
+ status[:time] = [nil, nil] if !status[:time] # elapsed, total
142
+ status[:audio] = [nil, nil, nil] if !status[:audio] # samp, bits, chans
143
+
144
+ status.each do |key, val|
145
+ next if val == old_status[key] # skip unchanged keys
146
+
147
+ if key == :song
148
+ emit(:song, mpd.current_song)
149
+ else # convert arrays to splat arguments
150
+ val.is_a?(Array) ? emit(key, *val) : emit(key, val)
151
+ end
152
+ end
153
+
154
+ old_status = status
155
+ sleep 0.1
156
+
157
+ if !connected
158
+ sleep 2
159
+ unless @stop_cb_thread
160
+ mpd.connect rescue nil
161
+ end
162
+ end
163
+ end
164
+ }
165
+ end
166
+
167
+ return true
168
+ end
169
+
170
+ # Check if the client is connected.
171
+ #
172
+ # @return [Boolean] True only if the server responds otherwise false.
173
+ def connected?
174
+ return false if !@socket
175
+
176
+ ret = send_command(:ping) rescue false
177
+ return ret
178
+ end
179
+
180
+ # Disconnect from the MPD daemon. This has no effect if the client is not
181
+ # connected. Reconnect using the {#connect} method. This will also stop
182
+ # the callback thread, thus disabling callbacks.
183
+ def disconnect
184
+ @stop_cb_thread = true
185
+
186
+ return if @socket.nil?
187
+
188
+ @socket.puts 'close'
189
+ @socket.close
190
+ @socket = nil
191
+ end
192
+
193
+ # Kills the MPD process.
194
+ # @macro returnraise
195
+ def kill
196
+ send_command :kill
197
+ end
198
+
199
+ # Used for authentication with the server
200
+ # @param [String] pass Plaintext password
201
+ def password(pass)
202
+ send_command :password, pass
203
+ end
204
+
205
+ # Ping the server.
206
+ # @macro returnraise
207
+ def ping
208
+ send_command :ping
209
+ end
210
+
211
+ ###--- OTHER ---###
212
+
213
+ # Lists all of the albums in the database.
214
+ # The optional argument is for specifying an artist to list
215
+ # the albums for
216
+ #
217
+ # @return [Array<String>] An array of album names.
218
+ def albums(artist = nil)
219
+ list :album, artist
220
+ end
221
+
222
+ # Lists all of the artists in the database.
223
+ #
224
+ # @return [Array<String>] An array of artist names.
225
+ def artists
226
+ list :artist
227
+ end
228
+
229
+ # List all of the directories in the database, starting at path.
230
+ # If path isn't specified, the root of the database is used
231
+ #
232
+ # @return [Array<String>] Array of directory names
233
+ def directories(path = nil)
234
+ response = send_command :listall, path
235
+ return response[:directory]
236
+ end
237
+
238
+ # List all of the files in the database, starting at path.
239
+ # If path isn't specified, the root of the database is used
240
+ #
241
+ # @return [Array<String>] Array of file names
242
+ def files(path = nil)
243
+ response = send_command(:listall, path)
244
+ return response[:file]
245
+ end
246
+
247
+ # List all of the songs by an artist.
248
+ #
249
+ # @return [Array<MPD::Song>]
250
+ def songs_by_artist(artist)
251
+ find :artist, artist
252
+ end
253
+
254
+ # Used to send a command to the server. This synchronizes
255
+ # on a mutex to be thread safe
256
+ #
257
+ # @return (see #handle_server_response)
258
+ # @raise [MPDError] if the command failed.
259
+ def send_command(command, *args)
260
+ raise MPDError, "Not Connected to the Server" if @socket.nil?
261
+
262
+ @mutex.synchronize do
263
+ begin
264
+ @socket.puts convert_command(command, *args)
265
+ return handle_server_response
266
+ rescue Errno::EPIPE
267
+ @socket = nil
268
+ raise MPDError, 'Broken Pipe (Disconnected)'
269
+ end
270
+ end
271
+ end
272
+
273
+ private
274
+
275
+ # Handles the server's response (called inside {#send_command}).
276
+ # Repeatedly reads the server's response from the socket and
277
+ # processes the output.
278
+ #
279
+ # @return (see Parser#build_response)
280
+ # @return [true] If "OK" is returned.
281
+ # @raise [MPDError] If an "ACK" is returned.
282
+ def handle_server_response
283
+ return if @socket.nil?
284
+
285
+ msg = ''
286
+ reading = true
287
+ error = nil
288
+ while reading
289
+ line = @socket.gets
290
+ case line
291
+ when "OK\n", nil
292
+ reading = false
293
+ when /^ACK/
294
+ error = line
295
+ reading = false
296
+ else
297
+ msg += line
298
+ end
299
+ end
300
+
301
+ if !error
302
+ return true if msg.empty?
303
+ return build_response(msg)
304
+ else
305
+ err = error.match(/^ACK \[(?<code>\d+)\@(?<pos>\d+)\] \{(?<command>.*)\} (?<message>.+)$/)
306
+ raise MPDError, "#{err[:code]}: #{err[:command]}: #{err[:message]}"
307
+ end
308
+ end
309
+
310
+ end
@@ -0,0 +1,151 @@
1
+ require 'time' # required for Time.iso8601
2
+
3
+ class MPD
4
+ # Parser module, being able to parse messages to and from the MPD daemon format.
5
+ module Parser
6
+ private
7
+
8
+ # Parses the command into MPD format.
9
+ def convert_command(command, *args)
10
+ args.map! do |word|
11
+ if word.is_a?(TrueClass) || word.is_a?(FalseClass)
12
+ word ? '1' : '0' # convert bool to 1 or 0
13
+ elsif word.is_a?(Range)
14
+ if word.end == -1 #negative means to end of range
15
+ "#{word.begin}:"
16
+ else
17
+ "#{word.begin}:#{word.end + (word.exclude_end? ? 0 : 1)}"
18
+ end
19
+ else
20
+ # escape any strings with space (wrap in double quotes)
21
+ word = word.to_s
22
+ word.match(/\s|'/) ? %Q["#{word}"] : word
23
+ end
24
+ end
25
+ return [command, args].join(' ').strip
26
+ end
27
+
28
+ INT_KEYS = [
29
+ :song, :artists, :albums, :songs, :uptime, :playtime, :db_playtime, :volume,
30
+ :playlistlength, :xfade, :pos, :id, :date, :track, :disc, :outputid, :mixrampdelay,
31
+ :bitrate, :nextsong, :nextsongid, :songid, :updating_db,
32
+ :musicbrainz_trackid, :musicbrainz_artistid, :musicbrainz_albumid, :musicbrainz_albumartistid
33
+ ]
34
+
35
+ SYM_KEYS = [:command, :state, :changed, :replay_gain_mode, :tagtype]
36
+ FLOAT_KEYS = [:mixrampdb, :elapsed]
37
+ BOOL_KEYS = [:repeat, :random, :single, :consume, :outputenabled]
38
+
39
+ # Parses key-value pairs into correct class
40
+ # @todo special parsing of playlist, it's a int in :status and a string in :listplaylists
41
+
42
+ def parse_key key, value
43
+ if INT_KEYS.include? key
44
+ value.to_i
45
+ elsif FLOAT_KEYS.include? key
46
+ value == 'nan' ? Float::NAN : value.to_f
47
+ elsif BOOL_KEYS.include? key
48
+ value != '0'
49
+ elsif SYM_KEYS.include? key
50
+ value.to_sym
51
+ elsif key == :playlist && !value.to_i.zero?
52
+ # doc states it's an unsigned int, meaning if we get 0,
53
+ # then it's a name string. HAXX! what if playlist name is '123'?
54
+ # @todo HAXX
55
+ value.to_i
56
+ elsif key == :db_update
57
+ Time.at(value.to_i)
58
+ elsif key == :"last-modified"
59
+ Time.iso8601(value)
60
+ elsif [:time, :audio].include? key
61
+ value.split(':').map(&:to_i)
62
+ else
63
+ value.force_encoding('UTF-8')
64
+ end
65
+ end
66
+
67
+ # Parses a single response line into an object.
68
+ def parse_line(string)
69
+ return nil if string.nil?
70
+ key, value = string.split(': ', 2)
71
+ key = key.downcase.to_sym
72
+ value ||= '' # no nil values please ("album: ")
73
+ return parse_key(key, value.chomp)
74
+ end
75
+
76
+ # This builds a hash out of lines returned from the server,
77
+ # elements parsed into the correct type.
78
+ #
79
+ # The end result is a hash containing the proper key/value pairs
80
+ def build_hash(string)
81
+ return {} if string.nil?
82
+
83
+ string.split("\n").each_with_object({}) do |line, hash|
84
+ key, value = line.split(': ', 2)
85
+ key = key.downcase.to_sym
86
+ value ||= '' # no nil values please ("album: ")
87
+
88
+ # if val appears more than once, make an array of vals.
89
+ if hash.include? key
90
+ hash[key] = [hash[key]] if !hash[key].is_a?(Array) # if necessary
91
+ hash[key] << parse_key(key, value.chomp) # add new val to array
92
+ else # val hasn't appeared yet, map it.
93
+ hash[key] = parse_key(key, value.chomp) # map val to key
94
+ end
95
+ end
96
+ end
97
+
98
+ # Converts the response to MPD::Song objects.
99
+ # @return [Array<MPD::Song>] An array of songs.
100
+ def build_songs_list(array)
101
+ return [] if !array.is_a?(Array)
102
+ return array.map {|hash| Song.new(hash) }
103
+ end
104
+
105
+ # Make chunks from string.
106
+ # @return [Array<String>]
107
+ def make_chunks(string)
108
+ first_key = string.match(/\A(.+?): /)[1]
109
+
110
+ chunks = string.split(/\n(?=#{first_key})/)
111
+ chunks.inject([]) do |result, chunk|
112
+ result << chunk.strip
113
+ end
114
+ end
115
+
116
+ # Parses the response into appropriate objects (either a single object,
117
+ # or an array of objects or an array of hashes).
118
+ #
119
+ # @return [Array<Hash>, Array<String>, String, Integer] Parsed response.
120
+ def build_response(string)
121
+ return [] if string.nil? || !string.is_a?(String)
122
+
123
+ chunks = make_chunks(string)
124
+ # if there are any new lines (more than one data piece), it's a hash, else an object.
125
+ is_hash = chunks.any? {|chunk| chunk.include? "\n"}
126
+
127
+ list = chunks.inject([]) do |result, chunk|
128
+ result << (is_hash ? build_hash(chunk) : parse_line(chunk))
129
+ end
130
+
131
+ # if list has only one element, return it, else return array
132
+ result = list.length == 1 ? list.first : list
133
+ return result
134
+ end
135
+
136
+ # Parse the response into groups that have the same key (used for file lists,
137
+ # groups together files, directories and playlists).
138
+ # @return [Hash<Array>] A hash of key groups.
139
+ def build_groups(string)
140
+ return [] if string.nil? || !string.is_a?(String)
141
+
142
+ string.split("\n").each_with_object({}) do |line, hash|
143
+ key, value = line.split(': ', 2)
144
+ key = key.downcase.to_sym
145
+ hash[key] ||= []
146
+ hash[key] << parse_key(key, value.chomp) # map val to key
147
+ end
148
+ end
149
+
150
+ end
151
+ end