diff --git a/Makefile b/Makefile index 5de2412..2f0b5c3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ all: mumbledj -mumbledj: main.go commands.go parseconfig.go strings.go service.go service_youtube.go songqueue.go cache.go +mumbledj: main.go commands.go parseconfig.go strings.go service.go service_youtube.go songqueue.go cache.go web.go go get github.com/nitrous-io/goop rm -rf Goopfile.lock goop install @@ -12,8 +12,10 @@ clean: 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; 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; build: diff --git a/README.md b/README.md index f58a1bd..71a878f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ All commandline parameters are optional. Below are descriptions of all the avail * `-key`: Path to user PEM key. Defaults to no key. * `-insecure`: If included, the bot will not check the certs for the server. Try using this commandline flag if you are having connection issues. * `-accesstokens`: List of access tokens for the bot separated by spaces. Defaults to no access tokens. +* `-verbose`: Prints out song status into the console. ## FEATURES * Plays audio from both YouTube videos and YouTube playlists! @@ -50,6 +51,7 @@ Command | Description | Arguments | Admin | Example **help** | Displays this list of commands in Mumble chat. | None | No | `!help` **volume** | Either outputs the current volume or changes the current volume. If desired volume is not provided, the current volume will be displayed in chat. Otherwise, the volume for the bot will be changed to desired volume if it is within the allowed volume range. | None OR desired volume | No | `!volume 0.5`, `!volume` **move** | Moves MumbleDJ into channel if it exists. | Channel | Yes | `!move Music` +**web** | Displays a unique url for the user to control mumbledj from a web browser | None | No | `!web` **reload** | Reloads `mumbledj.gcfg` to retrieve updated configuration settings. | None | Yes | `!reload` **reset** | Stops all audio and resets the song queue. | None | Yes | `!reset` **numsongs** | Outputs the number of songs in the queue in chat. Individual songs and songs within playlists are both counted. | None | No | `!numsongs` diff --git a/commands.go b/commands.go index 61c83fb..4be4eeb 100644 --- a/commands.go +++ b/commands.go @@ -36,7 +36,7 @@ func parseCommand(user *gumble.User, username, command string) { // Add command case dj.conf.Aliases.AddAlias: if dj.HasPermission(username, dj.conf.Permissions.AdminAdd) { - add(user, username, argument) + add(user, argument) } else { dj.SendPrivateMessage(user, NO_PERMISSION_MSG) } @@ -85,7 +85,7 @@ func parseCommand(user *gumble.User, username, command string) { // Web command case dj.conf.Aliases.WebAlias: if dj.HasPermission(username, dj.conf.Permissions.AdminWeb) { - GetWebAddress(user) + web.GetWebAddress(user) } else { dj.SendPrivateMessage(user, NO_PERMISSION_MSG) } @@ -166,41 +166,12 @@ func parseCommand(user *gumble.User, username, command string) { // add performs !add functionality. Checks input URL for service, and adds // the URL to the queue if the format matches. -func add(user *gumble.User, username, url string) { +func add(user *gumble.User, url string) error { if url == "" { dj.SendPrivateMessage(user, NO_ARGUMENT_MSG) + return errors.New("NO_ARGUMENT") } else { - var urlService Service - - // Checks all services to see if any can take the URL - for _, service := range services { - if service.URLRegex(url) { - urlService = service - } - } - - if urlService == nil { - dj.SendPrivateMessage(user, INVALID_URL_MSG) - } else { - oldLength := dj.queue.Len() - - if err := urlService.NewRequest(user, url); err == nil { - dj.client.Self.Channel.Send(SONG_ADDED_HTML, false) - - // Starts playing the new song if nothing else is playing - if oldLength == 0 && dj.queue.Len() != 0 && !dj.audioStream.IsPlaying() { - if err := dj.queue.CurrentSong().Download(); err == nil { - dj.queue.CurrentSong().Play() - } else { - dj.SendPrivateMessage(user, AUDIO_FAIL_MSG) - dj.queue.CurrentSong().Delete() - dj.queue.OnSongFinished() - } - } - } else { - dj.SendPrivateMessage(user, err.Error()) - } - } + return findServiceAndAdd(user, url) } } diff --git a/index.html b/index.html new file mode 100644 index 0000000..528d85d --- /dev/null +++ b/index.html @@ -0,0 +1,38 @@ + + + + + {{.User}} - mumbledj + + + + +

Add Song Form

+ + + + + + + \ No newline at end of file diff --git a/main.go b/main.go index bc8d710..ba85c0c 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "os" "os/user" "reflect" + "regexp" "strings" "time" @@ -102,7 +103,7 @@ func (dj *mumbledj) OnTextMessage(e *gumble.TextMessageEvent) { func (dj *mumbledj) OnUserChange(e *gumble.UserChangeEvent) { if e.Type.Has(gumble.UserChangeDisconnected) { if dj.audioStream.IsPlaying() { - if dj.queue.CurrentSong().Playlist() != nil { + if !isNil(dj.queue.CurrentSong().Playlist()) { dj.queue.CurrentSong().Playlist().RemoveSkip(e.User.Name) } dj.queue.CurrentSong().RemoveSkip(e.User.Name) @@ -143,7 +144,7 @@ func PerformStartupChecks() { func Verbose(msg string) { if dj.verbose { - fmt.Printf(msg) + fmt.Printf(msg + "\n") } } @@ -152,6 +153,17 @@ func isNil(a interface{}) bool { return a == nil || reflect.ValueOf(a).IsNil() } +func RegexpFromURL(url string, patterns []string) *regexp.Regexp { + for _, pattern := range patterns { + if re, err := regexp.Compile(pattern); err == nil { + if re.MatchString(url) { + return re + } + } + } + return nil +} + // dj variable declaration. This is done outside of main() to allow global use. var dj = mumbledj{ keepAlive: make(chan bool), @@ -160,6 +172,8 @@ var dj = mumbledj{ cache: NewSongCache(), } +var web *WebServer + // main primarily performs startup tasks. Grabs and parses commandline // args, sets up the gumble client and its listeners, and then connects to the server. func main() { @@ -228,9 +242,14 @@ func main() { if err := dj.client.Connect(); err != nil { fmt.Printf("Could not connect to Mumble server at %s:%s.\n", address, port) os.Exit(1) - } - - Webserver() + } + + web = NewWebServer(9563) + web.makeWeb() + + if isNil(web) { + Verbose("WEB IS NIL") + } <-dj.keepAlive } diff --git a/service.go b/service.go index 541605b..13a1d08 100644 --- a/service.go +++ b/service.go @@ -8,14 +8,17 @@ package main import ( + "errors" + "fmt" + "github.com/layeh/gumble/gumble" ) // Service interface. Each service should implement these functions type Service interface { ServiceName() string - URLRegex(string) bool // Can service deal with URL - NewRequest(*gumble.User, string) error // Create song/playlist and add to the queue + URLRegex(string) bool // Can service deal with URL + NewRequest(*gumble.User, string) (string, error) // Create song/playlist and add to the queue } // Song interface. Each service will implement these @@ -50,3 +53,40 @@ type Playlist interface { } var services = []Service{YouTube{}} + +func findServiceAndAdd(user *gumble.User, url string) error { + var urlService Service + + // Checks all services to see if any can take the URL + for _, service := range services { + if service.URLRegex(url) { + urlService = service + } + } + + if urlService == nil { + return errors.New("INVALID_URL") + } else { + oldLength := dj.queue.Len() + var title string + var err error + + if title, err = urlService.NewRequest(user, url); err == nil { + dj.client.Self.Channel.Send(fmt.Sprintf(SONG_ADDED_HTML, user.Name, title), false) + + // Starts playing the new song if nothing else is playing + if oldLength == 0 && dj.queue.Len() != 0 && !dj.audioStream.IsPlaying() { + if err := dj.queue.CurrentSong().Download(); err == nil { + dj.queue.CurrentSong().Play() + } else { + dj.queue.CurrentSong().Delete() + dj.queue.OnSongFinished() + return errors.New("FAILED_TO_DOWNLOAD") + } + } + } else { + dj.SendPrivateMessage(user, err.Error()) + } + return err + } +} diff --git a/service_youtube.go b/service_youtube.go index 4c60cd0..63a343d 100644 --- a/service_youtube.go +++ b/service_youtube.go @@ -35,63 +35,12 @@ var youtubeVideoPatterns = []string{ `https?:\/\/www.youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`, } -// --------------- -// YOUTUBE SERVICE -// --------------- +// ------ +// TYPES +// ------ -type YouTube struct {} - -// Name of the service -func (y YouTube) ServiceName() string { - return "Youtube" -} - -// Checks to see if service will accept URL -func (y YouTube) URLRegex(url string) bool { - return RegexpFromURL(url, append(youtubeVideoPatterns, []string{youtubePlaylistPattern}...)) != nil -} - -func RegexpFromURL(url string, patterns []string) *regexp.Regexp { - for _, pattern := range patterns { - if re, err := regexp.Compile(pattern); err == nil { - if re.MatchString(url) { - return re - } - } - } - return nil -} - -// Creates the requested song/playlist and adds to the queue -func (y YouTube) NewRequest(user *gumble.User, url string) error { - var shortURL, startOffset = "", "" - if re, err := regexp.Compile(youtubePlaylistPattern); err == nil { - if re.MatchString(url) { - if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) { - shortURL = re.FindStringSubmatch(url)[1] - _, err := NewYouTubePlaylist(user.Name, shortURL) - return err - } else { - return errors.New("NO_PLAYLIST_PERMISSION") - } - } else { - re = RegexpFromURL(url, youtubeVideoPatterns) - matches := re.FindAllStringSubmatch(url, -1) - shortURL = matches[0][1] - if len(matches[0]) == 3 { - startOffset = matches[0][2] - } - _, err := NewYouTubeSong(user.Name, shortURL, startOffset, nil) - return err - } - } else { - return err - } -} - -// ------------ -// YOUTUBE SONG -// ------------ +// YouTube implements the Service interface +type YouTube struct{} // YouTubeSong holds the metadata for a song extracted from a YouTube video. type YouTubeSong struct { @@ -107,14 +56,61 @@ type YouTubeSong struct { dontSkip bool } -// NewYouTubeSong gathers the metadata for a song extracted from a YouTube video, and returns +// YouTubePlaylist holds the metadata for a YouTube playlist. +type YouTubePlaylist struct { + id string + title string +} + +// --------------- +// YOUTUBE SERVICE +// --------------- + +// Name of the service +func (yt YouTube) ServiceName() string { + return "Youtube" +} + +// Checks to see if service will accept URL +func (yt YouTube) URLRegex(url string) bool { + return RegexpFromURL(url, append(youtubeVideoPatterns, []string{youtubePlaylistPattern}...)) != nil +} + +// Creates the requested song/playlist and adds to the queue +func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { + var shortURL, startOffset = "", "" + if re, err := regexp.Compile(youtubePlaylistPattern); err == nil { + 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) + return playlist.Title(), err + } else { + return "", errors.New("NO_PLAYLIST_PERMISSION") + } + } else { + re = RegexpFromURL(url, youtubeVideoPatterns) + matches := re.FindAllStringSubmatch(url, -1) + shortURL = matches[0][1] + if len(matches[0]) == 3 { + startOffset = matches[0][2] + } + song, err := yt.NewSong(user.Name, shortURL, startOffset, nil) + return song.Title(), err + } + } else { + return "", err + } +} + +// NewSong gathers the metadata for a song extracted from a YouTube video, and returns // the song. -func NewYouTubeSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTubeSong, error) { +func (yt YouTube) NewSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTubeSong, 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 = PerformGetRequest(url); err != nil { + if apiResponse, err = yt.PerformGetRequest(url); err != nil { return nil, errors.New(INVALID_API_KEY) } @@ -203,6 +199,46 @@ func NewYouTubeSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTub return nil, errors.New(VIDEO_TOO_LONG_MSG) } +// NewPlaylist gathers the metadata for a YouTube playlist and returns it. +func (yt YouTube) NewPlaylist(user, id string) (*YouTubePlaylist, 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 { + return nil, err + } + title, _ := apiResponse.String("items", "0", "snippet", "title") + + playlist := &YouTubePlaylist{ + id: id, + title: title, + } + + // 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 { + return nil, err + } + numVideos, _ := apiResponse.Int("pageInfo", "totalResults") + if numVideos > 50 { + numVideos = 50 + } + + for i := 0; i < numVideos; i++ { + index := strconv.Itoa(i) + videoID, _ := apiResponse.String("items", index, "snippet", "resourceId", "videoId") + yt.NewSong(user, videoID, "", playlist) + } + 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 { @@ -380,48 +416,6 @@ func (s *YouTubeSong) SetDontSkip(value bool) { // YOUTUBE PLAYLIST // ---------------- -// YouTubePlaylist holds the metadata for a YouTube playlist. -type YouTubePlaylist struct { - id string - title string -} - -// NewYouTubePlaylist gathers the metadata for a YouTube playlist and returns it. -func NewYouTubePlaylist(user, id string) (*YouTubePlaylist, 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 = PerformGetRequest(url); err != nil { - return nil, err - } - title, _ := apiResponse.String("items", "0", "snippet", "title") - - playlist := &YouTubePlaylist{ - id: id, - title: title, - } - - // 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 = PerformGetRequest(url); err != nil { - return nil, err - } - numVideos, _ := apiResponse.Int("pageInfo", "totalResults") - if numVideos > 50 { - numVideos = 50 - } - - for i := 0; i < numVideos; i++ { - index := strconv.Itoa(i) - videoID, _ := apiResponse.String("items", index, "snippet", "resourceId", "videoId") - NewYouTubeSong(user, videoID, "", playlist) - } - return playlist, nil -} - // AddSkip adds a skip to the playlist's skippers slice. func (p *YouTubePlaylist) AddSkip(username string) error { for _, user := range dj.playlistSkips[p.ID()] { @@ -475,7 +469,7 @@ func (p *YouTubePlaylist) Title() string { // ----------- // PerformGetRequest does all the grunt work for a YouTube HTTPS GET request. -func PerformGetRequest(url string) (*jsonq.JsonQuery, error) { +func (yt YouTube) PerformGetRequest(url string) (*jsonq.JsonQuery, error) { jsonString := "" if response, err := http.Get(url); err == nil { diff --git a/strings.go b/strings.go index e8cf2b5..9d5524f 100644 --- a/strings.go +++ b/strings.go @@ -174,4 +174,4 @@ const CURRENT_SONG_PLAYLIST_HTML = ` // URL of the server for connecting via a web address const WEB_ADDRESS = ` Control mumbledj from a web browser: http://%s:9563/%s -` \ No newline at end of file +` diff --git a/web.go b/web.go index 76f826b..0af4a7c 100644 --- a/web.go +++ b/web.go @@ -2,67 +2,115 @@ package main import ( "fmt" + "html" + "html/template" "io/ioutil" "math/rand" "net/http" + "os" + "path/filepath" + "strconv" "strings" "time" "github.com/layeh/gumble/gumble" ) -var client_token = make(map[string]string) -var token_client = make(map[string]string) -var external_ip = "" +type WebServer struct { + port int + client_token map[*gumble.User]string + token_client map[string]*gumble.User +} type Page struct { - Title string - Body []byte + Site string + Token string + User string } -func (p *Page) save() error { - filename := p.Title + ".txt" - return ioutil.WriteFile(filename, p.Body, 0600) -} +var external_ip = "" -func loadPage(title string) (*Page, error) { - filename := title + ".txt" - body, err := ioutil.ReadFile(filename) - if err != nil { - return nil, err - } - return &Page{Title: title, Body: body}, nil -} - -func handler(w http.ResponseWriter, r *http.Request) { - var uname = token_client[r.URL.Path[1:]] - if uname == "" { - fmt.Fprintf(w, "I don't know you") - } else { - fmt.Fprintf(w, "Hi there, I love %s!", uname) - } -} - -func Webserver() { - http.HandleFunc("/", handler) - http.ListenAndServe(":9563", nil) +func NewWebServer(port int) *WebServer { rand.Seed(time.Now().UnixNano()) + return &WebServer{ + port: port, + client_token: make(map[*gumble.User]string), + token_client: make(map[string]*gumble.User), + } } -func GetWebAddress(user *gumble.User) { - if client_token[user.Name] != "" { - token_client[client_token[user.Name]] = "" +func (web *WebServer) makeWeb() { + http.HandleFunc("/", web.homepage) + http.HandleFunc("/add", web.add) + http.HandleFunc("/volume", web.volume) + http.HandleFunc("/skip", web.skip) + http.ListenAndServe(":"+strconv.Itoa(web.port), nil) +} + +func (web *WebServer) homepage(w http.ResponseWriter, r *http.Request) { + var uname = web.token_client[r.URL.Path[1:]] + if uname == nil { + fmt.Fprintf(w, "Invalid Token") + } else { + cwd, _ := os.Getwd() + t, err := template.ParseFiles(filepath.Join(cwd, "./.mumbledj/web/index.html")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = t.Execute(w, Page{getIP() + ":" + strconv.Itoa(web.port), r.URL.Path[1:], uname.Name}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func (web *WebServer) add(w http.ResponseWriter, r *http.Request) { + var uname = web.token_client[r.FormValue("token")] + if uname == nil { + fmt.Fprintf(w, "Invalid Token") + } else { + add(uname, html.UnescapeString(r.FormValue("value"))) + } +} + +func (web *WebServer) volume(w http.ResponseWriter, r *http.Request) { + var uname = web.token_client[r.FormValue("token")] + if uname == nil { + fmt.Fprintf(w, "Invalid Token") + } else { + var url = html.UnescapeString(r.FormValue("value")) + add(uname, url) + } +} + +func (web *WebServer) skip(w http.ResponseWriter, r *http.Request) { + var uname = web.token_client[r.FormValue("token")] + if uname == nil { + fmt.Fprintf(w, "Invalid Token") + } else { + var url = html.UnescapeString(r.FormValue("value")) + add(uname, url) + } +} + +func (website *WebServer) GetWebAddress(user *gumble.User) { + Verbose("Port number: " + strconv.Itoa(web.port)) + if web.client_token[user] != "" { + web.token_client[web.client_token[user]] = nil } // dealing with collisions var firstLoop = true - for firstLoop || token_client[client_token[user.Name]] != "" { - client_token[user.Name] = randSeq(10) + for firstLoop || web.token_client[web.client_token[user]] != nil { + web.client_token[user] = randSeq(10) firstLoop = false } - token_client[client_token[user.Name]] = user.Name - dj.SendPrivateMessage(user, fmt.Sprintf(WEB_ADDRESS, getIP(), client_token[user.Name], getIP(), client_token[user.Name])) + web.token_client[web.client_token[user]] = user + dj.SendPrivateMessage(user, fmt.Sprintf(WEB_ADDRESS, getIP(), web.client_token[user], getIP(), web.client_token[user])) } +// Gets the external ip address for the server func getIP() string { if external_ip != "" { return external_ip @@ -79,9 +127,9 @@ func getIP() string { } } -var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - +// Generates a pseudorandom string of characters func randSeq(n int) string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") b := make([]rune, n) for i := range b { b[i] = letters[rand.Intn(len(letters))]