From aa86b570a06bcbcb388c10f81405e4f57bc970ba Mon Sep 17 00:00:00 2001 From: Matthieu Grieger Date: Sat, 3 Jan 2015 11:31:29 -0800 Subject: [PATCH] Added ability to add YouTube playlists to queue --- commands.go | 112 +++++++++++++++++++++++++++++++++++---------- main.go | 25 +--------- mumbledj.gcfg | 12 +++++ parseconfig.go | 38 ++++++++------- playlist.go | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ song.go | 9 ++++ songqueue.go | 94 +++++++++++++++++++++++++++++++------ strings.go | 31 +++++++++++-- 8 files changed, 359 insertions(+), 84 deletions(-) create mode 100644 playlist.go diff --git a/commands.go b/commands.go index 5b94464..acfb13d 100644 --- a/commands.go +++ b/commands.go @@ -22,8 +22,8 @@ import ( // it contains a command. func parseCommand(user *gumble.User, username, command string) { var com, argument string - if strings.Contains(command, " ") { - sanitizedCommand := sanitize.HTML(command) + sanitizedCommand := sanitize.HTML(command) + if strings.Contains(sanitizedCommand, " ") { parsedCommand := strings.Split(sanitizedCommand, " ") com, argument = parsedCommand[0], parsedCommand[1] } else { @@ -42,14 +42,28 @@ func parseCommand(user *gumble.User, username, command string) { // Skip command case dj.conf.Aliases.SkipAlias: if dj.HasPermission(username, dj.conf.Permissions.AdminSkip) { - skip(username, false) + skip(user, username, false, false) + } else { + user.Send(NO_PERMISSION_MSG) + } + // Skip playlist command + case dj.conf.Aliases.SkipPlaylistAlias: + if dj.HasPermission(username, dj.conf.Permissions.AdminAddPlaylists) { + skip(user, username, false, true) } else { user.Send(NO_PERMISSION_MSG) } // Forceskip command case dj.conf.Aliases.AdminSkipAlias: if dj.HasPermission(username, true) { - skip(username, true) + skip(user, username, true, false) + } else { + user.Send(NO_PERMISSION_MSG) + } + // Playlist forceskip command + case dj.conf.Aliases.AdminSkipPlaylistAlias: + if dj.HasPermission(username, true) { + skip(user, username, true, true) } else { user.Send(NO_PERMISSION_MSG) } @@ -114,45 +128,93 @@ func add(user *gumble.User, username, url string) { if matchFound { newSong := NewSong(username, shortUrl) - if err := dj.queue.AddSong(newSong); err == nil { + if err := dj.queue.AddItem(newSong); err == nil { dj.client.Self().Channel().Send(fmt.Sprintf(SONG_ADDED_HTML, username, newSong.title), false) if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() { - dj.currentSong = dj.queue.NextSong() - if err := dj.currentSong.Download(); err == nil { - dj.currentSong.Play() + if err := dj.queue.CurrentItem().(*Song).Download(); err == nil { + dj.queue.CurrentItem().(*Song).Play() } else { user.Send(AUDIO_FAIL_MSG) - dj.currentSong.Delete() + dj.queue.CurrentItem().(*Song).Delete() } } - } else { - panic(errors.New("Could not add the Song to the queue.")) } } else { - user.Send(INVALID_URL_MSG) + // 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] + newPlaylist := NewPlaylist(username, shortUrl) + if dj.queue.AddItem(newPlaylist); err == nil { + dj.client.Self().Channel().Send(fmt.Sprintf(PLAYLIST_ADDED_HTML, username, newPlaylist.title), false) + if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() { + if err := dj.queue.CurrentItem().(*Playlist).songs.CurrentItem().(*Song).Download(); err == nil { + dj.queue.CurrentItem().(*Playlist).songs.CurrentItem().(*Song).Play() + } else { + user.Send(AUDIO_FAIL_MSG) + dj.queue.CurrentItem().(*Playlist).songs.CurrentItem().(*Song).Delete() + } + } + } + } else { + user.Send(NO_PLAYLIST_PERMISSION_MSG) + } + } else { + user.Send(INVALID_URL_MSG) + } + } } } } // Performs skip functionality. Adds a skip to the skippers slice for the current song, and then // evaluates if a skip should be performed. Both skip and forceskip are implemented here. -func skip(user string, admin bool) { - if err := dj.currentSong.AddSkip(user); err == nil { - if admin { - dj.client.Self().Channel().Send(ADMIN_SONG_SKIP_MSG, false) - } else { - dj.client.Self().Channel().Send(fmt.Sprintf(SKIP_ADDED_HTML, user), false) - } - if dj.currentSong.SkipReached(len(dj.client.Self().Channel().Users())) || admin { - dj.client.Self().Channel().Send(SONG_SKIPPED_HTML, false) - if err := dj.audioStream.Stop(); err == nil { - dj.OnSongFinished() +func skip(user *gumble.User, username string, admin, playlistSkip bool) { + if playlistSkip { + if dj.queue.CurrentItem().ItemType() == "playlist" { + if err := dj.queue.CurrentItem().AddSkip(username); err == nil { + if admin { + dj.client.Self().Channel().Send(ADMIN_PLAYLIST_SKIP_MSG, false) + } else { + dj.client.Self().Channel().Send(fmt.Sprintf(PLAYLIST_SKIP_ADDED_HTML, username), false) + } + if dj.queue.CurrentItem().SkipReached(len(dj.client.Self().Channel().Users())) || admin { + dj.queue.CurrentItem().(*Playlist).skipped = true + dj.client.Self().Channel().Send(PLAYLIST_SKIPPED_HTML, false) + if err := dj.audioStream.Stop(); err != nil { + panic(errors.New("An error occurred while stopping the current song.")) + } + } } else { - panic(errors.New("An error occurred while stopping the current song.")) + panic(errors.New("An error occurred while adding a skip to the current playlist.")) } + } else { + user.Send(NO_PLAYLIST_PLAYING_MSG) } } else { - panic(errors.New("An error occurred while adding a skip to the current song.")) + var currentItem QueueItem + if dj.queue.CurrentItem().ItemType() == "playlist" { + currentItem = dj.queue.CurrentItem().(*Playlist).songs.CurrentItem() + } else { + currentItem = dj.queue.CurrentItem() + } + if err := currentItem.AddSkip(username); err == nil { + if admin { + dj.client.Self().Channel().Send(ADMIN_SONG_SKIP_MSG, false) + } else { + dj.client.Self().Channel().Send(fmt.Sprintf(SKIP_ADDED_HTML, username), false) + } + if currentItem.SkipReached(len(dj.client.Self().Channel().Users())) || admin { + dj.client.Self().Channel().Send(SONG_SKIPPED_HTML, false) + if err := dj.audioStream.Stop(); err != nil { + panic(errors.New("An error occurred while stopping the current song.")) + } + } + } else { + panic(errors.New("An error occurred while adding a skip to the current song.")) + } } } diff --git a/main.go b/main.go index c8884ec..3adfb1b 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,6 @@ type mumbledj struct { defaultChannel string conf DjConfig queue *SongQueue - currentSong *Song audioStream *gumble_ffmpeg.Stream homeDir string } @@ -51,7 +50,7 @@ func (dj *mumbledj) OnConnect(e *gumble.ConnectEvent) { if audioStream, err := gumble_ffmpeg.New(dj.client); err == nil { dj.audioStream = audioStream - dj.audioStream.Done = dj.OnSongFinished + dj.audioStream.Done = dj.queue.OnItemFinished dj.audioStream.SetVolume(dj.conf.Volume.DefaultVolume) } else { panic(err) @@ -86,28 +85,6 @@ func (dj *mumbledj) HasPermission(username string, command bool) bool { } } -// OnSongFinished event. Deletes song that just finished playing, then queues, downloads, and plays -// the next song if it exists. -func (dj *mumbledj) OnSongFinished() { - if err := dj.currentSong.Delete(); err == nil { - if dj.queue.Len() != 0 { - dj.currentSong = dj.queue.NextSong() - if dj.currentSong != nil { - if err := dj.currentSong.Download(); err == nil { - dj.currentSong.Play() - } else { - username := dj.currentSong.submitter - user := dj.client.Self().Channel().Users().Find(username) - user.Send(AUDIO_FAIL_MSG) - dj.OnSongFinished() - } - } - } - } else { - panic(err) - } -} - // dj variable declaration. This is done outside of main() to allow global use. var dj = mumbledj{ keepAlive: make(chan bool), diff --git a/mumbledj.gcfg b/mumbledj.gcfg index 53985db..140cc35 100644 --- a/mumbledj.gcfg +++ b/mumbledj.gcfg @@ -13,6 +13,10 @@ CommandPrefix = "!" # DEFAULT VALUE: 0.5 SkipRatio = 0.5 +# Ratio that must be met or exceeded to trigger a playlist skip +# DEFAULT VALUE: 0.5 +PlaylistSkipRatio = 0.5 + [Volume] @@ -39,6 +43,10 @@ AddAlias = "add" # DEFAULT VALUE: "skip" SkipAlias = "skip" +# Alias used for playlist skip command +# DEFAULT VALUE: "skipplaylist" +SkipPlaylistAlias = "skipplaylist" + # Alias used for admin skip command # DEFAULT VALUE: "forceskip" AdminSkipAlias = "forceskip" @@ -79,6 +87,10 @@ Admins = "Matt" # DEFAULT VALUE: false AdminAdd = false +# Make playlist adds an admin only action? +# DEFAULT VALUE: false +AdminAddPlaylists = false + # Make skip an admin command? # DEFAULT VALUE: false AdminSkip = false diff --git a/parseconfig.go b/parseconfig.go index 776e463..b991765 100644 --- a/parseconfig.go +++ b/parseconfig.go @@ -16,8 +16,9 @@ import ( // Golang struct representation of mumbledj.gcfg file structure for parsing. type DjConfig struct { General struct { - CommandPrefix string - SkipRatio float32 + CommandPrefix string + SkipRatio float32 + PlaylistSkipRatio float32 } Volume struct { DefaultVolume float32 @@ -25,23 +26,26 @@ type DjConfig struct { HighestVolume float32 } Aliases struct { - AddAlias string - SkipAlias string - AdminSkipAlias string - VolumeAlias string - MoveAlias string - ReloadAlias string - KillAlias string + AddAlias string + SkipAlias string + SkipPlaylistAlias string + AdminSkipAlias string + AdminSkipPlaylistAlias string + VolumeAlias string + MoveAlias string + ReloadAlias string + KillAlias string } Permissions struct { - AdminsEnabled bool - Admins []string - AdminAdd bool - AdminSkip bool - AdminVolume bool - AdminMove bool - AdminReload bool - AdminKill bool + AdminsEnabled bool + Admins []string + AdminAdd bool + AdminAddPlaylists bool + AdminSkip bool + AdminVolume bool + AdminMove bool + AdminReload bool + AdminKill bool } } diff --git a/playlist.go b/playlist.go new file mode 100644 index 0000000..aaba051 --- /dev/null +++ b/playlist.go @@ -0,0 +1,122 @@ +/* + * MumbleDJ + * By Matthieu Grieger + * playlist.go + * Copyright (c) 2014 Matthieu Grieger (MIT License) + */ + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/jmoiron/jsonq" + "io/ioutil" + "net/http" + "strconv" + "strings" +) + +// Playlist type declaration. +type Playlist struct { + songs *SongQueue + youtubeId string + title string + submitter string + skippers []string + skipped bool +} + +// Returns a new Playlist type. Before returning the new type, the playlist's metadata is collected +// via the YouTube Gdata API. +func NewPlaylist(user, id string) *Playlist { + queue := NewSongQueue() + jsonUrl := fmt.Sprintf("http://gdata.youtube.com/feeds/api/playlists/%s?v=2&alt=jsonc&maxresults=25", id) + jsonString := "" + + if response, err := http.Get(jsonUrl); err == nil { + defer response.Body.Close() + if body, err := ioutil.ReadAll(response.Body); err == nil { + jsonString = string(body) + } + } + + jsonData := map[string]interface{}{} + decoder := json.NewDecoder(strings.NewReader(jsonString)) + decoder.Decode(&jsonData) + jq := jsonq.NewQuery(jsonData) + + playlistTitle, _ := jq.String("data", "title") + playlistItems, _ := jq.Int("data", "totalItems") + if playlistItems > 25 { + playlistItems = 25 + } + + for i := 0; i < playlistItems; i++ { + index := strconv.Itoa(i) + songTitle, _ := jq.String("data", "items", index, "video", "title") + songId, _ := jq.String("data", "items", index, "video", "id") + songThumbnail, _ := jq.String("data", "items", index, "video", "thumbnail", "hqDefault") + duration, _ := jq.Int("data", "items", index, "video", "duration") + songDuration := fmt.Sprintf("%d:%02d", duration/60, duration%60) + newSong := &Song{ + submitter: user, + title: songTitle, + youtubeId: songId, + playlistId: id, + duration: songDuration, + thumbnailUrl: songThumbnail, + } + queue.AddItem(newSong) + } + + playlist := &Playlist{ + songs: queue, + youtubeId: id, + title: playlistTitle, + submitter: user, + skipped: false, + } + return playlist +} + +// 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 (p *Playlist) AddSkip(username string) error { + for _, user := range p.skippers { + if username == user { + return errors.New("This user has already skipped the current song.") + } + } + p.skippers = append(p.skippers, username) + return nil +} + +// Removes a skip from the skippers slice. If username is not in the slice, an error is +// returned. +func (p *Playlist) RemoveSkip(username string) error { + for i, user := range p.skippers { + if username == user { + p.skippers = append(p.skippers[:i], p.skippers[i+1:]...) + return nil + } + } + return errors.New("This user has not skipped the song.") +} + +// Calculates current skip ratio based on number of users within MumbleDJ's channel and the +// amount of values in the skippers slice. If the value is greater than or equal to the skip ratio +// defined in mumbledj.gcfg, the function returns true. Returns false otherwise. +func (p *Playlist) SkipReached(channelUsers int) bool { + if float32(len(p.skippers))/float32(channelUsers) >= dj.conf.General.PlaylistSkipRatio { + return true + } else { + return false + } +} + +// Returns "playlist" as the item type. Used for differentiating Songs from Playlists. +func (p *Playlist) ItemType() string { + return "playlist" +} diff --git a/song.go b/song.go index 5b1db67..c6590c8 100644 --- a/song.go +++ b/song.go @@ -24,8 +24,10 @@ type Song struct { submitter string title string youtubeId string + playlistId string duration string thumbnailUrl string + itemType string skippers []string } @@ -56,8 +58,10 @@ func NewSong(user, id string) *Song { submitter: user, title: videoTitle, youtubeId: id, + playlistId: "", duration: videoDuration, thumbnailUrl: videoThumbnail, + itemType: "song", } return song } @@ -127,3 +131,8 @@ func (s *Song) SkipReached(channelUsers int) bool { return false } } + +// Returns "song" as the item type. Used for differentiating Songs from Playlists. +func (s *Song) ItemType() string { + return "song" +} diff --git a/songqueue.go b/songqueue.go index c419110..82928a0 100644 --- a/songqueue.go +++ b/songqueue.go @@ -11,38 +11,106 @@ import ( "errors" ) +// QueueItem type declaration. QueueItem is an interface that groups together Song and Playlist +// types in a queue. +type QueueItem interface { + AddSkip(string) error + RemoveSkip(string) error + SkipReached(int) bool + ItemType() string +} + // SongQueue type declaration. Serves as a wrapper around the queue structure defined in queue.go. type SongQueue struct { - queue []*Song + queue []QueueItem } // Initializes a new queue and returns the new SongQueue. func NewSongQueue() *SongQueue { return &SongQueue{ - queue: make([]*Song, 0), + queue: make([]QueueItem, 0), } } -// Adds a song to the SongQueue. -func (q *SongQueue) AddSong(s *Song) error { - beforeLen := len(q.queue) - q.queue = append(q.queue, s) +// Adds an item to the SongQueue. +func (q *SongQueue) AddItem(i QueueItem) error { + beforeLen := q.Len() + q.queue = append(q.queue, i) if len(q.queue) == beforeLen+1 { return nil } else { - return errors.New("Could not add Song to the SongQueue.") + return errors.New("Could not add QueueItem to the SongQueue.") } } -// Moves to the next song in SongQueue. NextSong() pops the first value of the queue, and is stored -// in dj.currentSong. -func (q *SongQueue) NextSong() *Song { - s, queue := q.queue[0], q.queue[1:] - q.queue = queue - return s +// Returns the current QueueItem. +func (q *SongQueue) CurrentItem() QueueItem { + return q.queue[0] +} + +// Moves to the next item in SongQueue. NextItem() removes the first value in the queue. +func (q *SongQueue) NextItem() { + q.queue = q.queue[1:] } // Returns the length of the SongQueue. func (q *SongQueue) Len() int { return len(q.queue) } + +// OnItemFinished event. Deletes item that just finished playing, then queues the next item. +func (q *SongQueue) OnItemFinished() { + if q.CurrentItem().ItemType() == "playlist" { + if err := q.CurrentItem().(*Playlist).songs.CurrentItem().(*Song).Delete(); err == nil { + if q.CurrentItem().(*Playlist).skipped == true { + if q.Len() > 1 { + q.NextItem() + q.PrepareAndPlayNextItem() + } else { + q.queue = q.queue[1:] + } + } else if q.CurrentItem().(*Playlist).songs.Len() > 1 { + q.CurrentItem().(*Playlist).songs.NextItem() + q.PrepareAndPlayNextItem() + } else { + if q.Len() > 1 { + q.NextItem() + q.PrepareAndPlayNextItem() + } + } + } else { + panic(err) + } + } else { + if err := q.CurrentItem().(*Song).Delete(); err == nil { + if q.Len() > 1 { + q.NextItem() + q.PrepareAndPlayNextItem() + } + } else { + panic(err) + } + } +} + +func (q *SongQueue) PrepareAndPlayNextItem() { + if q.CurrentItem().ItemType() == "playlist" { + if err := q.CurrentItem().(*Playlist).songs.CurrentItem().(*Song).Download(); err == nil { + q.CurrentItem().(*Playlist).songs.CurrentItem().(*Song).Play() + } else { + username := q.CurrentItem().(*Playlist).submitter + user := dj.client.Self().Channel().Users().Find(username) + user.Send(AUDIO_FAIL_MSG) + q.OnItemFinished() + } + } else { + if err := q.CurrentItem().(*Song).Download(); err == nil { + q.CurrentItem().(*Song).Play() + } else { + username := q.CurrentItem().(*Song).submitter + user := dj.client.Self().Channel().Users().Find(username) + user.Send(AUDIO_FAIL_MSG) + q.OnItemFinished() + } + } +} diff --git a/strings.go b/strings.go index 8d8bf4a..da0dd5e 100644 --- a/strings.go +++ b/strings.go @@ -10,6 +10,9 @@ package main // 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." +// Message shown to users when they try to add a playlist to the queue and do not have permission to do so. +const NO_PLAYLIST_PERMISSION_MSG = "You do not have permission to add playlists to the queue." + // Message shown to users when they try to execute a command that doesn't exist. const COMMAND_DOESNT_EXIST_MSG = "The command you entered does not exist." @@ -23,6 +26,9 @@ const INVALID_URL_MSG = "The URL you submitted does not match the required forma // no song is playing. const NO_MUSIC_PLAYING_MSG = "There is no music playing at the moment." +// Message shown to users when they attempt to skip a playlist when there is no playlist playing. +const NO_PLAYLIST_PLAYING_MSG = "There is no playlist playing at the moment." + // Message shown to users when they issue a command that requires an argument and one was not supplied. const NO_ARGUMENT_MSG = "The command you issued requires an argument and you did not provide one." @@ -32,11 +38,11 @@ const NOT_IN_VOLUME_RANGE_MSG = "Out of range. The volume must be between %f and // Message shown to user when a successful configuration reload finishes. const CONFIG_RELOAD_SUCCESS_MSG = "The configuration has been successfully reloaded." -// Message shown to user when an admin skips a song. +// Message shown to users when an admin skips a song. const ADMIN_SONG_SKIP_MSG = "An admin has decided to skip the current song." -// Message shown to user when the kill command errors. -const KILL_ERROR_MSG = "An error occurred while attempting to kill the bot." +// Message shown to users when an admin skips a playlist. +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." @@ -61,14 +67,24 @@ const SONG_ADDED_HTML = ` %s has added "%s" to the queue. ` +// 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. +` + // Message shown to channel when a song has been skipped. const SONG_SKIPPED_HTML = ` The number of votes required for a skip has been met. Skipping song! ` +// Message shown to channel when a playlist has been skipped. +const PLAYLIST_SKIPPED_HTML = ` + The number of votes required for a skip has been met. Skipping playlist! +` + // Message shown to users when they ask for the current volume (volume command without argument) const CUR_VOLUME_HTML = ` - The current volume is %f. + The current volume is %.2f. ` // Message shown to users when another user votes to skip the current song. @@ -76,7 +92,12 @@ const SKIP_ADDED_HTML = ` %s has voted to skip the current song. ` +// 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. +` + // Message shown to users when they successfully change the volume. const VOLUME_SUCCESS_HTML = ` - %s has changed the volume to %s. + %s has changed the volume to %.2f. `