Added ability to add YouTube playlists to queue

This commit is contained in:
Matthieu Grieger 2015-01-03 11:31:29 -08:00
parent 39f7b8dcab
commit aa86b570a0
8 changed files with 359 additions and 84 deletions

View file

@ -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."))
}
}
}

25
main.go
View file

@ -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),

View file

@ -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

View file

@ -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
}
}

122
playlist.go Normal file
View file

@ -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"
}

View file

@ -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"
}

View file

@ -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()
}
}
}

View file

@ -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 = `
<b>%s</b> 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 = `
<b>%s</b> 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. <b>Skipping song!</b>
`
// 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. <b>Skipping playlist!</b>
`
// Message shown to users when they ask for the current volume (volume command without argument)
const CUR_VOLUME_HTML = `
The current volume is <b>%f</b>.
The current volume is <b>%.2f</b>.
`
// Message shown to users when another user votes to skip the current song.
@ -76,7 +92,12 @@ const SKIP_ADDED_HTML = `
<b>%s</b> 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 = `
<b>%s</b> has voted to skip the current playlist.
`
// Message shown to users when they successfully change the volume.
const VOLUME_SUCCESS_HTML = `
<b>%s</b> has changed the volume to <b>%s</b>.
<b>%s</b> has changed the volume to <b>%.2f</b>.
`