diff --git a/CHANGELOG.md b/CHANGELOG.md index f570490..debef08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ MumbleDJ Changelog ================== +### November 15, 2014 +* Created "v2" branch for Ruby rewrite. + ### November 9, 2014 * Fixed volume changed message showing wrong value. diff --git a/README.md b/README.md index ba954fc..d055742 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,6 @@ MumbleDJ ======== -A Mumble bot that plays music fetched from YouTube videos. There are ways to play music with a bot on Mumble already, but I wasn't really satisfied with them. Many of them require the Windows client to be opened along with other applications which is not ideal. My goal with this project is to make a Linux-friendly Mumble bot that can run on a webserver instead of a personal computer. - -## Setup -Since the setup process is a bit extensive, the setup guide can be found in [SETUP.md](https://github.com/matthieugrieger/mumbledj/blob/master/SETUP.md). - -## Dependencies -* [OpenSSL](http://www.openssl.org/) -* [Lua 5.2](http://www.lua.org/) -* [libev](http://libev.schmorp.de/) -* [protobuf-c](https://github.com/protobuf-c/protobuf-c) -* [Ogg Vorbis](https://xiph.org/vorbis/) -* [Opus](http://www.opus-codec.org/) -* [Python 2.6 or above](https://www.python.org/) -* [pafy](https://github.com/np1/pafy/) -* [piepan](https://github.com/layeh/piepan) -* [Jansson](http://www.digip.org/jansson/) -* [jshon](http://kmkeen.com/jshon/) -* [ffmpeg](https://www.ffmpeg.org/) +A Mumble bot that plays music fetched from YouTube videos. I have decided to experiment with rewriting the bot in Ruby using [mumble-ruby](https://github.com/perrym5/mumble-ruby). I am hoping this will cut down on the dependency list, make it easier to develop in the future, and allow for some extra functionality that wasn't previously possible. ## Author [Matthieu Grieger](http://matthieugrieger.com) diff --git a/mumbledj/config.lua b/mumbledj/config.lua deleted file mode 100644 index 377ff4e..0000000 --- a/mumbledj/config.lua +++ /dev/null @@ -1,174 +0,0 @@ -------------------------- --- MumbleDJ -- --- By Matthieu Grieger -- -------------------------------------------------- --- config.lua -- --- This is where all the configuration options -- --- for the bot can be set. -- -------------------------------------------------- - -local config = {} -------------------------- --- GENERAL CONFIGURATION -------------------------- - --- Default channel --- DEFAULT VALUE: "Music" -config.DEFAULT_CHANNEL = "Music" - --- Command prefix --- DEFAULT VALUE: "!" -config.COMMAND_PREFIX = "!" - --- Show status output in console? --- DEFAULT VALUE: true -config.OUTPUT = true - --- Default volume (1 being normal volume) --- DEFAULT VALUE: 0.5 -config.VOLUME = 0.5 - --- Lowest volume allowed --- DEFAULT VALUE: 0.01 -config.LOWEST_VOLUME = 0.01 - --- Highest volume allowed --- DEFAULT VALUE: 1.5 -config.HIGHEST_VOLUME = 1.5 - --- Ratio that must be met or exceeded to trigger a song skip. --- DEFAULT VALUE: 0.5 -config.SKIP_RATIO = 0.5 - - -------------------------- --- COMMAND CONFIGURATION -------------------------- - --- Alias used for add command. --- DEFAULT VALUE: "add" -config.ADD_ALIAS = "add" - --- Alias used for skip command. --- DEFAULT VALUE: "skip" -config.SKIP_ALIAS = "skip" - --- Alias used for volume command. --- DEFAULT VALUE: "volume" -config.VOLUME_ALIAS = "volume" - --- Alias used for move command. --- DEFAULT VALUE: "move" -config.MOVE_ALIAS = "move" - --- Alias used for kill command. --- DEFAULT VALUE: "kill" -config.KILL_ALIAS = "kill" - - ------------------------ --- ADMIN CONFIGURATION ------------------------ - --- Enable admins (true = on, false = off) --- DEFAULT VALUE: true -config.ENABLE_ADMINS = true - --- List of admins --- NOTE: I recommend only giving users admin privileges if they are registered --- on the server. Otherwise people can just take their username and issue admin --- commands. --- EXAMPLE: --- config.ADMINS = {"Matt", "Matthieu"} -config.ADMINS = {"Matt", "Matthieu"} - --- Make add an admin command? --- DEFAULT VALUE: false -config.ADMIN_ADD = false - --- Make skip an admin command? --- DEFAULT VALUE: false -config.ADMIN_SKIP = false - --- Make volume an admin command? --- DEFAULT VALUE: true -config.ADMIN_VOLUME = true - --- Make move an admin command? --- DEFAULT VALUE: true -config.ADMIN_MOVE = true - --- Make kill an admin command? --- DEFAULT VALUE: true (I recommend never changing this to false) -config.ADMIN_KILL = true - - ----------------------- --- CHAT CONFIGURATION ----------------------- - --- Enable/disable chat notifications for new songs (true = on, false = off) --- DEFAULT VALUE: true -config.SHOW_NOTIFICATIONS = true - -------------------------- --- MESSAGE CONFIGURATION -------------------------- - --- Message shown to users when they do not have permission to execute a command. -config.NO_PERMISSION_MSG = "You do not have permission to execute that command." - --- Message shown to users when they try to move the bot to a non-existant channel. -config.CHANNEL_DOES_NOT_EXIST_MSG = "The channel you specified does not exist." - --- Message shown to users when they attempt to add an invalid URL to the queue. -config.INVALID_URL_MSG = "The URL you submitted does not match the required format. Either you did not provide a YouTube URL, or an error occurred during the downloading & encoding process." - --- Message shown to users when they attempt to use the stop command when no music is playing. -config.NO_MUSIC_PLAYING_MSG = "There is no music playing at the moment." - --- Message shown to users when they issue a command that requires an argument and one was not supplied. -config.NO_ARGUMENT = "The command you issued requires an argument and you did not provide one. Make sure a space exists between the command and the argument." - --- Message shown to users when they try to change the volume to a value outside the volume range. -config.NOT_IN_VOLUME_RANGE = "The volume you tried to supply is not in the allowed volume range. The value must be between " .. config.LOWEST_VOLUME .. " and " .. config.HIGHEST_VOLUME .. "." - --- Message shown to users when they successfully change the volume. -config.VOLUME_SUCCESS = "You have successfully changed the volume to the following: %s." - - ----------------------- --- HTML CONFIGURATION ----------------------- - --- Message shown to channel when a new song starts playing. -config.NOW_PLAYING_HTML = [[ - - - - - - - - - - -
%s (%s)
Added by %s
-]] - --- Message shown to channel when a song is added to the queue by a user. -config.SONG_ADDED_HTML = [[ - %s has added "%s" to the queue. -]] - --- Message shown to channel when a user votes to skip a song. -config.USER_SKIP_HTML = [[ - %s has voted to skip this song. -]] - --- Message shown to channel when a song has been skipped. -config.SONG_SKIPPED_HTML = [[ - The number of votes required for a skip has been met. Skipping song! -]] - -return config diff --git a/mumbledj/deque.lua b/mumbledj/deque.lua deleted file mode 100644 index 7899f91..0000000 --- a/mumbledj/deque.lua +++ /dev/null @@ -1,156 +0,0 @@ ---Copyright (C) 2013-2014 by Pierre Chapuis --- ---Permission is hereby granted, free of charge, to any person obtaining a copy ---of this software and associated documentation files (the "Software"), to deal ---in the Software without restriction, including without limitation the rights ---to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ---copies of the Software, and to permit persons to whom the Software is ---furnished to do so, subject to the following conditions: --- ---The above copyright notice and this permission notice shall be included in ---all copies or substantial portions of the Software. --- ---THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ---IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ---FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ---AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ---LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ---OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ---THE SOFTWARE. - -local push_right = function(self, x) - assert(x ~= nil) - self.tail = self.tail + 1 - self[self.tail] = x -end - -local push_left = function(self, x) - assert(x ~= nil) - self[self.head] = x - self.head = self.head - 1 -end - -local peek_right = function(self) - return self[self.tail] -end - -local peek_left = function(self) - return self[self.head+1] -end - -local pop_right = function(self) - if self:is_empty() then return nil end - local r = self[self.tail] - self[self.tail] = nil - self.tail = self.tail - 1 - return r -end - -local pop_left = function(self) - if self:is_empty() then return nil end - local r = self[self.head+1] - self.head = self.head + 1 - local r = self[self.head] - self[self.head] = nil - return r -end - -local rotate_right = function(self, n) - n = n or 1 - if self:is_empty() then return nil end - for i=1,n do self:push_left(self:pop_right()) end -end - -local rotate_left = function(self, n) - n = n or 1 - if self:is_empty() then return nil end - for i=1,n do self:push_right(self:pop_left()) end -end - -local _remove_at_internal = function(self, idx) - for i=idx, self.tail do self[i] = self[i+1] end - self.tail = self.tail - 1 -end - -local remove_right = function(self, x) - for i=self.tail,self.head+1,-1 do - if self[i] == x then - _remove_at_internal(self, i) - return true - end - end - return false -end - -local remove_left = function(self, x) - for i=self.head+1,self.tail do - if self[i] == x then - _remove_at_internal(self, i) - return true - end - end - return false -end - -local length = function(self) - return self.tail - self.head -end - -local is_empty = function(self) - return self:length() == 0 -end - -local contents = function(self) - local r = {} - for i=self.head+1,self.tail do - r[i-self.head] = self[i] - end - return r -end - -local iter_right = function(self) - local i = self.tail+1 - return function() - if i > self.head+1 then - i = i-1 - return self[i] - end - end -end - -local iter_left = function(self) - local i = self.head - return function() - if i < self.tail then - i = i+1 - return self[i] - end - end -end - -local methods = { - push_right = push_right, - push_left = push_left, - peek_right = peek_right, - peek_left = peek_left, - pop_right = pop_right, - pop_left = pop_left, - rotate_right = rotate_right, - rotate_left = rotate_left, - remove_right = remove_right, - remove_left = remove_left, - iter_right = iter_right, - iter_left = iter_left, - length = length, - is_empty = is_empty, - contents = contents, -} - -local new = function() - local r = {head = 0, tail = 0} - return setmetatable(r, {__index = methods}) -end - -return { - new = new, -} diff --git a/mumbledj/download_audio.py b/mumbledj/download_audio.py deleted file mode 100644 index 492a489..0000000 --- a/mumbledj/download_audio.py +++ /dev/null @@ -1,44 +0,0 @@ -#---------------------# -# MumbleDJ # -# By Matthieu Grieger # -#---------------------#--------------------------------------------------# -# download_audio.py # -# Downloads audio (ogg format) from specified YouTube ID. If no ogg file # -# exists, it creates an empty file called .video_fail that tells the Lua # -# side of the program that the download failed. .video_fail will get # -# deleted on the next successful download. # -#------------------------------------------------------------------------# - -import pafy -from sys import argv -from os.path import isfile -from os import remove, system -from time import sleep - -url = argv[1] -video = pafy.new(url) - -try: - video.oggstreams[0].download(filepath = 'song.ogg', quiet = True) - if isfile('.video_fail'): - remove('.video_fail') -except: - with open('.video_fail', 'w+') as f: - f.close() - -while isfile('song.ogg.temp'): - sleep(1) - -if isfile('song.ogg'): - system('ffmpeg -i song.ogg -acodec libvorbis -ar 48000 -ac 1 -loglevel quiet song-converted.ogg -y') -else: - with open('.video_fail', 'w+') as f: - f.close() - -if not isfile('.video_fail'): - while not isfile('song-converted.ogg'): - sleep(1) - -if isfile('song.ogg'): - remove('song.ogg') - diff --git a/mumbledj/mumbledj.lua b/mumbledj/mumbledj.lua deleted file mode 100644 index baf2d7b..0000000 --- a/mumbledj/mumbledj.lua +++ /dev/null @@ -1,357 +0,0 @@ -------------------------- --- MumbleDJ -- --- By Matthieu Grieger -- ------------------------------------------------------------------- --- mumbledj.lua -- --- The main file which defines most of MumbleDJ's behavior. All -- --- commands are found here, and most of their implementation. -- ------------------------------------------------------------------- - -local config = require("config") -local deque = require("deque") - --- Connects to Mumble server. -function piepan.onConnect() - print(piepan.me.name .. " has connected to the server!") - local user = piepan.users[piepan.me.name] - local channel = user.channel(config.DEFAULT_CHANNEL) - if channel == nil then - print("The channel '" .. config.DEFAULT_CHANNEL .. "' does not exist. Moving bot to root of server...") - channel = piepan.channels[0] - end - piepan.me:moveTo(channel) -end - --- Function that is called when a new message is posted to the channel. -function piepan.onMessage(message) - if message.user == nil then - return - end - - if string.sub(message.text, 0, 1) == config.COMMAND_PREFIX then - parse_command(message) - end -end - --- Parses commands and its arguments (if they exist), and calls the appropriate --- functions for doing the requested task. -function parse_command(message) - local command = "" - local argument = "" - if string.find(message.text, " ") then - command = string.sub(message.text, 2, string.find(message.text, ' ') - 1) - argument = string.sub(message.text, string.find(message.text, ' ') + 1) - else - command = string.sub(message.text, 2) - end - - -- Add command - if command == config.ADD_ALIAS then - local has_permission = check_permissions(config.ADMIN_ADD, message.user.name) - - if has_permission then - if config.OUTPUT then - print(message.user.name .. " has added a song to the queue.") - if not add_song(argument, message.user.name) then - message.user:send(config.INVALID_URL_MSG) - end - end - else - message.user:send(config.NO_PERMISSION_MSG) - end - -- Skip command - elseif command == config.SKIP_ALIAS then - local has_permission = check_permissions(config.ADMIN_SKIP, message.user.name) - - if has_permission then - if config.OUTPUT then - print(message.user.name .. " has voted to skip the current song.") - end - - skip(message.user.name) - else - message.user:send(config.NO_PERMISSION_MSG) - end - -- Volume command - elseif command == config.VOLUME_ALIAS then - local has_permission = check_permissions(config.ADMIN_VOLUME, message.user.name) - - if has_permission then - if config.OUTPUT then - print(message.user.name .. " has changed the volume to the following: " .. argument .. ".") - if argument ~= nil then - if config.LOWEST_VOLUME <= tonumber(argument) and tonumber(argument) <= config.HIGHEST_VOLUME then - config.VOLUME = tonumber(argument) - message.user:send(string.format(config.VOLUME_SUCCESS, argument)) - else - message.user:send(config.NOT_IN_VOLUME_RANGE) - end - else - message.user:send(config.NO_ARGUMENT) - end - end - end - -- Move command - elseif command == config.MOVE_ALIAS then - local has_permission = check_permissions(config.ADMIN_MOVE, message.user.name) - - if has_permission then - if config.OUTPUT then - print(message.user.name .. " has told the bot to move to the following channel: " .. argument .. ".") - end - if not move(argument) then - message.user:send(config.CHANNEL_DOES_NOT_EXIST_MSG) - end - else - message.user:send(config.NO_PERMISSION_MSG) - end - -- Kill command - elseif command == config.KILL_ALIAS then - local has_permission = check_permissions(config.ADMIN_KILL, message.user.name) - - if has_permission then - if config.OUTPUT then - print(message.user.name .. " has told the bot to kill itself.") - end - kill() - else - message.user:send(config.NO_PERMISSION_MSG) - end - else - message.user:send("The command you have entered is not valid.") - end -end - --- Handles a skip request through the use of helper functions found within --- song_queue.lua. Once done processing, it will compare the skip ratio with --- the one defined in the settings and decide whether to skip the current song --- or not. -function skip(username) - if add_skip(username) then - local skip_ratio = count_skippers() / count_users() - piepan.me.channel:send(string.format(config.USER_SKIP_HTML, username)) - if skip_ratio > config.SKIP_RATIO then - piepan.me.channel:send(config.SONG_SKIPPED_HTML) - piepan.Audio:stop() - next_song() - end - end -end - --- Moves the bot to the channel specified by the "chan" argument. --- NOTE: This only supports moving to a sibling channel at the moment. -function move(chan) - local user = piepan.users[piepan.me.name] - local channel = user.channel("../" .. chan) - if channel == nil then - return false - else - piepan.me:moveTo(channel) - return true - end -end - --- Performs functions that allow the bot to safely exit. -function kill() - os.remove("song.ogg") - os.remove("song-converted.ogg") - os.remove(".video_fail") - piepan.disconnect() - os.exit(0) -end - --- Checks the permissions of a user against the config to see if they are --- allowed to execute a certain command. -function check_permissions(ADMIN_COMMAND, username) - if config.ENABLE_ADMINS and ADMIN_COMMAND then - return is_admin(username) - end - - return true -end - --- Checks if a user is an admin, as specified in config.lua. -function is_admin(username) - for _,user in pairs(config.ADMINS) do - if user == username then - return true - end - end - - return false -end - --- Switches to the next song. -function next_song() - reset_skips() - if get_length() ~= 0 then - local success = get_next_song() - if not success then - piepan.me.channel:send("An error occurred while preparing the next track. Skipping...") - end - end -end - --- Checks if a file exists. -function file_exists(file) - local f=io.open(file,"r") - if f~=nil then io.close(f) return true else return false end -end - --- Returns the number of users in the Mumble server. -function count_users() - local user_count = -1 -- Set to -1 to account for the bot - for name,_ in pairs(piepan.users) do - user_count = user_count + 1 - end - return user_count -end - -------------------------------------------------- --- Song Queue Stuff -- --- Contains the definition of the song queue -- --- used for queueing up songs. -- -------------------------------------------------- - -local song_queue = deque.new() -local skippers = {} - --- Begins the process of adding a new song to the song queue. -function add_song(url, username) - local patterns = { - "https?://www%.youtube%.com/watch%?v=([%d%a_%-]+)", - "https?://youtube%.com/watch%?v=([%d%a_%-]+)", - "https?://youtu.be/([%d%a_%-]+)", - "https?://youtube.com/v/([%d%a_%-]+)", - "https?://www.youtube.com/v/([%d%a_%-]+)" - } - - for _,pattern in ipairs(patterns) do - local video_id = string.match(url, pattern) - if video_id ~= nil and string.len(video_id) < 20 then - return get_youtube_info(video_id, username) - else - return false - end - end -end - --- Retrieves the metadata for the specified YouTube video via the gdata API. -function get_youtube_info(id, username) - if id == nil then - return false - end - local cmd = [[ - wget -q -O - 'http://gdata.youtube.com/feeds/api/videos/%s?v=2&alt=jsonc' | - jshon -Q -e data -e title -u -p -e duration -u -p -e thumbnail -e hqDefault -u - ]] - local jshon = io.popen(string.format(cmd, id)) - local name = jshon:read() - local duration = jshon:read() - local thumbnail = jshon:read() - if name == nil or duration == nil then - return false - end - - return youtube_info_completed({ - id = id, - title = name, - duration = string.format("%d:%02d", duration / 60, duration % 60), - thumbnail = thumbnail, - username = username - }) -end - --- Notifies the channel that a song has been added to the queue, and plays the --- song if it is the first one in the queue. -function youtube_info_completed(info) - if info == nil then - return false - end - - song_queue:push_right(info) - - local message = string.format(config.SONG_ADDED_HTML, info.username, info.title) - piepan.me.channel:send(message) - - if not piepan.Audio.isPlaying() then - return get_next_song() - end - - return true -end - --- Deletes the old song and begins the process of retrieving a new one. -function get_next_song() - reset_skips() - if file_exists("song-converted.ogg") then - os.remove("song-converted.ogg") - end - if song_queue:length() ~= 0 then - local next_song = song_queue:pop_left() - return start_song(next_song) - end -end - --- Downloads/encodes the audio file and then begins to play it. -function start_song(info) - os.execute("python download_audio.py " .. info.id) - if not file_exists(".video_fail") then - while not file_exists("song-converted.ogg") do - os.execute("sleep " .. tonumber(2)) - end - if piepan.Audio:isPlaying() then - piepan.Audio:stop() - end - piepan.me.channel:play({filename="song-converted.ogg", volume=config.VOLUME}, get_next_song) - else - return false - end - - if piepan.Audio:isPlaying() then - local message = string.format(config.NOW_PLAYING_HTML, info.thumbnail, info.id, info.title, info.duration, info.username) - piepan.me.channel:send(message) - return true - end - - return false -end - --- Adds the username of a user who requested a skip. If their name is --- already in the list nothing will happen. -function add_skip(username) - local already_skipped = false - for _,name in pairs(skippers) do - if name == username then - already_skipped = true - end - end - if not already_skipped then - table.insert(skippers, username) - return true - end - - return false -end - --- Counts the number of users who would like to skip the current song and --- returns it. -function count_skippers() - local skipper_count = 0 - for name,_ in pairs(skippers) do - skipper_count = skipper_count + 1 - end - - return skipper_count -end - --- Resets the list of users who would like to skip a song. Called during a --- transition between songs. -function reset_skips() - skippers = {} -end - --- Retrieves the length of the song queue and returns it. -function get_length() - return song_queue:length() -end