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,45 +128,93 @@ 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 {
panic(errors.New("Could not add the Song to the queue."))
} }
} else { } 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 // 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 admin { if dj.queue.CurrentItem().ItemType() == "playlist" {
dj.client.Self().Channel().Send(ADMIN_SONG_SKIP_MSG, false) if err := dj.queue.CurrentItem().AddSkip(username); err == nil {
} else { if admin {
dj.client.Self().Channel().Send(fmt.Sprintf(SKIP_ADDED_HTML, user), false) dj.client.Self().Channel().Send(ADMIN_PLAYLIST_SKIP_MSG, false)
} } else {
if dj.currentSong.SkipReached(len(dj.client.Self().Channel().Users())) || admin { dj.client.Self().Channel().Send(fmt.Sprintf(PLAYLIST_SKIP_ADDED_HTML, username), false)
dj.client.Self().Channel().Send(SONG_SKIPPED_HTML, false) }
if err := dj.audioStream.Stop(); err == nil { if dj.queue.CurrentItem().SkipReached(len(dj.client.Self().Channel().Users())) || admin {
dj.OnSongFinished() 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 { } 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 { } 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 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

@ -16,8 +16,9 @@ import (
// Golang struct representation of mumbledj.gcfg file structure for parsing. // Golang struct representation of mumbledj.gcfg file structure for parsing.
type DjConfig struct { 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
@ -25,23 +26,26 @@ type DjConfig struct {
HighestVolume float32 HighestVolume float32
} }
Aliases struct { Aliases struct {
AddAlias string AddAlias string
SkipAlias string SkipAlias string
AdminSkipAlias string SkipPlaylistAlias string
VolumeAlias string AdminSkipAlias string
MoveAlias string AdminSkipPlaylistAlias string
ReloadAlias string VolumeAlias string
KillAlias string MoveAlias string
ReloadAlias string
KillAlias string
} }
Permissions struct { Permissions struct {
AdminsEnabled bool AdminsEnabled bool
Admins []string Admins []string
AdminAdd bool AdminAdd bool
AdminSkip bool AdminAddPlaylists bool
AdminVolume bool AdminSkip bool
AdminMove bool AdminVolume bool
AdminReload bool AdminMove bool
AdminKill 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 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>.
` `