diff --git a/.travis.yml b/.travis.yml index 8864edb..c832279 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,9 +10,26 @@ cache: - $HOME/gopath/src/github.com/MichaelOultram/mumbledj/.vendor before_script: +<<<<<<< HEAD - export PATH=$PATH:~/bin/ - export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$HOME/opus/lib/pkgconfig - bash install-dependencies.sh install: - - ls -R $HOME/opus \ No newline at end of file + - 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 diff --git a/Makefile b/Makefile index 23a95e1..8c48293 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ install: mkdir -p ~/.mumbledj/config mkdir -p ~/.mumbledj/songs 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 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; diff --git a/README.md b/README.md index fd54ba5..a54d434 100644 --- a/README.md +++ b/README.md @@ -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.** diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..936fb68 --- /dev/null +++ b/circle.yml @@ -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 \ No newline at end of file diff --git a/commands.go b/commands.go index 5dde0c4..e0413cc 100644 --- a/commands.go +++ b/commands.go @@ -159,6 +159,13 @@ func parseCommand(user *gumble.User, username, command string) { } else { 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: 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) return errors.New("NO_ARGUMENT") } else { - return findServiceAndAdd(user, url) + err := findServiceAndAdd(user, url) + if err != nil { + dj.SendPrivateMessage(user, err.Error()) + } + return err } } diff --git a/config.gcfg b/config.gcfg index 5c7a595..eea2750 100644 --- a/config.gcfg +++ b/config.gcfg @@ -144,7 +144,7 @@ AdminsEnabled = true # 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 # be entered in this matter. -Admins = "Matt" +Admins = "BottleOToast" # Make add an admin command? # DEFAULT VALUE: false diff --git a/index.html b/index.html index 9ef2b91..7bd1999 100644 --- a/index.html +++ b/index.html @@ -1,38 +1,49 @@ - - - {{.User}} - mumbledj - - + - - -

Add Song Form

- - - - - - + function txtBox(type) { + var txt = $("#textbox"); + api(type, txt.attr("value")); + txt.attr("value", ""); + } + + + +

Add Song Form

+ + + + + +
+ + \ No newline at end of file diff --git a/install-dependencies.sh b/install-dependencies.sh index 90d3db1..2331fb7 100644 --- a/install-dependencies.sh +++ b/install-dependencies.sh @@ -3,16 +3,18 @@ set -e # check to see if ffmpeg is installed 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 tar -xvf /tmp/ffmpeg.tar.xz --strip 1 --no-anchored ffmpeg ffprobe chmod a+rx ffmpeg ffprobe - mv ff* $HOME/bin + mv ff* ~/bin else echo 'Using cached version of ffmpeg.'; fi # check to see if opus is installed if [ ! -d "$HOME/opus/lib" ]; then + echo 'Installing opus' wget http://downloads.xiph.org/releases/opus/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 @@ -22,6 +24,7 @@ fi # check to see if youtube-dl is installed 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 chmod a+rx ~/bin/youtube-dl else diff --git a/main.go b/main.go index ba85c0c..3a2ae5f 100644 --- a/main.go +++ b/main.go @@ -142,12 +142,14 @@ func PerformStartupChecks() { } } +// Prints out messages only if verbose flag is true func Verbose(msg string) { if dj.verbose { fmt.Printf(msg + "\n") } } +// Checks to see if an object is nil func isNil(a interface{}) bool { defer func() { recover() }() return a == nil || reflect.ValueOf(a).IsNil() @@ -191,7 +193,7 @@ func main() { } 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(&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(&accesstokens, "accesstokens", "", "list of access tokens for channel auth") 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() dj.config = gumble.Config{ @@ -244,6 +247,11 @@ func main() { os.Exit(1) } + if testcode { + Verbose("Testing is enabled") + Test(password, address, port, strings.Split(accesstokens, " ")) + } + web = NewWebServer(9563) web.makeWeb() diff --git a/service.go b/service.go index 13a1d08..15a5947 100644 --- a/service.go +++ b/service.go @@ -52,7 +52,7 @@ type Playlist interface { Title() string } -var services = []Service{YouTube{}} +var services = []Service{YouTube{}, SoundCloud{}} func findServiceAndAdd(user *gumble.User, url string) error { var urlService Service @@ -65,6 +65,7 @@ func findServiceAndAdd(user *gumble.User, url string) error { } if urlService == nil { + Verbose("Invalid_URL") return errors.New("INVALID_URL") } else { oldLength := dj.queue.Len() diff --git a/service_soundcloud.go b/service_soundcloud.go new file mode 100644 index 0000000..4e47e8d --- /dev/null +++ b/service_soundcloud.go @@ -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 +} diff --git a/service_youtube.go b/service_youtube.go index 62c422f..b437d71 100644 --- a/service_youtube.go +++ b/service_youtube.go @@ -14,15 +14,12 @@ import ( "io/ioutil" "net/http" "os" - "os/exec" "regexp" "strconv" "strings" - "time" "github.com/jmoiron/jsonq" "github.com/layeh/gumble/gumble" - "github.com/layeh/gumble/gumble_ffmpeg" ) // Regular expressions for youtube urls @@ -42,26 +39,6 @@ var youtubeVideoPatterns = []string{ // YouTube implements the Service interface 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 // --------------- @@ -83,7 +60,7 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { if re.MatchString(url) { if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) { shortURL = re.FindStringSubmatch(url)[1] - playlist, err := yt.NewPlaylist(user.Name, shortURL) + playlist, err := yt.NewPlaylist(user, shortURL) return playlist.Title(), err } else { 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 { startOffset = matches[0][2] } - song, err := yt.NewSong(user.Name, shortURL, startOffset, nil) + song, err := yt.NewSong(user, shortURL, startOffset, nil) if !isNil(song) { - return song.Title(), err + return song.Title(), nil } else { + Verbose("youtube.NewRequest: " + err.Error()) 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 // 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 err error url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=%s&key=%s", 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) } @@ -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 { - song := &YouTubeSong{ + song := &YouTubeDLSong{ submitter: user, title: title, id: id, + url: "https://youtu.be/" + id, offset: int((offsetDays * 86400) + (offsetHours * 3600) + (offsetMinutes * 60) + offsetSeconds), - filename: id + ".m4a", duration: durationString, thumbnail: thumbnail, + format: "m4a", skippers: make([]string, 0), playlist: playlist, 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. -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 err error // Retrieve title of playlist - url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s", - id, os.Getenv("YOUTUBE_API_KEY")) - if apiResponse, err = yt.PerformGetRequest(url); err != nil { + url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s", id, os.Getenv("YOUTUBE_API_KEY")) + if apiResponse, err = PerformGetRequest(url); err != nil { return nil, err } title, _ := apiResponse.String("items", "0", "snippet", "title") - playlist := &YouTubePlaylist{ + playlist := &YouTubeDLPlaylist{ id: id, title: title, } @@ -223,7 +201,7 @@ func (yt YouTube) NewPlaylist(user, id string) (*YouTubePlaylist, error) { // Retrieve items in playlist url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=50&playlistId=%s&key=%s", id, os.Getenv("YOUTUBE_API_KEY")) - if apiResponse, err = yt.PerformGetRequest(url); err != nil { + if apiResponse, err = PerformGetRequest(url); err != nil { return nil, err } numVideos, _ := apiResponse.Int("pageInfo", "totalResults") @@ -239,241 +217,8 @@ func (yt YouTube) NewPlaylist(user, id string) (*YouTubePlaylist, error) { 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 := ` - - - - - - - - - - -
%s (%s)
Added by %s
- ` - dj.client.Self.Channel.Send(fmt.Sprintf(message, s.Thumbnail(), s.ID(), s.Title(), - s.Duration(), s.Submitter()), false) - } else { - message := ` - - - - - - - - - - - - - -
%s (%s)
Added by %s
From playlist "%s"
- ` - 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. -func (yt YouTube) PerformGetRequest(url string) (*jsonq.JsonQuery, error) { +func PerformGetRequest(url string) (*jsonq.JsonQuery, error) { jsonString := "" 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 { return nil, errors.New("Invalid API key supplied.") } - return nil, errors.New("Invalid YouTube ID supplied.") + return nil, errors.New("Invalid ID supplied.") } } else { return nil, errors.New("An error occurred while receiving HTTP GET response.") diff --git a/songqueue.go b/songqueue.go index f1bdc1c..d00f771 100644 --- a/songqueue.go +++ b/songqueue.go @@ -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). func (q *SongQueue) OnSongFinished() { resetOffset, _ := time.ParseDuration(fmt.Sprintf("%ds", 0)) diff --git a/test.go b/test.go new file mode 100644 index 0000000..2258187 --- /dev/null +++ b/test.go @@ -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() +} diff --git a/web.go b/web.go index 4cd57e4..84ee1d2 100644 --- a/web.go +++ b/web.go @@ -1,6 +1,7 @@ package main import ( + //"encoding/json" "fmt" "html" "html/template" @@ -8,7 +9,6 @@ import ( "math/rand" "net/http" "os" - "path/filepath" "strconv" "strings" "time" @@ -28,6 +28,21 @@ type Page struct { 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 = "" func NewWebServer(port int) *WebServer { @@ -41,9 +56,10 @@ func NewWebServer(port int) *WebServer { func (web *WebServer) makeWeb() { http.HandleFunc("/", web.homepage) - http.HandleFunc("/add", web.add) - http.HandleFunc("/volume", web.volume) - http.HandleFunc("/skip", web.skip) + http.HandleFunc("/api/add", web.add) + http.HandleFunc("/api/volume", web.volume) + http.HandleFunc("/api/skip", web.skip) + //http.HandleFunc("/api/status", web.status) http.ListenAndServe(":"+strconv.Itoa(web.port), nil) } @@ -52,8 +68,14 @@ func (web *WebServer) homepage(w http.ResponseWriter, r *http.Request) { if uname == nil { fmt.Fprintf(w, "Invalid Token") } else { - cwd, _ := os.Getwd() - t, err := template.ParseFiles(filepath.Join(cwd, "./.mumbledj/web/index.html")) + var webpage = uname.Name + + // 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 { http.Error(w, err.Error(), http.StatusInternalServerError) 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) { Verbose("Port number: " + strconv.Itoa(web.port)) if web.client_token[user] != "" { @@ -110,7 +161,7 @@ func (website *WebServer) GetWebAddress(user *gumble.User) { } // dealing with collisions 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) firstLoop = false } diff --git a/youtube_dl.go b/youtube_dl.go new file mode 100644 index 0000000..c1685ee --- /dev/null +++ b/youtube_dl.go @@ -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 := `` + message = fmt.Sprintf(message, dl.thumbnail, dl.url, dl.title, dl.duration, dl.submitter.Name) + if !isNil(dl.playlist) { + message = fmt.Sprintf(message+``, dl.playlist.Title()) + } + dj.client.Self.Channel.Send(message+`
%s (%s)
Added by %s
From playlist "%s"
`, 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 +}