├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── audio.go ├── bot.toml.sample ├── commands.go ├── config.go ├── db.go ├── discord.go ├── docker-compose.yml ├── musicbot.go ├── queue.go ├── structs.go ├── vars.go ├── youtube.go └── youtube_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | ################################################ 2 | ############### .gitignore ################## 3 | ################################################ 4 | # 5 | # This file is only relevant if you are using git. 6 | # 7 | # Files which match the splat patterns below will 8 | # be ignored by git. This keeps random crap and 9 | # and sensitive credentials from being uploaded to 10 | # your repository. It allows you to configure your 11 | # app for your machine without accidentally 12 | # committing settings which will smash the local 13 | # settings of other developers on your team. 14 | # 15 | # Some reasonable defaults are included below, 16 | # but, of course, you should modify/extend/prune 17 | # to fit your needs! 18 | ################################################ 19 | 20 | ################################################ 21 | # Local Configuration 22 | # 23 | # Explicitly ignore files which contain: 24 | # 25 | # 1. Sensitive information you'd rather not push to 26 | # your git repository. 27 | # e.g., your personal API keys or passwords. 28 | # 29 | # 2. Environment-specific configuration 30 | # Basically, anything that would be annoying 31 | # to have to change every time you do a 32 | # `git pull` 33 | # e.g., your local development database, or 34 | # the S3 bucket you're using for file uploads 35 | # development. 36 | # 37 | ################################################ 38 | 39 | bin/* 40 | ignore/* 41 | musicbot 42 | MusicBot.db 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | MAINTAINER Leonardo Javier Gago 3 | 4 | RUN apk update && apk add git ffmpeg ca-certificates && update-ca-certificates 5 | 6 | RUN CGO_ENABLED=0 go get github.com/ljgago/MusicBot 7 | 8 | RUN mkdir /bot 9 | 10 | WORKDIR /bot 11 | 12 | CMD ["MusicBot", "-f", "bot.toml"] 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Leonardo Javier Gago 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MusicBot 2 | 3 | **MusicBot** is a multiserver music bot for Discord built in Go. **MusicBot** plays youtube audio and radio stream url. 4 | 5 | ### Features: 6 | - Plays YouTube audio with query parameters or the url link. 7 | - Plays radio stream url. 8 | - Search YouTube videos. 9 | - Support queue. 10 | - Support remove song of queue by index, by user or by the last song. 11 | - Support for skip, pause and resume. 12 | - Support ignore commands of a channel. 13 | - Support for message lifetime (config file) 14 | - Support view title of song in status (config file) (Use this if you have one server only) 15 | - Add Dockerfile and docker-compose for automatic build and run. 16 | 17 | ### Build and install 18 | 19 | You need to have installed in your system **go>1.7** and **ffmpeg>3.0** 20 | 21 | ```bash 22 | # Install MusicBot 23 | go get -u github.com/ljgago/MusicBot 24 | ``` 25 | 26 | ### Use 27 | 28 | **MusicBot** use a simple TOML config file. 29 | 30 | ```bash 31 | MusicBot -f bot.toml 32 | ``` 33 | 34 | ### Docker 35 | 36 | Edit and rename **_bot.toml.sample_** to **_bot.toml_** 37 | 38 | ```bash 39 | # Run docker 40 | docker build -t musicbot-img . 41 | docker run -d --name musicbot --restart always -v $PWD/bot.toml:/bot/bot.toml -it musicbot-img 42 | ``` 43 | 44 | If you have docker-compose: 45 | 46 | ```bash 47 | # Run docker-compose (automatic build and run) 48 | docker-compose up -d 49 | ``` 50 | 51 | ### Example bot.toml config file: 52 | 53 | ```bash 54 | [discord] 55 | token = "YjQ4ODMyNTI0NzG3NDMwsDAw.CdNZBQ.fG5QVSUj7Gunf7CTTh69jG18tiQ" # Token bot 56 | status = "Music Bot | !help" 57 | prefix = "!" 58 | purgetime = 60 # message time to live 59 | playstatus = false # Set 'true' if this bot run one server only 60 | 61 | [youtube] 62 | token = "UIzRSyFyg75iDJbsKhaYk97UtgFriJjbo8uLH57" 63 | ``` 64 | 65 | License MIT. -------------------------------------------------------------------------------- /audio.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jonas747/dca" 5 | "io" 6 | "log" 7 | "time" 8 | ) 9 | 10 | const ( 11 | channels int = 2 // 1 for mono, 2 for stereo 12 | frameRate int = 48000 // audio sampling rate 13 | frameSize int = 960 // uint16 size of each audio frame 960/48KHz = 20ms 14 | bufferSize int = 1024 // max size of opus data 1K 15 | ) 16 | 17 | /* 18 | func (v *VoiceInstance) InitVoice() { 19 | v.songSig = make(chan PkgSong) 20 | v.radioSig = make(chan PkgRadio) 21 | v.endSig = make(chan bool) 22 | v.speaking = false 23 | go v.Play(v.songSig, v.radioSig, v.endSig) 24 | } 25 | */ 26 | /* 27 | func (v *VoiceInstance) Play(songSig chan Song, radioSig chan string, endSig chan bool) { 28 | for { 29 | select { 30 | case song := <-songSig: 31 | if v.radioFlag { 32 | v.Stop() 33 | time.Sleep(200 * time.Millisecond) 34 | } 35 | go v.PlayQueue(song) 36 | case radio := <-radioSig: 37 | v.Stop() 38 | time.Sleep(200 * time.Millisecond) 39 | go v.Radio(radio) 40 | case <-endSig: 41 | v.Stop() 42 | return 43 | //time.Sleep(200 * time.Millisecond) 44 | } 45 | } 46 | } 47 | */ 48 | 49 | func GlobalPlay(songSig chan PkgSong) { 50 | for { 51 | select { 52 | case song := <-songSig: 53 | if song.v.radioFlag { 54 | song.v.Stop() 55 | time.Sleep(200 * time.Millisecond) 56 | } 57 | go song.v.PlayQueue(song.data) 58 | } 59 | } 60 | } 61 | 62 | func GlobalRadio(radioSig chan PkgRadio) { 63 | for { 64 | select { 65 | case radio := <-radioSig: 66 | radio.v.Stop() 67 | time.Sleep(200 * time.Millisecond) 68 | go radio.v.Radio(radio.data) 69 | } 70 | } 71 | } 72 | 73 | func (v *VoiceInstance) PlayQueue(song Song) { 74 | // add song to queue 75 | v.QueueAdd(song) 76 | if v.speaking { 77 | // the bot is playing 78 | return 79 | } 80 | go func() { 81 | v.audioMutex.Lock() 82 | defer v.audioMutex.Unlock() 83 | for { 84 | if len(v.queue) == 0 { 85 | dg.UpdateStatus(0, o.DiscordStatus) 86 | ChMessageSend(v.nowPlaying.ChannelID, "[**Music**] End of queue!") 87 | return 88 | } 89 | v.nowPlaying = v.QueueGetSong() 90 | go ChMessageSend(v.nowPlaying.ChannelID, "[**Music**] Playing, **`"+ 91 | v.nowPlaying.Title+"` - `("+v.nowPlaying.Duration+")` - **<@"+v.nowPlaying.ID+">\n") //*`"+ v.nowPlaying.User +"`***") 92 | // If monoserver 93 | if o.DiscordPlayStatus { 94 | dg.UpdateStatus(0, v.nowPlaying.Title) 95 | } 96 | v.stop = false 97 | v.skip = false 98 | v.speaking = true 99 | v.pause = false 100 | v.voice.Speaking(true) 101 | 102 | v.DCA(v.nowPlaying.VideoURL) 103 | 104 | v.QueueRemoveFisrt() 105 | if v.stop { 106 | v.QueueRemove() 107 | } 108 | v.stop = false 109 | v.skip = false 110 | v.speaking = false 111 | v.voice.Speaking(false) 112 | } 113 | }() 114 | } 115 | 116 | func (v *VoiceInstance) Radio(url string) { 117 | v.audioMutex.Lock() 118 | defer v.audioMutex.Unlock() 119 | if o.DiscordPlayStatus { 120 | dg.UpdateStatus(0, "Radio") 121 | } 122 | v.radioFlag = true 123 | v.stop = false 124 | v.speaking = true 125 | v.pause = false 126 | v.voice.Speaking(true) 127 | 128 | v.DCA(url) 129 | 130 | dg.UpdateStatus(0, o.DiscordStatus) 131 | v.radioFlag = false 132 | v.stop = false 133 | v.speaking = false 134 | v.voice.Speaking(false) 135 | } 136 | 137 | // DCA 138 | func (v *VoiceInstance) DCA(url string) { 139 | opts := dca.StdEncodeOptions 140 | opts.RawOutput = true 141 | opts.Bitrate = 96 142 | opts.Application = "lowdelay" 143 | 144 | encodeSession, err := dca.EncodeFile(url, opts) 145 | if err != nil { 146 | log.Println("FATA: Failed creating an encoding session: ", err) 147 | } 148 | v.encoder = encodeSession 149 | done := make(chan error) 150 | stream := dca.NewStream(encodeSession, v.voice, done) 151 | v.stream = stream 152 | for { 153 | select { 154 | case err := <-done: 155 | if err != nil && err != io.EOF { 156 | log.Println("FATA: An error occured", err) 157 | } 158 | // Clean up incase something happened and ffmpeg is still running 159 | encodeSession.Cleanup() 160 | return 161 | } 162 | } 163 | } 164 | 165 | // Stop stop the audio 166 | func (v *VoiceInstance) Stop() { 167 | v.stop = true 168 | if v.encoder != nil { 169 | v.encoder.Cleanup() 170 | } 171 | } 172 | 173 | func (v *VoiceInstance) Skip() bool { 174 | if v.speaking { 175 | if v.pause { 176 | return true 177 | } else { 178 | if v.encoder != nil { 179 | v.encoder.Cleanup() 180 | } 181 | } 182 | } 183 | return false 184 | } 185 | 186 | // Pause pause the audio 187 | func (v *VoiceInstance) Pause() { 188 | v.pause = true 189 | if v.stream != nil { 190 | v.stream.SetPaused(true) 191 | } 192 | } 193 | 194 | // Resume resume the audio 195 | func (v *VoiceInstance) Resume() { 196 | v.pause = false 197 | if v.stream != nil { 198 | v.stream.SetPaused(false) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /bot.toml.sample: -------------------------------------------------------------------------------- 1 | # Example file 2 | [discord] 3 | token = "YjQ4ODMyNTI0NzG3NDMwsDAw.CdNZBQ.fG5QVSUj7Gunf7CTTh69jG18tiQ" # Token bot 4 | status = "Music Bot | !help" 5 | prefix = "!" 6 | purgetime = 60 7 | playstatus = false # Set 'true' if this bot run one server only 8 | 9 | [youtube] 10 | token = "UIzRSyFyg75iDJbsKhaYk97UtgFriJjbo8uLH57" 11 | 12 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bwmarrin/discordgo" 6 | "log" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // HelpReporter 13 | func HelpReporter(m *discordgo.MessageCreate) { 14 | log.Println("INFO:", m.Author.Username, "send 'help'") 15 | help := "```go\n`Standard Commands List`\n```\n" + 16 | "**`" + o.DiscordPrefix + "help`** or **`" + o.DiscordPrefix + "h`** -> show help commands.\n" + 17 | "**`" + o.DiscordPrefix + "join`** or **`" + o.DiscordPrefix + "j`** -> the bot join in to voice channel.\n" + 18 | "**`" + o.DiscordPrefix + "leave`** or **`" + o.DiscordPrefix + "l`** -> the bot leave the voice channel.\n" + 19 | "**`" + o.DiscordPrefix + "play`** -> play and add a one song in the queue.\n" + 20 | "**`" + o.DiscordPrefix + "radio`** -> play a URL radio.\n" + 21 | "**`" + o.DiscordPrefix + "stop`** -> stop the player and remove the queue.\n" + 22 | "**`" + o.DiscordPrefix + "skip`** -> skip the actual song and play the next song of the queue.\n" + 23 | "**`" + o.DiscordPrefix + "pause`** -> pause the player.\n" + 24 | "**`" + o.DiscordPrefix + "resume`** -> resume the player.\n" + 25 | "**`" + o.DiscordPrefix + "time`** -> show the time remaining of song.\n" + 26 | "**`" + o.DiscordPrefix + "queue list`** -> show the list of song in the queue.\n" + 27 | "**`" + o.DiscordPrefix + "queue remove `** -> remove a song of queue indexed for a ***number***, an ***@User*** or the ***last*** song, i.e. ***" + o.DiscordPrefix + "queue remove 2***\n" + 28 | "**`" + o.DiscordPrefix + "queue clean`** -> clean all queue.\n" + 29 | "**`" + o.DiscordPrefix + "youtube`** -> search from youtube.\n\n" + 30 | "```go\n`Owner Commands List`\n```\n" + 31 | "**`" + o.DiscordPrefix + "ignore`** -> ignore commands of a channel.\n" + 32 | "**`" + o.DiscordPrefix + "unignore`** -> unignore commands of a channel.\n" 33 | 34 | ChMessageSend(m.ChannelID, help) 35 | //ChMessageSendEmbed(m.ChannelID, "Help", help) 36 | } 37 | 38 | // JoinReporter 39 | func JoinReporter(v *VoiceInstance, m *discordgo.MessageCreate, s *discordgo.Session) { 40 | log.Println("INFO:", m.Author.Username, "send 'join'") 41 | voiceChannelID := SearchVoiceChannel(m.Author.ID) 42 | if voiceChannelID == "" { 43 | log.Println("ERROR: Voice channel id not found.") 44 | ChMessageSend(m.ChannelID, "[**Music**] <@"+m.Author.ID+"> You need to join a voice channel!") 45 | return 46 | } 47 | if v != nil { 48 | log.Println("INFO: Voice Instance already created.") 49 | } else { 50 | guildID := SearchGuild(m.ChannelID) 51 | // create new voice instance 52 | mutex.Lock() 53 | v = new(VoiceInstance) 54 | voiceInstances[guildID] = v 55 | v.guildID = guildID 56 | v.session = s 57 | mutex.Unlock() 58 | //v.InitVoice() 59 | } 60 | var err error 61 | v.voice, err = dg.ChannelVoiceJoin(v.guildID, voiceChannelID, false, false) 62 | if err != nil { 63 | v.Stop() 64 | log.Println("ERROR: Error to join in a voice channel: ", err) 65 | return 66 | } 67 | v.voice.Speaking(false) 68 | log.Println("INFO: New Voice Instance created") 69 | ChMessageSend(m.ChannelID, "[**Music**] I've joined a voice channel!") 70 | } 71 | 72 | // LeaveReporter 73 | func LeaveReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 74 | log.Println("INFO:", m.Author.Username, "send 'leave'") 75 | if v == nil { 76 | log.Println("INFO: The bot is not joined in a voice channel") 77 | return 78 | } 79 | v.Stop() 80 | time.Sleep(200 * time.Millisecond) 81 | v.voice.Disconnect() 82 | log.Println("INFO: Voice channel destroyed") 83 | mutex.Lock() 84 | delete(voiceInstances, v.guildID) 85 | mutex.Unlock() 86 | dg.UpdateStatus(0, o.DiscordStatus) 87 | ChMessageSend(m.ChannelID, "[**Music**] I left the voice channel!") 88 | } 89 | 90 | // PlayReporter 91 | func PlayReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 92 | log.Println("INFO:", m.Author.Username, "send 'play'") 93 | if v == nil { 94 | log.Println("INFO: The bot is not joined in a voice channel") 95 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 96 | return 97 | } 98 | if len(strings.Fields(m.Content)) < 2 { 99 | ChMessageSend(m.ChannelID, "[**Music**] You need specify a name or URL.") 100 | return 101 | } 102 | // if the user is not a voice channel not accept the command 103 | voiceChannelID := SearchVoiceChannel(m.Author.ID) 104 | if v.voice.ChannelID != voiceChannelID { 105 | ChMessageSend(m.ChannelID, "[**Music**] <@"+m.Author.ID+"> You need to join in my voice channel for send play!") 106 | return 107 | } 108 | // send play my_song_youtube 109 | command := strings.SplitAfter(m.Content, strings.Fields(m.Content)[0]) 110 | query := strings.TrimSpace(command[1]) 111 | song, err := YoutubeFind(query, v, m) 112 | if err != nil || song.data.ID == "" { 113 | log.Println("ERROR: Youtube search: ", err) 114 | ChMessageSend(m.ChannelID, "[**Music**] I can't found this song!") 115 | return 116 | } 117 | //***`"+ song.data.User +"`*** 118 | ChMessageSend(m.ChannelID, "[**Music**] **`"+song.data.User+"`** has added , **`"+ 119 | song.data.Title+"`** to the queue. **`("+song.data.Duration+")` `["+strconv.Itoa(len(v.queue))+"]`**") 120 | go func() { 121 | songSignal <- song 122 | }() 123 | } 124 | 125 | // ReadioReporter 126 | func RadioReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 127 | log.Println("INFO:", m.Author.Username, "send 'radio'") 128 | if v == nil { 129 | log.Println("INFO: The bot is not joined in a voice channel") 130 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 131 | return 132 | } 133 | if len(strings.Fields(m.Content)) < 2 { 134 | ChMessageSend(m.ChannelID, "[**Music**] You need to specify a url!") 135 | return 136 | } 137 | radio := PkgRadio{"", v} 138 | radio.data = strings.Fields(m.Content)[1] 139 | 140 | go func() { 141 | radioSignal <- radio 142 | }() 143 | ChMessageSend(m.ChannelID, "[**Music**] **`"+m.Author.Username+"`** I'm playing a radio now!") 144 | } 145 | 146 | // StopReporter 147 | func StopReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 148 | log.Println("INFO:", m.Author.Username, "send 'stop'") 149 | if v == nil { 150 | log.Println("INFO: The bot is not joined in a voice channel") 151 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 152 | return 153 | } 154 | voiceChannelID := SearchVoiceChannel(m.Author.ID) 155 | if v.voice.ChannelID != voiceChannelID { 156 | ChMessageSend(m.ChannelID, "[**Music**] <@"+m.Author.ID+"> You need to join in my voice channel for send stop!") 157 | return 158 | } 159 | v.Stop() 160 | dg.UpdateStatus(0, o.DiscordStatus) 161 | log.Println("INFO: The bot stop play audio") 162 | ChMessageSend(m.ChannelID, "[**Music**] I'm stoped now!") 163 | } 164 | 165 | // PauseReporter 166 | func PauseReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 167 | log.Println("INFO:", m.Author.Username, "send 'pause'") 168 | if v == nil { 169 | log.Println("INFO: The bot is not joined in a voice channel") 170 | return 171 | } 172 | if !v.speaking { 173 | ChMessageSend(m.ChannelID, "[**Music**] I'm not playing nothing!") 174 | return 175 | } 176 | if !v.pause { 177 | v.Pause() 178 | ChMessageSend(m.ChannelID, "[**Music**] I'm `PAUSED` now!") 179 | } 180 | } 181 | 182 | // ResumeReporter 183 | func ResumeReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 184 | log.Println("INFO:", m.Author.Username, "send 'resume'") 185 | if v == nil { 186 | log.Println("INFO: The bot is not joined in a voice channel") 187 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 188 | return 189 | } 190 | if !v.speaking { 191 | ChMessageSend(m.ChannelID, "[**Music**] I'm not playing nothing!") 192 | return 193 | } 194 | if v.pause { 195 | v.Resume() 196 | ChMessageSend(m.ChannelID, "[**Music**] I'm `RESUMED` now!") 197 | } 198 | } 199 | 200 | // TimeReporter 201 | func TimeReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 202 | log.Println("INFO:", m.Author.Username, "send 'time'") 203 | if v == nil { 204 | log.Println("INFO: The bot is not joined in a voice channel") 205 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 206 | return 207 | } 208 | if v.speaking == true && v.radioFlag == false { 209 | var duration TimeDuration 210 | var message string 211 | if v.stream != nil { 212 | d := v.stream.PlaybackPosition() 213 | duration.Second = int(d.Seconds()) 214 | t := AddTimeDuration(duration) 215 | 216 | if len(strings.Split(v.nowPlaying.Duration, ":")) == 2 { 217 | message = fmt.Sprintf("[**Music**] The playback time of **`%s`** is **`(%d:%02d)`** of **`(%s)`** - **`%s`**", 218 | v.nowPlaying.Title, t.Minute, t.Second, v.nowPlaying.Duration, v.nowPlaying.User) 219 | } else if len(strings.Split(v.nowPlaying.Duration, ":")) == 3 { 220 | message = fmt.Sprintf("[**Music**] The playback time of **`%s`** is **`(%d:%02d:%02d)`** of **`(%s)`** - **`%s`**", 221 | v.nowPlaying.Title, t.Hour, t.Minute, t.Second, v.nowPlaying.Duration, v.nowPlaying.User) 222 | } else if len(strings.Split(v.nowPlaying.Duration, ":")) == 4 { 223 | message = fmt.Sprintf("[**Music**] The playback time of **`%s`** is **`(%d:%02d:%02d:%02d)`** of **`(%s)`** - **`%s`**", 224 | v.nowPlaying.Title, t.Day, t.Hour, t.Minute, t.Second, v.nowPlaying.Duration, v.nowPlaying.User) 225 | } 226 | ChMessageSend(m.ChannelID, message) 227 | return 228 | } 229 | } 230 | } 231 | 232 | // QueueReporter 233 | func QueueReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 234 | log.Println("INFO:", m.Author.Username, "send 'queue'") 235 | if v == nil { 236 | log.Println("INFO: The bot is not joined in a voice channel") 237 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 238 | return 239 | } 240 | if len(v.queue) == 0 { 241 | log.Println("INFO: The queue is empty.") 242 | ChMessageSend(m.ChannelID, "[**Music**] The song queue is empty!") 243 | return 244 | } 245 | if len(strings.Fields(m.Content)) < 2 { 246 | ChMessageSend(m.ChannelID, "[**Music**] You need specify a `sub-command`!") 247 | return 248 | } 249 | if strings.HasSuffix(m.Content, "queue clean") { 250 | log.Println("INFO:", m.Author.Username, "send 'queue clean'") 251 | v.QueueClean() 252 | ChMessageSend(m.ChannelID, "[**Music**] Queue cleaned") 253 | return 254 | } 255 | if strings.Contains(m.Content, "queue remove") { 256 | voiceChannelID := SearchVoiceChannel(m.Author.ID) 257 | if v.voice.ChannelID != voiceChannelID { 258 | ChMessageSend(m.ChannelID, "[**Music**] <@"+m.Author.ID+"> You need to join in my voice channel to remove of queue!") 259 | return 260 | } 261 | log.Println("INFO:", m.Author.Username, "send 'queue remove'") 262 | if len(strings.Fields(m.Content)) != 3 { 263 | ChMessageSend(m.ChannelID, "[**Music**] You need define a `number`, an `@User` or `last` command") 264 | return 265 | } 266 | // is a number? 267 | if k, err := strconv.Atoi(strings.Fields(m.Content)[2]); err == nil { 268 | if k < len(v.queue) && k != 0 { 269 | song := v.queue[k] 270 | v.QueueRemoveIndex(k) 271 | ChMessageSend(m.ChannelID, "[**Music**] The songs **`["+strconv.Itoa(k)+"]` - `"+song.Title+"`** was removed of queue!") 272 | return 273 | } else { 274 | ChMessageSend(m.ChannelID, "[**Music**] The songs **`["+strconv.Itoa(k)+"]`** not exist!") 275 | return 276 | } 277 | } 278 | // is an user? 279 | if len(m.Mentions) != 0 { 280 | v.QueueRemoveUser(m.Mentions[0].Username) 281 | ChMessageSend(m.ChannelID, "[**Music**] The songs indexed by **`"+m.Mentions[0].Username+"`** was removed of queue!") 282 | return 283 | } 284 | // the `last` song? 285 | if strings.HasSuffix(m.Content, "queue remove last") { 286 | log.Println("INFO:", m.Author.Username, "send 'queue remove last'") 287 | if len(v.queue) > 1 { 288 | v.QueueRemoveLast() 289 | ChMessageSend(m.ChannelID, "[**Music**] The last songs indexed was removed of queue!") 290 | return 291 | } 292 | ChMessageSend(m.ChannelID, "[**Music**] No more songs in the queue!") 293 | return 294 | } 295 | 296 | } 297 | // queue list 298 | if strings.HasSuffix(m.Content, "queue list") { 299 | log.Println("INFO:", m.Author.Username, "send 'queue list'") 300 | message := "[**Music**] My songs are:\n\nNow Playing: **`" + v.nowPlaying.Title + "` - `(" + 301 | v.nowPlaying.Duration + ")` - " + v.nowPlaying.User + "**\n" 302 | 303 | queue := v.queue[1:] 304 | if len(queue) != 0 { 305 | var duration TimeDuration 306 | for i, q := range queue { 307 | message = message + "\n**`[" + strconv.Itoa(i+1) + "]` - `" + q.Title + "` - `(" + q.Duration + ")` - " + q.User + "**" 308 | d := strings.Split(q.Duration, ":") 309 | 310 | switch len(d) { 311 | case 2: 312 | // mm:ss 313 | ss, _ := strconv.Atoi(d[1]) 314 | duration.Second = duration.Second + ss 315 | mm, _ := strconv.Atoi(d[0]) 316 | duration.Minute = duration.Minute + mm 317 | case 3: 318 | // hh:mm:ss 319 | ss, _ := strconv.Atoi(d[2]) 320 | duration.Second = duration.Second + ss 321 | mm, _ := strconv.Atoi(d[1]) 322 | duration.Minute = duration.Minute + mm 323 | hh, _ := strconv.Atoi(d[0]) 324 | duration.Hour = duration.Hour + hh 325 | case 4: 326 | // dd:hh:mm:ss 327 | ss, _ := strconv.Atoi(d[3]) 328 | duration.Second = duration.Second + ss 329 | mm, _ := strconv.Atoi(d[2]) 330 | duration.Minute = duration.Minute + mm 331 | hh, _ := strconv.Atoi(d[1]) 332 | duration.Hour = duration.Hour + hh 333 | dd, _ := strconv.Atoi(d[0]) 334 | duration.Day = duration.Day + dd 335 | } 336 | } 337 | t := AddTimeDuration(duration) 338 | message = message + "\n\nThe total duration: **`" + 339 | strconv.Itoa(t.Day) + "d` `" + 340 | strconv.Itoa(t.Hour) + "h` `" + 341 | strconv.Itoa(t.Minute) + "m` `" + 342 | strconv.Itoa(t.Second) + "s`**" 343 | } 344 | ChMessageSend(m.ChannelID, message) 345 | return 346 | } 347 | } 348 | 349 | // SkipReporter 350 | func SkipReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 351 | log.Println("INFO:", m.Author.Username, "send 'skip'") 352 | if v == nil { 353 | log.Println("INFO: The bot is not joined in a voice channel") 354 | ChMessageSend(m.ChannelID, "[**Music**] I need join in a voice channel!") 355 | return 356 | } 357 | if len(v.queue) == 0 { 358 | log.Println("INFO: The queue is empty.") 359 | ChMessageSend(m.ChannelID, "[**Music**] Currently there's no music playing, add some? ;)") 360 | return 361 | } 362 | if v.Skip() { 363 | ChMessageSend(m.ChannelID, "[**Music**] I'm `PAUSED`, please `resume` first.") 364 | } 365 | } 366 | 367 | // YoutubeReporter 368 | func YoutubeReporter(v *VoiceInstance, m *discordgo.MessageCreate) { 369 | log.Println("INFO:", m.Author.Username, "send 'youtube'") 370 | command := strings.SplitAfter(m.Content, strings.Fields(m.Content)[0]) 371 | query := strings.TrimSpace(command[1]) 372 | song, err := YoutubeFind(query, v, m) 373 | if err != nil || song.data.ID == "" { 374 | log.Println("ERROR: Youtube search: ", err) 375 | ChMessageSend(m.ChannelID, "[**Music**] I can't found this song!") 376 | return 377 | } 378 | ChMessageSendHold(m.ChannelID, "[**Music**] **`"+song.data.User+"`**, Youtube URL: https://www.youtube.com/watch?v="+song.data.VidID) 379 | } 380 | 381 | // Not used for now 382 | // StatusReporter 383 | func StatusReporter(m *discordgo.MessageCreate) { 384 | log.Println("INFO:", m.Author.Username, "send 'status'") 385 | if len(strings.Fields(m.Content)) < 2 { 386 | ChMessageSend(m.ChannelID, "[**Music**] You need to specify a status!") 387 | return 388 | } 389 | command := strings.SplitAfter(m.Content, "status") 390 | status := strings.TrimSpace(command[1]) 391 | dg.UpdateStatus(0, status) 392 | ChMessageSend(m.ChannelID, "[**Music**] Status: `"+status+"`") 393 | } 394 | 395 | // StatusCleanReporter 396 | func StatusCleanReporter(m *discordgo.MessageCreate) { 397 | log.Println("INFO:", m.Author.Username, "send 'statusclean'") 398 | dg.UpdateStatus(0, "") 399 | } 400 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "github.com/fsnotify/fsnotify" 6 | "github.com/spf13/viper" 7 | "log" 8 | ) 9 | 10 | var o = &Options{} 11 | 12 | // LoadConfig 13 | func LoadConfig(filename string) (err error) { 14 | // Read the config.toml file 15 | viper.SetConfigType("toml") 16 | viper.SetConfigFile(filename) 17 | //viper.AddConfigPath(".") 18 | err = viper.ReadInConfig() 19 | if err != nil { 20 | return err 21 | } 22 | if o.DiscordToken = viper.GetString("discord.token"); o.DiscordToken == "" { 23 | return errors.New("'token' must be present in config file") 24 | } 25 | if o.DiscordStatus = viper.GetString("discord.status"); o.DiscordStatus == "" { 26 | return errors.New("'status' must be present in config file") 27 | } 28 | if o.DiscordPrefix = viper.GetString("discord.prefix"); o.DiscordPrefix == "" { 29 | return errors.New("'prefix' must be present in config file") 30 | } 31 | if o.DiscordPurgeTime = viper.GetInt64("discord.purgetime"); o.DiscordPurgeTime < 0 { 32 | return errors.New("'purgetime' must be major or equal to 0") 33 | } 34 | o.DiscordPlayStatus = viper.GetBool("discord.playstatus") 35 | if o.DiscordPlayStatus == true { 36 | log.Println("INFO: 'playstatus' true") 37 | } else { 38 | log.Println("INFO: 'playstatus' not set or false") 39 | } 40 | if o.YoutubeToken = viper.GetString("youtube.token"); o.YoutubeToken == "" { 41 | return errors.New("'token' must be present in config file") 42 | } 43 | return nil 44 | } 45 | 46 | // Watch 47 | func Watch() { 48 | // Hot reload 49 | viper.WatchConfig() 50 | viper.OnConfigChange(Reload) 51 | } 52 | 53 | // Reload 54 | func Reload(e fsnotify.Event) { 55 | log.Println("INFO: The config file changed:", e.Name) 56 | LoadConfig(e.Name) 57 | //StopStream() 58 | } 59 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/boltdb/bolt" 5 | "time" 6 | ) 7 | 8 | // CreateDB create a database file if it if was not exist 9 | func CreateDB() error { 10 | db, err := bolt.Open("MusicBot.db", 0600, &bolt.Options{Timeout: 1 * time.Second}) 11 | if err != nil { 12 | return err 13 | } 14 | defer db.Close() 15 | 16 | err = db.Update(func(tx *bolt.Tx) error { 17 | _, err := tx.CreateBucketIfNotExists([]byte("ChannelDB")) 18 | if err != nil { 19 | return err 20 | } 21 | return nil 22 | }) 23 | return err 24 | } 25 | 26 | // PutDB ignore o unignore a test channel 27 | func PutDB(channelID, ignored string) error { 28 | db, err := bolt.Open("MusicBot.db", 0600, &bolt.Options{Timeout: 1 * time.Second}) 29 | if err != nil { 30 | return err 31 | } 32 | defer db.Close() 33 | 34 | db.Update(func(tx *bolt.Tx) error { 35 | b := tx.Bucket([]byte("ChannelDB")) 36 | err := b.Put([]byte(channelID), []byte(ignored)) 37 | return err 38 | }) 39 | return err 40 | } 41 | 42 | // GetDB read if a text channel is ignored 43 | func GetDB(channelID string) string { 44 | var v []byte 45 | db, err := bolt.Open("MusicBot.db", 0600, &bolt.Options{ReadOnly: true}) 46 | if err != nil { 47 | return "" 48 | } 49 | defer db.Close() 50 | 51 | db.View(func(tx *bolt.Tx) error { 52 | b := tx.Bucket([]byte("ChannelDB")) 53 | v = b.Get([]byte(channelID)) 54 | return nil 55 | }) 56 | return string(v) 57 | } 58 | -------------------------------------------------------------------------------- /discord.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/bwmarrin/discordgo" 5 | "log" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // DiscordConnect make a new connection to Discord 11 | func DiscordConnect() (err error) { 12 | dg, err = discordgo.New("Bot " + o.DiscordToken) 13 | if err != nil { 14 | log.Println("FATA: error creating Discord session,", err) 15 | return 16 | } 17 | log.Println("INFO: Bot is Opening") 18 | dg.AddHandler(MessageCreateHandler) 19 | dg.AddHandler(GuildCreateHandler) 20 | dg.AddHandler(GuildDeleteHandler) 21 | dg.AddHandler(ConnectHandler) 22 | // Open Websocket 23 | err = dg.Open() 24 | if err != nil { 25 | log.Println("FATA: Error Open():", err) 26 | return 27 | } 28 | _, err = dg.User("@me") 29 | if err != nil { 30 | // Login unsuccessful 31 | log.Println("FATA:", err) 32 | return 33 | } // Login successful 34 | log.Println("INFO: Bot user test") 35 | log.Println("INFO: Bot is now running. Press CTRL-C to exit.") 36 | purgeRoutine() 37 | initRoutine() 38 | dg.UpdateStatus(0, o.DiscordStatus) 39 | return nil 40 | } 41 | 42 | // SearchVoiceChannel search the voice channel id into from guild. 43 | func SearchVoiceChannel(user string) (voiceChannelID string) { 44 | for _, g := range dg.State.Guilds { 45 | for _, v := range g.VoiceStates { 46 | if v.UserID == user { 47 | return v.ChannelID 48 | } 49 | } 50 | } 51 | return "" 52 | } 53 | 54 | // SearchGuild search the guild ID 55 | func SearchGuild(textChannelID string) (guildID string) { 56 | channel, _ := dg.Channel(textChannelID) 57 | guildID = channel.GuildID 58 | return 59 | } 60 | 61 | // AddTimeDuration calculate the total time duration 62 | func AddTimeDuration(t TimeDuration) (total TimeDuration) { 63 | total.Second = t.Second % 60 64 | t.Minute = t.Minute + t.Second/60 65 | total.Minute = t.Minute % 60 66 | t.Hour = t.Hour + t.Minute/60 67 | total.Hour = t.Hour % 24 68 | total.Day = t.Day + t.Hour/24 69 | return 70 | } 71 | 72 | // ChMessageSendEmbed 73 | func ChMessageSendEmbed(textChannelID, title, description string) { 74 | embed := discordgo.MessageEmbed{} 75 | embed.Title = title 76 | embed.Description = description 77 | embed.Color = 0xb20000 78 | for i := 0; i < 10; i++ { 79 | msg, err := dg.ChannelMessageSendEmbed(textChannelID, &embed) 80 | if err != nil { 81 | time.Sleep(1 * time.Second) 82 | continue 83 | } 84 | msgToPurgeQueue(msg) 85 | break 86 | } 87 | } 88 | 89 | // ChMessageSendHold send a message 90 | func ChMessageSendHold(textChannelID, message string) { 91 | for i := 0; i < 10; i++ { 92 | _, err := dg.ChannelMessageSend(textChannelID, message) 93 | if err != nil { 94 | time.Sleep(1 * time.Second) 95 | continue 96 | } 97 | break 98 | } 99 | } 100 | 101 | // ChMessageSend send a message and auto-remove it in a time 102 | func ChMessageSend(textChannelID, message string) { 103 | for i := 0; i < 10; i++ { 104 | msg, err := dg.ChannelMessageSend(textChannelID, message) 105 | if err != nil { 106 | time.Sleep(1 * time.Second) 107 | continue 108 | } 109 | msgToPurgeQueue(msg) 110 | break 111 | } 112 | } 113 | 114 | // msgToPurgeQueue 115 | func msgToPurgeQueue(m *discordgo.Message) { 116 | if o.DiscordPurgeTime > 0 { 117 | timestamp := time.Now().UTC().Unix() 118 | message := PurgeMessage{ 119 | m.ID, 120 | m.ChannelID, 121 | timestamp, 122 | } 123 | purgeQueue = append(purgeQueue, message) 124 | } 125 | } 126 | 127 | // purgeRoutine 128 | func purgeRoutine() { 129 | go func() { 130 | for { 131 | for k, v := range purgeQueue { 132 | if time.Now().Unix()-o.DiscordPurgeTime > v.TimeSent { 133 | purgeQueue = append(purgeQueue[:k], purgeQueue[k+1:]...) 134 | dg.ChannelMessageDelete(v.ChannelID, v.ID) 135 | // Break at first match to avoid panic, timing isn't that important here 136 | break 137 | } 138 | } 139 | time.Sleep(time.Second * 1) 140 | } 141 | }() 142 | } 143 | 144 | func initRoutine() { 145 | songSignal = make(chan PkgSong) 146 | radioSignal = make(chan PkgRadio) 147 | go GlobalPlay(songSignal) 148 | go GlobalRadio(radioSignal) 149 | } 150 | 151 | // ConnectHandler 152 | func ConnectHandler(s *discordgo.Session, connect *discordgo.Connect) { 153 | log.Println("INFO: Connected!!") 154 | s.UpdateStatus(0, o.DiscordStatus) 155 | } 156 | 157 | // GuildCreateHandler 158 | func GuildCreateHandler(s *discordgo.Session, guild *discordgo.GuildCreate) { 159 | log.Println("INFO: Guild Create:", guild.ID) 160 | } 161 | 162 | // GuildDeleteHandler 163 | func GuildDeleteHandler(s *discordgo.Session, guild *discordgo.GuildDelete) { 164 | log.Println("INFO: Guild Delete:", guild.ID) 165 | v := voiceInstances[guild.ID] 166 | if v != nil { 167 | v.Stop() 168 | time.Sleep(200 * time.Millisecond) 169 | mutex.Lock() 170 | delete(voiceInstances, guild.ID) 171 | mutex.Unlock() 172 | } 173 | } 174 | 175 | // MessageCreateHandler 176 | func MessageCreateHandler(s *discordgo.Session, m *discordgo.MessageCreate) { 177 | if !strings.HasPrefix(m.Content, o.DiscordPrefix) { 178 | return 179 | } 180 | /* 181 | // Method with memory (volatile) 182 | guildID := SearchGuild(m.ChannelID) 183 | v := voiceInstances[guildID] 184 | owner, _:= s.Guild(guildID) 185 | content := strings.Replace(m.Content, o.DiscordPrefix, "", 1) 186 | command := strings.Fields(content) 187 | if len(command) == 0 { 188 | return 189 | } 190 | if owner.OwnerID == m.Author.ID { 191 | if strings.HasPrefix(command[0], "ignore") { 192 | ignore[m.ChannelID] = true 193 | ChMessageSend(m.ChannelID, "[**Music**] `Ignoring` comands in this channel!") 194 | } 195 | if strings.HasPrefix(command[0], "unignore") { 196 | if ignore[m.ChannelID] == true { 197 | delete(ignore, m.ChannelID) 198 | ChMessageSend(m.ChannelID, "[**Music**] `Unignoring` comands in this channel!") 199 | } 200 | } 201 | } 202 | if ignore[m.ChannelID] == true { 203 | return 204 | } 205 | */ 206 | // Method with database (persistent) 207 | guildID := SearchGuild(m.ChannelID) 208 | v := voiceInstances[guildID] 209 | owner, _ := s.Guild(guildID) 210 | content := strings.Replace(m.Content, o.DiscordPrefix, "", 1) 211 | command := strings.Fields(content) 212 | if len(command) == 0 { 213 | return 214 | } 215 | if owner.OwnerID == m.Author.ID { 216 | if strings.HasPrefix(command[0], "ignore") { 217 | err := PutDB(m.ChannelID, "true") 218 | if err == nil { 219 | ChMessageSend(m.ChannelID, "[**Music**] `Ignoring` comands in this channel!") 220 | } else { 221 | log.Println("FATA: Error writing in DB,", err) 222 | } 223 | } 224 | if strings.HasPrefix(command[0], "unignore") { 225 | err := PutDB(m.ChannelID, "false") 226 | if err == nil { 227 | ChMessageSend(m.ChannelID, "[**Music**] `Unignoring` comands in this channel!") 228 | } else { 229 | log.Println("FATA: Error writing in DB,", err) 230 | } 231 | } 232 | } 233 | if GetDB(m.ChannelID) == "true" { 234 | return 235 | } 236 | 237 | switch command[0] { 238 | case "help", "h": 239 | HelpReporter(m) 240 | case "join", "j": 241 | JoinReporter(v, m, s) 242 | case "leave", "l": 243 | LeaveReporter(v, m) 244 | case "play": 245 | PlayReporter(v, m) 246 | case "radio": 247 | RadioReporter(v, m) 248 | case "stop": 249 | StopReporter(v, m) 250 | case "pause": 251 | PauseReporter(v, m) 252 | case "resume": 253 | ResumeReporter(v, m) 254 | case "time": 255 | TimeReporter(v, m) 256 | case "queue": 257 | QueueReporter(v, m) 258 | case "skip": 259 | SkipReporter(v, m) 260 | case "youtube": 261 | YoutubeReporter(v, m) 262 | default: 263 | return 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | musicbot: 5 | build: . 6 | image: musicbot-img 7 | restart: always 8 | volumes: 9 | - ./bot.toml:/bot/bot.toml 10 | 11 | -------------------------------------------------------------------------------- /musicbot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "runtime" 7 | ) 8 | 9 | func main() { 10 | runtime.GOMAXPROCS(1) 11 | filename := flag.String("f", "bot.toml", "Set path for the config file.") 12 | flag.Parse() 13 | log.Println("INFO: Opening", *filename) 14 | err := LoadConfig(*filename) 15 | if err != nil { 16 | log.Println("FATA:", err) 17 | return 18 | } 19 | // Hot reload 20 | Watch() 21 | // Connecto to Discord 22 | err = DiscordConnect() 23 | if err != nil { 24 | log.Println("FATA: Discord", err) 25 | return 26 | } 27 | err = CreateDB() 28 | if err != nil { 29 | log.Println("FATA: DB", err) 30 | return 31 | } 32 | <-make(chan struct{}) 33 | } 34 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // QueueGetSong 4 | func (v *VoiceInstance) QueueGetSong() (song Song) { 5 | v.queueMutex.Lock() 6 | defer v.queueMutex.Unlock() 7 | if len(v.queue) != 0 { 8 | return v.queue[0] 9 | } 10 | return 11 | } 12 | 13 | // QueueAdd 14 | func (v *VoiceInstance) QueueAdd(song Song) { 15 | v.queueMutex.Lock() 16 | defer v.queueMutex.Unlock() 17 | v.queue = append(v.queue, song) 18 | } 19 | 20 | // QueueRemoveFirst 21 | func (v *VoiceInstance) QueueRemoveFisrt() { 22 | v.queueMutex.Lock() 23 | defer v.queueMutex.Unlock() 24 | if len(v.queue) != 0 { 25 | v.queue = v.queue[1:] 26 | } 27 | } 28 | 29 | // QueueRemoveIndex 30 | func (v *VoiceInstance) QueueRemoveIndex(k int) { 31 | v.queueMutex.Lock() 32 | defer v.queueMutex.Unlock() 33 | if len(v.queue) != 0 && k <= len(v.queue) { 34 | v.queue = append(v.queue[:k], v.queue[k+1:]...) 35 | } 36 | } 37 | 38 | // QueueRemoveUser 39 | func (v *VoiceInstance) QueueRemoveUser(user string) { 40 | v.queueMutex.Lock() 41 | defer v.queueMutex.Unlock() 42 | queue := v.queue 43 | v.queue = []Song{} 44 | if len(v.queue) != 0 { 45 | for _, q := range queue { 46 | if q.User != user { 47 | v.queue = append(v.queue, q) 48 | } 49 | } 50 | } 51 | } 52 | 53 | // QueueRemoveLast 54 | func (v *VoiceInstance) QueueRemoveLast() { 55 | v.queueMutex.Lock() 56 | defer v.queueMutex.Unlock() 57 | if len(v.queue) != 0 { 58 | v.queue = append(v.queue[:len(v.queue)-1], v.queue[len(v.queue):]...) 59 | } 60 | } 61 | 62 | // QueueClean 63 | func (v *VoiceInstance) QueueClean() { 64 | v.queueMutex.Lock() 65 | defer v.queueMutex.Unlock() 66 | // hold the actual song in the queue 67 | v.queue = v.queue[:1] 68 | } 69 | 70 | // QueueRemove 71 | func (v *VoiceInstance) QueueRemove() { 72 | v.queueMutex.Lock() 73 | defer v.queueMutex.Unlock() 74 | v.queue = []Song{} 75 | } 76 | -------------------------------------------------------------------------------- /structs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/boltdb/bolt" 5 | "github.com/bwmarrin/discordgo" 6 | "github.com/jonas747/dca" 7 | "os/exec" 8 | "sync" 9 | //"gopkg.in/hraban/opus.v2" 10 | ) 11 | 12 | type Options struct { 13 | DiscordToken string 14 | DiscordStatus string 15 | DiscordPrefix string 16 | DiscordPurgeTime int64 17 | DiscordPlayStatus bool 18 | YoutubeToken string 19 | } 20 | 21 | type TimeDuration struct { 22 | Day int 23 | Hour int 24 | Minute int 25 | Second int 26 | } 27 | 28 | type Song struct { 29 | ChannelID string 30 | User string 31 | ID string 32 | VidID string 33 | Title string 34 | Duration string 35 | VideoURL string 36 | } 37 | 38 | type PurgeMessage struct { 39 | ID, ChannelID string 40 | TimeSent int64 41 | } 42 | 43 | type Channel struct { 44 | db *bolt.DB 45 | } 46 | 47 | type PkgSong struct { 48 | data Song 49 | v *VoiceInstance 50 | } 51 | 52 | type PkgRadio struct { 53 | data string 54 | v *VoiceInstance 55 | } 56 | 57 | type VoiceInstance struct { 58 | voice *discordgo.VoiceConnection 59 | session *discordgo.Session 60 | encoder *dca.EncodeSession 61 | stream *dca.StreamingSession 62 | run *exec.Cmd 63 | queueMutex sync.Mutex 64 | audioMutex sync.Mutex 65 | nowPlaying Song 66 | queue []Song 67 | recv []int16 68 | guildID string 69 | channelID string 70 | speaking bool 71 | pause bool 72 | stop bool 73 | skip bool 74 | radioFlag bool 75 | } 76 | -------------------------------------------------------------------------------- /vars.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/bwmarrin/discordgo" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | dg *discordgo.Session 10 | voiceInstances = map[string]*VoiceInstance{} 11 | purgeTime int64 12 | purgeQueue []PurgeMessage 13 | mutex sync.Mutex 14 | songSignal chan PkgSong 15 | radioSignal chan PkgRadio 16 | //ignore = map[string]bool{} 17 | ) 18 | -------------------------------------------------------------------------------- /youtube.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/bwmarrin/discordgo" 6 | "github.com/google/google-api-go-client/googleapi/transport" 7 | "github.com/rylio/ytdl" 8 | "google.golang.org/api/youtube/v3" 9 | "log" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | func getDuration(stringRawFull, stringRawOffset string) (stringRemain string) { 17 | // stringRawFull format: P1DT3H45M2S or PT3H45M2S 18 | // stringRawOffset format: 4325s (at seconds) 19 | 20 | var stringFull string 21 | var duration TimeDuration 22 | var partial time.Duration 23 | 24 | stringFull = strings.Replace(stringRawFull, "P", "", 1) 25 | stringFull = strings.Replace(stringFull, "T", "", 1) 26 | stringFull = strings.ToLower(stringFull) 27 | 28 | var secondsFull, secondsOffset int 29 | value := strings.Split(stringFull, "d") 30 | if len(value) == 2 { 31 | secondsFull, _ = strconv.Atoi(value[0]) 32 | // get the days in seconds 33 | secondsFull = secondsFull * 86400 34 | // get the format 1h1m1s in seconds 35 | partial, _ = time.ParseDuration(value[1]) 36 | secondsFull = secondsFull + int(partial.Seconds()) 37 | } else { 38 | partial, _ = time.ParseDuration(stringFull) 39 | secondsFull = int(partial.Seconds()) 40 | } 41 | 42 | if stringRawOffset != "" { 43 | value = strings.Split(stringRawOffset, "s") 44 | if len(value) == 2 { 45 | secondsOffset, _ = strconv.Atoi(value[0]) 46 | } 47 | } 48 | // substact the time offset 49 | duration.Second = secondsFull - secondsOffset 50 | 51 | if duration.Second <= 0 { 52 | return "0:00" 53 | } 54 | 55 | // print the time 56 | t := AddTimeDuration(duration) 57 | if t.Day == 0 && t.Hour == 0 { 58 | return fmt.Sprintf("%02d:%02d", t.Minute, t.Second) 59 | } 60 | if t.Day == 0 { 61 | return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second) 62 | } 63 | return fmt.Sprintf("%d:%02d:%02d:%02d", t.Day, t.Hour, t.Minute, t.Second) 64 | } 65 | 66 | func YoutubeFind(searchString string, v *VoiceInstance, m *discordgo.MessageCreate) (song_struct PkgSong, err error) { //(url, title, time string, err error) 67 | 68 | client := &http.Client{ 69 | Transport: &transport.APIKey{Key: o.YoutubeToken}, 70 | } 71 | 72 | service, err := youtube.New(client) 73 | if err != nil { 74 | //log.Fatalf("Error creating new YouTube client: %v", err) 75 | return 76 | } 77 | 78 | var timeOffset string 79 | if strings.Contains(searchString, "?t=") || strings.Contains(searchString, "&feature=youtu.be&t=") { 80 | var split []string 81 | switch { 82 | case strings.Contains(searchString, "?t="): 83 | split = strings.Split(searchString, "?t=") 84 | break 85 | 86 | case strings.Contains(searchString, "&feature=youtu.be&t="): 87 | split = strings.Split(searchString, "&feature=youtu.be&t=") 88 | break 89 | } 90 | searchString = split[0] 91 | timeOffset = split[1] 92 | 93 | if !strings.ContainsAny(timeOffset, "h | m | s") { 94 | timeOffset = timeOffset + "s" // secons 95 | } 96 | } 97 | 98 | call := service.Search.List("id,snippet").Q(searchString).MaxResults(1) 99 | response, err := call.Do() 100 | if err != nil { 101 | //log.Fatalf("Error making search API call: %v", err) 102 | return 103 | } 104 | 105 | var ( 106 | audioId, audioTitle string //, fileVideoID string 107 | ) 108 | 109 | for _, item := range response.Items { 110 | audioId = item.Id.VideoId 111 | audioTitle = item.Snippet.Title 112 | } 113 | if audioId == "" { 114 | ChMessageSend(m.ChannelID, "Sorry, I can't found this song.") 115 | return 116 | } 117 | 118 | vid, err := ytdl.GetVideoInfo("https://www.youtube.com/watch?v=" + audioId) 119 | if err != nil { 120 | //ChMessageSend(textChannelID, "Sorry, nothing found for query: "+strings.Trim(searchString, " ")) 121 | return 122 | } 123 | format := vid.Formats.Extremes(ytdl.FormatAudioBitrateKey, true)[0] 124 | videoURL, err := vid.GetDownloadURL(format) 125 | //log.Println(err) 126 | 127 | videos := service.Videos.List("contentDetails").Id(vid.ID) 128 | resp, err := videos.Do() 129 | 130 | duration := resp.Items[0].ContentDetails.Duration 131 | durationString := getDuration(duration, timeOffset) 132 | 133 | var videoURLString string 134 | if videoURL != nil { 135 | if timeOffset != "" { 136 | offset, _ := time.ParseDuration(timeOffset) 137 | query := videoURL.Query() 138 | query.Set("begin", fmt.Sprint(int64(offset/time.Millisecond))) 139 | videoURL.RawQuery = query.Encode() 140 | } 141 | videoURLString = videoURL.String() 142 | } else { 143 | log.Println("Video URL not found") 144 | } 145 | 146 | guildID := SearchGuild(m.ChannelID) 147 | member, _ := v.session.GuildMember(guildID, m.Author.ID) 148 | name := "" 149 | if member.Nick == "" { 150 | name = m.Author.Username 151 | } else { 152 | name = member.Nick 153 | } 154 | 155 | song := Song{ 156 | m.ChannelID, 157 | name, 158 | m.Author.ID, 159 | vid.ID, 160 | audioTitle, 161 | durationString, 162 | videoURLString, 163 | } 164 | 165 | song_struct.data = song 166 | song_struct.v = v 167 | 168 | return 169 | } 170 | -------------------------------------------------------------------------------- /youtube_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestGetDuration(t *testing.T) { 9 | assert := assert.New(t) 10 | 11 | duration := "P1DT23H45M20S" 12 | offset := "25877s" 13 | expected := "1:16:34:03" 14 | actual := getDuration(duration, offset) 15 | assert.Equal(expected, actual, "the data have to be equal") 16 | 17 | duration = "P1DT" 18 | offset = "5000s" 19 | expected = "22:36:40" 20 | actual = getDuration(duration, offset) 21 | assert.Equal(expected, actual, "the data have to be equal") 22 | 23 | duration = "PT1H" 24 | offset = "300s" 25 | expected = "55:00" 26 | actual = getDuration(duration, offset) 27 | assert.Equal(expected, actual, "the data have to be equal") 28 | 29 | duration = "PT1M" 30 | offset = "20s" 31 | expected = "00:40" 32 | actual = getDuration(duration, offset) 33 | assert.Equal(expected, actual, "the data have to be equal") 34 | 35 | duration = "PT4H2S" 36 | offset = "260s" 37 | expected = "03:55:42" 38 | actual = getDuration(duration, offset) 39 | assert.Equal(expected, actual, "the data have to be equal") 40 | 41 | } 42 | --------------------------------------------------------------------------------