Updated README and CHANGELOG

This commit is contained in:
Matthieu Grieger 2014-11-15 18:46:40 -08:00
parent c156d4051f
commit 49940cd08e
6 changed files with 4 additions and 749 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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 = [[
<table>
<tr>
<td align="center"><img src="%s" width=150 /></td>
</tr>
<tr>
<td align="center"><b><a href="http://youtu.be/%s">%s</a> (%s)</b></td>
</tr>
<tr>
<td align="center">Added by %s</td>
</tr>
</table>
]]
-- Message shown to channel when a song is added to the queue by a user.
config.SONG_ADDED_HTML = [[
<b>%s</b> has added "%s" to the queue.
]]
-- Message shown to channel when a user votes to skip a song.
config.USER_SKIP_HTML = [[
<b>%s</b> 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. <b>Skipping song!</b>
]]
return config

View file

@ -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,
}

View file

@ -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')

View file

@ -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