# frozen_string_literal: true
require 'socket'
require 'stringio'
require 'mpd_client/version'
module MPD
HELLO_PREFIX = 'OK MPD '
ERROR_PREFIX = 'ACK '
SUCCESS = "OK\n"
NEXT = "list_OK\n"
# MPD changelog: http://git.musicpd.org/cgit/master/mpd.git/plain/NEWS
# http://www.musicpd.org/doc/protocol/command_reference.html
# http://git.musicpd.org/cgit/cirrus/mpd.git/plain/doc/protocol.xml
COMMANDS = {
# Status Commands
'clearerror' => 'fetch_nothing',
'currentsong' => 'fetch_object',
'idle' => 'fetch_list',
'noidle' => '',
'status' => 'fetch_object',
'stats' => 'fetch_object',
# Playback Option Commands
'consume' => 'fetch_nothing',
'crossfade' => 'fetch_nothing',
'mixrampdb' => 'fetch_nothing',
'mixrampdelay' => 'fetch_nothing',
'random' => 'fetch_nothing',
'repeat' => 'fetch_nothing',
'setvol' => 'fetch_nothing',
'single' => 'fetch_nothing',
'replay_gain_mode' => 'fetch_nothing',
'replay_gain_status' => 'fetch_item',
'volume' => 'fetch_nothing',
# Playback Control Commands
'next' => 'fetch_nothing',
'pause' => 'fetch_nothing',
'play' => 'fetch_nothing',
'playid' => 'fetch_nothing',
'previous' => 'fetch_nothing',
'seek' => 'fetch_nothing',
'seekid' => 'fetch_nothing',
'seekcur' => 'fetch_nothing',
'stop' => 'fetch_nothing',
# Playlist Commands
'add' => 'fetch_nothing',
'addid' => 'fetch_item',
'addtagid' => 'fetch_nothing',
'cleartagid' => 'fetch_nothing',
'clear' => 'fetch_nothing',
'delete' => 'fetch_nothing',
'deleteid' => 'fetch_nothing',
'move' => 'fetch_nothing',
'moveid' => 'fetch_nothing',
'playlist' => 'fetch_playlist',
'playlistfind' => 'fetch_songs',
'playlistid' => 'fetch_songs',
'playlistinfo' => 'fetch_songs',
'playlistsearch' => 'fetch_songs',
'plchanges' => 'fetch_songs',
'plchangesposid' => 'fetch_changes',
'prio' => 'fetch_nothing',
'prioid' => 'fetch_nothing',
'rangeid' => 'fetch_nothing',
'shuffle' => 'fetch_nothing',
'swap' => 'fetch_nothing',
'swapid' => 'fetch_nothing',
# Stored Playlist Commands
'listplaylist' => 'fetch_list',
'listplaylistinfo' => 'fetch_songs',
'listplaylists' => 'fetch_playlists',
'load' => 'fetch_nothing',
'playlistadd' => 'fetch_nothing',
'playlistclear' => 'fetch_nothing',
'playlistdelete' => 'fetch_nothing',
'playlistmove' => 'fetch_nothing',
'rename' => 'fetch_nothing',
'rm' => 'fetch_nothing',
'save' => 'fetch_nothing',
# Database Commands
'count' => 'fetch_object',
'find' => 'fetch_songs',
'findadd' => 'fetch_nothing',
'list' => 'fetch_list',
'listall' => 'fetch_database',
'listallinfo' => 'fetch_database',
'listfiles' => 'fetch_database',
'lsinfo' => 'fetch_database',
'search' => 'fetch_songs',
'searchadd' => 'fetch_nothing',
'searchaddp1' => 'fetch_nothing',
'update' => 'fetch_item',
'rescan' => 'fetch_item',
'readcomments' => 'fetch_object',
# Mounts and neighbors
'mount' => 'fetch_nothing',
'unmount' => 'fetch_nothing',
'listmounts' => 'fetch_mounts',
'listneighbors' => 'fetch_neighbors',
# Sticker Commands
'sticker get' => 'fetch_sticker',
'sticker set' => 'fetch_nothing',
'sticker delete' => 'fetch_nothing',
'sticker list' => 'fetch_stickers',
'sticker find' => 'fetch_songs',
# Connection Commands
'close' => '',
'kill' => '',
'password' => 'fetch_nothing',
'ping' => 'fetch_nothing',
# Audio Output Commands
'disableoutput' => 'fetch_nothing',
'enableoutput' => 'fetch_nothing',
'outputs' => 'fetch_outputs',
'toggleoutput' => 'fetch_nothing',
# Reflection Commands
'config' => 'fetch_item',
'commands' => 'fetch_list',
'notcommands' => 'fetch_list',
'tagtypes' => 'fetch_list',
'urlhandlers' => 'fetch_list',
'decoders' => 'fetch_plugins',
# Client To Client
'subscribe' => 'fetch_nothing',
'unsubscribe' => 'fetch_nothing',
'channels' => 'fetch_list',
'readmessages' => 'fetch_messages',
'sendmessage' => 'fetch_nothing'
}.freeze
# The `MPD::Client` is used for interactions with a MPD server.
#
# Example:
#
# ```ruby
# require 'mpd_client'
# require 'logger'
#
# client = MPD::Client.new
# client.log = Logger.new($stderr)
# client.connect('/var/run/mpd/socket')
# ```
class Client
attr_reader :mpd_version
class << self
# Default logger for all `MPD::Client`` instances
#
# ```ruby
# MPD::Client.log = Logger.new($stderr)
# ```
attr_accessor :log
def connect(host = 'localhost', port = 6600)
client = MPD::Client.new
client.connect(host, port)
client
end
def add_command(name, retval)
escaped_name = name.tr(' ', '_')
define_method escaped_name.to_sym do |*args|
ensure_connected
execute(name, *args, retval)
end
end
def remove_command(name)
raise "Can't remove not existent '#{name}' command" unless method_defined? name.to_sym
remove_method name.to_sym
end
end
def initialize
@mutex = Mutex.new
reset
end
def connect(host = 'localhost', port = 6600)
@host = host
@port = port
reconnect
end
def reconnect
log&.info("MPD (re)connect #{@host}, #{@port}")
@socket =
if @host.start_with?('/')
UNIXSocket.new(@host)
else
TCPSocket.new(@host, @port)
end
hello
@connected = true
end
def disconnect
log&.info('MPD disconnect')
@socket.close
reset
end
def reset
@mpd_version = nil
@command_list = nil
@socket = nil
@log = nil
@connected = false
end
def connected?
@connected
end
# https://www.musicpd.org/doc/protocol/command_lists.html
def command_list_ok_begin
raise 'Already in command list' unless @command_list.nil?
write_command('command_list_ok_begin')
@command_list = []
end
def command_list_end
raise 'Not in command list' if @command_list.nil?
write_command('command_list_end')
fetch_command_list
end
# The current logger. If no logger has been set MPD::Client.log is used
def log
@log || MPD::Client.log
end
# Sets the +logger+ used by this instance of MPD::Client
attr_writer :log
def albumart(uri)
fetch_binary(StringIO.new, 0, 'albumart', uri)
end
private
def ensure_connected
raise 'Please connect to MPD server' unless connected?
end
def execute(command, *args, retval)
@mutex.synchronize do
write_command(command, *args)
if @command_list.nil?
eval retval
else
@command_list << retval
end
end
end
def write_line(line)
begin
@socket.puts line
rescue Errno::EPIPE
reconnect
@socket.puts line
end
@socket.flush
end
def write_command(command, *args)
parts = [command]
args.each do |arg|
line =
if arg.is_a?(Array)
arg.size == 1 ? "\"#{arg[0].to_i}:\"" : "\"#{arg[0].to_i}:#{arg[1].to_i}\""
else
"\"#{escape(arg)}\""
end
parts << line
end
# log.debug("Calling MPD: #{command}#{args}") if log
log&.debug("Calling MPD: #{parts.join(' ')}")
write_line(parts.join(' '))
end
def read_line
line = @socket.gets
raise 'Connection lost while reading line' unless line.end_with?("\n")
if line.start_with?(ERROR_PREFIX)
error = line[/#{ERROR_PREFIX}(.*)/, 1].strip
raise error
end
if !@command_list.nil?
return if line == NEXT
raise "Got unexpected '#{SUCCESS}'" if line == SUCCESS
elsif line == SUCCESS
return
end
line
end
def read_pair(separator)
line = read_line
return if line.nil?
line.split(separator, 2)
end
def read_pairs(separator = ': ')
result = []
pair = read_pair(separator)
while pair
result << pair
pair = read_pair(separator)
end
result
end
def fetch_item
pairs = read_pairs
return nil if pairs.size != 1
pairs[0][1]
end
def fetch_nothing
line = read_line
raise "Got unexpected value: #{line}" unless line.nil?
end
def fetch_list
result = []
seen = nil
read_pairs.each do |key, value|
value = value.chomp.force_encoding('utf-8')
if key != seen
raise "Expected key '#{seen}', got '#{key}'" unless seen.nil?
seen = key
end
result << value
end
result
end
def fetch_objects(delimeters = [])
result = []
obj = {}
read_pairs.each do |key, value|
key = key.downcase
value = value.chomp.force_encoding('utf-8')
if delimeters.include?(key)
result << obj unless obj.empty?
obj = {}
elsif obj.include?(key)
obj[key] << value
end
obj[key] = value
end
result << obj unless obj.empty?
result
end
def fetch_object
objs = fetch_objects
objs ? objs[0] : {}
end
def fetch_binary(io = StringIO.new, offset = 0, *args)
data = {}
@mutex.synchronize do
write_command(*args, offset)
binary = false
read_pairs.each do |item|
if binary
io << item.join(': ')
next
end
key = item[0]
value = item[1].chomp
binary = (key == 'binary')
data[key] = value
end
end
size = data['size'].to_i
binary = data['binary'].to_i
next_offset = offset + binary
return [data, io] if next_offset >= size
io.seek(-1, IO::SEEK_CUR)
fetch_binary(io, next_offset, *args)
end
def fetch_changes
fetch_objects(['cpos'])
end
def fetch_songs
fetch_objects(['file'])
end
def fetch_mounts
fetch_objects(['mount'])
end
def fetch_neighbors
fetch_objects(['neighbor'])
end
def fetch_messages
fetch_objects('channel')
end
def fetch_outputs
fetch_objects(['outputid'])
end
def fetch_plugins
fetch_objects(['plugin'])
end
def fetch_database
fetch_objects(%w[file directory playlist])
end
def fetch_playlists
fetch_objects(['playlist'])
end
def fetch_playlist
result = []
read_pairs(':').each do |_key, value|
value = value.chomp.force_encoding('utf-8')
result << value
end
result
end
def fetch_stickers
result = []
read_pairs.each do |_key, sticker|
value = sticker.split('=', 2)
raise "Could now parse sticker: #{sticker}" if value.size < 2
result << Hash[*value]
end
result
end
def fetch_sticker
fetch_stickers[0]
end
def fetch_command_list
result = []
begin
@command_list.each do |retval|
result << (eval retval)
end
ensure
@command_list = nil
end
result
end
def hello
line = @socket.gets
raise 'Connection lost while reading MPD hello' unless line.end_with?("\n")
line.chomp!
raise "Got invalid MPD hello: #{line}" unless line.start_with?(HELLO_PREFIX)
@mpd_version = line[/#{HELLO_PREFIX}(.*)/, 1]
end
def escape(text)
text.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
end
end
end
MPD::COMMANDS.each_pair do |name, callback|
MPD::Client.add_command(name, callback)
end