add code
This commit is contained in:
parent
6dfb9bc0b1
commit
d2fae31d1a
13
TODO.md
Normal file
13
TODO.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- [ ] Improve code documentation.
|
||||||
|
|
||||||
|
- [ ] Reduce the number of long, thin, cylindrical Italian pasta contained within the code.
|
||||||
|
|
||||||
|
- [x] Implement the queue system.
|
||||||
|
|
||||||
|
- [ ] Fix seek for larger durations; inform the user if the desired position hasn't been loaded yet.
|
||||||
|
|
||||||
|
- [x] Add playlist support.
|
||||||
|
|
||||||
|
- [ ] Receive playlist items gradually, making it so you don't have to wait until youtube-dl has retrieved the entire playlist.
|
16
args.go
Normal file
16
args.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Parses the commands sent through Discord. This program does not use any
|
||||||
|
// traditional command line options, as it uses config.json for configuration.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Returns a false and a nil slice if cmd was not intended for the bot.
|
||||||
|
func CmdGetArgs(cmd string) (args []string, ok bool) {
|
||||||
|
if !strings.HasPrefix(cmd, cfg.Prefix) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cmd = strings.TrimPrefix(cmd, cfg.Prefix)
|
||||||
|
return strings.Fields(cmd), true
|
||||||
|
}
|
512
commands.go
Normal file
512
commands.go
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dcbot/dca0"
|
||||||
|
"dcbot/ytdl"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// Helper functions.
|
||||||
|
////////////////////////////////
|
||||||
|
func generateHelpMsg() string {
|
||||||
|
// Align all commands nicely so that the descriptions are in the same
|
||||||
|
// column.
|
||||||
|
longestCmd := 0
|
||||||
|
var cmds, descs []string
|
||||||
|
addCmd := func(cmd, desc string) {
|
||||||
|
if len(cmd) > longestCmd {
|
||||||
|
longestCmd = len(cmd)
|
||||||
|
}
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
descs = append(descs, desc)
|
||||||
|
}
|
||||||
|
addCmd("help", "show this page")
|
||||||
|
addCmd("play <URL|query>", "play audio from a URL or a youtube search query")
|
||||||
|
addCmd("play", "start playing queue/resume playback")
|
||||||
|
addCmd("seek <time>", "seek to the specified time (format: mm:ss or seconds)")
|
||||||
|
addCmd("pos", "get the current playback time")
|
||||||
|
addCmd("loop", "start/stop looping the current track")
|
||||||
|
addCmd("add <URL|query>", "add a URL or a youtube search query to the queue; note: be patient when adding large playlists")
|
||||||
|
addCmd("queue", "print the current queue; used to obtain track IDs for some other commands")
|
||||||
|
addCmd("pause", "pause playback")
|
||||||
|
addCmd("stop", "clear playlist and stop playback")
|
||||||
|
addCmd("skip", "skip the current track")
|
||||||
|
addCmd("delete <ID|ID-ID>...", "delete one or multiple tracks from the queue")
|
||||||
|
addCmd("swap <ID> <ID>", "swap the position of two tracks in the queue")
|
||||||
|
addCmd("shuffle", "shuffle all items in the current queue")
|
||||||
|
|
||||||
|
var msg strings.Builder
|
||||||
|
msg.WriteString("Commands:\n")
|
||||||
|
for i := range cmds {
|
||||||
|
msg.WriteString("\u2022 `" + cfg.Prefix + cmds[i])
|
||||||
|
msg.WriteString(strings.Repeat(" ", longestCmd-len(cmds[i])))
|
||||||
|
msg.WriteString(" - " + descs[i] + "`\n")
|
||||||
|
}
|
||||||
|
return msg.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts seconds to string in format mm:ss.
|
||||||
|
func secsToMinsSecs(secs int) string {
|
||||||
|
return fmt.Sprintf("%02d:%02d", secs/60, secs%60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes/replaces some characters with special formatting meaning in discord
|
||||||
|
// messages (for example *).
|
||||||
|
func dcSanitize(in string) string {
|
||||||
|
in = strings.ReplaceAll(in, "*", "\u2217")
|
||||||
|
in = strings.ReplaceAll(in, "~~", "\u02dc\u02dc")
|
||||||
|
in = strings.ReplaceAll(in, "`", "'")
|
||||||
|
return strings.ReplaceAll(in, "||", "\u2758\u2758")
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// Global variables.
|
||||||
|
////////////////////////////////
|
||||||
|
var helpMsg string
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// The actual commands.
|
||||||
|
////////////////////////////////
|
||||||
|
func commandHelp(c *Client) {
|
||||||
|
// We're not generating the help message in the var declaration because
|
||||||
|
// generateHelpMsg() relies on the config which hasn't been read at that
|
||||||
|
// point.
|
||||||
|
if helpMsg == "" {
|
||||||
|
helpMsg = generateHelpMsg()
|
||||||
|
}
|
||||||
|
c.Messagef("%s", helpMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPlay(s *discordgo.Session, g *discordgo.Guild, c *Client, args []string) {
|
||||||
|
var playbackActive bool
|
||||||
|
{
|
||||||
|
var playback Playback
|
||||||
|
if playback, playbackActive = c.GetPlaybackInfo(); playbackActive {
|
||||||
|
if playback.Paused {
|
||||||
|
c.Messagef("Resuming playback.")
|
||||||
|
playback.CmdCh <- dca0.CommandResume{}
|
||||||
|
c.Lock()
|
||||||
|
c.Playback.Paused = false
|
||||||
|
c.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.QueueLen() == 0 && len(args) == 0 {
|
||||||
|
c.Messagef("Nothing in queue. Please add an item to the queue or specify a URL or a youtube search query.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
// Add the current track/playlist in place.
|
||||||
|
commandAdd(c, args, true)
|
||||||
|
// We only want one player active at once.
|
||||||
|
if playbackActive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.VoiceChannelID == "" {
|
||||||
|
c.Messagef("I don't know which voice channel to join.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vc, err := s.ChannelVoiceJoin(g.ID, c.VoiceChannelID, false, true)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Error joining voice channel: %s.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer vc.Disconnect()
|
||||||
|
|
||||||
|
// Set playback to nothing once we're done or if an error occurs.
|
||||||
|
defer func() {
|
||||||
|
c.Lock()
|
||||||
|
c.Playback = nil
|
||||||
|
c.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Play the queue.
|
||||||
|
for c.QueueLen() > 0 {
|
||||||
|
track, _ := c.QueuePopFront()
|
||||||
|
mediaUrl := track.MediaUrl
|
||||||
|
c.Messagef("Playing: %s.\n", dcSanitize(track.Title))
|
||||||
|
|
||||||
|
// Set up dca0 encoder.
|
||||||
|
dcaOpts := dca0.GetDefaultOptions(cfg.FfmpegPath)
|
||||||
|
enc, err := dca0.NewEncoder(dcaOpts)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Error: %s.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up audio playback.
|
||||||
|
c.Lock()
|
||||||
|
c.Playback = &Playback{
|
||||||
|
CmdCh: make(chan dca0.Command),
|
||||||
|
RespCh: make(chan dca0.Response),
|
||||||
|
Track: track,
|
||||||
|
}
|
||||||
|
c.Unlock()
|
||||||
|
// We just set the playback info so we don't have to check if it's there.
|
||||||
|
playback, _ := c.GetPlaybackInfo()
|
||||||
|
errCh := make(chan error)
|
||||||
|
// Start downloading and sending audio data.
|
||||||
|
vc.Speaking(true)
|
||||||
|
go func() {
|
||||||
|
enc.GetOpusFrames(mediaUrl, dcaOpts, vc.OpusSend, errCh, playback.CmdCh, playback.RespCh)
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
// Process errors: Get and print the first error put out by the extractor.
|
||||||
|
err = nil
|
||||||
|
for e := range errCh {
|
||||||
|
if e != nil && err == nil {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Playback error: %s.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Done with this song.
|
||||||
|
playback, _ = c.GetPlaybackInfo()
|
||||||
|
}
|
||||||
|
c.Messagef("Done playing queue.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If inPlace is set to true, the track will be added to the front and replace
|
||||||
|
// the currently playing one. If inPlace is set to true when dealing with a
|
||||||
|
// playlist, the entire queue is replaced with that playlist.
|
||||||
|
func commandAdd(c *Client, args []string, inPlace bool) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
c.Messagef("Plase specify a URL or a youtube search query.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL or search query.
|
||||||
|
input := strings.Join(args, " ")
|
||||||
|
|
||||||
|
// TODO: This is some very shitty detection for if we're dealing with a
|
||||||
|
// playlist.
|
||||||
|
if strings.HasPrefix(path.Base(input), "playlist") {
|
||||||
|
c.Messagef("Long playlists may take a while to add, please be patient.")
|
||||||
|
}
|
||||||
|
|
||||||
|
ytdlEx := ytdl.NewExtractor(cfg.YtdlPath)
|
||||||
|
|
||||||
|
meta, err := ytdlEx.GetMetadata(input)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Error getting audio metadata: %s.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var plural string
|
||||||
|
if len(meta) != 1 {
|
||||||
|
plural = "s"
|
||||||
|
}
|
||||||
|
c.Messagef("Adding %d track%s to queue.", len(meta), plural)
|
||||||
|
|
||||||
|
isPlaylist := len(meta) > 1
|
||||||
|
if inPlace && isPlaylist {
|
||||||
|
c.QueueClear()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range meta {
|
||||||
|
title, titleOk := m["title"]
|
||||||
|
webpageUrl, webpageUrlOk := m["webpage_url"]
|
||||||
|
if !(titleOk && webpageUrlOk) {
|
||||||
|
c.Messagef("Error getting video metadata: title=%t, url=%t.", titleOk, webpageUrlOk)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaUrl, err := ytdl.GetAudioURL(m)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Error getting URL: %s.", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &Track{
|
||||||
|
Title: title.(string),
|
||||||
|
Url: webpageUrl.(string),
|
||||||
|
MediaUrl: mediaUrl,
|
||||||
|
}
|
||||||
|
if inPlace && !isPlaylist {
|
||||||
|
if playback, ok := c.GetPlaybackInfo(); ok {
|
||||||
|
// To replace the currently playing track, insert the new one
|
||||||
|
// at Queue[0] and skip the current one.
|
||||||
|
if c.QueueLen() == 0 {
|
||||||
|
c.QueuePushBack(track)
|
||||||
|
} else {
|
||||||
|
c.QueuePushFront(track)
|
||||||
|
}
|
||||||
|
// Skip the current track.
|
||||||
|
playback.CmdCh <- dca0.CommandStop{}
|
||||||
|
} else {
|
||||||
|
c.QueuePushFront(track)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.QueuePushBack(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandQueue(c *Client) {
|
||||||
|
playback, playbackOk := c.GetPlaybackInfo()
|
||||||
|
ql := c.QueueLen()
|
||||||
|
if ql == 0 && !playbackOk {
|
||||||
|
c.Messagef("Queue is empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines = 15
|
||||||
|
var msg strings.Builder
|
||||||
|
// flush() writes the string buffer into a new Discord message, then clears
|
||||||
|
// the buffer.
|
||||||
|
flush := func() {
|
||||||
|
c.Messagef("%s", msg.String())
|
||||||
|
msg.Reset()
|
||||||
|
msg.WriteString("\u2800\n")
|
||||||
|
}
|
||||||
|
msg.WriteString("\u2800\n")
|
||||||
|
if playbackOk {
|
||||||
|
var loop string
|
||||||
|
if playback.Loop {
|
||||||
|
loop = " [LOOP]"
|
||||||
|
}
|
||||||
|
msg.WriteString(fmt.Sprintf("PLAYING: " + dcSanitize(playback.Track.Title) + loop + "\n"))
|
||||||
|
}
|
||||||
|
for i := 0; i < ql; i++ {
|
||||||
|
t, _ := c.QueueAt(i)
|
||||||
|
msg.WriteString(fmt.Sprintf("%02d. %s\n", i+1, dcSanitize(t.Title)))
|
||||||
|
// Only send a maximum of 15 lines at a time.
|
||||||
|
if (i+1)%maxLines == 0 && i != ql-1 {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandSeek(c *Client, args []string) {
|
||||||
|
playback, ok := c.GetPlaybackInfo()
|
||||||
|
if !ok {
|
||||||
|
c.Messagef("Not playing anything.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const invalidFormat = "Please specify where to seek, either in seconds or in the format of mm:ss."
|
||||||
|
if len(args) == 0 {
|
||||||
|
c.Messagef(invalidFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
splits := strings.Split(args[0], ":")
|
||||||
|
var sMins, sSecs string
|
||||||
|
if len(splits) == 2 {
|
||||||
|
sMins, sSecs = splits[0], splits[1]
|
||||||
|
} else if len(splits) == 1 {
|
||||||
|
sMins, sSecs = "", splits[0]
|
||||||
|
} else {
|
||||||
|
c.Messagef(invalidFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var mins, secs int64
|
||||||
|
var err error
|
||||||
|
if sMins != "" {
|
||||||
|
mins, err = strconv.ParseInt(sMins, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef(invalidFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secs, err = strconv.ParseInt(sSecs, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef(invalidFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secs = 60*mins + secs
|
||||||
|
c.Messagef("Seeking to %s.", secsToMinsSecs(int(secs)))
|
||||||
|
playback.CmdCh <- dca0.CommandSeek(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPos(c *Client) {
|
||||||
|
playback, ok := c.GetPlaybackInfo()
|
||||||
|
if !ok {
|
||||||
|
c.Messagef("Not playing anything.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sTime, sDur string
|
||||||
|
// Get current playback time.
|
||||||
|
playback.CmdCh <- dca0.CommandGetPlaybackTime{}
|
||||||
|
respTime := <-playback.RespCh
|
||||||
|
if t, ok := respTime.(dca0.ResponsePlaybackTime); ok {
|
||||||
|
sTime = secsToMinsSecs(int(t))
|
||||||
|
} else {
|
||||||
|
c.Messagef("Error receiving response: invalid type.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Attempt to get duration.
|
||||||
|
playback.CmdCh <- dca0.CommandGetDuration{}
|
||||||
|
respDur := <-playback.RespCh
|
||||||
|
switch d := respDur.(type) {
|
||||||
|
case dca0.ResponseDurationUnknown:
|
||||||
|
sDur = "??:??"
|
||||||
|
case dca0.ResponseDuration:
|
||||||
|
sDur = secsToMinsSecs(int(d))
|
||||||
|
default:
|
||||||
|
c.Messagef("Error receiving response: invalid type.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Messagef("Current playback position: %s / %s.", sTime, sDur)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandLoop(c *Client) {
|
||||||
|
playback, ok := c.GetPlaybackInfo()
|
||||||
|
if !ok {
|
||||||
|
c.Messagef("Not playing anything.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if playback.Loop {
|
||||||
|
playback.CmdCh <- dca0.CommandStopLooping{}
|
||||||
|
c.Messagef("Looping disabled.")
|
||||||
|
} else {
|
||||||
|
playback.CmdCh <- dca0.CommandStartLooping{}
|
||||||
|
c.Messagef("Looping enabled.")
|
||||||
|
}
|
||||||
|
c.Lock()
|
||||||
|
c.Playback.Loop = !playback.Loop
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandStop(c *Client) {
|
||||||
|
playback, ok := c.GetPlaybackInfo()
|
||||||
|
if !ok {
|
||||||
|
c.Messagef("Not playing anything.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Messagef("Stopping playback.")
|
||||||
|
c.QueueClear()
|
||||||
|
playback.CmdCh <- dca0.CommandStop{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandSkip(c *Client) {
|
||||||
|
playback, ok := c.GetPlaybackInfo()
|
||||||
|
if !ok {
|
||||||
|
c.Messagef("Not playing anything.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Messagef("Skipping current track.")
|
||||||
|
playback.CmdCh <- dca0.CommandStop{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPause(c *Client) {
|
||||||
|
playback, ok := c.GetPlaybackInfo()
|
||||||
|
if !ok {
|
||||||
|
c.Messagef("Not playing anything.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if playback.Paused {
|
||||||
|
c.Messagef("Already paused.")
|
||||||
|
} else {
|
||||||
|
c.Messagef("Pausing playback.")
|
||||||
|
playback.CmdCh <- dca0.CommandPause{}
|
||||||
|
c.Lock()
|
||||||
|
c.Playback.Paused = true
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandDelete(c *Client, args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
c.Messagef("Please specify which item(s) to delete from the queue. IDs can be obtained with %squeue.", cfg.Prefix)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toDel := make(map[int]struct{})
|
||||||
|
for _, arg := range args {
|
||||||
|
splits := strings.Split(arg, "-")
|
||||||
|
switch len(splits) {
|
||||||
|
case 1, 2:
|
||||||
|
ids := [2]int{-1, -1}
|
||||||
|
for i, s := range splits {
|
||||||
|
id, err := strconv.ParseInt(s, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Invalid format: %s.", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id--
|
||||||
|
if id < 0 || int(id) >= c.QueueLen() {
|
||||||
|
c.Messagef("Index out of bounds: %s.", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids[i] = int(id)
|
||||||
|
}
|
||||||
|
if ids[1] == -1 {
|
||||||
|
toDel[ids[0]] = struct{}{}
|
||||||
|
} else {
|
||||||
|
if ids[0] > ids[1] {
|
||||||
|
c.Messagef("The first id of the range must be not be larger: %s.", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := ids[0]; i <= ids[1]; i++ {
|
||||||
|
toDel[i] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.Messagef("Invalid format: %s.", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newQueue []*Track
|
||||||
|
queueLen := c.QueueLen()
|
||||||
|
for i := 0; i < queueLen; i++ {
|
||||||
|
if _, del := toDel[i]; !del {
|
||||||
|
c.RLock()
|
||||||
|
newQueue = append(newQueue, c.Queue[i])
|
||||||
|
c.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Lock()
|
||||||
|
c.Queue = newQueue
|
||||||
|
c.Unlock()
|
||||||
|
c.Messagef("Successfully deleted %d items.", len(toDel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandSwap(c *Client, args []string) {
|
||||||
|
if len(args) != 2 {
|
||||||
|
c.Messagef("Please specify 2 items to swap with one another. IDs can be obtained with %squeue.", cfg.Prefix)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ids [2]int
|
||||||
|
for i, arg := range args {
|
||||||
|
id, err := strconv.ParseInt(arg, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.Messagef("Invalid format: %s.", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id--
|
||||||
|
ids[i] = int(id)
|
||||||
|
}
|
||||||
|
c.Messagef("Swapping: %d and %d.", ids[0]+1, ids[1]+1)
|
||||||
|
c.QueueSwap(ids[0], ids[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandShuffle(c *Client) {
|
||||||
|
rand.Seed(time.Now().Unix())
|
||||||
|
queueLen := c.QueueLen()
|
||||||
|
c.Lock()
|
||||||
|
rand.Shuffle(queueLen, func(a, b int) {
|
||||||
|
c.Queue[a], c.Queue[b] = c.Queue[b], c.Queue[a]
|
||||||
|
})
|
||||||
|
c.Unlock()
|
||||||
|
c.Messagef("Successfully shuffled %d items.", queueLen)
|
||||||
|
}
|
45
config.go
Normal file
45
config.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
YtdlPath string `json:"youtube-dl_path"`
|
||||||
|
FfmpegPath string `json:"ffmpeg_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = "config.json"
|
||||||
|
|
||||||
|
const tokenDefaultString = "insert your discord bot token here"
|
||||||
|
|
||||||
|
func ReadConfig(cfg *Config) error {
|
||||||
|
configData, err := os.ReadFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("unable to read config file: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal(configData, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("unable to decode config file: " + err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteDefaultConfig() error {
|
||||||
|
data, err := json.MarshalIndent(Config{
|
||||||
|
Prefix: "!",
|
||||||
|
Token: tokenDefaultString,
|
||||||
|
YtdlPath: "youtube-dl",
|
||||||
|
FfmpegPath: "ffmpeg",
|
||||||
|
}, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configFile, data, 0666)
|
||||||
|
}
|
261
dca0/dca0.go
Normal file
261
dca0/dca0.go
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
package dca0
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"layeh.com/gopus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheOverflowError struct {
|
||||||
|
MaxCacheBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CacheOverflowError) Error() string {
|
||||||
|
return "audio too large: the maximum cache limit of " + strconv.Itoa(e.MaxCacheBytes) + " bytes has been exceeded"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Command interface{}
|
||||||
|
|
||||||
|
type CommandStop struct{}
|
||||||
|
type CommandPause struct{}
|
||||||
|
type CommandResume struct{}
|
||||||
|
type CommandStartLooping struct{}
|
||||||
|
type CommandStopLooping struct{}
|
||||||
|
type CommandSeek float32 // In seconds.
|
||||||
|
type CommandGetPlaybackTime struct{} // Gets the playback time.
|
||||||
|
type CommandGetDuration struct{} // Attempts to get the duration. Only succeeds if the encoder is already done.
|
||||||
|
|
||||||
|
type Response interface{}
|
||||||
|
|
||||||
|
type ResponsePlaybackTime float32 // Playback time in seconds.
|
||||||
|
type ResponseDuration float32 // Duration in seconds.
|
||||||
|
type ResponseDurationUnknown struct{} // Returned if the duration is unknown.
|
||||||
|
|
||||||
|
type Dca0Options struct {
|
||||||
|
PcmOptions
|
||||||
|
// 960 (20ms at 48kHz) is currently the only one supported by discordgo.
|
||||||
|
// If it is changed nonetheless, it changes the playback speed (without
|
||||||
|
// changing the pitch because of how discord and opus work).
|
||||||
|
FrameSize int
|
||||||
|
Bitrate int
|
||||||
|
// Maximum number of bytes that may be cached. A 3-minute song usually uses
|
||||||
|
// about 2.7MB. If the capacity is full, an error will be sent and the
|
||||||
|
// function will exit.
|
||||||
|
MaxCacheBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefaultOptions(ffmpegPath string) Dca0Options {
|
||||||
|
return Dca0Options{
|
||||||
|
PcmOptions: getDefaultPcmOptions(ffmpegPath),
|
||||||
|
FrameSize: 960,
|
||||||
|
// 64000 is Discord's default.
|
||||||
|
Bitrate: 64000,
|
||||||
|
// Max cache size of 20MB.
|
||||||
|
MaxCacheBytes: 20000000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dca0Encoder struct {
|
||||||
|
opusEnc *gopus.Encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEncoder(opts Dca0Options) (*Dca0Encoder, error) {
|
||||||
|
opusEnc, err := gopus.NewEncoder(opts.SampleRate, opts.Channels, gopus.Audio)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Dca0Encoder{
|
||||||
|
opusEnc: opusEnc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends the individual opus frames as byte arrays through the specified
|
||||||
|
// channel.
|
||||||
|
// Input can be either a local file or an http(s) address. It can be of any
|
||||||
|
// format supported by ffmpeg.
|
||||||
|
// Caches the entire opus data due to some problems when reading from ffmpeg
|
||||||
|
// too slowly.
|
||||||
|
func (e *Dca0Encoder) GetOpusFrames(input string, opts Dca0Options, ch chan<- []byte, errCh chan<- error, cmdCh <-chan Command, respCh chan<- Response) {
|
||||||
|
pcm, cmd, err := getPcm(input, opts.PcmOptions)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many opus frames are played per second.
|
||||||
|
framesPerSecond := float32(opts.SampleRate) / float32(opts.FrameSize)
|
||||||
|
// Potential maximum samples an audio frame can have.
|
||||||
|
maxSamples := opts.FrameSize * opts.Channels
|
||||||
|
// One pcm sample equals two bytes.
|
||||||
|
maxBytes := maxSamples * 2
|
||||||
|
|
||||||
|
// Size of the opus frame cache.
|
||||||
|
cacheSize := 0
|
||||||
|
// We're storing all opus frames in this array as a cache.
|
||||||
|
opusFrames := make([][]byte, 0, 512)
|
||||||
|
// Opus frame read position.
|
||||||
|
rp := 0
|
||||||
|
// We're sending frames through this before appending them to opusFrames.
|
||||||
|
frameCh := make(chan []byte, 8)
|
||||||
|
|
||||||
|
sampleBytes := make([]byte, maxBytes)
|
||||||
|
samples := make([]int16, maxSamples)
|
||||||
|
|
||||||
|
// Used by the encoder to tell the main process when it's done encoding.
|
||||||
|
encoderDone := make(chan struct{})
|
||||||
|
// Used by the main process to tell the encoder to stop.
|
||||||
|
encoderStop := make(chan struct{})
|
||||||
|
// Launch the encoder.
|
||||||
|
// Encode opus data and send it through frameCh.
|
||||||
|
go func() {
|
||||||
|
var killedFfmpeg bool
|
||||||
|
encoderLoop:
|
||||||
|
for {
|
||||||
|
// Stop encoding if the main process tells us to.
|
||||||
|
select {
|
||||||
|
case <-encoderStop:
|
||||||
|
// Kill ffmpeg using SIGINT.
|
||||||
|
cmd.Process.Signal(os.Interrupt)
|
||||||
|
pcm.Close()
|
||||||
|
killedFfmpeg = true
|
||||||
|
// Exit the loop.
|
||||||
|
break encoderLoop
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Efficiently read the sample bytes outputted by ffmpeg into the
|
||||||
|
// int16 sample slice.
|
||||||
|
_, err := io.ReadFull(pcm, sampleBytes)
|
||||||
|
if err != nil {
|
||||||
|
// We also want to stop on ErrUnexpectedEOF because a frame can
|
||||||
|
// currently ONLY be 960 * 2 samples large. If a frame were
|
||||||
|
// smaller, Discord would just slow the audio down. Since a
|
||||||
|
// frame is just 20ms long, we can discard the last one without
|
||||||
|
// any issues.
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read samples from binary, similarly to how the go package binary does
|
||||||
|
// it but more efficiently since we're not allocating anything.
|
||||||
|
for i := range samples {
|
||||||
|
samples[i] = int16(binary.LittleEndian.Uint16(sampleBytes[2*i:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode samples as opus.
|
||||||
|
frame, err := e.opusEnc.Encode(samples, opts.FrameSize, maxBytes)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cacheSize > opts.MaxCacheBytes {
|
||||||
|
errCh <- &CacheOverflowError{
|
||||||
|
MaxCacheBytes: opts.MaxCacheBytes,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSize += len(frame)
|
||||||
|
frameCh <- frame
|
||||||
|
}
|
||||||
|
// Wait for ffmpeg to close.
|
||||||
|
err = cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
// Ffmpeg returns 255 if it was killed using SIGINT. Therefore that
|
||||||
|
// wouldn't be an error.
|
||||||
|
switch e := err.(type) {
|
||||||
|
case *exec.ExitError:
|
||||||
|
if e.ExitCode() == 255 {
|
||||||
|
if !killedFfmpeg {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tell the main process that the encoder is done.
|
||||||
|
encoderDone <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
encoderRunning := true
|
||||||
|
paused := false
|
||||||
|
loop := false
|
||||||
|
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case v := <-frameCh:
|
||||||
|
opusFrames = append(opusFrames, v)
|
||||||
|
case <-encoderDone:
|
||||||
|
encoderRunning = false
|
||||||
|
case receivedCmd := <-cmdCh:
|
||||||
|
switch v := receivedCmd.(type) {
|
||||||
|
case CommandStop:
|
||||||
|
if encoderRunning {
|
||||||
|
encoderStop <- struct{}{}
|
||||||
|
}
|
||||||
|
break loop
|
||||||
|
case CommandPause:
|
||||||
|
paused = true
|
||||||
|
case CommandResume:
|
||||||
|
paused = false
|
||||||
|
case CommandStartLooping:
|
||||||
|
loop = true
|
||||||
|
case CommandStopLooping:
|
||||||
|
loop = false
|
||||||
|
case CommandSeek:
|
||||||
|
rp = int(float32(v) * framesPerSecond)
|
||||||
|
case CommandGetPlaybackTime:
|
||||||
|
respCh <- ResponsePlaybackTime(float32(rp) / framesPerSecond)
|
||||||
|
case CommandGetDuration:
|
||||||
|
if encoderRunning {
|
||||||
|
respCh <- ResponseDurationUnknown{}
|
||||||
|
} else {
|
||||||
|
respCh <- ResponseDuration(float32(len(opusFrames)) / framesPerSecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !paused && rp < len(opusFrames) {
|
||||||
|
if encoderRunning {
|
||||||
|
select {
|
||||||
|
case ch <- opusFrames[rp]:
|
||||||
|
rp++
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ch <- opusFrames[rp]
|
||||||
|
rp++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !encoderRunning && rp >= len(opusFrames) {
|
||||||
|
if loop {
|
||||||
|
rp = 0
|
||||||
|
} else {
|
||||||
|
// We're done sending opus data.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the encoder to finish if it's still running.
|
||||||
|
if encoderRunning {
|
||||||
|
<-encoderDone
|
||||||
|
encoderRunning = false
|
||||||
|
// TODO: I want to make this unnecessary. I have just noticed that
|
||||||
|
// this channel often get stuck so a panic is more helpful than that.
|
||||||
|
close(respCh)
|
||||||
|
}
|
||||||
|
}
|
73
dca0/pcm.go
Normal file
73
dca0/pcm.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// Helper functions for extracting pcm audio from web and local sources.
|
||||||
|
package dca0
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PcmOptions struct {
|
||||||
|
FfmpegPath string
|
||||||
|
Channels int
|
||||||
|
SampleRate int
|
||||||
|
// Seek means where to start in seconds.
|
||||||
|
Seek float32
|
||||||
|
// Duration means where to stop (in seconds after the time specified by Seek).
|
||||||
|
// If Duration is set to 0, the stream will ignore it and encode all the way
|
||||||
|
// to the end of the input.
|
||||||
|
Duration float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDefaultPcmOptions(ffmpegPath string) PcmOptions {
|
||||||
|
return PcmOptions{
|
||||||
|
FfmpegPath: ffmpegPath,
|
||||||
|
Channels: 2,
|
||||||
|
// 48000 is the only one used by discord currently.
|
||||||
|
SampleRate: 48000,
|
||||||
|
Seek: 0,
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input can be either a local file or an http(s) address. It can be of any
|
||||||
|
// audio format supported by ffmpeg.
|
||||||
|
// Wait must be called on the returned command to free its resources after
|
||||||
|
// everything has been read.
|
||||||
|
func getPcm(input string, opts PcmOptions) (io.ReadCloser, *exec.Cmd, error) {
|
||||||
|
if input == "" {
|
||||||
|
return nil, nil, errors.New("dca0.getPcm() called with empty input")
|
||||||
|
}
|
||||||
|
var cmdOpts []string
|
||||||
|
cmdOpts = append(cmdOpts, []string{
|
||||||
|
"-vn", // No video.
|
||||||
|
"-sn", // No subtitle.
|
||||||
|
"-dn", // No data encoding.
|
||||||
|
}...)
|
||||||
|
if opts.Seek != 0.0 {
|
||||||
|
cmdOpts = append(cmdOpts,
|
||||||
|
"-accurate_seek",
|
||||||
|
"-ss", strconv.FormatFloat(float64(opts.Seek), 'f', 5, 32))
|
||||||
|
}
|
||||||
|
if opts.Duration != 0.0 {
|
||||||
|
cmdOpts = append(cmdOpts,
|
||||||
|
"-t", strconv.FormatFloat(float64(opts.Duration), 'f', 5, 32))
|
||||||
|
}
|
||||||
|
cmdOpts = append(cmdOpts, []string{
|
||||||
|
"-i", input,
|
||||||
|
"-f", "s16le", // Signed int16 samples.
|
||||||
|
"-ar", strconv.Itoa(opts.SampleRate),
|
||||||
|
"-ac", strconv.Itoa(opts.Channels), // Number of audio channels.
|
||||||
|
"pipe:1", // Output to stdout.
|
||||||
|
}...)
|
||||||
|
cmd := exec.Command(opts.FfmpegPath, cmdOpts...)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return stdout, cmd, nil
|
||||||
|
}
|
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module dcbot
|
||||||
|
|
||||||
|
go 1.16
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bwmarrin/discordgo v0.23.2
|
||||||
|
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32
|
||||||
|
)
|
8
go.sum
Normal file
8
go.sum
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
|
||||||
|
github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
|
||||||
|
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||||
|
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
|
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA=
|
||||||
|
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32 h1:/S1gOotFo2sADAIdSGk1sDq1VxetoCWr6f5nxOG0dpY=
|
||||||
|
layeh.com/gopus v0.0.0-20210501142526-1ee02d434e32/go.mod h1:yDtyzWZDFCVnva8NGtg38eH2Ns4J0D/6hD+MMeUGdF0=
|
359
main.go
Normal file
359
main.go
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"dcbot/dca0"
|
||||||
|
"dcbot/util"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// Helper functions.
|
||||||
|
////////////////////////////////
|
||||||
|
// The first return value contains the voice channel ID, if it was found. If it
|
||||||
|
// was not found, it is set to "".
|
||||||
|
// The second return value indicates whether the voice channel was found.
|
||||||
|
func GetUserVoiceChannel(g *discordgo.Guild, userID string) (string, bool) {
|
||||||
|
for _, vs := range g.VoiceStates {
|
||||||
|
if vs.UserID == userID {
|
||||||
|
return vs.ChannelID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// Structs.
|
||||||
|
////////////////////////////////
|
||||||
|
type Playback struct {
|
||||||
|
Track
|
||||||
|
CmdCh chan dca0.Command
|
||||||
|
RespCh chan dca0.Response
|
||||||
|
Paused bool
|
||||||
|
Loop bool // Whether playback is looping right now.
|
||||||
|
}
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
Title string // Title, if any.
|
||||||
|
Url string // Short URL, for example from YouTube.
|
||||||
|
MediaUrl string // Long URL of the associated media file.
|
||||||
|
}
|
||||||
|
|
||||||
|
// All methods of Client are thread safe, however manual locking is required
|
||||||
|
// when accessing any fields.
|
||||||
|
type Client struct {
|
||||||
|
sync.RWMutex
|
||||||
|
|
||||||
|
// The discordgo session.
|
||||||
|
s *discordgo.Session
|
||||||
|
|
||||||
|
// TextChannelID and VoiceChannelID indicate the current channels through
|
||||||
|
// which the bot should send text / audio. They may be set to "".
|
||||||
|
TextChannelID string
|
||||||
|
VoiceChannelID string
|
||||||
|
|
||||||
|
// Current audio playback.
|
||||||
|
Playback *Playback
|
||||||
|
// Queue.
|
||||||
|
Queue []*Track
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(s *discordgo.Session) *Client {
|
||||||
|
return &Client{
|
||||||
|
s: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Messagef(format string, a ...interface{}) {
|
||||||
|
c.RLock()
|
||||||
|
if c.TextChannelID == "" {
|
||||||
|
fmt.Printf(format+"\n", a...)
|
||||||
|
} else {
|
||||||
|
c.s.ChannelMessageSend(c.TextChannelID, fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
c.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the text channel and voice channel IDs. May set them to "" if there
|
||||||
|
// are none associated with the message.
|
||||||
|
func (c *Client) UpdateChannels(g *discordgo.Guild, m *discordgo.Message) {
|
||||||
|
c.Lock()
|
||||||
|
c.TextChannelID = m.ChannelID
|
||||||
|
|
||||||
|
vc, _ := GetUserVoiceChannel(g, m.Author.ID)
|
||||||
|
c.VoiceChannelID = vc
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetTextChannelID() string {
|
||||||
|
c.RLock()
|
||||||
|
ret := c.TextChannelID
|
||||||
|
c.RUnlock()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetVoiceChannelID() string {
|
||||||
|
c.RLock()
|
||||||
|
ret := c.TextChannelID
|
||||||
|
c.RUnlock()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a COPY of c.Playback. Modifications to the returned Playback struct
|
||||||
|
// are NOT preserved, as it is a copy.
|
||||||
|
func (c *Client) GetPlaybackInfo() (p Playback, ok bool) {
|
||||||
|
c.RLock()
|
||||||
|
ret := c.Playback
|
||||||
|
c.RUnlock()
|
||||||
|
if ret == nil {
|
||||||
|
return Playback{}, false
|
||||||
|
} else {
|
||||||
|
return *ret, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QueueLen() int {
|
||||||
|
c.RLock()
|
||||||
|
l := len(c.Queue)
|
||||||
|
c.RUnlock()
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarly to GetPlaybackInfo, this function returns a COPY. Any modifications
|
||||||
|
// are NOT preserved.
|
||||||
|
// ok field returns false if the index is out of bounds.
|
||||||
|
func (c *Client) QueueAt(i int) (t Track, ok bool) {
|
||||||
|
l := c.QueueLen()
|
||||||
|
if i >= l {
|
||||||
|
return Track{}, false
|
||||||
|
}
|
||||||
|
c.RLock()
|
||||||
|
ret := c.Queue[i]
|
||||||
|
c.RUnlock()
|
||||||
|
return *ret, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QueuePushBack(t *Track) {
|
||||||
|
c.Lock()
|
||||||
|
c.Queue = append(c.Queue, t)
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QueuePushFront(t *Track) {
|
||||||
|
c.Lock()
|
||||||
|
c.Queue = append([]*Track{t}, c.Queue...)
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QueuePopFront() (t Track, ok bool) {
|
||||||
|
t, ok = c.QueueAt(0)
|
||||||
|
if ok {
|
||||||
|
c.Lock()
|
||||||
|
c.Queue = c.Queue[1:]
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes a single item at any position.
|
||||||
|
// Returns false if i was out of bounds.
|
||||||
|
func (c *Client) QueueDelete(i int) bool {
|
||||||
|
if i >= c.QueueLen() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.Lock()
|
||||||
|
c.Queue = append(c.Queue[:i], c.Queue[i+1:]...)
|
||||||
|
c.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swaps two items in the queue.
|
||||||
|
// Returns false if a or b is out of bounds.
|
||||||
|
func (c *Client) QueueSwap(a, b int) bool {
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
l := c.QueueLen()
|
||||||
|
if a >= l || b >= l {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.Lock()
|
||||||
|
c.Queue[a], c.Queue[b] = c.Queue[b], c.Queue[a]
|
||||||
|
c.Unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QueueClear() {
|
||||||
|
c.Lock()
|
||||||
|
c.Queue = nil
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) QueueFront() (t Track, ok bool) {
|
||||||
|
c.Lock()
|
||||||
|
defer c.Unlock()
|
||||||
|
if len(c.Queue) == 0 {
|
||||||
|
return Track{}, false
|
||||||
|
}
|
||||||
|
ret := *c.Queue[0]
|
||||||
|
return ret, true
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// Global variables.
|
||||||
|
////////////////////////////////
|
||||||
|
var clients map[string]*Client // Guild ID to client
|
||||||
|
var mClients sync.Mutex
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
|
||||||
|
////////////////////////////////
|
||||||
|
// Main program.
|
||||||
|
////////////////////////////////
|
||||||
|
func main() {
|
||||||
|
if err := ReadConfig(&cfg); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
if err := WriteDefaultConfig(); err != nil {
|
||||||
|
fmt.Println("Failed to create the default configuration file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("Wrote the default configuration to " + configFile + ".")
|
||||||
|
fmt.Println("You will have to manually configure the token by editing " + configFile + ".")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Token == tokenDefaultString {
|
||||||
|
fmt.Println("Please set your bot token in " + configFile + " first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all binary dependencies are installed correctly.
|
||||||
|
const notInstalledErrMsg = "Unable to find %s in the specified path '%s', please make sure it's installed correctly.\nYou can manually set its path by editing %s\n"
|
||||||
|
if !util.CheckInstalled(cfg.YtdlPath, "--version") {
|
||||||
|
fmt.Printf(notInstalledErrMsg, "youtube-dl", cfg.YtdlPath, configFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !util.CheckInstalled(cfg.FfmpegPath, "-version") {
|
||||||
|
fmt.Printf(notInstalledErrMsg, "ffmpeg", cfg.FfmpegPath, configFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize client map.
|
||||||
|
clients = make(map[string]*Client)
|
||||||
|
|
||||||
|
// Initialize bot.
|
||||||
|
dg, err := discordgo.New("Bot " + cfg.Token)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating Discord session:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dg.AddHandler(ready)
|
||||||
|
dg.AddHandler(banAdd)
|
||||||
|
dg.AddHandler(messageCreate)
|
||||||
|
|
||||||
|
// What information we need about guilds.
|
||||||
|
dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsGuildVoiceStates | discordgo.IntentsGuildBans
|
||||||
|
|
||||||
|
// Open the websocket and begin listening.
|
||||||
|
err = dg.Open()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error opening Discord session:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait here until Ctrl+c or other term signal is received.
|
||||||
|
fmt.Println("Bot is now running. Press Ctrl+c to exit.")
|
||||||
|
sc := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
|
||||||
|
<-sc
|
||||||
|
|
||||||
|
fmt.Println("\nSignal received, closing Discord session.")
|
||||||
|
|
||||||
|
// Cleanly close down the Discord session.
|
||||||
|
dg.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ready(s *discordgo.Session, event *discordgo.Ready) {
|
||||||
|
u := s.State.User
|
||||||
|
fmt.Println("Logged in as", u.Username+"#"+u.Discriminator+".")
|
||||||
|
s.UpdateListeningStatus(cfg.Prefix + "help")
|
||||||
|
}
|
||||||
|
|
||||||
|
func banAdd(s *discordgo.Session, event *discordgo.GuildBanAdd) {
|
||||||
|
fmt.Println(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||||
|
// Ignore messages from the bot itself.
|
||||||
|
if m.Author.ID == s.State.User.ID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var g *discordgo.Guild
|
||||||
|
g, err := s.State.Guild(m.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
// Could not find guild.
|
||||||
|
s.ChannelMessageSend(m.ChannelID, "This bot only works in guilds (servers).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var c *Client
|
||||||
|
mClients.Lock()
|
||||||
|
{
|
||||||
|
var ok bool
|
||||||
|
if c, ok = clients[m.GuildID]; !ok {
|
||||||
|
c = NewClient(s)
|
||||||
|
clients[m.GuildID] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mClients.Unlock()
|
||||||
|
// Update the text and voice channels associated with the client.
|
||||||
|
c.UpdateChannels(g, m.Message)
|
||||||
|
|
||||||
|
args, ok := CmdGetArgs(m.Content)
|
||||||
|
if !ok {
|
||||||
|
// Not a command.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
c.Messagef("No command specified. Type `%shelp` for help.", cfg.Prefix)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "help":
|
||||||
|
commandHelp(c)
|
||||||
|
case "play":
|
||||||
|
commandPlay(s, g, c, args[1:])
|
||||||
|
case "seek":
|
||||||
|
commandSeek(c, args[1:])
|
||||||
|
case "pos":
|
||||||
|
commandPos(c)
|
||||||
|
case "loop":
|
||||||
|
commandLoop(c)
|
||||||
|
case "add":
|
||||||
|
commandAdd(c, args[1:], false)
|
||||||
|
case "queue":
|
||||||
|
commandQueue(c)
|
||||||
|
case "pause":
|
||||||
|
commandPause(c)
|
||||||
|
case "stop":
|
||||||
|
commandStop(c)
|
||||||
|
case "skip":
|
||||||
|
commandSkip(c)
|
||||||
|
case "delete":
|
||||||
|
commandDelete(c, args[1:])
|
||||||
|
case "swap":
|
||||||
|
commandSwap(c, args[1:])
|
||||||
|
case "shuffle":
|
||||||
|
commandShuffle(c)
|
||||||
|
}
|
||||||
|
}
|
15
util/util.go
Normal file
15
util/util.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not using "command -v" because it doesn't work with Windows.
|
||||||
|
// testArg will usually be something like --version.
|
||||||
|
func CheckInstalled(program, testArg string) bool {
|
||||||
|
cmd := exec.Command(program, testArg)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
26
ytdl/error.go
Normal file
26
ytdl/error.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package ytdl
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
// `Input` is the input given to youtube-dl when the error occurred.
|
||||||
|
// If `Input` is set to "", it is ignored.
|
||||||
|
Input string
|
||||||
|
// `Err` is the underlying error.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// `input` may be set to "", in which case it is ignored when outputting the
|
||||||
|
// error message.
|
||||||
|
func newError(input string, err error) *Error {
|
||||||
|
return &Error{
|
||||||
|
Input: input,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e.Input == "" {
|
||||||
|
return "ytdl: " + e.Err.Error()
|
||||||
|
} else {
|
||||||
|
return "ytdl['" + e.Input + "']: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
}
|
165
ytdl/extractor.go
Normal file
165
ytdl/extractor.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package ytdl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Extractor struct {
|
||||||
|
DefaultSearch string
|
||||||
|
YtdlPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExtractor(ytdlPath string) *Extractor {
|
||||||
|
return &Extractor{
|
||||||
|
DefaultSearch: "ytsearch",
|
||||||
|
YtdlPath: ytdlPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidMetadata = errors.New("invalid metadata received from youtube-dl")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Metadata map[string]interface{}
|
||||||
|
|
||||||
|
/*// `input` can be a URL or a search query.
|
||||||
|
// Returns a slice with size 1 if the input is a single media file. Returns a
|
||||||
|
// larger slice if the input is a playlist.
|
||||||
|
// Progress sends a struct{} every time a single item has been added.
|
||||||
|
func (e *Extractor) GetMetadata(input string, progress chan<- struct{}) ([]Metadata, error) {
|
||||||
|
cmd := exec.Command(e.YtdlPath, "--default-search", e.DefaultSearch, "-j", input)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, newError(input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer close(progress)
|
||||||
|
|
||||||
|
var m sync.Mutex
|
||||||
|
var done bool
|
||||||
|
var ret []Metadata
|
||||||
|
var ytdlErr error
|
||||||
|
var killedYtdl bool
|
||||||
|
go func() {
|
||||||
|
killYtdl := func(err error) {
|
||||||
|
m.Lock()
|
||||||
|
ytdlErr = err
|
||||||
|
cmd.Process.Signal(os.Interrupt)
|
||||||
|
killedYtdl = true
|
||||||
|
m.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(&out)
|
||||||
|
m.Lock()
|
||||||
|
d := done
|
||||||
|
m.Unlock()
|
||||||
|
for !d && dec.More() {
|
||||||
|
var meta interface{}
|
||||||
|
if err := dec.Decode(&meta); err != nil {
|
||||||
|
killYtdl(newError(input, err))
|
||||||
|
}
|
||||||
|
progress <- struct{}{}
|
||||||
|
|
||||||
|
if m, ok := meta.(map[string]interface{}); ok {
|
||||||
|
ret = append(ret, m)
|
||||||
|
} else {
|
||||||
|
killYtdl(newError(input, errInvalidMetadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := cmd.Wait()
|
||||||
|
m.Lock()
|
||||||
|
done = true
|
||||||
|
m.Unlock()
|
||||||
|
if ytdlErr != nil {
|
||||||
|
return nil, ytdlErr
|
||||||
|
}
|
||||||
|
if err != nil && !killedYtdl {
|
||||||
|
return nil, newError(input, err)
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}*/
|
||||||
|
|
||||||
|
|
||||||
|
// `input` can be a URL or a search query.
|
||||||
|
// Returns a slice with size 1 if the input is a single media file. Returns a
|
||||||
|
// larger slice if the input is a playlist.
|
||||||
|
func (e *Extractor) GetMetadata(input string) ([]Metadata, error) {
|
||||||
|
cmd := exec.Command(e.YtdlPath, "--default-search", e.DefaultSearch, "-j", input)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, newError(input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret []Metadata
|
||||||
|
dec := json.NewDecoder(&out)
|
||||||
|
for dec.More() {
|
||||||
|
var meta interface{}
|
||||||
|
if err := dec.Decode(&meta); err != nil {
|
||||||
|
return nil, newError(input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, ok := meta.(map[string]interface{}); ok {
|
||||||
|
ret = append(ret, m)
|
||||||
|
} else {
|
||||||
|
return nil, newError(input, errInvalidMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the best available audio-only format. If the given URL links directly
|
||||||
|
// to a media file, it just returns that URL.
|
||||||
|
func GetAudioURL(meta Metadata) (string, error) {
|
||||||
|
// This function has a lot of code, but what it does is actually pretty
|
||||||
|
// simple. We just have to do a lot to ensure that there are no integrity
|
||||||
|
// issues with the metadata.
|
||||||
|
// 'iSomething' stands for 'interface something' here.
|
||||||
|
|
||||||
|
iExtractor, ok := meta["extractor"]
|
||||||
|
if !ok {
|
||||||
|
// Extractor not specified.
|
||||||
|
return "", newError("", errInvalidMetadata)
|
||||||
|
}
|
||||||
|
if extractor, ok := iExtractor.(string); ok {
|
||||||
|
if extractor == "generic" {
|
||||||
|
url, ok := meta["url"]
|
||||||
|
if u := url.(string); ok {
|
||||||
|
return u, nil
|
||||||
|
} else {
|
||||||
|
return "", newError("", errors.New("unable to get any audio or video URL"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iFormats, ok := meta["formats"]
|
||||||
|
if !ok {
|
||||||
|
return "", newError("", errors.New("no format selection available and no raw URL specified"))
|
||||||
|
}
|
||||||
|
// Get the best audio format.
|
||||||
|
if formats, ok := iFormats.([]interface{}); ok {
|
||||||
|
// In youtube-dl, the last format is always the best one. Here we're
|
||||||
|
// looking for the last (=best) format that contains no video.
|
||||||
|
for i := len(formats) - 1; i >= 0; i-- {
|
||||||
|
iFormat := formats[i]
|
||||||
|
format, ok := iFormat.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return "", newError("", errInvalidMetadata)
|
||||||
|
}
|
||||||
|
if format["vcodec"].(string) == "none" {
|
||||||
|
return format["url"].(string), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", newError("", errors.New("unable to find any audio-only format"))
|
||||||
|
} else {
|
||||||
|
return "", newError("", errInvalidMetadata)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", newError("", errInvalidMetadata)
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user