Skip to content
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
37 changes: 20 additions & 17 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 @@ -251,9 +253,10 @@ def callback_thread
# @return [true] If "OK" is returned.
# @raise [MPDError] If an "ACK" is returned.
def handle_server_response
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", nil
break
when /^ACK/
Expand Down
29 changes: 23 additions & 6 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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Playlists can be passed as params in command lists methods where a playlist name is required.

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 @@ -59,7 +61,7 @@ def quotable_param(value)
RETURN_ARRAY = Set[:channels, :outputs, :readmessages, :list,
:listallinfo, :find, :search, :listplaylists, :listplaylist, :playlistfind,
:playlistsearch, :plchanges, :tagtypes, :commands, :notcommands, :urlhandlers,
:decoders, :listplaylistinfo, :playlistinfo]
:decoders, :listplaylistinfo, :playlistinfo, :commandlist]

# Parses key-value pairs into correct class.
def parse_key(key, value)
Expand Down Expand Up @@ -105,13 +107,20 @@ 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]
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
Expand All @@ -120,8 +129,16 @@ def build_hash(string)

# 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) }
def build_songs_list(array=nil)
array.map{ |hash| hash.is_a?(Hash) ? Song.new(self, hash) : nil }.compact if array
end

# Converts the response to MPD::Playlist objects.
# @return [Array<MPD::Playlist>] An array of playlists.
def build_playlists(array)
array.map do |hash|
Playlist.new(self, hash) if hash.is_a?(Hash) && hash[:playlist]
end.compact
end

# Make chunks from string.
Expand Down Expand Up @@ -154,10 +171,10 @@ def parse_response(command, string)
# 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" }
is_hash = force_hash || 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
Expand Down
177 changes: 177 additions & 0 deletions lib/ruby-mpd/plugins/command_list.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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 +response_type+ as one of the following options:
#
# * +:hash+ — a single hash of return values; multiple return values for the same key are grouped in an array
# * +:values+ — an array of individual values
# * +:hashes+ — an array of hashes (where value boundaries are guessed based on the first result)
# * +:songs+ — an array of Song instances from the results
# * +:playlists+ - an array of Playlist instances from the results
#
# 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 +response_type+ is +:values+, +:hashes+, +:songs+, or +:playlists+.
# @return [Hash] if +response_type+ is +:hash+.
#
# @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(:values){ my_songs.each{ |song| addid(song) } }
# #=> [799,800,801,802,803]
#
# ids = @mpd.command_list(:hashes){ my_songs.each{ |song| addid(song) } }
# #=> [ {:id=>809}, {:id=>810}, {:id=>811}, {:id=>812}, {:id=>813} ]
#
# ids = @mpd.command_list(:hash){ my_songs.each{ |song| addid(song) } }
# #=> { :id=>[804,805,806,807,808] }
#
# @example Finding songs matching various genres
# songs = @mpd.command_list(:songs) do
# where genre:'Alternative Rock'
# where genre:'Alt. Rock'
# where genre:'alt-rock'
# end
#
# @see CommandList::Commands CommandList::Commands for a list of supported commands.
def command_list(response_type=nil,&commands)
@mutex.synchronize do
begin
socket.puts "command_list_begin"
CommandList::Commands.new(self).instance_eval(&commands)
Copy link
Owner

Choose a reason for hiding this comment

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

Hmm, command lists are executed synchronous with the other calls, such as the callback thread. I'm having some concerns that it could block other execution, what do you think about opening another MPD connection, executing, then closing the connection?

Copy link
Owner

Choose a reason for hiding this comment

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

(In a new thread of course)

This is ruby though, so maybe it won't make any difference since we lack real concurrency.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In the case explicitly where response_type==nil I can imagine doing what you suggest: starting another thread, making a new connection (basically a new MPD instance matching the same settings) and sending off the commands.

As it is, I find that about half the time I use the result of the command list.

Given that we do not know if this would provide any real benefit (both because of no real concurrency, and the overhead of establishing a new connection), and it's a moderate amount of additional code and work, I'd suggest that it's a theoretically good idea that should be investigated in the future separate from this pull request.

socket.puts "command_list_end"

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

case response_type
Copy link
Owner

Choose a reason for hiding this comment

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

Hmm, the command list will return multiple responses though, right? One for each command. So the type might vary from command to command

Copy link
Owner

Choose a reason for hiding this comment

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

I honestly think we might be safe to just throw the responses away and return nil, in most cases this will be used for batching anyway

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, when response_type is nil (or omitted) I do just throw away the responses.

As I mentioned in another comment, about half my current uses of command_list I do use the response. For example, getting MPD::Song instances related to a bunch of different URIs:

songs = @mpd.command_list(:songs){ uris.each{ |uri| where({file:uri},{strict:true}) } }

Yes, the command_list does return multiple responses, and in the 'common' (as I'm imagining them) use cases they will be the same type. There are edge cases where the user might want to send mixed commands with varying return types AND want the return types. That's not currently possible with my current code, since I'm using command_list_begin (so all responses come one after another, not separated by "OK") and thus using the existing parsing heuristics to split them up.

I decided to use command_list_begin instead of command_list_ok_begin to remove all the extra "OK" responses when I don't care about the responses, or when they are short (such as with addid. However, I could imagine a different approach that works like this:

  • If the user indicates no responses needed, use command_list_begin and throw away the response.
  • If the user indicates responses needed:
    • Set an instance variable flag that we're in a command list.
    • Use command_list_ok_begin so all responses are separated by "list_OK".
    • Create an empty array of response types.
    • When send_command is called within the command list, for each command add the response type to the array.
    • Create a new parse_command_list_response method in parser (not re-using parse_response) that splits the response on "list_OK" and then uses the array of response types to create the appropriate objects.

This would be a little more code than I currently have, and the resulting responses and parsing MIGHT be a little slower, but it would be more DWIM, more robust, and less geeky. Something like:

results = mpd.command_list results:true do
  addid(song1)
  where genre:'rock'
  added(song2)
end

p results[0] #=> 42
p results[1] #=> [ <MPD::Song#...>, <MPD::Song#...>, <MPD::Song#...>, ... ]
p results[2] #=> 43

I'm not sure if I believe there is a strong need for the intermingling, but I am a fan of things just working as expected (POLS and all that).

Does this sound like a good idea to you?

when :values then response.lines.map{ |line| parse_line(line).last }
when :hash then build_hash(response)
when :hashes then build_response(:commandlist,response,true)
when :songs then build_songs_list parse_response(:listallinfo,response)
when :playlists then build_playlists parse_response(:listplaylists,response)
end
rescue Errno::EPIPE
reconnect
retry
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

# List all of the playlists in the database
def playlists
send_command(:listplaylists)
Copy link
Owner

Choose a reason for hiding this comment

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

Hmm, I'm not too thrilled about having another implementation of the playlists just for the command list. Do you think it would be possible to reuse the original MPD::Playlist objects? It won't be possible to fetch it and then reuse it in subsequent commands in the list, but that's not possible with these plain method calls either

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All the playlist methods currently use @mpd.send_command. MPD#send_command includes handle_server_response and parse_response which we can't have called immediately with a command list. I can get around this for all the other MPD methods because they just call send_command (without an explicit receiver) and I provide my own send_command implementation for CommandList::Commands.

The only way that I can think to re-use the playlist objects would be to use an instance variable set as a flag when the command list was started and mutate the way send_command works everywhere under such circumstances. (I don't think I need to worry about tracking this state per thread, since multiple threads trying to use the same MPD instance would cause problems with the socket communication.)

I'd also have to modify MPD::Playlist#songs to handle the case when no result is returned from send_command, but I've already done something similar in a few other places as part of this patch.

Would you prefer that approach? Having MPD#send_command modify its behavior based on an instance variable tracking whether we're in a command list or not? Does this feel better to you?

end

# Fetch full details for all songs in a playlist
# @param [String,Playlist] playlist The string name (or playlist object) to get songs for.
def songs_in_playlist(playlist)
send_command(:listplaylistinfo, playlist)
end

# Fetch file names only for all songs in a playlist
# @param [String,Playlist] playlist The string name (or playlist object) to get files for.
def files_in_playlist(playlist)
send_command(:listplaylist, playlist)
end

# Load the playlist's songs into the queue.
#
# Since MPD v0.17 a range can be passed to load only a part of the playlist.
# @param [String,Playlist] playlist The string name (or playlist object) to load songs from.
# @param [Range] range The index range of songs to add.
def load_playlist(playlist, range=nil)
send_command :load, playlist, range
end

# Add a song to the playlist.
# @param [String,Playlist] playlist The string name (or playlist object) to add to.
# @param [String,Song] song The string uri (or song object) to add to the playlist
def add_to_playlist(playlist, song)
send_command :playlistadd, playlist, song
end

# Search for any song that contains +value+ in the +tag+ field
# and add them to a playlist.
# Searches are *NOT* case sensitive.
#
# @param [String,Playlist] playlist The string name (or playlist object) to add to.
# @param [Symbol] tag Can be any tag supported by MPD, or one of the two special
# parameters: +:file+ to search by full path (relative to database root),
# and +:any+ to match against all available tags.
# @param [String] value The string to search for.
def searchadd_to_playlist(playlist, tag, value)
send_command :searchaddpl, playlist, tag, value
end

# Remove all songs from a playlist.
# @param [String,Playlist] playlist The string name (or playlist object) to clear.
def clear_playlist(playlist)
send_command :playlistclear, playlist
end

# Delete song at +index+ from a playlist.
# @param [String,Playlist] playlist The string name (or playlist object) to affect.
# @param [Integer] index The index of the song to remove.
def remove_from_playlist(playlist, index)
send_command :playlistdelete, playlist, index
end

# Move a song with +song_id+ in a playlist to a new +index+.
# @param [String,Playlist] playlist The string name (or playlist object) to affect.
# @param [Integer] songid The +id+ of the song to move.
# @param [Integer] index The index to move the song to.
def reorder_playlist(playlist, song_id, index)
send_command :playlistmove, playlist, song_id, songpos
end

# Rename a playlist to +new_name+.
# @param [String,Playlist] playlist The string name (or playlist object) to rename.
# @param [String] new_name The new name for the playlist.
def rename_playlist(playlist,new_name)
send_command :rename, playlist, new_name
end

# Delete a playlist from the disk.
# @param [String,Playlist] playlist The string name (or playlist object) to delete.
def destroy_playlist(playlist)
send_command :rm, playlist
end

private
def send_command(command,*args)
@mpd.send(:socket).puts @mpd.send(:convert_command, command, *args)
end
end
end
5 changes: 3 additions & 2 deletions lib/ruby-mpd/plugins/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ def where(params, options = {})
end

response = send_command(command, params)
if response == true
return true
case response
when true then true
when nil then nil
else
build_songs_list response
end
Expand Down
5 changes: 3 additions & 2 deletions lib/ruby-mpd/plugins/stickers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ def delete_sticker(type, uri, name = nil)
# @return [Hash] Hash mapping sticker names (as strings) to values (as strings).
def list_stickers(type, uri)
result = send_command :sticker, :list, type, uri
if result==true # response when there are no
{}
case result
when true then {} # response when there are no stickers
when nil then nil # when called in a command_list
else
result = [result] if result.is_a?(String)
Hash[result.map{|s| s.split('=',2)}]
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby-mpd/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
class MPD
VERSION = '0.3.4'
VERSION = '0.4.0'
end
11 changes: 11 additions & 0 deletions test/socket_recordings/commandlist-clear_addid
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
command_list_begin
clear
addid test1.mp3
addid "test 2.mp3"
addid test3.mp3
command_list_end
--putsabove--getsbelow--
Id: 107
Id: 108
Id: 109
OK
21 changes: 21 additions & 0 deletions test/socket_recordings/commandlist-playlists
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
command_list_begin
listplaylists
command_list_end
--putsabove--getsbelow--
playlist: Mix Rock Alt Electric
Last-Modified: 2015-11-23T15:58:51Z
playlist: SNBRN
Last-Modified: 2016-01-26T00:25:52Z
playlist: Enya-esque
Last-Modified: 2015-11-18T16:19:12Z
playlist: RecentNice
Last-Modified: 2015-12-01T15:52:38Z
playlist: Dancetown
Last-Modified: 2015-11-18T16:19:26Z
playlist: Piano
Last-Modified: 2015-11-18T16:17:13Z
playlist: Thump
Last-Modified: 2015-11-20T15:32:30Z
playlist: Smooth Town
Last-Modified: 2015-11-20T15:54:49Z
OK
Loading