# :title: Music Player Client for MPD in RBot # # Author:: Yohann MONNIER - Internethic # # Version:: 0.0.1 # # License:: MIT license # # Thanks to mpd.rb/the Ruby MPD Library from which I Start require 'pp' require 'timeout' require 'socket' require "cgi" class MusicPlayerPlugin < Plugin # MPD::SongInfo elements are: # # +file+ :: full pathname of file as seen by server # +album+ :: name of the album # +artist+ :: name of the artist # +dbid+ :: mpd db id for track # +pos+ :: playlist array index (starting at 0) # +time+ :: time of track in seconds # +title+ :: track title # +track+ :: track number within album # SongInfo = Struct.new("SongInfo", "file", "album", "artist", "dbid", "pos", "time", "title", "track") # MPD::Error elements are: # # +number+ :: ID number of the error as Integer # +index+ :: Line number of the error (0 if not in a command list) as Integer # +command+ :: Command name that caused the error # +description+ :: Human readable description of the error # Error = Struct.new("Error", "number", "index", "command", "description") # initialize configuration def initialize super ############### ## SETTINGS ## ############### #common regexps precompiled for speed and clarity @@re = { 'ACK_MESSAGE' => Regexp.new(/^ACK \[(\d+)\@(\d+)\] \{(.+)\} (.+)$/), 'DIGITS_ONLY' => Regexp.new(/^\d+$/), 'OK_MPD_VERSION' => Regexp.new(/^OK MPD (.+)$/), 'NON_DIGITS' => Regexp.new(/^\D+$/), 'LISTALL' => Regexp.new(/^file:\s/), 'PING' => Regexp.new(/^OK/), 'PLAYLIST' => Regexp.new(/^(\d+?):(.+)$/), 'PLAYLISTINFO' => Regexp.new(/^(.+?):\s(.+)$/), 'STATS' => Regexp.new(/^(.+?):\s(.+)$/), 'STATUS' => Regexp.new(/^(.+?):\s(.+)$/), } # SERVER PARAMETERS @mpd_host = 'ip-address-host' @mpd_port = 6600 @authorized_network = "ip-adress-autorized" @irc_shoutroom = "#irc-room" @password = nil # BEHAVIOR RELATED PARAMETERS (Modify if you understand what it does) @overwrite_playlist = true @allow_toggle_states = true @publish_status_in_shoutbox = false @debug_socket = false # if you set this parameter to true, commands will be accepted only from @authorized_network IP @authorized_network_filter = false @message_wrong_network = "Vous n'êtes pas connectés au bon réseau" # PLUGIN INSIDE PARAMETERS (Do not Modify) @socket = nil @mpd_version = nil @error = nil @volume_saved = nil @current_song_in_progress = nil if ( @publish_status_in_shoutbox == true ) @update_song_timerhelp = @bot.timer.add(1){ song_listened = currentsong if @current_song_in_progress.nil? @current_song_in_progress = song_listened elsif @current_song_in_progress.file != song_listened.file @current_song_in_progress = song_listened # BLOCK DISPLAY SONG if !(song_listened.album).nil? currentalbum = "#{song_listened.album} ->" else currentalbum = "" end if !(song_listened.title).nil? currenttitle = song_listened.title else currenttitle = "Unknown" end if !(song_listened.artist).nil? currentartist = song_listened.artist else currentartist = "" end if ( (song_listened.album).nil? && (song_listened.title).nil? ) currenttitle = url_decode(song_listened.file) end @bot.say @irc_shoutroom, "Listening : #{currentalbum} #{currenttitle}. #{currentartist}" end } end end def cleanup @bot.timer.remove(@update_song_timerhelp) end def close return nil unless is_connected? socket_puts("close") @socket = nil end # Private method for creating command lists. # def command_list_begin @command_list = ["command_list_begin"] end def url_decode(address) clean_address = CGI::unescape(address) end # Wish this would take a block, but haven't quite figured out to get that to work # For now just put commands in the list. # def command(cmd) @command_list << cmd end # Closes and executes a command list. # def command_list_end @command_list << "command_list_end" sp = @command_list.flatten.join("\n") @command_list = [] socket_puts(sp) end # Activate a closed connection. Will automatically send password if one has been set. # def connect begin unless is_connected? then warn "connecting to socket" if @debug_socket @socket = TCPSocket.new(@mpd_host, @mpd_port) if md = @@re['OK_MPD_VERSION'].match(@socket.readline) then @mpd_version = md[1] unless @password.nil? then warn "connect sending password" if @debug_socket @socket.puts("password #{@password}") get_server_response end else warn "Connection error (Invalid Version Response)" end warn "connected to the server!" if @debug_socket end rescue Exception => e warn e.message warn e.backtrace.inspect end end # Turns off socket command debugging. # def debug_off @debug_socket = false end # Turns on socket command debugging (prints each socket command to STDERR as well as the socket) # def debug_on @debug_socket = true end # Private method for handling the messages the server sends. # def get_server_response response = [] while line = @socket.readline.chomp do # Did we cause an error? Save the data! if md = @@re['ACK_MESSAGE'].match(line) then @error = Error.new(md[1].to_i, md[2].to_i, md[3], md[4]) raise "MPD Error #{md[1]}: #{md[4]}" end return response if @@re['PING'].match(line) response << line end return response end # Internal method for converting results from currentsong, playlistinfo, playlistid to # MPD::SongInfo structs # def hash_to_songinfo(h) SongInfo.new(h['file'], h['Album'], h['Artist'], h['Id'].nil? ? nil : h['Id'].to_i, h['Pos'].nil? ? nil : h['Pos'].to_i, h['Time'], h['Title'], h['Track'] ) end # Pings the server and returns true or false depending on whether a response was receieved. # def is_connected? return false if @socket.nil? || @socket.closed? warn "is_connected to socket: ping" if @debug_socket @socket.puts("ping") if @@re['PING'].match(@socket.readline) then return true end return false rescue return false end def mpd_version @mpd_version end # Send the password pass to the server and sets it for this MPD instance. # If pass is omitted, uses any previously set password (see MPD#password=). # Once a password is set by either method MPD#connect can automatically send the password if # disconnected. # def password(pass = @password) @password = pass socket_puts("password #{pass}") end # Pause playback on the server # Returns ('pause'|'play'|'stop'). # def pause(value = nil) cstatus = status['state'] return cstatus if cstatus == 'stop' if value.nil? && @allow_toggle_states then value = cstatus == 'pause' ? '0' : '1' end socket_puts("pause #{value}") status['state'] end # Send a ping to the server and keep the connection alive. # def ping socket_puts("ping") end # Private method to convert playlistinfo style server output into MPD#SongInfo list # re is the Regexp to use to match ": ". # response is the output from MPD#socket_puts. def response_to_songinfo(re, response) list = [] hash = {} response.each do |f| if md = re.match(f) then if md[1] == 'file' then if hash == {} then list << nil unless list == [] else list << hash_to_songinfo(hash) end hash = {} end hash[md[1]] = md[2] end end if hash == {} then list << nil unless list == [] else list << hash_to_songinfo(hash) end return list end # Pass a format string (like strftime) and get back a string of MPD information. # # Format string elements are: # %f :: filename # %a :: artist # %A :: album # %i :: MPD database ID # %p :: playlist position # %t :: title # %T :: track time (in seconds) # %n :: track number # %e :: elapsed playtime (MM:SS form) # %l :: track length (MM:SS form) # # song_info can either be an existing MPD::SongInfo object (such as the one returned by # MPD#currentsong) or the MPD database ID for a song. If no song_info is given, all # song-related elements will come from the current song. # def strf(format_string, song_info = currentsong) unless song_info.class == Struct::SongInfo if @@re['DIGITS_ONLY'].match(song_info.to_s) then song_info = playlistid(song_info) end end s = '' format_string.scan(/%[EO]?.|./o) do |x| case x when '%f' s << song_info.file.to_s when '%a' s << song_info.artist.to_s when '%A' s << song_info.album.to_s when '%i' s << song_info.dbid.to_s when '%p' s << song_info.pos.to_s when '%t' s << song_info.title.to_s when '%T' s << song_info.time.to_s when '%n' s << song_info.track.to_s when '%e' t = status['time'].split(/:/)[0].to_f s << sprintf( "%d:%02d", t / 60, t % 60 ) when '%l' t = status['time'].split(/:/)[1].to_f s << sprintf( "%d:%02d", t / 60, t % 60 ) else s << x.to_s end end return s end # Returns the types of URLs that can be handled by the server. # def urlhandlers handlers = [] socket_puts("urlhandlers").each do |f| handlers << f if /^handler: (.+)$/.match(f) end return handlers end # Returns a hash containing various status elements: # # +audio+ :: '::' describes audio stream # +bitrate+ :: bitrate of audio stream in kbps # +error+ :: if there is an error, returns message here # +playlist+ :: the playlist version number as String # +playlistlength+ :: number indicating the length of the playlist as String # +repeat+ :: '0' or '1' # +song+ :: playlist index number of current song (stopped on or playing) # +songid+ :: song ID number of current song (stopped on or playing) # +state+ :: 'pause'|'play'|'stop' # +time+ :: ':' (both in seconds) of current playing/paused song # +updating_db+ :: '' if currently updating db # +volume+ :: '0' to '100' # +xfade+ :: crossfade in seconds # def status s = {} socket_puts("status").each do |f| if md = @@re['STATUS'].match(f) then s[md[1]] = md[2] end end return s end # Sets random mode on the server, either directly, or by toggling (if # no argument given and @allow_toggle_states = true). Mode "0" = not # random; Mode "1" = random. Random affects playback order, but not playlist # order. When random is on the playlist is shuffled and then used instead # of the actual playlist. Previous and next in random go to the previous # and next songs in the shuffled playlist. Calling MPD#next and then # MPD#prev would start playback at the beginning of the current song. # def random(mode = nil) return nil if mode.nil? && !@allow_toggle_states return nil unless /^(0|1)$/.match(mode) || @allow_toggle_states if mode.nil? then mode = status['random'] == '1' ? '0' : '1' end socket_puts("random #{mode}") status['random'] end # Play previous song in the playlist. See note about shuffling in MPD#set_random. # Return songid as Integer # def previous(m, params) if ( whereami(m, params) ) connect unless is_connected? socket_puts("previous") mcurrentsong(m, params) else m.reply "#{@message_wrong_network}" end end # Play next song in the playlist. See note about shuffling in MPD#set_random # Returns songid as Integer. # def next(m, params) if ( whereami(m, params) ) connect unless is_connected? socket_puts("next") mcurrentsong(m, params) else m.reply "#{@message_wrong_network}" end end # Start playback of songs in the playlist with song at index # number in the playlist. # Empty number starts playing from current spot or beginning. # Returns current song as MPD::SongInfo. # def play(m, params) if ( whereami(m, params) ) connect unless is_connected? socket_puts("play #{params['number']}") mcurrentsong(m, params) else m.reply "#{@message_wrong_network}" end end # Stops playback. # Returns ('pause'|'play'|'stop'). # def stop(m, params) if ( whereami(m, params) ) connect unless is_connected? socket_puts("stop") m.reply "No Music" if ( status['state'] =="stop" ) else m.reply "#{@message_wrong_network}" end end # Pause playback on the server # Returns ('pause'|'play'|'stop'). # def pause(m, params) if ( whereami(m, params) ) params['value'] = nil connect unless is_connected? cstatus = status['state'] m.reply "No music" if cstatus == 'stop' if params['value'].nil? && @allow_toggle_states then value = cstatus == 'pause' ? '0' : '1' end socket_puts("pause #{value}") m.reply "en pause" if ( status['state'] == "pause" ) mcurrentsong(m, params) if ( status['state'] == "play" ) else m.reply "#{@message_wrong_network}" end end # Set the volume to volume. Range is limited to 0-100. MPD#set_volume # will adjust any value passed less than 0 or greater than 100. # def setvol(m, params) if ( whereami(m, params) ) connect unless is_connected? if (!params[:percent].nil?) params[:percent] = 0 if params[:percent].to_i < 0 params[:percent] = 100 if params[:percent].to_i > 100 socket_puts("setvol #{params[:percent]}") end m.reply "Volume : #{status['volume']}%" else m.reply "#{@message_wrong_network}" end end def mute(m, params) if ( whereami(m, params) ) if (status['volume'].to_i > 0) m.reply "shut down sound" if @debug_socket @volume_saved = status['volume'] params[:percent] = 0 setvol(m, params) elsif (!@volume_saved.nil?) m.reply "back to saved value #{@volume_saved}" if @debug_socket params[:percent] = @volume_saved.to_i setvol(m, params) else params[:percent] = 20 setvol(m, params) end else m.reply "#{@message_wrong_network}" end end # Sends a command to the MPD server and optionally to STDOUT if # MPD#debug_on has been used to turn debugging on # def socket_puts(cmd) connect unless is_connected? warn "socket_puts to socket: #{cmd}" if @debug_socket @socket.puts(cmd) return get_server_response end def mversion(m, params) if ( whereami(m, params) ) connect unless is_connected? m.reply mpd_version else m.reply "#{@message_wrong_network}" end end # Returns an instance of Struct MPD::SongInfo. # def currentsong connect unless is_connected? response_to_songinfo(@@re['PLAYLISTINFO'], socket_puts("currentsong") )[0] end # Private method to convert playlistinfo style server output into MPD#SongInfo list # re is the Regexp to use to match ": ". # response is the output from MPD#socket_puts. def response_to_songinfo(re, response) list = [] hash = {} response.each do |f| if md = re.match(f) then if md[1] == 'file' then if hash == {} then list << nil unless list == [] else list << hash_to_songinfo(hash) end hash = {} end hash[md[1]] = md[2] end end if hash == {} then list << nil unless list == [] else list << hash_to_songinfo(hash) end return list end def whereami(m, params) if ( @authorized_network_filter == true ) if ( m.source.host == @authorized_network ) return true else return false end else return true end end def mcurrentsong(m, params) if ( whereami(m, params) ) currentsongobject = currentsong m.reply currentsongobject if @debug_socket if !(currentsongobject.album).nil? currentalbum = "#{currentsongobject.album} ->" else currentalbum = "" end if !(currentsongobject.title).nil? currenttitle = currentsongobject.title else currenttitle = "Unknown" end if !(currentsongobject.artist).nil? currentartist = currentsongobject.artist else currentartist = "" end if ( (currentsongobject.album).nil? && (currentsongobject.title).nil? ) currenttitle = url_decode(currentsongobject.file) end #{task_logger.task} m.reply "Listening : #{currentalbum} #{currenttitle}. #{currentartist}" else m.reply "#{@message_wrong_network}" end end def help(plugin, topic="") case topic when "mplay" "mplay => Lance la musique" when "mpause" "mpause => Met en pause/Relance la musique" when "mstop" "mstop => Arrête la musique" when "mprev" "mprev => Va en arrière dans la playlist" when "mnext" "mnext => Va en avant dans la playlist" when "msong" "msong => Affiche la musique en cours de lecture" when "mvolume" "mvolume => Affiche le volume actuel, volume 35 => met le volume à 35%" when "mute" "mute => Coupe le volume / Reprend le volume à la valeur précédent le mute " else "ECRIRE 'help musicplayer mplay|mpause|mstop|mprev|mnext|msong|mvolume|mute' pour avoir plus d'informations" end end end plugin = MusicPlayerPlugin.new plugin.map 'whereami', :action => 'whereami' plugin.map 'mversion', :action => 'mversion' plugin.map 'mute', :action => 'mute' plugin.map 'mpcstart', :action => 'connect' plugin.map 'mnext', :action => 'next' plugin.map 'mprev', :action => 'previous' plugin.map 'mplay', :action => 'play' plugin.map 'mstop', :action => 'stop' plugin.map 'mpause', :action => 'pause' plugin.map 'msong', :action => 'mcurrentsong' plugin.map 'mvolume :percent', :action => 'setvol' plugin.map 'mvolume', :action => 'setvol'