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. // it contains a command.
func parseCommand(user *gumble.User, username, command string) { func parseCommand(user *gumble.User, username, command string) {
var com, argument 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, " ") parsedCommand := strings.Split(sanitizedCommand, " ")
com, argument = parsedCommand[0], parsedCommand[1] com, argument = parsedCommand[0], parsedCommand[1]
} else { } else {
@ -42,14 +42,28 @@ func parseCommand(user *gumble.User, username, command string) {
// Skip command // Skip command
case dj.conf.Aliases.SkipAlias: case dj.conf.Aliases.SkipAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminSkip) { 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 { } else {
user.Send(NO_PERMISSION_MSG) user.Send(NO_PERMISSION_MSG)
} }
// Forceskip command // Forceskip command
case dj.conf.Aliases.AdminSkipAlias: case dj.conf.Aliases.AdminSkipAlias:
if dj.HasPermission(username, true) { 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 { } else {
user.Send(NO_PERMISSION_MSG) user.Send(NO_PERMISSION_MSG)
} }
@ -114,46 +128,94 @@ func add(user *gumble.User, username, url string) {
if matchFound { if matchFound {
newSong := NewSong(username, shortUrl) 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) dj.client.Self().Channel().Send(fmt.Sprintf(SONG_ADDED_HTML, username, newSong.title), false)
if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() { if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() {
dj.currentSong = dj.queue.NextSong() if err := dj.queue.CurrentItem().(*Song).Download(); err == nil {
if err := dj.currentSong.Download(); err == nil { dj.queue.CurrentItem().(*Song).Play()
dj.currentSong.Play()
} else { } else {
user.Send(AUDIO_FAIL_MSG) user.Send(AUDIO_FAIL_MSG)
dj.currentSong.Delete() dj.queue.CurrentItem().(*Song).Delete()
}
} }
} }
} else { } else {
panic(errors.New("Could not add the Song to the queue.")) // 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 { } else {
user.Send(INVALID_URL_MSG) user.Send(INVALID_URL_MSG)
} }
} }
}
}
} }
// Performs skip functionality. Adds a skip to the skippers slice for the current song, and then // 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. // evaluates if a skip should be performed. Both skip and forceskip are implemented here.
func skip(user string, admin bool) { func skip(user *gumble.User, username string, admin, playlistSkip bool) {
if err := dj.currentSong.AddSkip(user); err == nil { 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 adding a skip to the current playlist."))
}
} else {
user.Send(NO_PLAYLIST_PLAYING_MSG)
}
} else {
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 { if admin {
dj.client.Self().Channel().Send(ADMIN_SONG_SKIP_MSG, false) dj.client.Self().Channel().Send(ADMIN_SONG_SKIP_MSG, false)
} else { } else {
dj.client.Self().Channel().Send(fmt.Sprintf(SKIP_ADDED_HTML, user), false) dj.client.Self().Channel().Send(fmt.Sprintf(SKIP_ADDED_HTML, username), false)
} }
if dj.currentSong.SkipReached(len(dj.client.Self().Channel().Users())) || admin { if currentItem.SkipReached(len(dj.client.Self().Channel().Users())) || admin {
dj.client.Self().Channel().Send(SONG_SKIPPED_HTML, false) dj.client.Self().Channel().Send(SONG_SKIPPED_HTML, false)
if err := dj.audioStream.Stop(); err == nil { if err := dj.audioStream.Stop(); err != nil {
dj.OnSongFinished()
} else {
panic(errors.New("An error occurred while stopping the current song.")) panic(errors.New("An error occurred while stopping the current song."))
} }
} }
} else { } else {
panic(errors.New("An error occurred while adding a skip to the current song.")) panic(errors.New("An error occurred while adding a skip to the current song."))
} }
}
} }
// Performs volume functionality. Checks input value against LowestVolume and HighestVolume from // Performs volume functionality. Checks input value against LowestVolume and HighestVolume from

25
main.go
View file

@ -24,7 +24,6 @@ type mumbledj struct {
defaultChannel string defaultChannel string
conf DjConfig conf DjConfig
queue *SongQueue queue *SongQueue
currentSong *Song
audioStream *gumble_ffmpeg.Stream audioStream *gumble_ffmpeg.Stream
homeDir string homeDir string
} }
@ -51,7 +50,7 @@ func (dj *mumbledj) OnConnect(e *gumble.ConnectEvent) {
if audioStream, err := gumble_ffmpeg.New(dj.client); err == nil { if audioStream, err := gumble_ffmpeg.New(dj.client); err == nil {
dj.audioStream = audioStream dj.audioStream = audioStream
dj.audioStream.Done = dj.OnSongFinished dj.audioStream.Done = dj.queue.OnItemFinished
dj.audioStream.SetVolume(dj.conf.Volume.DefaultVolume) dj.audioStream.SetVolume(dj.conf.Volume.DefaultVolume)
} else { } else {
panic(err) 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. // dj variable declaration. This is done outside of main() to allow global use.
var dj = mumbledj{ var dj = mumbledj{
keepAlive: make(chan bool), keepAlive: make(chan bool),

View file

@ -13,6 +13,10 @@ CommandPrefix = "!"
# DEFAULT VALUE: 0.5 # DEFAULT VALUE: 0.5
SkipRatio = 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] [Volume]
@ -39,6 +43,10 @@ AddAlias = "add"
# DEFAULT VALUE: "skip" # DEFAULT VALUE: "skip"
SkipAlias = "skip" SkipAlias = "skip"
# Alias used for playlist skip command
# DEFAULT VALUE: "skipplaylist"
SkipPlaylistAlias = "skipplaylist"
# Alias used for admin skip command # Alias used for admin skip command
# DEFAULT VALUE: "forceskip" # DEFAULT VALUE: "forceskip"
AdminSkipAlias = "forceskip" AdminSkipAlias = "forceskip"
@ -79,6 +87,10 @@ Admins = "Matt"
# DEFAULT VALUE: false # DEFAULT VALUE: false
AdminAdd = false AdminAdd = false
# Make playlist adds an admin only action?
# DEFAULT VALUE: false
AdminAddPlaylists = false
# Make skip an admin command? # Make skip an admin command?
# DEFAULT VALUE: false # DEFAULT VALUE: false
AdminSkip = false AdminSkip = false

View file

@ -18,6 +18,7 @@ type DjConfig struct {
General struct { General struct {
CommandPrefix string CommandPrefix string
SkipRatio float32 SkipRatio float32
PlaylistSkipRatio float32
} }
Volume struct { Volume struct {
DefaultVolume float32 DefaultVolume float32
@ -27,7 +28,9 @@ type DjConfig struct {
Aliases struct { Aliases struct {
AddAlias string AddAlias string
SkipAlias string SkipAlias string
SkipPlaylistAlias string
AdminSkipAlias string AdminSkipAlias string
AdminSkipPlaylistAlias string
VolumeAlias string VolumeAlias string
MoveAlias string MoveAlias string
ReloadAlias string ReloadAlias string
@ -37,6 +40,7 @@ type DjConfig struct {
AdminsEnabled bool AdminsEnabled bool
Admins []string Admins []string
AdminAdd bool AdminAdd bool
AdminAddPlaylists bool
AdminSkip bool AdminSkip bool
AdminVolume bool AdminVolume bool
AdminMove bool AdminMove 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 submitter string
title string title string
youtubeId string youtubeId string
playlistId string
duration string duration string
thumbnailUrl string thumbnailUrl string
itemType string
skippers []string skippers []string
} }
@ -56,8 +58,10 @@ func NewSong(user, id string) *Song {
submitter: user, submitter: user,
title: videoTitle, title: videoTitle,
youtubeId: id, youtubeId: id,
playlistId: "",
duration: videoDuration, duration: videoDuration,
thumbnailUrl: videoThumbnail, thumbnailUrl: videoThumbnail,
itemType: "song",
} }
return song return song
} }
@ -127,3 +131,8 @@ func (s *Song) SkipReached(channelUsers int) bool {
return false 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" "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. // SongQueue type declaration. Serves as a wrapper around the queue structure defined in queue.go.
type SongQueue struct { type SongQueue struct {
queue []*Song queue []QueueItem
} }
// Initializes a new queue and returns the new SongQueue. // Initializes a new queue and returns the new SongQueue.
func NewSongQueue() *SongQueue { func NewSongQueue() *SongQueue {
return &SongQueue{ return &SongQueue{
queue: make([]*Song, 0), queue: make([]QueueItem, 0),
} }
} }
// Adds a song to the SongQueue. // Adds an item to the SongQueue.
func (q *SongQueue) AddSong(s *Song) error { func (q *SongQueue) AddItem(i QueueItem) error {
beforeLen := len(q.queue) beforeLen := q.Len()
q.queue = append(q.queue, s) q.queue = append(q.queue, i)
if len(q.queue) == beforeLen+1 { if len(q.queue) == beforeLen+1 {
return nil return nil
} else { } 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 // Returns the current QueueItem.
// in dj.currentSong. func (q *SongQueue) CurrentItem() QueueItem {
func (q *SongQueue) NextSong() *Song { return q.queue[0]
s, queue := q.queue[0], q.queue[1:] }
q.queue = queue
return s // 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. // Returns the length of the SongQueue.
func (q *SongQueue) Len() int { func (q *SongQueue) Len() int {
return len(q.queue) 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. // 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." 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. // 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." 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. // no song is playing.
const NO_MUSIC_PLAYING_MSG = "There is no music playing at the moment." 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. // 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." 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. // Message shown to user when a successful configuration reload finishes.
const CONFIG_RELOAD_SUCCESS_MSG = "The configuration has been successfully reloaded." 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." const ADMIN_SONG_SKIP_MSG = "An admin has decided to skip the current song."
// Message shown to user when the kill command errors. // Message shown to users when an admin skips a playlist.
const KILL_ERROR_MSG = "An error occurred while attempting to kill the bot." 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. // 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." 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. <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. // Message shown to channel when a song has been skipped.
const SONG_SKIPPED_HTML = ` const SONG_SKIPPED_HTML = `
The number of votes required for a skip has been met. <b>Skipping song!</b> 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) // Message shown to users when they ask for the current volume (volume command without argument)
const CUR_VOLUME_HTML = ` 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. // 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. <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. // Message shown to users when they successfully change the volume.
const VOLUME_SUCCESS_HTML = ` 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>.
` `