Merge branch 'dev'

Conflicts:
	.travis.yml
	README.md
	install-dependencies.sh
	service_youtube.go
This commit is contained in:
MichaelOultram 2015-08-13 15:41:03 +01:00
commit 6aeca636a4
16 changed files with 652 additions and 319 deletions

View file

@ -10,9 +10,26 @@ cache:
- $HOME/gopath/src/github.com/MichaelOultram/mumbledj/.vendor - $HOME/gopath/src/github.com/MichaelOultram/mumbledj/.vendor
before_script: before_script:
<<<<<<< HEAD
- export PATH=$PATH:~/bin/ - export PATH=$PATH:~/bin/
- export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$HOME/opus/lib/pkgconfig - export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$HOME/opus/lib/pkgconfig
- bash install-dependencies.sh - bash install-dependencies.sh
install: install:
- ls -R $HOME/opus - ls -R $HOME/opus
=======
- export PATH=$PATH:$HOME/bin/
- export LD_RUN_PATH=$LD_RUN_PATH:$HOME/opus/lib
- export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$HOME/opus/lib
- export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$HOME/opus/lib/pkgconfig
- bash install-dependencies.sh
script:
- make
- make install
after_success:
- ffmpeg -version
- youtube-dl --output ~/.mumbledj/songs/QcIy9NiNbmo.m4a --format m4a --prefer-ffmpeg -4 --verbose http://www.youtube.com/watch?v=QcIy9NiNbmo
- mumbledj -server=$MUMBLE_IP -port=$MUMBLE_PORT -username=travis -password=$MUMBLE_PASSWORD -verbose=true -test=true
>>>>>>> dev

View file

@ -13,7 +13,7 @@ install:
mkdir -p ~/.mumbledj/config mkdir -p ~/.mumbledj/config
mkdir -p ~/.mumbledj/songs mkdir -p ~/.mumbledj/songs
mkdir -p ~/.mumbledj/web mkdir -p ~/.mumbledj/web
if [ -a ~/.mumbledj/config/mumbledj.gcfg ]; then mv ~/.mumbledj/config/mumbledj.gcfg ~/.mumbledj/config/mumbledj_backup.gcfg; fi; if [ -f ~/.mumbledj/config/mumbledj.gcfg ]; then mv ~/.mumbledj/config/mumbledj.gcfg ~/.mumbledj/config/mumbledj_backup.gcfg; fi;
cp -u config.gcfg ~/.mumbledj/config/mumbledj.gcfg cp -u config.gcfg ~/.mumbledj/config/mumbledj.gcfg
cp -u index.html ~/.mumbledj/web/index.html cp -u index.html ~/.mumbledj/web/index.html
if [ -d ~/bin ]; then cp -f mumbledj* ~/bin/mumbledj; else sudo cp -f mumbledj* /usr/local/bin/mumbledj; fi; if [ -d ~/bin ]; then cp -f mumbledj* ~/bin/mumbledj; else sudo cp -f mumbledj* /usr/local/bin/mumbledj; fi;

View file

@ -1,4 +1,4 @@
MumbleDJ [![Build Status](https://travis-ci.org/MichaelOultram/mumbledj.svg?branch=master)](https://travis-ci.org/MichaelOultram/mumbledj) MumbleDJ [![Circle CI](https://circleci.com/gh/MichaelOultram/mumbledj/tree/master.svg?style=svg)](https://circleci.com/gh/MichaelOultram/mumbledj/tree/master)
======== ========
**A Mumble bot that plays music fetched from YouTube videos.** **A Mumble bot that plays music fetched from YouTube videos.**

32
circle.yml Normal file
View file

@ -0,0 +1,32 @@
machine:
environment:
PATH: $HOME/bin/:$PATH
LD_RUN_PATH: $LD_RUN_PATH:$HOME/opus/lib
LD_LIBRARY_PATH: $LD_LIBRARY_PATH:$HOME/opus/lib
PKG_CONFIG_PATH: $PKG_CONFIG_PATH:$HOME/opus/lib/pkgconfig
dependencies:
pre:
- ffmpeg -version
- sudo apt-get remove -y ffmpeg
- ffmpeg -version
- bash install-dependencies.sh
- ffmpeg -version
override:
- make
- make install
cache_directories:
- "~/opus"
- "~/bin"
- "~/mumbledj/.vendor"
- "/home/ubuntu/.go_workspace/bin"
- "/home/ubuntu/.go_workspace/pkg"
- "/home/ubuntu/.go_workspace/src/github.com/nitrous-io"
test:
override:
- youtube-dl --output ~/.mumbledj/songs/QcIy9NiNbmo.m4a --format m4a --prefer-ffmpeg -v http://www.youtube.com/watch?v=QcIy9NiNbmo
- mumbledj -server=$MUMBLE_IP -port=$MUMBLE_PORT -username=circleci -password=$MUMBLE_PASSWORD -verbose=true -test=true:
timeout: 180

View file

@ -159,6 +159,13 @@ func parseCommand(user *gumble.User, username, command string) {
} else { } else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG) dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
} }
// Test command (WORKAROUND)
case "test":
if dj.HasPermission(username, dj.conf.Permissions.AdminKill) {
test.testYoutubeSong()
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
default: default:
dj.SendPrivateMessage(user, COMMAND_DOESNT_EXIST_MSG) dj.SendPrivateMessage(user, COMMAND_DOESNT_EXIST_MSG)
} }
@ -171,7 +178,11 @@ func add(user *gumble.User, url string) error {
dj.SendPrivateMessage(user, NO_ARGUMENT_MSG) dj.SendPrivateMessage(user, NO_ARGUMENT_MSG)
return errors.New("NO_ARGUMENT") return errors.New("NO_ARGUMENT")
} else { } else {
return findServiceAndAdd(user, url) err := findServiceAndAdd(user, url)
if err != nil {
dj.SendPrivateMessage(user, err.Error())
}
return err
} }
} }

View file

@ -144,7 +144,7 @@ AdminsEnabled = true
# SYNTAX: In order to specify multiple admins, repeat the Admins="username" # SYNTAX: In order to specify multiple admins, repeat the Admins="username"
# line of code. Each line has one username, and an unlimited amount of usernames may # line of code. Each line has one username, and an unlimited amount of usernames may
# be entered in this matter. # be entered in this matter.
Admins = "Matt" Admins = "BottleOToast"
# Make add an admin command? # Make add an admin command?
# DEFAULT VALUE: false # DEFAULT VALUE: false

View file

@ -1,38 +1,49 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="ISO-8859-1"> <meta charset="ISO-8859-1">
<title>{{.User}} - mumbledj</title> <title>{{.User}} - mumbledj</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script> <script
<script type="text/javascript"> src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script>
function api(type) { <script type="text/javascript">
return "http://{{.Site}}/" + type + "?token={{.Token}}"; function onLoad() {
window.setInterval(function() {
// Get the song queue
}, 6000);
} }
function addURL() { function setAPI(type, val) {
var url = $("#textbox"); $.ajax({
$.ajax(api("add") + "&value=" + url.attr("value")); url : "http://{{.Site}}/api/" + type + "?token={{.Token}}"
url.attr("value", ""); + "&value=" + val,
complete : apiComplete,
cache : false
});
} }
function volume() { function apiComplete(jqXHR, textStatus) {
var volume = $("#textbox"); alert(textStatus);
$.ajax(api("volume") + "&value=" + volume.attr("value"));
volume.attr("value", "");
} }
function skip(val) { function txtBox(type) {
$.ajax(api("skip") + "&value=" + val); var txt = $("#textbox");
api(type, txt.attr("value"));
txt.attr("value", "");
} }
</script>
</script> </head>
</head> <body onload="onLoad();" bgcolor="#FFFFFF">
<body>
<h1>Add Song Form</h1> <h1>Add Song Form</h1>
<input id="textbox" type="text"/> <input id="textbox" type="text" />
<input id="add" type="button" value="Add Song" onclick="addURL()"/> <input id="add" type="button" value="Add Song"
<input id="volume" type="button" value="Set Volume" onclick="volume()"/> onclick="setAPI('add', $('#textbox').attr('value'))" />
<input id="skipSong" type="button" value="Skip Current Song" onclick="skip('false')"/> <input id="volume" type="button" value="Set Volume"
<input id="skipPlaylist" type="button" value="Skip Current Playlist" onclick="skip('true')"/> onclick="setAPI('volume', $('#textbox').attr('value'))" />
</body> <input id="skipSong" type="button" value="Skip Current Song"
onclick="setAPI('skip', false)" />
<input id="skipPlaylist" type="button" value="Skip Current Playlist"
onclick="setAPI('skip', true)" />
<br />
<textarea id="status" rows="10" cols="30"></textarea>
</body>
</html> </html>

View file

@ -3,16 +3,18 @@ set -e
# check to see if ffmpeg is installed # check to see if ffmpeg is installed
if [ ! -f "$HOME/bin/ffmpeg" ]; then if [ ! -f "$HOME/bin/ffmpeg" ]; then
echo 'Installing ffmpeg'
wget http://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz -O /tmp/ffmpeg.tar.xz wget http://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz -O /tmp/ffmpeg.tar.xz
tar -xvf /tmp/ffmpeg.tar.xz --strip 1 --no-anchored ffmpeg ffprobe tar -xvf /tmp/ffmpeg.tar.xz --strip 1 --no-anchored ffmpeg ffprobe
chmod a+rx ffmpeg ffprobe chmod a+rx ffmpeg ffprobe
mv ff* $HOME/bin mv ff* ~/bin
else else
echo 'Using cached version of ffmpeg.'; echo 'Using cached version of ffmpeg.';
fi fi
# check to see if opus is installed # check to see if opus is installed
if [ ! -d "$HOME/opus/lib" ]; then if [ ! -d "$HOME/opus/lib" ]; then
echo 'Installing opus'
wget http://downloads.xiph.org/releases/opus/opus-1.0.3.tar.gz wget http://downloads.xiph.org/releases/opus/opus-1.0.3.tar.gz
tar xzvf opus-1.0.3.tar.gz tar xzvf opus-1.0.3.tar.gz
cd opus-1.0.3 && ./configure --prefix=$HOME/opus && make && make install cd opus-1.0.3 && ./configure --prefix=$HOME/opus && make && make install
@ -22,6 +24,7 @@ fi
# check to see if youtube-dl is installed # check to see if youtube-dl is installed
if [ ! -f "$HOME/bin/youtube-dl" ]; then if [ ! -f "$HOME/bin/youtube-dl" ]; then
echo 'Installing youtube-dl'
curl https://yt-dl.org/downloads/2015.07.28/youtube-dl -o ~/bin/youtube-dl curl https://yt-dl.org/downloads/2015.07.28/youtube-dl -o ~/bin/youtube-dl
chmod a+rx ~/bin/youtube-dl chmod a+rx ~/bin/youtube-dl
else else

12
main.go
View file

@ -142,12 +142,14 @@ func PerformStartupChecks() {
} }
} }
// Prints out messages only if verbose flag is true
func Verbose(msg string) { func Verbose(msg string) {
if dj.verbose { if dj.verbose {
fmt.Printf(msg + "\n") fmt.Printf(msg + "\n")
} }
} }
// Checks to see if an object is nil
func isNil(a interface{}) bool { func isNil(a interface{}) bool {
defer func() { recover() }() defer func() { recover() }()
return a == nil || reflect.ValueOf(a).IsNil() return a == nil || reflect.ValueOf(a).IsNil()
@ -191,7 +193,7 @@ func main() {
} }
var address, port, username, password, channel, pemCert, pemKey, accesstokens string var address, port, username, password, channel, pemCert, pemKey, accesstokens string
var insecure, verbose bool var insecure, verbose, testcode bool
flag.StringVar(&address, "server", "localhost", "address for Mumble server") flag.StringVar(&address, "server", "localhost", "address for Mumble server")
flag.StringVar(&port, "port", "64738", "port for Mumble server") flag.StringVar(&port, "port", "64738", "port for Mumble server")
@ -202,7 +204,8 @@ func main() {
flag.StringVar(&pemKey, "key", "", "path to user PEM key for MumbleDJ") flag.StringVar(&pemKey, "key", "", "path to user PEM key for MumbleDJ")
flag.StringVar(&accesstokens, "accesstokens", "", "list of access tokens for channel auth") flag.StringVar(&accesstokens, "accesstokens", "", "list of access tokens for channel auth")
flag.BoolVar(&insecure, "insecure", false, "skip certificate checking") flag.BoolVar(&insecure, "insecure", false, "skip certificate checking")
flag.BoolVar(&verbose, "verbose", false, "prints out debug messages to the console") flag.BoolVar(&verbose, "verbose", false, "[debug] prints out debug messages to the console")
flag.BoolVar(&testcode, "test", false, "[debug] tests the features of mumbledj")
flag.Parse() flag.Parse()
dj.config = gumble.Config{ dj.config = gumble.Config{
@ -244,6 +247,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if testcode {
Verbose("Testing is enabled")
Test(password, address, port, strings.Split(accesstokens, " "))
}
web = NewWebServer(9563) web = NewWebServer(9563)
web.makeWeb() web.makeWeb()

View file

@ -52,7 +52,7 @@ type Playlist interface {
Title() string Title() string
} }
var services = []Service{YouTube{}} var services = []Service{YouTube{}, SoundCloud{}}
func findServiceAndAdd(user *gumble.User, url string) error { func findServiceAndAdd(user *gumble.User, url string) error {
var urlService Service var urlService Service
@ -65,6 +65,7 @@ func findServiceAndAdd(user *gumble.User, url string) error {
} }
if urlService == nil { if urlService == nil {
Verbose("Invalid_URL")
return errors.New("INVALID_URL") return errors.New("INVALID_URL")
} else { } else {
oldLength := dj.queue.Len() oldLength := dj.queue.Len()

126
service_soundcloud.go Normal file
View file

@ -0,0 +1,126 @@
package main
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/jmoiron/jsonq"
"github.com/layeh/gumble/gumble"
)
// Regular expressions for soundcloud urls
var soundcloudSongPattern = `https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/([\w-]+)`
var soundcloudPlaylistPattern = `https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/sets\/([\w-]+)`
// SoundCloud implements the Service interface
type SoundCloud struct{}
// ------------------
// SOUNDCLOUD SERVICE
// ------------------
// Name of the service
func (sc SoundCloud) ServiceName() string {
return "SoundCloud"
}
// Checks to see if service will accept URL
func (sc SoundCloud) URLRegex(url string) bool {
return RegexpFromURL(url, []string{soundcloudSongPattern, soundcloudPlaylistPattern}) != nil
}
// Creates the requested song/playlist and adds to the queue
func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) {
var apiResponse *jsonq.JsonQuery
var err error
url = fmt.Sprintf("http://api.soundcloud.com/resolve?url=%s&client_id=%s", url, os.Getenv("SOUNDCLOUD_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return "", errors.New(INVALID_API_KEY)
}
tracks, err := apiResponse.ArrayOfObjects("tracks")
if err == nil {
// PLAYLIST
if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) {
// Check duration of playlist
//duration, _ := apiResponse.Int("duration")
// Create playlist
title, _ := apiResponse.String("title")
permalink, _ := apiResponse.String("permalink_url")
playlist := &YouTubeDLPlaylist{
id: permalink,
title: title,
}
// Add all tracks
for _, t := range tracks {
sc.NewSong(user, jsonq.NewQuery(t), playlist)
}
if err == nil {
return playlist.Title(), nil
} else {
Verbose("soundcloud.NewRequest: " + err.Error())
return "", err
}
} else {
return "", errors.New("NO_PLAYLIST_PERMISSION")
}
} else {
// SONG
return sc.NewSong(user, apiResponse, nil)
}
}
// Creates a track and adds to the queue
func (sc SoundCloud) NewSong(user *gumble.User, trackData *jsonq.JsonQuery, playlist Playlist) (string, error) {
title, err := trackData.String("title")
if err != nil {
return "", err
}
id, err := trackData.Int("id")
if err != nil {
return "", err
}
duration, err := trackData.Int("duration")
if err != nil {
return "", err
}
thumbnail, err := trackData.String("artwork_url")
if err != nil {
// Song has no artwork, using profile avatar instead
userObj, err := trackData.Object("user")
if err != nil {
return "", err
}
thumbnail, err = jsonq.NewQuery(userObj).String("avatar_url")
if err != nil {
return "", err
}
}
url, err := trackData.String("permalink_url")
if err != nil {
return "", err
}
song := &YouTubeDLSong{
id: strconv.Itoa(id),
title: title,
url: url,
thumbnail: thumbnail,
submitter: user,
duration: strconv.Itoa(duration),
format: "mp3",
playlist: playlist,
skippers: make([]string, 0),
dontSkip: false,
}
dj.queue.AddSong(song)
return title, nil
}

View file

@ -14,15 +14,12 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "os"
"os/exec"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/jmoiron/jsonq" "github.com/jmoiron/jsonq"
"github.com/layeh/gumble/gumble" "github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumble_ffmpeg"
) )
// Regular expressions for youtube urls // Regular expressions for youtube urls
@ -42,26 +39,6 @@ var youtubeVideoPatterns = []string{
// YouTube implements the Service interface // YouTube implements the Service interface
type YouTube struct{} type YouTube struct{}
// YouTubeSong holds the metadata for a song extracted from a YouTube video.
type YouTubeSong struct {
submitter string
title string
id string
offset int
filename string
duration string
thumbnail string
skippers []string
playlist Playlist
dontSkip bool
}
// YouTubePlaylist holds the metadata for a YouTube playlist.
type YouTubePlaylist struct {
id string
title string
}
// --------------- // ---------------
// YOUTUBE SERVICE // YOUTUBE SERVICE
// --------------- // ---------------
@ -83,7 +60,7 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) {
if re.MatchString(url) { if re.MatchString(url) {
if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) { if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) {
shortURL = re.FindStringSubmatch(url)[1] shortURL = re.FindStringSubmatch(url)[1]
playlist, err := yt.NewPlaylist(user.Name, shortURL) playlist, err := yt.NewPlaylist(user, shortURL)
return playlist.Title(), err return playlist.Title(), err
} else { } else {
return "", errors.New("NO_PLAYLIST_PERMISSION") return "", errors.New("NO_PLAYLIST_PERMISSION")
@ -95,10 +72,11 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) {
if len(matches[0]) == 3 { if len(matches[0]) == 3 {
startOffset = matches[0][2] startOffset = matches[0][2]
} }
song, err := yt.NewSong(user.Name, shortURL, startOffset, nil) song, err := yt.NewSong(user, shortURL, startOffset, nil)
if !isNil(song) { if !isNil(song) {
return song.Title(), err return song.Title(), nil
} else { } else {
Verbose("youtube.NewRequest: " + err.Error())
return "", err return "", err
} }
} }
@ -109,12 +87,12 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) {
// NewSong gathers the metadata for a song extracted from a YouTube video, and returns // NewSong gathers the metadata for a song extracted from a YouTube video, and returns
// the song. // the song.
func (yt YouTube) NewSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTubeSong, error) { func (yt YouTube) NewSong(user *gumble.User, id, offset string, playlist Playlist) (Song, error) {
var apiResponse *jsonq.JsonQuery var apiResponse *jsonq.JsonQuery
var err error var err error
url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=%s&key=%s", url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=%s&key=%s",
id, os.Getenv("YOUTUBE_API_KEY")) id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = yt.PerformGetRequest(url); err != nil { if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, errors.New(INVALID_API_KEY) return nil, errors.New(INVALID_API_KEY)
} }
@ -183,14 +161,15 @@ func (yt YouTube) NewSong(user, id, offset string, playlist *YouTubePlaylist) (*
} }
if dj.conf.General.MaxSongDuration == 0 || totalSeconds <= dj.conf.General.MaxSongDuration { if dj.conf.General.MaxSongDuration == 0 || totalSeconds <= dj.conf.General.MaxSongDuration {
song := &YouTubeSong{ song := &YouTubeDLSong{
submitter: user, submitter: user,
title: title, title: title,
id: id, id: id,
url: "https://youtu.be/" + id,
offset: int((offsetDays * 86400) + (offsetHours * 3600) + (offsetMinutes * 60) + offsetSeconds), offset: int((offsetDays * 86400) + (offsetHours * 3600) + (offsetMinutes * 60) + offsetSeconds),
filename: id + ".m4a",
duration: durationString, duration: durationString,
thumbnail: thumbnail, thumbnail: thumbnail,
format: "m4a",
skippers: make([]string, 0), skippers: make([]string, 0),
playlist: playlist, playlist: playlist,
dontSkip: false, dontSkip: false,
@ -204,18 +183,17 @@ func (yt YouTube) NewSong(user, id, offset string, playlist *YouTubePlaylist) (*
} }
// NewPlaylist gathers the metadata for a YouTube playlist and returns it. // NewPlaylist gathers the metadata for a YouTube playlist and returns it.
func (yt YouTube) NewPlaylist(user, id string) (*YouTubePlaylist, error) { func (yt YouTube) NewPlaylist(user *gumble.User, id string) (Playlist, error) {
var apiResponse *jsonq.JsonQuery var apiResponse *jsonq.JsonQuery
var err error var err error
// Retrieve title of playlist // Retrieve title of playlist
url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s", url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s", id, os.Getenv("YOUTUBE_API_KEY"))
id, os.Getenv("YOUTUBE_API_KEY")) if apiResponse, err = PerformGetRequest(url); err != nil {
if apiResponse, err = yt.PerformGetRequest(url); err != nil {
return nil, err return nil, err
} }
title, _ := apiResponse.String("items", "0", "snippet", "title") title, _ := apiResponse.String("items", "0", "snippet", "title")
playlist := &YouTubePlaylist{ playlist := &YouTubeDLPlaylist{
id: id, id: id,
title: title, title: title,
} }
@ -223,7 +201,7 @@ func (yt YouTube) NewPlaylist(user, id string) (*YouTubePlaylist, error) {
// Retrieve items in playlist // Retrieve items in playlist
url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=50&playlistId=%s&key=%s", url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=50&playlistId=%s&key=%s",
id, os.Getenv("YOUTUBE_API_KEY")) id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = yt.PerformGetRequest(url); err != nil { if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, err return nil, err
} }
numVideos, _ := apiResponse.Int("pageInfo", "totalResults") numVideos, _ := apiResponse.Int("pageInfo", "totalResults")
@ -239,241 +217,8 @@ func (yt YouTube) NewPlaylist(user, id string) (*YouTubePlaylist, error) {
return playlist, nil return playlist, nil
} }
// ------------
// YOUTUBE SONG
// ------------
// Download downloads the song via youtube-dl if it does not already exist on disk.
// All downloaded songs are stored in ~/.mumbledj/songs and should be automatically cleaned.
func (s *YouTubeSong) Download() error {
// Checks to see if song is already downloaded
if _, err := os.Stat(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, s.Filename())); os.IsNotExist(err) {
Verbose("Downloading " + s.Title())
cmd := exec.Command("youtube-dl", "--output", fmt.Sprintf(`~/.mumbledj/songs/%s`, s.Filename()), "--format", "m4a", "--", s.ID())
if err := cmd.Run(); err == nil {
if dj.conf.Cache.Enabled {
dj.cache.CheckMaximumDirectorySize()
}
Verbose(s.Title() + " downloaded")
return nil
}
Verbose(s.Title() + " failed to download")
return errors.New("Song download failed.")
}
return nil
}
// Play plays the song. Once the song is playing, a notification is displayed in a text message that features the video
// thumbnail, URL, title, duration, and submitter.
func (s *YouTubeSong) Play() {
if s.offset != 0 {
offsetDuration, _ := time.ParseDuration(fmt.Sprintf("%ds", s.offset))
dj.audioStream.Offset = offsetDuration
}
dj.audioStream.Source = gumble_ffmpeg.SourceFile(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, s.Filename()))
if err := dj.audioStream.Play(); err != nil {
panic(err)
} else {
if isNil(s.Playlist()) {
message := `
<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>
`
dj.client.Self.Channel.Send(fmt.Sprintf(message, s.Thumbnail(), s.ID(), s.Title(),
s.Duration(), s.Submitter()), false)
} else {
message := `
<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>
<tr>
<td align="center">From playlist "%s"</td>
</tr>
</table>
`
dj.client.Self.Channel.Send(fmt.Sprintf(message, s.Thumbnail(), s.ID(),
s.Title(), s.Duration(), s.Submitter(), s.Playlist().Title()), false)
}
Verbose("Now playing " + s.Title())
go func() {
dj.audioStream.Wait()
dj.queue.OnSongFinished()
}()
}
}
// Delete deletes the song from ~/.mumbledj/songs if the cache is disabled.
func (s *YouTubeSong) Delete() error {
if dj.conf.Cache.Enabled == false {
filePath := fmt.Sprintf("%s/.mumbledj/songs/%s.m4a", dj.homeDir, s.ID())
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err == nil {
Verbose("Deleted " + s.Title())
return nil
}
Verbose("Failed to delete " + s.Title())
return errors.New("Error occurred while deleting audio file.")
}
return nil
}
return nil
}
// AddSkip adds a skip to the skippers slice. If the user is already in the slice, AddSkip
// returns an error and does not add a duplicate skip.
func (s *YouTubeSong) AddSkip(username string) error {
for _, user := range s.skippers {
if username == user {
return errors.New("This user has already skipped the current song.")
}
}
s.skippers = append(s.skippers, username)
return nil
}
// RemoveSkip removes a skip from the skippers slice. If username is not in slice, an error is
// returned.
func (s *YouTubeSong) RemoveSkip(username string) error {
for i, user := range s.skippers {
if username == user {
s.skippers = append(s.skippers[:i], s.skippers[i+1:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// SkipReached calculates the current skip ratio based on the number of users within MumbleDJ's
// channel and the number of usernames in the skippers slice. If the value is greater than or equal
// to the skip ratio defined in the config, the function returns true, and returns false otherwise.
func (s *YouTubeSong) SkipReached(channelUsers int) bool {
if float32(len(s.skippers))/float32(channelUsers) >= dj.conf.General.SkipRatio {
return true
}
return false
}
// Submitter returns the name of the submitter of the YouTubeSong.
func (s *YouTubeSong) Submitter() string {
return s.submitter
}
// Title returns the title of the YouTubeSong.
func (s *YouTubeSong) Title() string {
return s.title
}
// ID returns the id of the YouTubeSong.
func (s *YouTubeSong) ID() string {
return s.id
}
// Filename returns the filename of the YouTubeSong.
func (s *YouTubeSong) Filename() string {
return s.filename
}
// Duration returns the duration of the YouTubeSong.
func (s *YouTubeSong) Duration() string {
return s.duration
}
// Thumbnail returns the thumbnail URL for the YouTubeSong.
func (s *YouTubeSong) Thumbnail() string {
return s.thumbnail
}
// Playlist returns the playlist type for the YouTubeSong (may be nil).
func (s *YouTubeSong) Playlist() Playlist {
return s.playlist
}
// DontSkip returns the DontSkip boolean value for the YouTubeSong.
func (s *YouTubeSong) DontSkip() bool {
return s.dontSkip
}
// SetDontSkip sets the DontSkip boolean value for the YouTubeSong.
func (s *YouTubeSong) SetDontSkip(value bool) {
s.dontSkip = value
}
// ----------------
// YOUTUBE PLAYLIST
// ----------------
// AddSkip adds a skip to the playlist's skippers slice.
func (p *YouTubePlaylist) AddSkip(username string) error {
for _, user := range dj.playlistSkips[p.ID()] {
if username == user {
return errors.New("This user has already skipped the current song.")
}
}
dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()], username)
return nil
}
// RemoveSkip removes a skip from the playlist's skippers slice. If username is not in the slice
// an error is returned.
func (p *YouTubePlaylist) RemoveSkip(username string) error {
for i, user := range dj.playlistSkips[p.ID()] {
if username == user {
dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()][:i], dj.playlistSkips[p.ID()][i+1:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// DeleteSkippers removes the skippers entry in dj.playlistSkips.
func (p *YouTubePlaylist) DeleteSkippers() {
delete(dj.playlistSkips, p.ID())
}
// SkipReached calculates the current skip ratio based on the number of users within MumbleDJ's
// channel and the number of usernames in the skippers slice. If the value is greater than or equal
// to the skip ratio defined in the config, the function returns true, and returns false otherwise.
func (p *YouTubePlaylist) SkipReached(channelUsers int) bool {
if float32(len(dj.playlistSkips[p.ID()]))/float32(channelUsers) >= dj.conf.General.PlaylistSkipRatio {
return true
}
return false
}
// ID returns the id of the YouTubePlaylist.
func (p *YouTubePlaylist) ID() string {
return p.id
}
// Title returns the title of the YouTubePlaylist.
func (p *YouTubePlaylist) Title() string {
return p.title
}
// -----------
// YOUTUBE API
// -----------
// PerformGetRequest does all the grunt work for a YouTube HTTPS GET request. // PerformGetRequest does all the grunt work for a YouTube HTTPS GET request.
func (yt YouTube) PerformGetRequest(url string) (*jsonq.JsonQuery, error) { func PerformGetRequest(url string) (*jsonq.JsonQuery, error) {
jsonString := "" jsonString := ""
if response, err := http.Get(url); err == nil { if response, err := http.Get(url); err == nil {
@ -486,7 +231,7 @@ func (yt YouTube) PerformGetRequest(url string) (*jsonq.JsonQuery, error) {
if response.StatusCode == 403 { if response.StatusCode == 403 {
return nil, errors.New("Invalid API key supplied.") return nil, errors.New("Invalid API key supplied.")
} }
return nil, errors.New("Invalid YouTube ID supplied.") return nil, errors.New("Invalid ID supplied.")
} }
} else { } else {
return nil, errors.New("An error occurred while receiving HTTP GET response.") return nil, errors.New("An error occurred while receiving HTTP GET response.")

View file

@ -77,6 +77,14 @@ func (q *SongQueue) Traverse(visit func(i int, s Song)) {
} }
} }
// Gets the song at a specific point in the queue
func (q *SongQueue) Get(i int) (Song, error) {
if q.Len() > i+1 {
return q.queue[i], nil
}
return nil, errors.New("Out of Bounds")
}
// OnSongFinished event. Deletes Song that just finished playing, then queues the next Song (if exists). // OnSongFinished event. Deletes Song that just finished playing, then queues the next Song (if exists).
func (q *SongQueue) OnSongFinished() { func (q *SongQueue) OnSongFinished() {
resetOffset, _ := time.ParseDuration(fmt.Sprintf("%ds", 0)) resetOffset, _ := time.ParseDuration(fmt.Sprintf("%ds", 0))

85
test.go Normal file
View file

@ -0,0 +1,85 @@
package main
import (
"fmt"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumbleutil"
"os"
"time"
)
type TestSettings struct {
password string
ip string
port string
accesstokens []string
}
var test TestSettings
func Test(password, ip, port string, accesstokens []string) {
test = TestSettings{
password: password,
ip: ip,
port: port,
accesstokens: accesstokens,
}
test.testYoutubeSong()
}
func (t TestSettings) createClient(uname string) *gumble.Client {
client := gumble.NewClient(&gumble.Config{
Username: uname,
Password: t.password,
Address: t.ip + ":" + t.port,
Tokens: t.accesstokens,
})
gumbleutil.CertificateLockFile(client, fmt.Sprintf("%s/.mumbledj/cert.lock", dj.homeDir))
return client
}
func (t TestSettings) testYoutubeSong() {
// dummyClient := t.createClient("dummy")
// if err := dummyClient.Connect(); err != nil {
// panic(err)
// }
dj.client.Request(gumble.RequestUserList)
time.Sleep(time.Second * 5)
dummyUser := dj.client.Users.Find("BottleOToast")
if dummyUser == nil {
fmt.Printf("User does not exist, printing users\n")
for _, user := range dj.client.Users {
fmt.Printf(user.Name + "\n")
}
fmt.Printf("End of user list\n")
os.Exit(1)
}
// Don't judge, I used the (autogenerated) Top Tracks for United Kingdom playlist
songs := map[string]string{
"http://www.youtube.com/watch?v=QcIy9NiNbmo": "Taylor Swift - Bad Blood ft. Kendrick Lamar",
"https://www.youtube.com/watch?v=vjW8wmF5VWc": "Silentó - Watch Me (Whip/Nae Nae) (Official)",
"http://youtu.be/nsDwItoNlLc": "Tinie Tempah ft. Jess Glynne - Not Letting Go (Official Video)",
"https://youtu.be/hXTAn4ELEwM": "Years & Years - Shine",
"http://youtube.com/watch?v=RgKAFK5djSk": "Wiz Khalifa - See You Again ft. Charlie Puth [Official Video] Furious 7 Soundtrack",
"https://youtube.com/watch?v=qWWSM3wCiKY": "Calvin Harris & Disciples - How Deep Is Your Love (Audio)",
"http://www.youtube.com/v/yzTuBuRdAyA": "The Weeknd - The Hills",
"https://www.youtube.com/v/cNw8A5pwbVI": "Pia Mia - Do It Again ft. Chris Brown, Tyga",
}
for url, title := range songs {
err := add(dummyUser, url)
if err != nil {
fmt.Printf("For: %s; Expected: %s; Got: %s\n", url, title, err.Error())
} else if dj.queue.CurrentSong().Title() != title {
fmt.Printf("For: %s; Expected: %s; Got: %s\n", url, title, dj.queue.CurrentSong().Title())
}
time.Sleep(time.Second * 5)
skip(dummyUser, false, false)
}
os.Exit(0)
//dummyClient.Disconnect()
}

65
web.go
View file

@ -1,6 +1,7 @@
package main package main
import ( import (
//"encoding/json"
"fmt" "fmt"
"html" "html"
"html/template" "html/template"
@ -8,7 +9,6 @@ import (
"math/rand" "math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -28,6 +28,21 @@ type Page struct {
User string User string
} }
type Status struct {
Error bool
ErrorMsg string
Queue []SongInfo
}
type SongInfo struct {
TitleID string
PlaylistID string
Title string
Playlist string
Submitter string
Duration string
Thumbnail string
}
var external_ip = "" var external_ip = ""
func NewWebServer(port int) *WebServer { func NewWebServer(port int) *WebServer {
@ -41,9 +56,10 @@ func NewWebServer(port int) *WebServer {
func (web *WebServer) makeWeb() { func (web *WebServer) makeWeb() {
http.HandleFunc("/", web.homepage) http.HandleFunc("/", web.homepage)
http.HandleFunc("/add", web.add) http.HandleFunc("/api/add", web.add)
http.HandleFunc("/volume", web.volume) http.HandleFunc("/api/volume", web.volume)
http.HandleFunc("/skip", web.skip) http.HandleFunc("/api/skip", web.skip)
//http.HandleFunc("/api/status", web.status)
http.ListenAndServe(":"+strconv.Itoa(web.port), nil) http.ListenAndServe(":"+strconv.Itoa(web.port), nil)
} }
@ -52,8 +68,14 @@ func (web *WebServer) homepage(w http.ResponseWriter, r *http.Request) {
if uname == nil { if uname == nil {
fmt.Fprintf(w, "Invalid Token") fmt.Fprintf(w, "Invalid Token")
} else { } else {
cwd, _ := os.Getwd() var webpage = uname.Name
t, err := template.ParseFiles(filepath.Join(cwd, "./.mumbledj/web/index.html"))
// Check to see if user has a custom webpage
if _, err := os.Stat(fmt.Sprintf("%s/.mumbledj/web/%s.html", dj.homeDir, uname.Name)); os.IsNotExist(err) {
webpage = "index"
}
t, err := template.ParseFiles(fmt.Sprintf("%s/.mumbledj/web/%s.html", dj.homeDir, webpage))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -103,6 +125,35 @@ func (web *WebServer) skip(w http.ResponseWriter, r *http.Request) {
} }
} }
//func (web *WebServer) status(w http.ResponseWriter, r *http.Request) {
// var uname = web.token_client[r.FormValue("token")]
// if uname == nil {
// str, ok := json.Marshal(&Status{true, "Invalid Token"}).(string)
// fmt.Fprintf(w, str)
// } else {
// // Generate song queue
// queueLength := dj.queue.Len()
// var songsInQueue [queueLength]SongInfo
// for i := 0; i < dj.queue.Len(); i++ {
// songItem := dj.queue.Get(i)
// songsInQueue[i] = &SongInfo{
// TitleID: songItem.ID(),
// Title: songItem.Title(),
// Submitter: songItem.Submitter(),
// Duration: songItem.Duration(),
// Thumbnail: songItem.Thumbnail(),
// }
// if !isNil(songItem.Playlist()) {
// songsInQueue[i].PlaylistID = songItem.Playlist().ID()
// songsInQueue[i].Playlist = songItem.Playlist().Title()
// }
// }
//
// // Output status
// fmt.Fprintf(w, string(json.MarshalIndent(&Status{false, "", songsInQueue})))
// }
//}
func (website *WebServer) GetWebAddress(user *gumble.User) { func (website *WebServer) GetWebAddress(user *gumble.User) {
Verbose("Port number: " + strconv.Itoa(web.port)) Verbose("Port number: " + strconv.Itoa(web.port))
if web.client_token[user] != "" { if web.client_token[user] != "" {
@ -110,7 +161,7 @@ func (website *WebServer) GetWebAddress(user *gumble.User) {
} }
// dealing with collisions // dealing with collisions
var firstLoop = true var firstLoop = true
for firstLoop || web.token_client[web.client_token[user]] != nil { for firstLoop || web.token_client[web.client_token[user]] != nil || web.client_token[user] == "api" {
web.client_token[user] = randSeq(10) web.client_token[user] = randSeq(10)
firstLoop = false firstLoop = false
} }

235
youtube_dl.go Normal file
View file

@ -0,0 +1,235 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"time"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumble_ffmpeg"
)
// Extends a Song
type YouTubeDLSong struct {
id string
title string
thumbnail string
submitter *gumble.User
duration string
url string
offset int
format string
playlist Playlist
skippers []string
dontSkip bool
}
type YouTubeDLPlaylist struct {
id string
title string
}
// -------------
// YouTubeDLSong
// -------------
// Download downloads the song via youtube-dl if it does not already exist on disk.
// All downloaded songs are stored in ~/.mumbledj/songs and should be automatically cleaned.
func (dl *YouTubeDLSong) Download() error {
// Checks to see if song is already downloaded
if _, err := os.Stat(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename())); os.IsNotExist(err) {
cmd := exec.Command("youtube-dl", "--no-mtime", "--output", fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename()), "--format", dl.format, "--prefer-ffmpeg", dl.url)
output, err := cmd.CombinedOutput()
if err == nil {
if dj.conf.Cache.Enabled {
dj.cache.CheckMaximumDirectorySize()
}
return nil
} else {
args := ""
for s := range cmd.Args {
args += cmd.Args[s] + " "
}
Verbose(args)
Verbose(string(output))
Verbose("youtube-dl: " + err.Error())
return errors.New("Song download failed.")
}
}
return nil
}
// Play plays the song. Once the song is playing, a notification is displayed in a text message that features the song
// thumbnail, URL, title, duration, and submitter.
func (dl *YouTubeDLSong) Play() {
if dl.offset != 0 {
offsetDuration, _ := time.ParseDuration(fmt.Sprintf("%ds", dl.offset))
dj.audioStream.Offset = offsetDuration
}
dj.audioStream.Source = gumble_ffmpeg.SourceFile(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename()))
if err := dj.audioStream.Play(); err != nil {
panic(err)
} else {
message := `<table><tr><td align="center"><img src="%s" width=150 /></td></tr><tr><td align="center"><b><a href="%s">%s</a> (%s)</b></td></tr><tr><td align="center">Added by %s</td></tr>`
message = fmt.Sprintf(message, dl.thumbnail, dl.url, dl.title, dl.duration, dl.submitter.Name)
if !isNil(dl.playlist) {
message = fmt.Sprintf(message+`<tr><td align="center">From playlist "%s"</td></tr>`, dl.playlist.Title())
}
dj.client.Self.Channel.Send(message+`</table>`, false)
Verbose("Now playing " + dl.title)
go func() {
dj.audioStream.Wait()
dj.queue.OnSongFinished()
}()
}
}
// Delete deletes the song from ~/.mumbledj/songs if the cache is disabled.
func (dl *YouTubeDLSong) Delete() error {
if dj.conf.Cache.Enabled == false {
filePath := fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename())
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err == nil {
return nil
}
return errors.New("Error occurred while deleting audio file.")
}
return nil
}
return nil
}
// AddSkip adds a skip to the skippers slice. If the user is already in the slice, AddSkip
// returns an error and does not add a duplicate skip.
func (dl *YouTubeDLSong) AddSkip(username string) error {
for _, user := range dl.skippers {
if username == user {
return errors.New("This user has already skipped the current song.")
}
}
dl.skippers = append(dl.skippers, username)
return nil
}
// RemoveSkip removes a skip from the skippers slice. If username is not in slice, an error is
// returned.
func (dl *YouTubeDLSong) RemoveSkip(username string) error {
for i, user := range dl.skippers {
if username == user {
dl.skippers = append(dl.skippers[:i], dl.skippers[i+1:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// SkipReached calculates the current skip ratio based on the number of users within MumbleDJ's
// channel and the number of usernames in the skippers slice. If the value is greater than or equal
// to the skip ratio defined in the config, the function returns true, and returns false otherwise.
func (dl *YouTubeDLSong) SkipReached(channelUsers int) bool {
if float32(len(dl.skippers))/float32(channelUsers) >= dj.conf.General.SkipRatio {
return true
}
return false
}
// Submitter returns the name of the submitter of the Song.
func (dl *YouTubeDLSong) Submitter() string {
return dl.submitter.Name
}
// Title returns the title of the Song.
func (dl *YouTubeDLSong) Title() string {
return dl.title
}
// ID returns the id of the Song.
func (dl *YouTubeDLSong) ID() string {
return dl.id
}
// Filename returns the filename of the Song.
func (dl *YouTubeDLSong) Filename() string {
return dl.id + dl.format
}
// Duration returns the duration of the Song.
func (dl *YouTubeDLSong) Duration() string {
return dl.duration
}
// Thumbnail returns the thumbnail URL for the Song.
func (dl *YouTubeDLSong) Thumbnail() string {
return dl.thumbnail
}
// Playlist returns the playlist type for the Song (may be nil).
func (dl *YouTubeDLSong) Playlist() Playlist {
return dl.playlist
}
// DontSkip returns the DontSkip boolean value for the Song.
func (dl *YouTubeDLSong) DontSkip() bool {
return dl.dontSkip
}
// SetDontSkip sets the DontSkip boolean value for the Song.
func (dl *YouTubeDLSong) SetDontSkip(value bool) {
dl.dontSkip = value
}
// ----------------
// YOUTUBE PLAYLIST
// ----------------
// AddSkip adds a skip to the playlist's skippers slice.
func (p *YouTubeDLPlaylist) AddSkip(username string) error {
for _, user := range dj.playlistSkips[p.ID()] {
if username == user {
return errors.New("This user has already skipped the current song.")
}
}
dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()], username)
return nil
}
// RemoveSkip removes a skip from the playlist's skippers slice. If username is not in the slice
// an error is returned.
func (p *YouTubeDLPlaylist) RemoveSkip(username string) error {
for i, user := range dj.playlistSkips[p.ID()] {
if username == user {
dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()][:i], dj.playlistSkips[p.ID()][i+1:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// DeleteSkippers removes the skippers entry in dj.playlistSkips.
func (p *YouTubeDLPlaylist) DeleteSkippers() {
delete(dj.playlistSkips, p.ID())
}
// SkipReached calculates the current skip ratio based on the number of users within MumbleDJ's
// channel and the number of usernames in the skippers slice. If the value is greater than or equal
// to the skip ratio defined in the config, the function returns true, and returns false otherwise.
func (p *YouTubeDLPlaylist) SkipReached(channelUsers int) bool {
if float32(len(dj.playlistSkips[p.ID()]))/float32(channelUsers) >= dj.conf.General.PlaylistSkipRatio {
return true
}
return false
}
// ID returns the id of the YouTubeDLPlaylist.
func (p *YouTubeDLPlaylist) ID() string {
return p.id
}
// Title returns the title of the YouTubeDLPlaylist.
func (p *YouTubeDLPlaylist) Title() string {
return p.title
}