This commit is contained in:
r4 2021-07-28 17:30:01 +02:00
parent 6dfb9bc0b1
commit d2fae31d1a
12 changed files with 1501 additions and 0 deletions

13
TODO.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}