ruby-mpd 0.1.4

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