diff --git a/service.go b/service.go index 097c412..62b6449 100644 --- a/service.go +++ b/service.go @@ -11,14 +11,18 @@ import ( "errors" "fmt" "regexp" + "strconv" + "time" "github.com/layeh/gumble/gumble" ) // Service interface. Each service will implement these functions type Service interface { + ServiceName() string + TrackName() string URLRegex(string) bool - NewRequest(*gumble.User, string) (string, error) + NewRequest(*gumble.User, string) ([]Song, error) } // Song interface. Each service will implement these @@ -54,7 +58,7 @@ type Playlist interface { var services []Service -// FindServiceAndAdd tries the given url with each service +// FindServiceAndAdd tries the given url with each service // and adds the song/playlist with the correct service func FindServiceAndAdd(user *gumble.User, url string) error { var urlService Service @@ -69,27 +73,54 @@ func FindServiceAndAdd(user *gumble.User, url string) error { if urlService == nil { return errors.New(INVALID_URL_MSG) } else { - oldLength := dj.queue.Len() var title string - var err error + var songsAdded = 0 + var err errors - 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()) + // Get service to create songs + if songArray, err = urlService.NewRequest(user, url); err != nil { + return err + } + + // Check Playlist Permission + if len(songArray) > 1 && !dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) { + return errors.New(NO_PLAYLIST_PERMISSION_MSG) + } + + // Loop through all songs and add to the queue + oldLength := dj.queue.Len() + for song := range songArray { + time, _ := time.ParseDuration(song.Duration()) + if dj.conf.General.MaxSongDuration == 0 || int(time.Seconds()) <= dj.conf.General.MaxSongDuration { + if !isNil(song.Playlist()) { + title = song.Playlist().Title() + } else { + title = song.Title() + } + + dj.queue.AddSong(song) + songsAdded++ + } + } + + if songsAdded == 0 { + return errors.New(TRACK_TOO_LONG_MSG) + } else if songsAdded == 1 { + dj.client.Self.Channel.Send(fmt.Sprintf(SONG_ADDED_HTML, user.Name, title), false) + } else { + dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_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(AUDIO_FAIL_MSG) + } } - return err } } diff --git a/service_soundcloud.go b/service_soundcloud.go index 055a946..b8722dc 100644 --- a/service_soundcloud.go +++ b/service_soundcloud.go @@ -30,14 +30,25 @@ type SoundCloud struct{} // SOUNDCLOUD SERVICE // ------------------ +// ServiceName is the human readable version of the service name +func (sc SoundCloud) ServiceName() string { + return "Soundcloud" +} + +// TrackName is the human readable version of the service name +func (sc SoundCloud) TrackName() { + return "Song" +} + // URLRegex checks to see if service will accept URL func (sc SoundCloud) URLRegex(url string) bool { return RegexpFromURL(url, []string{soundcloudSongPattern, soundcloudPlaylistPattern}) != nil } // NewRequest creates the requested song/playlist and adds to the queue -func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { +func (sc SoundCloud) NewRequest(user *gumble.User, url string) ([]Song, error) { var apiResponse *jsonq.JsonQuery + var songArray []Song var err error timesplit := strings.Split(url, "#t=") url = fmt.Sprintf("http://api.soundcloud.com/resolve?url=%s&client_id=%s", timesplit[0], os.Getenv("SOUNDCLOUD_API_KEY")) @@ -48,24 +59,24 @@ func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { tracks, err := apiResponse.ArrayOfObjects("tracks") if err == nil { // PLAYLIST - if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) { - // Create playlist - title, _ := apiResponse.String("title") - permalink, _ := apiResponse.String("permalink_url") - playlist := &YouTubePlaylist{ - id: permalink, - title: title, - } - - // Add all tracks - for _, t := range tracks { - sc.NewSong(user, jsonq.NewQuery(t), 0, playlist) - } - return playlist.Title(), nil + // Create playlist + title, _ := apiResponse.String("title") + permalink, _ := apiResponse.String("permalink_url") + playlist := &YouTubePlaylist{ + id: permalink, + title: title, } - return "", errors.New(NO_PLAYLIST_PERMISSION_MSG) + + // Add all tracks + for _, t := range tracks { + if song, err = sc.NewSong(user, jsonq.NewQuery(t), 0, playlist); err == nil { + songArray = append(songArray, song) + } + } + return songArray, nil } else { // SONG + // Calculate offset offset := 0 if len(timesplit) == 2 { timesplit = strings.Split(timesplit[1], ":") @@ -76,12 +87,17 @@ func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { multiplier *= 60 } } - return sc.NewSong(user, apiResponse, offset, nil) + + // Add the track + if song, err = sc.NewSong(user, apiResponse, offset, nil); err != nil { + return nil, err + } + return append(songArray, song), err } } // NewSong creates a track and adds to the queue -func (sc SoundCloud) NewSong(user *gumble.User, trackData *jsonq.JsonQuery, offset int, playlist Playlist) (string, error) { +func (sc SoundCloud) NewSong(user *gumble.User, trackData *jsonq.JsonQuery, offset int, playlist Playlist) (Song, error) { title, _ := trackData.String("title") id, _ := trackData.Int("id") durationMS, _ := trackData.Int("duration") @@ -93,26 +109,22 @@ func (sc SoundCloud) NewSong(user *gumble.User, trackData *jsonq.JsonQuery, offs thumbnail, _ = jsonq.NewQuery(userObj).String("avatar_url") } - // Check song is not longer than the MaxSongDuration - if dj.conf.General.MaxSongDuration == 0 || (durationMS/1000) <= dj.conf.General.MaxSongDuration { - timeDuration, _ := time.ParseDuration(strconv.Itoa(durationMS/1000) + "s") - duration := strings.NewReplacer("h", ":", "m", ":", "s", "").Replace(timeDuration.String()) + timeDuration, _ := time.ParseDuration(strconv.Itoa(durationMS/1000) + "s") + duration := timeDuration.String() //Lazy way to display time - song := &YouTubeSong{ - id: strconv.Itoa(id), - title: title, - url: url, - thumbnail: thumbnail, - submitter: user, - duration: duration, - offset: offset, - format: "mp3", - playlist: playlist, - skippers: make([]string, 0), - dontSkip: false, - } - dj.queue.AddSong(song) - return song.Title(), nil + song := &YouTubeSong{ + id: strconv.Itoa(id), + title: title, + url: url, + thumbnail: thumbnail, + submitter: user, + duration: duration, + offset: offset, + format: "mp3", + playlist: playlist, + skippers: make([]string, 0), + dontSkip: false, + service: sc, } - return "", errors.New(VIDEO_TOO_LONG_MSG) + return song, nil } diff --git a/service_youtube.go b/service_youtube.go index 7a05e36..73df91d 100644 --- a/service_youtube.go +++ b/service_youtube.go @@ -17,6 +17,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/jmoiron/jsonq" "github.com/layeh/gumble/gumble" @@ -35,23 +36,30 @@ var youtubeVideoPatterns = []string{ // YouTube implements the Service interface type YouTube struct{} +// ServiceName is the human readable version of the service name +func (yt YouTube) ServiceName() string { + return "YouTube" +} + +// TrackName is the human readable version of the service name +func (yt YouTube) TrackName() string { + return "Video" +} + // URLRegex checks to see if service will accept URL func (yt YouTube) URLRegex(url string) bool { return RegexpFromURL(url, append(youtubeVideoPatterns, []string{youtubePlaylistPattern}...)) != nil } // NewRequest creates the requested song/playlist and adds to the queue -func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { +func (yt YouTube) NewRequest(user *gumble.User, url string) ([]Song, error) { + var songArray []Song 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, shortURL) - return playlist.Title(), err - } else { - return "", errors.New("NO_PLAYLIST_PERMISSION") - } + shortURL = re.FindStringSubmatch(url)[1] + playlist, err := yt.NewPlaylist(user, shortURL) + return playlist.Title(), err } else { re = RegexpFromURL(url, youtubeVideoPatterns) matches := re.FindAllStringSubmatch(url, -1) @@ -60,10 +68,11 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { startOffset = matches[0][2] } song, err := yt.NewSong(user, shortURL, startOffset, nil) - if !isNil(song) { - return song.Title(), nil + if isNil(song) { + songArray = append(songArray, song) + return songArray, nil } else { - return "", err + return nil, err } } } else { @@ -73,97 +82,64 @@ 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 *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 = PerformGetRequest(url); err != nil { - return nil, errors.New(INVALID_API_KEY) - } + 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 { + title, _ := apiResponse.String("items", "0", "snippet", "title") + thumbnail, _ := apiResponse.String("items", "0", "snippet", "thumbnails", "high", "url") + duration, _ := apiResponse.String("items", "0", "contentDetails", "duration") - var offsetDays, offsetHours, offsetMinutes, offsetSeconds int64 - if offset != "" { - offsetExp := regexp.MustCompile(`t\=(?P\d+d)?(?P\d+h)?(?P\d+m)?(?P\d+s)?`) - offsetMatch := offsetExp.FindStringSubmatch(offset) - offsetResult := make(map[string]string) - for i, name := range offsetExp.SubexpNames() { - if i < len(offsetMatch) { - offsetResult[name] = offsetMatch[i] - } - } - - if offsetResult["days"] != "" { - offsetDays, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["days"], "d"), 10, 32) - } - if offsetResult["hours"] != "" { - offsetHours, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["hours"], "h"), 10, 32) - } - if offsetResult["minutes"] != "" { - offsetMinutes, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["minutes"], "m"), 10, 32) - } - if offsetResult["seconds"] != "" { - offsetSeconds, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["seconds"], "s"), 10, 32) - } - } - - title, _ := apiResponse.String("items", "0", "snippet", "title") - thumbnail, _ := apiResponse.String("items", "0", "snippet", "thumbnails", "high", "url") - duration, _ := apiResponse.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(duration) - 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 { song := &YouTubeSong{ submitter: user, title: title, id: id, url: "https://youtu.be/" + id, - offset: int((offsetDays * 86400) + (offsetHours * 3600) + (offsetMinutes * 60) + offsetSeconds), - duration: durationString, + offset: yt.parseTime(offset).Seconds(), + duration: yt.parseTime(duration).Seconds(), thumbnail: thumbnail, format: "m4a", skippers: make([]string, 0), playlist: playlist, dontSkip: false, + service: yt, } dj.queue.AddSong(song) return song, nil } - return nil, errors.New(VIDEO_TOO_LONG_MSG) + return nil, errors.New(fmt.Sprintf(INVALID_API_KEY, yt.ServiceName())) +} + +// parseTime converts from the string youtube returns to a time.Duration +func (yt YouTube) parseTime(duration string) time.Duration { + var days, hours, minutes, seconds, totalSeconds int64 + if duration != "" { + timestampExp := regexp.MustCompile(`P(?P\d+D)?T(?P\d+H)?(?P\d+M)?(?P\d+S)?`) + timestampMatch := timestampExp.FindStringSubmatch(duration) + 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) + } else { + totalSeconds = 0 + } + return time.ParseDuration(totalSeconds + "s") } // NewPlaylist gathers the metadata for a YouTube playlist and returns it. @@ -200,31 +176,3 @@ func (yt YouTube) NewPlaylist(user *gumble.User, id string) (Playlist, error) { } return playlist, nil } - -// PerformGetRequest does all the grunt work for a YouTube HTTPS GET request. -func PerformGetRequest(url string) (*jsonq.JsonQuery, error) { - jsonString := "" - - if response, err := http.Get(url); err == nil { - defer response.Body.Close() - if response.StatusCode == 200 { - if body, err := ioutil.ReadAll(response.Body); err == nil { - jsonString = string(body) - } - } else { - if response.StatusCode == 403 { - return nil, errors.New("Invalid API key supplied.") - } - return nil, errors.New("Invalid ID supplied.") - } - } else { - return nil, errors.New("An error occurred while receiving HTTP GET response.") - } - - jsonData := map[string]interface{}{} - decoder := json.NewDecoder(strings.NewReader(jsonString)) - decoder.Decode(&jsonData) - jq := jsonq.NewQuery(jsonData) - - return jq, nil -} diff --git a/strings.go b/strings.go index d48e7a7..54b55f5 100644 --- a/strings.go +++ b/strings.go @@ -7,8 +7,8 @@ package main -// Message shown to users when the bot has an invalid YouTube API key. -const INVALID_API_KEY = "MumbleDJ does not have a valid YouTube API key." +// Message shown to users when the bot has an invalid API key. +const INVALID_API_KEY = "MumbleDJ does not have a valid %s API key." // Message shown to users when they do not have permission to execute a command. const NO_PERMISSION_MSG = "You do not have permission to execute that command." @@ -26,7 +26,7 @@ const CHANNEL_DOES_NOT_EXIST_MSG = "The channel you specified does not exist." const INVALID_URL_MSG = "The URL you submitted does not match the required format." // Message shown to users when they attempt to add a video that's too long -const VIDEO_TOO_LONG_MSG = "The video you submitted exceeds the duration allowed by the server." +const TRACK_TOO_LONG_MSG = "The %s you submitted exceeds the duration allowed by the server." // Message shown to users when they attempt to perform an action on a song when // no song is playing. @@ -54,10 +54,10 @@ const ADMIN_SONG_SKIP_MSG = "An admin has decided to skip the current song." const ADMIN_PLAYLIST_SKIP_MSG = "An admin has decided to skip the current playlist." // Message shown to users when the audio for a video could not be downloaded. -const AUDIO_FAIL_MSG = "The audio download for this video failed. YouTube has likely not generated the audio files for this video yet. Skipping to the next song!" +const AUDIO_FAIL_MSG = "The audio download for this video failed. %s has likely not generated the audio files for this %s yet. Skipping to the next song!" -// Message shown to users when they supply a YouTube URL that does not contain a valid ID. -const INVALID_YOUTUBE_ID_MSG = "The YouTube URL you supplied did not contain a valid YouTube ID." +// Message shown to users when they supply an URL that does not contain a valid ID. +const INVALID_ID_MSG = "The %s URL you supplied did not contain a valid ID." // Message shown to user when they successfully update the bot's comment. const COMMENT_UPDATED_MSG = "The comment for the bot has successfully been updated." @@ -78,7 +78,7 @@ const SONG_ADDED_HTML = ` // Message shown to channel when a playlist is added to the queue by a user. const PLAYLIST_ADDED_HTML = ` - %s has added the playlist "%s" to the queue. + %s has added the %s "%s" to the queue. ` // Message shown to channel when a song has been skipped. @@ -95,7 +95,7 @@ const PLAYLIST_SKIPPED_HTML = ` const HELP_HTML = `
User Commands:

!help - Displays this help.

-

!add - Adds songs to queue.

+

!add - Adds songs/playlists to queue.

!volume - Either tells you the current volume or sets it to a new volume.

!skip - Casts a vote to skip the current song

!skipplaylist - Casts a vote to skip over the current playlist.

@@ -132,12 +132,12 @@ const SUBMITTER_SKIP_HTML = ` // Message shown to users when another user votes to skip the current playlist. const PLAYLIST_SKIP_ADDED_HTML = ` - %s has voted to skip the current playlist. + %s has voted to skip the current %s. ` // Message shown to users when the submitter of a song decides to skip their song. const PLAYLIST_SUBMITTER_SKIP_HTML = ` - The current playlist has been skipped by %s, the submitter. + The current %s has been skipped by %s, the submitter. ` // Message shown to users when they successfully change the volume. @@ -168,5 +168,5 @@ const CURRENT_SONG_HTML = ` // Message shown to users when the currentsong command is issued when a song from a // playlist is playing. const CURRENT_SONG_PLAYLIST_HTML = ` - The song currently playing is "%s", added %s from the playlist "%s". + The %s currently playing is "%s", added %s from the %s "%s". ` diff --git a/youtube_dl.go b/youtube_dl.go index efd9963..479b43e 100644 --- a/youtube_dl.go +++ b/youtube_dl.go @@ -31,6 +31,7 @@ type YouTubeSong struct { playlist Playlist skippers []string dontSkip bool + service Service } // YouTubePlaylist implements the Playlist interface @@ -238,3 +239,31 @@ func (p *YouTubePlaylist) ID() string { func (p *YouTubePlaylist) Title() string { return p.title } + +// PerformGetRequest does all the grunt work for HTTPS GET request. +func PerformGetRequest(url string) (*jsonq.JsonQuery, error) { + jsonString := "" + + if response, err := http.Get(url); err == nil { + defer response.Body.Close() + if response.StatusCode == 200 { + if body, err := ioutil.ReadAll(response.Body); err == nil { + jsonString = string(body) + } + } else { + if response.StatusCode == 403 { + return nil, errors.New("Invalid API key supplied.") + } + return nil, errors.New("Invalid ID supplied.") + } + } else { + return nil, errors.New("An error occurred while receiving HTTP GET response.") + } + + jsonData := map[string]interface{}{} + decoder := json.NewDecoder(strings.NewReader(jsonString)) + decoder.Decode(&jsonData) + jq := jsonq.NewQuery(jsonData) + + return jq, nil +}