diff --git a/Goopfile.lock b/Goopfile.lock new file mode 100644 index 0000000..887e548 --- /dev/null +++ b/Goopfile.lock @@ -0,0 +1,8 @@ +code.google.com/p/gcfg #c2d3050044d0 +github.com/golang/protobuf #0f7a9caded1fb3c9cc5a9b4bcf2ff633cc8ae644 +github.com/jmoiron/jsonq #7c27c8eb9f6831555a4209f6a7d579159e766a3c +github.com/layeh/gopus #2f86fa22bc209cc0ccbc6418dfbad9199e3dbc78 +github.com/layeh/gumble/gumble #8b9989d9c4090874546c45ceaa6ff21e95705bc4 +github.com/layeh/gumble/gumble_ffmpeg #c9fcce8fc4b71c7c53a5d3d9d48a1e001ad19a19 +github.com/layeh/gumble/gumbleutil #abf58b0ea8b2661897f81cf69c2a6a3e37152d74 +github.com/timshannon/go-openal #f4fbb66b2922de93753ac8069ff62d20a56a7450 diff --git a/commands.go b/commands.go index a313ae6..c5f282f 100644 --- a/commands.go +++ b/commands.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "os" - "regexp" "strconv" "strings" @@ -158,41 +157,31 @@ func parseCommand(user *gumble.User, username, command string) { } } -// add performs !add functionality. Checks input URL for YouTube format, and adds +// 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) { if url == "" { dj.SendPrivateMessage(user, NO_ARGUMENT_MSG) } else { - youtubePatterns := []string{ - `https?:\/\/www\.youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`, - `https?:\/\/youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`, - `https?:\/\/youtu.be\/([\w-]+)(\?t=\d*m?\d*s?)?`, - `https?:\/\/youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`, - `https?:\/\/www.youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`, - } - matchFound := false - shortURL := "" - startOffset := "" + var urlService Service - for _, pattern := range youtubePatterns { - if re, err := regexp.Compile(pattern); err == nil { - if re.MatchString(url) { - matchFound = true - matches := re.FindAllStringSubmatch(url, -1) - shortURL = matches[0][1] - if len(matches[0]) == 3 { - startOffset = matches[0][2] - } - break - } + // Checks all services to see if any can take the URL + for _, service := range services { + if service.URLRegex(url) { + urlService = service } } - if matchFound { - if newSong, err := NewYouTubeSong(username, shortURL, startOffset, nil); err == nil { - dj.client.Self.Channel.Send(fmt.Sprintf(SONG_ADDED_HTML, username, newSong.title), false) - if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() { + 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 { @@ -201,41 +190,8 @@ func add(user *gumble.User, username, url string) { dj.queue.OnSongFinished() } } - } else if fmt.Sprint(err) == "Song exceeds the maximum allowed duration." { - dj.SendPrivateMessage(user, VIDEO_TOO_LONG_MSG) - } else if fmt.Sprint(err) == "Invalid API key supplied." { - dj.SendPrivateMessage(user, INVALID_API_KEY) } else { - dj.SendPrivateMessage(user, INVALID_YOUTUBE_ID_MSG) - } - } else { - // Check to see if we have a playlist URL instead. - youtubePlaylistPattern := `https?:\/\/www\.youtube\.com\/playlist\?list=([\w-]+)` - if re, err := regexp.Compile(youtubePlaylistPattern); err == nil { - if re.MatchString(url) { - if dj.HasPermission(username, dj.conf.Permissions.AdminAddPlaylists) { - shortURL = re.FindStringSubmatch(url)[1] - oldLength := dj.queue.Len() - if newPlaylist, err := NewYouTubePlaylist(username, shortURL); err == nil { - dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_ADDED_HTML, username, newPlaylist.title), false) - 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, INVALID_YOUTUBE_ID_MSG) - } - } else { - dj.SendPrivateMessage(user, NO_PLAYLIST_PERMISSION_MSG) - } - } else { - dj.SendPrivateMessage(user, INVALID_URL_MSG) - } + dj.SendPrivateMessage(user, err.Error()) } } } diff --git a/main.go b/main.go index 88bf38e..6ef1170 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "os/user" "strings" "time" + "reflect" "github.com/layeh/gopus" "github.com/layeh/gumble/gumble" @@ -35,6 +36,7 @@ type mumbledj struct { homeDir string playlistSkips map[string][]string cache *SongCache + verbose bool } // OnConnect event. First moves MumbleDJ into the default channel specified @@ -139,6 +141,17 @@ func PerformStartupChecks() { } } +func Verbose(msg string) { + if dj.verbose { + fmt.Printf(msg) + } +} + +func isNil(a interface{}) bool { + defer func() { recover() }() + return a == nil || reflect.ValueOf(a).IsNil() +} + // dj variable declaration. This is done outside of main() to allow global use. var dj = mumbledj{ keepAlive: make(chan bool), @@ -164,7 +177,7 @@ func main() { } var address, port, username, password, channel, pemCert, pemKey, accesstokens string - var insecure bool + var insecure, verbose bool flag.StringVar(&address, "server", "localhost", "address for Mumble server") flag.StringVar(&port, "port", "64738", "port for Mumble server") @@ -175,6 +188,7 @@ 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.Parse() dj.config = gumble.Config{ @@ -201,6 +215,7 @@ func main() { } dj.defaultChannel = strings.Split(channel, "/") + dj.verbose = verbose dj.client.Attach(gumbleutil.Listener{ Connect: dj.OnConnect, diff --git a/service.go b/service.go index 205c277..541605b 100644 --- a/service.go +++ b/service.go @@ -7,6 +7,17 @@ package main +import ( + "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 +} + // Song interface. Each service will implement these // functions in their Song types. type Song interface { @@ -37,3 +48,5 @@ type Playlist interface { ID() string Title() string } + +var services = []Service{YouTube{}} diff --git a/service_youtube.go b/service_youtube.go index b93b30a..7754935 100644 --- a/service_youtube.go +++ b/service_youtube.go @@ -21,9 +21,75 @@ import ( "time" "github.com/jmoiron/jsonq" + "github.com/layeh/gumble/gumble" "github.com/layeh/gumble/gumble_ffmpeg" ) +// Regular expressions for youtube urls +var youtubePlaylistPattern = `https?:\/\/www\.youtube\.com\/playlist\?list=([\w-]+)` +var youtubeVideoPatterns = []string{ + `https?:\/\/www\.youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`, + `https?:\/\/youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`, + `https?:\/\/youtu.be\/([\w-]+)(\?t=\d*m?\d*s?)?`, + `https?:\/\/youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`, + `https?:\/\/www.youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`, +} + +// --------------- +// YOUTUBE SERVICE +// --------------- + +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 // ------------ @@ -50,7 +116,7 @@ func NewYouTubeSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTub 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 { - return nil, err + return nil, errors.New(INVALID_API_KEY) } var offsetDays, offsetHours, offsetMinutes, offsetSeconds int64 @@ -127,26 +193,33 @@ func NewYouTubeSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTub duration: durationString, thumbnail: thumbnail, skippers: make([]string, 0), - playlist: nil, + playlist: playlist, dontSkip: false, } dj.queue.AddSong(song) + Verbose(song.Submitter() + " added track " + song.Title() + "\n") + return song, nil } - return nil, errors.New("Song exceeds the maximum allowed duration.") + return nil, errors.New(VIDEO_TOO_LONG_MSG) } // 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() + "\n") 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\n") return nil } + Verbose(s.Title() + " failed to download\n") return errors.New("Song download failed.") } return nil @@ -163,7 +236,7 @@ func (s *YouTubeSong) Play() { if err := dj.audioStream.Play(); err != nil { panic(err) } else { - if s.Playlist() == nil { + if isNil(s.Playlist()) { message := ` @@ -199,6 +272,8 @@ func (s *YouTubeSong) Play() { 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() + "\n") + go func() { dj.audioStream.Wait() dj.queue.OnSongFinished() @@ -212,8 +287,10 @@ func (s *YouTubeSong) Delete() error { 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() + "\n") return nil } + Verbose("Failed to delete " + s.Title() + "\n") return errors.New("Error occurred while deleting audio file.") } return nil @@ -328,81 +405,20 @@ func NewYouTubePlaylist(user, id string) (*YouTubePlaylist, error) { } // Retrieve items in playlist - url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=25&playlistId=%s&key=%s", + url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=2&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 > 25 { - numVideos = 25 + if numVideos > 2 { + numVideos = 2 } for i := 0; i < numVideos; i++ { index := strconv.Itoa(i) - videoTitle, err := apiResponse.String("items", index, "snippet", "title") videoID, _ := apiResponse.String("items", index, "snippet", "resourceId", "videoId") - videoThumbnail, _ := apiResponse.String("items", index, "snippet", "thumbnails", "high", "url") - - // A completely separate API call just to get the duration of a video in a - // playlist? WHY GOOGLE, WHY?! - var durationResponse *jsonq.JsonQuery - url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=%s&key=%s", - videoID, os.Getenv("YOUTUBE_API_KEY")) - if durationResponse, err = PerformGetRequest(url); err != nil { - return nil, err - } - videoDuration, _ := durationResponse.String("items", "0", "contentDetails", "duration") - - var days, hours, minutes, seconds int64 - timestampExp := regexp.MustCompile(`P(?P\d+D)?T(?P\d+H)?(?P\d+M)?(?P\d+S)?`) - timestampMatch := timestampExp.FindStringSubmatch(videoDuration) - timestampResult := make(map[string]string) - for i, name := range timestampExp.SubexpNames() { - if i < len(timestampMatch) { - timestampResult[name] = timestampMatch[i] - } - } - - if timestampResult["days"] != "" { - days, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["days"], "D"), 10, 32) - } - if timestampResult["hours"] != "" { - hours, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["hours"], "H"), 10, 32) - } - if timestampResult["minutes"] != "" { - minutes, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["minutes"], "M"), 10, 32) - } - if timestampResult["seconds"] != "" { - seconds, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["seconds"], "S"), 10, 32) - } - - totalSeconds := int((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) - var durationString string - if hours != 0 { - if days != 0 { - durationString = fmt.Sprintf("%d:%02d:%02d:%02d", days, hours, minutes, seconds) - } else { - durationString = fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) - } - } else { - durationString = fmt.Sprintf("%d:%02d", minutes, seconds) - } - - if dj.conf.General.MaxSongDuration == 0 || totalSeconds <= dj.conf.General.MaxSongDuration { - playlistSong := &YouTubeSong{ - submitter: user, - title: videoTitle, - id: videoID, - filename: videoID + ".m4a", - duration: durationString, - thumbnail: videoThumbnail, - skippers: make([]string, 0), - playlist: playlist, - dontSkip: false, - } - dj.queue.AddSong(playlistSong) - } + NewYouTubeSong(user, videoID, "", playlist) } return playlist, nil } diff --git a/songqueue.go b/songqueue.go index 3321ca2..67db542 100644 --- a/songqueue.go +++ b/songqueue.go @@ -42,14 +42,14 @@ func (q *SongQueue) CurrentSong() Song { // NextSong moves to the next Song in SongQueue. NextSong() removes the first Song in the queue. func (q *SongQueue) NextSong() { - if q.CurrentSong().Playlist() != nil { - if s, err := q.PeekNext(); err == nil { - if s.Playlist() != nil && (q.CurrentSong().Playlist().ID() != s.Playlist().ID()) { + if s, err := q.PeekNext(); err == nil { + if !isNil(q.CurrentSong().Playlist()) && !isNil(s.Playlist()) { + if q.CurrentSong().Playlist().ID() != s.Playlist().ID() { q.CurrentSong().Playlist().DeleteSkippers() } - } else { - q.CurrentSong().Playlist().DeleteSkippers() } + } else { + q.CurrentSong().Playlist().DeleteSkippers() } q.queue = q.queue[1:] }