Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Command Lists (Automatic Result Types) #76

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,20 +247,6 @@ Easy as pie. The above will connect to the server like normal, but this time it

This section documents the features that are missing in this library at the moment.

### Command lists

Command lists are not implemented yet. The proposed API would look like:

```ruby
mpd.command_list do
volume 80
repeat true
status
end
```

What makes me not so eager to implement this is that MPD returns all values one after another. This gets fixed with `command_list_ok_begin`, which returns `list_OK` for every command used, however then we still get more than one response, and I can't think of a reasonable way to retun all of them back to the user. Maybe just ignore the return values?

### Idle

To implement idle, what is needed is a lock that prevents sending commands to the daemon while waiting for the response (except `noidle`). An intermediate solution would be to queue the commands to send them later, when idle has returned the response.
Expand Down
60 changes: 34 additions & 26 deletions lib/ruby-mpd.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
require 'socket'
require 'thread'

require 'ruby-mpd/version'
require 'ruby-mpd/exceptions'
require 'ruby-mpd/song'
require 'ruby-mpd/parser'
require 'ruby-mpd/playlist'

require 'ruby-mpd/plugins/information'
require 'ruby-mpd/plugins/playback_options'
require 'ruby-mpd/plugins/controls'
require 'ruby-mpd/plugins/queue'
require 'ruby-mpd/plugins/playlists'
require 'ruby-mpd/plugins/database'
require 'ruby-mpd/plugins/stickers'
require 'ruby-mpd/plugins/outputs'
require 'ruby-mpd/plugins/reflection'
require 'ruby-mpd/plugins/channels'
require_relative 'ruby-mpd/version'
require_relative 'ruby-mpd/exceptions'
require_relative 'ruby-mpd/song'
require_relative 'ruby-mpd/parser'
require_relative 'ruby-mpd/playlist'

require_relative 'ruby-mpd/plugins/information'
require_relative 'ruby-mpd/plugins/playback_options'
require_relative 'ruby-mpd/plugins/controls'
require_relative 'ruby-mpd/plugins/queue'
require_relative 'ruby-mpd/plugins/playlists'
require_relative 'ruby-mpd/plugins/database'
require_relative 'ruby-mpd/plugins/stickers'
require_relative 'ruby-mpd/plugins/outputs'
require_relative 'ruby-mpd/plugins/reflection'
require_relative 'ruby-mpd/plugins/channels'
require_relative 'ruby-mpd/plugins/command_list'

# @!macro [new] error_raise
# @raise (see #send_command)
Expand All @@ -38,6 +39,7 @@ class MPD
include Plugins::Outputs
include Plugins::Reflection
include Plugins::Channels
include Plugins::CommandList

attr_reader :version, :hostname, :port

Expand Down Expand Up @@ -187,15 +189,19 @@ def ping
# @raise [MPDError] if the command failed.
def send_command(command, *args)
raise ConnectionError, "Not connected to the server!" unless socket

@mutex.synchronize do
begin
socket.puts convert_command(command, *args)
response = handle_server_response
return parse_response(command, response)
rescue Errno::EPIPE, ConnectionError
reconnect
retry
if @command_list_commands
@command_list_commands << command
socket.puts convert_command(command, *args)
else
@mutex.synchronize do
begin
socket.puts convert_command(command, *args)
response = handle_server_response
parse_response(command, response)
rescue Errno::EPIPE, ConnectionError
reconnect
retry
end
end
end
end
Expand Down Expand Up @@ -251,9 +257,11 @@ def callback_thread
# @return [true] If "OK" is returned.
# @raise [MPDError] If an "ACK" is returned.
def handle_server_response
raise "Cannot read from the server during a command list" if @command_list_active
sock = socket # Cache to prevent an extra method call for every response line
msg = ''
while true
case line = socket.gets
case line = sock.gets
when "OK\n"
break
when /^ACK/
Expand Down
56 changes: 44 additions & 12 deletions lib/ruby-mpd/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def convert_command(command, *params)
end
when MPD::Song
quotable_param param.file
when MPD::Playlist
quotable_param param.name
when Hash # normally a search query
param.each_with_object("") do |(type, what), query|
query << "#{type} #{quotable_param what} "
Expand Down Expand Up @@ -55,12 +57,19 @@ def quotable_param(value)
FLOAT_KEYS = Set[:mixrampdb, :elapsed]
BOOL_KEYS = Set[:repeat, :random, :single, :consume, :outputenabled]

# Commands, where it makes sense to always explicitly return an array.
# Commands where it makes sense to always explicitly return an array.
RETURN_ARRAY = Set[:channels, :outputs, :readmessages, :list,
:listallinfo, :find, :search, :listplaylists, :listplaylist, :playlistfind,
:playlistsearch, :plchanges, :tagtypes, :commands, :notcommands, :urlhandlers,
:decoders, :listplaylistinfo, :playlistinfo]

# Commands that should always return MPD::Song instances
SONG_COMMANDS = Set[:listallinfo,:playlistinfo,:find,:findadd,:search,
:searchadd,:playlistfind,:playlistsearch,:plchanges,:listplaylistinfo]

# Commands that should always return MPD::Playlist instances
PLAYLIST_COMMANDS = Set[:listplaylists]

# Parses key-value pairs into correct class.
def parse_key(key, value)
if INT_KEYS.include? key
Expand Down Expand Up @@ -105,25 +114,26 @@ def parse_line(line)
# The end result is a hash containing the proper key/value pairs
def build_hash(string)
return {} if string.nil?
array_keys = {}

string.lines.each_with_object({}) do |line, hash|
key, object = parse_line(line)

# if val appears more than once, make an array of vals.
if hash.include? key
hash[key] = Array(hash[key]) << object
# cannot use Array(hash[key]) or [*hash[key]] because Time instances get splatted
# cannot check for is_a?(Array) because some values (time) are already arrays
unless array_keys[key]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you try using Array.wrap? I think that one should safely work with Time objects as well, since it uses to_ary instead of to_a. http://apidock.com/rails/Array/wrap/class

hash[key] = [hash[key]]
array_keys[key] = true
end
hash[key] << object
else # val hasn't appeared yet, map it.
hash[key] = object # map obj to key
end
end
end

# Converts the response to MPD::Song objects.
# @return [Array<MPD::Song>] An array of songs.
def build_songs_list(array)
return array.map { |hash| Song.new(self, hash) }
end

# Make chunks from string.
# @return [Array<String>]
def make_chunks(string)
Expand All @@ -150,17 +160,39 @@ def parse_response(command, string)
build_response(command, string)
end

def parse_command_list(commands, string)
[].tap do |results|
string.split("list_OK\n").each do |str|
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can get rid of tap by using string.split("list_OK\n").each_with_object([]) do |str, results|

command = commands.shift
results << parse_response(command, str) unless str.empty?
end
end
end

# Parses the response into appropriate objects (either a single object,
# or an array of objects or an array of hashes).
#
# @return [Array<Hash>, Array<String>, String, Integer] Parsed response.
def build_response(command, string)
def build_response(command, string, force_hash=nil)
chunks = make_chunks(string)
# if there are any new lines (more than one data piece), it's a hash, else an object.
is_hash = chunks.any? { |chunk| chunk.include? "\n" }

make_song = SONG_COMMANDS.include?(command)
make_plist = PLAYLIST_COMMANDS.include?(command)
make_hash = force_hash || make_song || make_plist || chunks.any?{ |chunk| chunk.include? "\n" }

list = chunks.inject([]) do |result, chunk|
result << (is_hash ? build_hash(chunk) : parse_line(chunk)[1]) # parse_line(chunk)[1] is object
result << (make_hash ? build_hash(chunk) : parse_line(chunk)[1]) # parse_line(chunk)[1] is object
end

if make_song
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use a splat with a when statement:

case command
when *SONG_COMMANDS
when *PLAYLIST_COMMANDS
end

list.map! do |opts|
if opts[:file] && opts[:file] =~ %r{^https?://}i
opts = { file:opts[:file], time:[0] }
end
Song.new(@mpd, opts)
end
elsif make_plist
list.map!{ |opts| Playlist.new(self,opts) }
end

# if list has only one element and not set to explicit array, return it, else return array
Expand Down
15 changes: 4 additions & 11 deletions lib/ruby-mpd/playlist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,7 @@ def initialize(mpd, options)
# Lists the songs in the playlist. Playlist plugins are supported.
# @return [Array<MPD::Song>] songs in the playlist.
def songs
result = @mpd.send_command(:listplaylistinfo, @name)
result.map do |hash|
if hash[:file] && !hash[:file].match(/^(https?:\/\/)?/)[0].empty?
Song.new(@mpd, {:file => hash[:file], :time => [0]})
else
Song.new(@mpd, hash)
end
end
@mpd.send_command(:listplaylistinfo, @name)
rescue TypeError
puts "Files inside Playlist '#{@name}' do not exist!"
return []
Expand Down Expand Up @@ -77,10 +70,10 @@ def delete(pos)
@mpd.send_command :playlistdelete, @name, pos
end

# Moves song with SONGID in the playlist to the position SONGPOS.
# Move a song in the playlist to a new 0-based index.
# @macro returnraise
def move(songid, songpos)
@mpd.send_command :playlistmove, @name, songid, songpos
def move(from_index, to_index)
@mpd.send_command :playlistmove, @name, from_index, to_index
end

# Renames the playlist to +new_name+.
Expand Down
99 changes: 99 additions & 0 deletions lib/ruby-mpd/plugins/command_list.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
module MPD::Plugins

# Batch send multiple commands at once for speed.
module CommandList
# Send multiple commands at once.
#
# By default, any response from the server is ignored (for speed).
# To get results, pass +{results:true}+ to the method.
#
# Note that each supported command has no return value inside the block.
# Instead, the block itself returns the array of results.
#
# @param [Symbol] response_type the type of responses desired.
# @return [nil] default behavior.
# @return [Array] if +results+ is +true+.
#
# @example Simple batched control commands
# @mpd.command_list do
# stop
# shuffle
# save "shuffled"
# end
#
# @example Adding songs to the queue, ignoring the response
# @mpd.command_list do
# my_songs.each do |song|
# add(song)
# end
# end
#
# @example Adding songs to the queue and getting the song ids
# ids = @mpd.command_list(results:true){ my_songs.each{ |song| addid(song) } }
# #=> [799,800,801,802,803]
#
# @example Finding songs matching various genres
# results = @mpd.command_list(results:true) do
# where genre:'Alternative Rock'
# where genre:'Alt. Rock'
# where genre:'alt-rock'
# end
# p results.class #=> Array (One result for each command that gives results)
# p results.length #=> 3 (One for each command that returns results)
# p results.first.class #=> Array (Each `where` command returns its own results)
#
#
# @example Using playlists inside a command list
# def shuffle_playlist( playlist )
# song_count = @mpd.send_command(:listplaylist, playlist.name).length
# @mpd.command_list do
# (song_count-1).downto(1){ |i| playlist.move i, rand(i+1) }
# end
# end
#
#
# @see CommandList::Commands CommandList::Commands for a list of supported commands.
def command_list(opts={},&commands)
@mutex.synchronize do
begin
@command_list_commands = []
socket.puts( opts[:results] ? "command_list_ok_begin" : "command_list_begin" )
@command_list_active = true
CommandList::Commands.new(self).instance_eval(&commands)
@command_list_active = false
socket.puts "command_list_end"

# clear the response from the socket, even if we will not parse it
response = handle_server_response || ""

parse_command_list( @command_list_commands, response ) if opts[:results]
rescue Errno::EPIPE
reconnect
retry
ensure
@command_list_commands = nil
@command_list_active = false
end
end
end

end

class CommandList::Commands
def initialize(mpd)
@mpd = mpd
end

include MPD::Plugins::Controls
include MPD::Plugins::PlaybackOptions
include MPD::Plugins::Queue
include MPD::Plugins::Stickers
include MPD::Plugins::Database
include MPD::Plugins::Playlists

private
def send_command(command,*args)
@mpd.send_command(command,*args)
end
end
end
9 changes: 2 additions & 7 deletions lib/ruby-mpd/plugins/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def files(path = nil)
#
# @return [Array<MPD::Song>]
def songs(path = nil)
build_songs_list send_command(:listallinfo, path)
send_command(:listallinfo, path)
end

# lsinfo - Clients that are connected via UNIX domain socket may use this
Expand Down Expand Up @@ -92,12 +92,7 @@ def where(params, options = {})
command = options[:strict] ? :find : :search
end

response = send_command(command, params)
if response == true
return true
else
build_songs_list response
end
send_command(command, params)
end

# Tell the server to update the database. Optionally,
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby-mpd/plugins/playlists.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Playlists
#
# @return [Array<MPD::Playlist>] Array of playlists
def playlists
send_command(:listplaylists).map { |opt| MPD::Playlist.new(self, opt) }
send_command(:listplaylists)
end

end
Expand Down
Loading