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/.gitignore +22 -0
- data/AUTHORS +1 -0
- data/COPYING +340 -0
- data/DOC.rdoc +78 -0
- data/README.rdoc +174 -0
- data/data/database.yaml +347 -0
- data/examples/rmpc.rb +67 -0
- data/examples/tailmpc.rb +115 -0
- data/lib/mpdserver.rb +1206 -0
- data/lib/ruby-mpd.rb +310 -0
- data/lib/ruby-mpd/parser.rb +151 -0
- data/lib/ruby-mpd/playlist.rb +77 -0
- data/lib/ruby-mpd/plugins/channels.rb +58 -0
- data/lib/ruby-mpd/plugins/controls.rb +72 -0
- data/lib/ruby-mpd/plugins/database.rb +79 -0
- data/lib/ruby-mpd/plugins/information.rb +146 -0
- data/lib/ruby-mpd/plugins/outputs.rb +26 -0
- data/lib/ruby-mpd/plugins/playback_options.rb +88 -0
- data/lib/ruby-mpd/plugins/playlists.rb +17 -0
- data/lib/ruby-mpd/plugins/queue.rb +125 -0
- data/lib/ruby-mpd/plugins/reflection.rb +46 -0
- data/lib/ruby-mpd/plugins/stickers.rb +52 -0
- data/lib/ruby-mpd/song.rb +31 -0
- data/ruby-mpd.gemspec +17 -0
- data/tests/libtests.rb +1145 -0
- data/tests/servertests.rb +3405 -0
- metadata +73 -0
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
|