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