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.
`