From b1923fe80925efb3c46fe479c1d8ea54e13e6a8f Mon Sep 17 00:00:00 2001 From: Stein Ivar Berghei Date: Fri, 18 Nov 2022 13:29:39 +0100 Subject: [PATCH] Initial commit --- .gitignore | 10 + ambiance.go | 24 ++ bot.go | 103 ++++++++ css/solarized-dark.min.css | 1 + css/style.css | 69 +++++ db.go | 65 +++++ discord.go | 39 +++ events.go | 438 +++++++++++++++++++++++++++++++ ffmpeg/ffmpeg.go | 91 +++++++ ffmpeg/pcm.go | 96 +++++++ js/Sortable.min.js | 2 + js/reconnecting-websocket.min.js | 1 + js/script.js | 204 ++++++++++++++ queue.go | 111 ++++++++ routes.go | 75 ++++++ speaker/discord.go | 139 ++++++++++ tmpl/index.tmpl | 55 ++++ ws.go | 118 +++++++++ youtube.go | 119 +++++++++ ytdl/ytdl.go | 47 ++++ 20 files changed, 1807 insertions(+) create mode 100644 .gitignore create mode 100644 ambiance.go create mode 100644 bot.go create mode 100644 css/solarized-dark.min.css create mode 100644 css/style.css create mode 100644 db.go create mode 100644 discord.go create mode 100644 events.go create mode 100644 ffmpeg/ffmpeg.go create mode 100644 ffmpeg/pcm.go create mode 100644 js/Sortable.min.js create mode 100644 js/reconnecting-websocket.min.js create mode 100644 js/script.js create mode 100644 queue.go create mode 100644 routes.go create mode 100644 speaker/discord.go create mode 100644 tmpl/index.tmpl create mode 100644 ws.go create mode 100644 youtube.go create mode 100644 ytdl/ytdl.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e97f665 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.sh +dndmusicbot +youtube-dl +oauth2-proxy +test/* +cookies.txt +config + +bin +ambiance diff --git a/ambiance.go b/ambiance.go new file mode 100644 index 0000000..eca233e --- /dev/null +++ b/ambiance.go @@ -0,0 +1,24 @@ +package main + +import ( + "os" + "path/filepath" +) + +func fnNoExt(fileName string) string { + return fileName[:len(fileName)-len(filepath.Ext(fileName))] +} + +func GetAmbiance() ([]string, error) { + files, err := os.ReadDir("./ambiance") + if err != nil { + return nil, err + } + + var out []string + for _, file := range files { + out = append(out, fnNoExt(file.Name())) + } + + return out, nil +} diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..eb99b8e --- /dev/null +++ b/bot.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/dpup/gohubbub" + "github.com/jackc/pgx/v5" + "github.com/julienschmidt/httprouter" + "github.com/kataras/go-events" + "github.com/r3labs/sse/v2" + "github.com/spf13/viper" + "google.golang.org/api/youtube/v3" +) + +const ( + channels int = 2 // 1 for mono, 2 for stereo + sampleRate int = 48000 // audio sampling rate + frameSize int = 960 // uint16 size of each audio frame + maxBytes int = (frameSize * 2) * 2 // max size of opus data +) + +/* +var ( + + token = "MTA0MDQwNTk3MDc3MTI1NTM1OQ.Gyg_y7.DqK6Tudd-iQVvgItkWvwGEwQZ7tY4qJBaBqTuQ" + channel = "675319944853979140" + server = "675319944853979136" + //channel = "1029091047902547980" + //server = "231872254525440000" + +) +*/ + +var ( + app = new(App) + config = viper.GetViper() +) + +func init() { + log.Println("bot.go loading..") + config.SetConfigName("config") + config.SetConfigType("yaml") + config.AddConfigPath(".") + err := config.ReadInConfig() + if err != nil { + log.Fatal(err) + } + + app.mux = http.NewServeMux() + + go http.ListenAndServe(":8826", app.mux) + + log.Println("bot.go done.") +} + +type App struct { + discord *discordgo.Session + voice *discordgo.VoiceConnection + youtube *youtube.Service + queue *Queue + ambiance *Queue + curamb string + events events.EventEmmiter + next bool + db *pgx.Conn + sse *sse.Server + router *httprouter.Router + active []string + plidx int + playlist *Playlist + plm *sync.RWMutex + hubbub *gohubbub.Client + mux *http.ServeMux +} + +func main() { + app.plm = &sync.RWMutex{} + ticker := time.NewTicker(300 * time.Millisecond) + + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill) + + for { + select { + case <-sc: + app.db.Close(context.Background()) + app.queue.Reset() + app.voice.Close() + app.discord.Close() + return + case <-ticker.C: + app.events.Emit("tick") + } + } +} diff --git a/css/solarized-dark.min.css b/css/solarized-dark.min.css new file mode 100644 index 0000000..dc0849b --- /dev/null +++ b/css/solarized-dark.min.css @@ -0,0 +1 @@ +@import url(https://fonts.googleapis.com/css?family=Inconsolata);@import url(https://fonts.googleapis.com/css?family=PT+Sans);@import url(https://fonts.googleapis.com/css?family=PT+Sans+Narrow:400,700);img,legend{border:0}html,pre{color:#839496}pre,pre code{background-color:#002b36}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section,summary{display:block}audio,canvas,video{display:inline-block}audio:not([controls]){display:none;height:0}[hidden]{display:none}body,figure{margin:0}a:focus{outline:dotted thin}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}mark{background:#ff0;color:#000}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em}pre{white-space:pre-wrap;word-wrap:break-word;border:1pt solid #586e75;padding:1em;box-shadow:5pt 5pt 8pt #073642}.tag,code,html{background-color:#073642}q{quotes:"\201C" "\201D" "\2018" "\2019"}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}svg:not(:root){overflow:hidden}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{padding:0}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0}button,input{line-height:normal}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0}html{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-family:'PT Sans',sans-serif;margin:1em}code,pre{font-family:Inconsolata,sans-serif}h1,h2,h3,h4,h5,h6{font-family:'PT Sans Narrow',sans-serif;font-weight:700}code{padding:2px}a{color:#b58900}.tag,h1{color:#d33682}a:hover,a:visited{color:#cb4b16}h1{font-size:2.8em}h2,h3,h4,h5,h6{color:#859900}h2{font-size:2.4em}h3{font-size:1.8em}h4{font-size:1.4em}h5{font-size:1.3em}h6{font-size:1.15em}.tag{padding:0 .2em;-webkit-border-radius:0.35em;-moz-border-radius:.35em;border-radius:.35em}.ACTIVE,.NEXT,.TODO{-webkit-border-radius:0.2em;-moz-border-radius:.2em}.done,.next,.todo{color:#002b36;background-color:#dc322f;padding:0 .2em}.TODO{border-radius:.2em;background-color:#2aa198}.ACTIVE,.NEXT{border-radius:.2em;background-color:#268bd2}.CANCELLED,.DONE,.WAITING{-webkit-border-radius:0.2em;-moz-border-radius:.2em}.CANCELLED,.DONE{border-radius:.2em;background-color:#859900}.WAITING{border-radius:.2em;background-color:#cb4b16}.HOLD,.NOTE{-webkit-border-radius:0.2em;-moz-border-radius:.2em;border-radius:.2em;background-color:#d33682} diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..4c39f9d --- /dev/null +++ b/css/style.css @@ -0,0 +1,69 @@ +.container { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: auto; + padding: 10px; + border-radius: 10px; +} + +.playing { + background-color: burlywood; +} + +h2.bot { + margin-top: 10px; + margin-bottom: 0px; + text-align: center; +} + +.item { + background-color: #80cbc4; + border: 1px solid black; + padding: 20px; + font-size: 30px; + text-align: center; + cursor: pointer; + color: #073642 +} + +.container2 { + display: block; + border-radius: 10px; + padding: 10px; +} + +.box { +/* background-color: #fefefe;*/ + margin: 15% auto; /* 15% from the top and centered */ + padding: 20px; + /*border: 1px solid #888;*/ + width: 80%; /* Could be more or less, depending on screen size */ + align-content: center; +} + +/* The Modal (background) */ +.modal { + display: block; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +/* Modal Content/Box */ +.modal-content { + margin: 15% auto; /* 15% from the top and centered */ + padding: 20px; + width: 80%; /* Could be more or less, depending on screen size */ + text-align: center; + font-size: 32px; +} + +body > div.container2 > input[type=text]:nth-child(2) { + width: 512px; +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..074f5a3 --- /dev/null +++ b/db.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "log" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var ( + connstring = "host=localhost user=steino dbname=dndmusicbot sslmode=disable" +) + +func init() { + log.Println("db.go loading..") + var err error + + app.db, err = pgx.Connect(context.Background(), connstring) + if err != nil { + log.Fatal(err) + } + log.Println("db.go done.") +} + +type Playlist struct { + Id uuid.UUID + Url string + Title string +} + +func (app App) GetPlaylists() (playlists []Playlist, err error) { + log.Println(app.db.Ping(context.Background())) + rows, err := app.db.Query(context.Background(), "SELECT id, url, title FROM playlists") + if err != nil { + return nil, err + } + + playlists, err = pgx.CollectRows(rows, pgx.RowToStructByName[Playlist]) + return +} + +func (app App) GetPlaylist(id uuid.UUID) (*Playlist, error) { + rows, err := app.db.Query(context.Background(), "SELECT id, url, title FROM playlists where id=$1 limit 1", id) + if err != nil { + return nil, err + } + + //var out Playlist + out, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Playlist]) + if err != nil { + return new(Playlist), err + } + + return out, nil +} + +func (app App) AddPlaylist(title string, uri string) (uuid.UUID, error) { + id := uuid.New() + _, err := app.db.Exec(context.Background(), "INSERT INTO playlists VALUES ($1, $2, $3)", id, title, uri) + if err != nil { + return *new(uuid.UUID), err + } + return id, nil +} diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..06c556c --- /dev/null +++ b/discord.go @@ -0,0 +1,39 @@ +package main + +import ( + discordspeaker "dndmusicbot/speaker" + "log" + + "github.com/bwmarrin/discordgo" +) + +func init() { + log.Println("discord.go loading..") + var err error + + token := config.GetString("discord.token") + guild := config.GetString("discord.guild") + channel := config.GetString("discord.channel") + + app.discord, err = discordgo.New("Bot " + token) + if err != nil { + return + } + + // app.discord.LogLevel = discordgo.LogDebug + + err = app.discord.Open() + if err != nil { + return + } + + app.voice, err = app.discord.ChannelVoiceJoin(guild, channel, false, true) + if err != nil { + return + } + + // app.voice.LogLevel = discordgo.LogDebug + + discordspeaker.Init(app.voice) + log.Println("discord.go done.") +} diff --git a/events.go b/events.go new file mode 100644 index 0000000..ace6953 --- /dev/null +++ b/events.go @@ -0,0 +1,438 @@ +package main + +import ( + "context" + "dndmusicbot/ffmpeg" + discordspeaker "dndmusicbot/speaker" + "encoding/json" + "fmt" + "log" + "net/url" + "os" + "time" + + "github.com/faiface/beep/mp3" + "github.com/google/uuid" + "github.com/kataras/go-events" + "golang.org/x/time/rate" +) + +type SongInfo struct { + Playlist uuid.UUID `json:"playlist"` + PlaylistName string `json:"playlistname"` + Title string `json:"current"` + Channel string `json:"channel"` + Position int `json:"position"` + Length int `json:"len"` + Pause bool `json:"pause"` + Song string `json:"song"` +} + +var l = rate.Sometimes{Interval: 1 * time.Second} + +func init() { + log.Println("events.go loading...") + app.events = events.New() + + app.events.On("load_playlist", app.loadPlaylist) + app.events.On("add_playlist", app.addPlaylist) + + app.events.On("preload_song", app.preloadSong) + app.events.On("song_over", app.songOver) + //app.events.On("song_position", app.songPosition) + app.events.On("ambiance_play", app.ambiancePlay) + app.events.On("ambiance_stop", app.ambianceStop) + + app.events.On("stop", app.stop) + app.events.On("next", app.nextSong) + app.events.On("prev", app.prevSong) + + //app.events.On("tick", app.checkQueue) + app.events.On("tick", app.songPosition) + app.events.On("tick", app.checkTimeleft) +} + +func (app *App) songInfoEvent(event string) map[string]interface{} { + msg := make(map[string]interface{}) + msg["event"] = event + + var title, channel string + + switch current := app.queue.Current().(type) { + case *ffmpeg.PCM: + title = current.Player.Title + channel = current.Player.Channel + } + + var plid uuid.UUID + var pltitle string + + if app.playlist != nil { + plid = app.playlist.Id + pltitle = app.playlist.Title + } + + var song string + + if app.active != nil && len(app.active) > 0 { + song = app.active[app.plidx] + } + + msg["payload"] = SongInfo{ + Playlist: plid, + PlaylistName: pltitle, + Title: title, + Channel: channel, + Position: app.queue.Position(), + Length: app.queue.Len(), + Pause: !app.queue.IsPlaying(), + Song: song, + } + + return msg +} + +func (app *App) ambiancePlay(payload ...interface{}) { + if !(len(payload) > 0) { + log.Println("ambiance_play called without a payload.") + return + } + + var fn string + switch data := payload[0].(type) { + case json.RawMessage: + var err error + err = json.Unmarshal(data, &fn) + if err != nil { + log.Println(err) + return + } + default: + log.Println("loadPlaylist called with invalid payload.") + return + } + + f, err := os.Open(fmt.Sprintf("./ambiance/%s.mp3", fn)) + if err != nil { + log.Fatal(err) + } + + play, _, err := mp3.Decode(f) + if err != nil { + log.Fatal(err) + } + + discordspeaker.Pause(false) + discordspeaker.Lock() + if app.ambiance.IsPlaying() { + app.ambiance.Reset() + } + app.ambiance.Add(play) + discordspeaker.Unlock() + + msg := make(map[string]interface{}) + out := make(map[string]interface{}) + + app.curamb = fn + + msg["event"] = "ambiance_play" + out["type"] = fn + msg["payload"] = out + ws_msg <- msg +} + +func (app *App) ambianceStop(payload ...interface{}) { + log.Println("Stopping ambiance") + discordspeaker.Lock() + app.ambiance.Reset() + discordspeaker.Unlock() + + msg := make(map[string]interface{}) + msg["event"] = "ambiance_stop" + ws_msg <- msg + +} + +func (app *App) songPosition(payload ...interface{}) { + if !app.queue.IsPlaying() { + return + } + + l.Do(func() { + msg := make(map[string]interface{}) + out := make(map[string]interface{}) + + msg["event"] = "song_position" + if app.queue != nil { + out["len"] = app.queue.Len() + out["position"] = app.queue.Position() + } + + msg["payload"] = out + ws_msg <- msg + }) + +} + +func (app *App) checkQueue(payload ...interface{}) { + if !app.queue.IsPlaying() { + return + } + + // This needs some tweaking. + if app.queue.playing && !app.next && app.queue.QLen() == 0 { + + log.Println("Queue is 0. It should never be 0..") + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + select { + case <-ctx.Done(): + if app.queue.QLen() == 0 { + log.Println("Queue is still 0. Queueing a song.") + app.events.Emit("next") + } + log.Println("Seems queue is filled, doing nothing.") + } + } +} + +func (app *App) checkTimeleft(payload ...interface{}) { + if !app.queue.IsPlaying() { + return + } + timeleft := app.queue.Len() - app.queue.Position() + if timeleft <= 10000 && timeleft > 0 && !app.next { + app.events.Emit("preload_song") + } +} + +func (app *App) songOver(payload ...interface{}) { + log.Println("song_over event received") + + msg := app.songInfoEvent("song_info") + ws_msg <- msg + + app.next = false +} + +func (app *App) stop(payload ...interface{}) { + log.Println("stop event received") + discordspeaker.Lock() + app.queue.Reset() + discordspeaker.Unlock() + + msg := make(map[string]interface{}) + msg["event"] = "stop" + + ws_msg <- msg + +} + +func (app *App) prevSong(payload ...interface{}) { + log.Println("prev_song event received") + song := app.GetPrevSong(app.active) + f, err := ffmpeg.NewPCM(song, sampleRate, channels) + if err != nil { + log.Println("Unable to start new ffmpeg") + return + } + + discordspeaker.Lock() + app.queue.Reset() + app.queue.Add(f) + discordspeaker.Unlock() + + msg := app.songInfoEvent("song_info") + ws_msg <- msg +} + +func (app *App) nextSong(payload ...interface{}) { + log.Println("next_song event received") + song := app.GetNextSong(app.active) + f, err := ffmpeg.NewPCM(song, sampleRate, channels) + if err != nil { + log.Println("Unable to start new ffmpeg") + return + } + + for { + time.Sleep(1 * time.Second) + if app.queue.QLen() == 0 { + log.Println("retrying...") + song = app.GetSong(app.active) + } else { + break + } + } + + discordspeaker.Lock() + app.queue.Reset() + app.queue.Add(f) + discordspeaker.Unlock() + + msg := app.songInfoEvent("song_info") + ws_msg <- msg +} + +func (app *App) addPlaylist(payload ...interface{}) { + if !(len(payload) > 0) { + log.Println("addPlaylist called without a payload.") + return + } + + log.Println("add_playlist event received") + + var data map[string]string + switch js := payload[0].(type) { + case json.RawMessage: + json.Unmarshal(js, &data) + default: + log.Println("newPlaylist called with invalid payload.") + return + } + + plurl, ok := data["url"] + if !ok { + log.Println("addPlaylist without url") + return + } + pltitle, ok := data["title"] + if !ok { + log.Println("addPlaylist without title") + return + } + + pl, err := url.Parse(plurl) + if err != nil { + log.Println("addPlaylist invalid url") + return + } + + plid := pl.Query().Get("list") + if plid == "" { + log.Println("addPlaylist missing list in url") + return + } + + _, err = app.Playlist(plid) + if err != nil { + log.Println("Error getting youtube playlist info,", plid) + } + + id, err := app.AddPlaylist(pltitle, plid) + if err != nil { + log.Println("Error getting youtube playlist info,", plid) + } + + msg := make(map[string]interface{}) + + msg["event"] = "new_playlist" + msg["payload"] = map[string]string{"url": id.String(), "title": pltitle} + + ws_msg <- msg +} + +func (app *App) loadPlaylist(payload ...interface{}) { + log.Println("load_playlist event received") + + if !(len(payload) > 0) { + log.Println("loadPlaylist called without a payload.") + return + } + + var id uuid.UUID + switch data := payload[0].(type) { + case json.RawMessage: + var err error + var tmp string + + json.Unmarshal(data, &tmp) + id, err = uuid.Parse(tmp) + if err != nil { + log.Println("Unable to parse UUID,", err) + } + case uuid.UUID: + id = data + default: + log.Println("loadPlaylist called with invalid payload.") + return + } + + log.Println("Loading new playlist: ", id) + pl, err := app.GetPlaylist(id) + if err != nil { + log.Println("Unable to find playlist with id,", id) + return + } + + app.plm.Lock() + app.playlist = pl + app.plm.Unlock() + + app.next = true + discordspeaker.Pause(false) + + list, err := app.Playlist(pl.Url) + if err != nil { + log.Println("Error getting playlist info,", id) + return + } + + list, err = ShufflePlaylist(list) + if err != nil { + log.Println("Unable to shuffle playlist") + return + } + + app.active = list + + song := app.GetNextSong(list) + f, err := ffmpeg.NewPCM(song, sampleRate, channels) + if err != nil { + log.Println("Unable to start new ffmpeg") + return + } + + discordspeaker.Lock() + app.queue.Reset() + app.queue.Add(f) + discordspeaker.Unlock() + + app.next = false + + msg := app.songInfoEvent("song_info") + ws_msg <- msg + log.Println("Added song", song) +} + +func (app *App) preloadSong(payload ...interface{}) { + log.Println("preload_song event received") + app.next = true + discordspeaker.Pause(false) + + var song string + switch current := app.queue.Current().(type) { + case *ffmpeg.PCM: + for { + song = app.GetNextSong(app.active) + if current.Uri != song { + break + } + log.Println("Got same song, try again!") + } + default: + song = app.GetNextSong(app.active) + } + + f, err := ffmpeg.NewPCM(song, sampleRate, channels) + if err != nil { + log.Fatal(err) + } + + discordspeaker.Lock() + app.queue.Add(f) + discordspeaker.Unlock() + + log.Println("Added song.", song) +} diff --git a/ffmpeg/ffmpeg.go b/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..f2e89be --- /dev/null +++ b/ffmpeg/ffmpeg.go @@ -0,0 +1,91 @@ +package ffmpeg + +import ( + "bytes" + "context" + "log" + "os" + "os/exec" + "strconv" + "sync" + "time" + + "dndmusicbot/ytdl" +) + +type FFmpeg struct { + Out *bytes.Buffer + Cmd *exec.Cmd + Started bool + Cancel context.CancelFunc + Len time.Duration + Title string + Channel string + PMutex *sync.RWMutex + ProcessState *os.ProcessState + err chan error +} + +func NewFFmpeg(uri string, sampleRate int, channels int) (ff *FFmpeg, err error) { + var yt *ytdl.YTdl + for { + yt, err = ytdl.NewYTdl(uri) + if err != nil { + log.Println("Something went wrong, trying again.", err) + continue + } + if yt.Url != "" { + break + } + + log.Println("Something went wrong, trying again.") + time.Sleep(500 * time.Millisecond) + } + + ff = new(FFmpeg) + ff.PMutex = &sync.RWMutex{} + ff.Len = yt.Len + ff.Title = yt.Title + ff.Channel = yt.Channel + ctx, cancel := context.WithCancel(context.Background()) + ff.Cancel = cancel + + ff.Cmd = exec.CommandContext( + ctx, + "ffmpeg", + "-i", yt.Url, + "-f", "s16le", + "-v", "error", + // "-stats", + //"-progress", "pipe:2", + "-ar", strconv.Itoa(sampleRate), + "-ac", strconv.Itoa(channels), + "-af", "loudnorm=I=-16:LRA=11:TP=-1.5", + "pipe:1", + ) + + ff.Cmd.Stderr = os.Stdin + + // FFmpeg requires a certain buffer size to start writing. This seems to be enough? + // This will grow big enough to fit the whole song. + ff.Out = bytes.NewBuffer(make([]byte, 43920)) + ff.Cmd.Stdout = ff.Out + + ff.Start() + + return +} + +func (ff *FFmpeg) Start() { + go func() { + ff.Cmd.Start() + ff.err <- ff.Cmd.Wait() + }() + + ff.Started = true +} + +func (ff *FFmpeg) Close() error { + ff.Cancel() + return nil +} diff --git a/ffmpeg/pcm.go b/ffmpeg/pcm.go new file mode 100644 index 0000000..bad7219 --- /dev/null +++ b/ffmpeg/pcm.go @@ -0,0 +1,96 @@ +package ffmpeg + +import ( + "fmt" + "io" + "math" + "time" + + "github.com/faiface/beep" +) + +type PCM struct { + f beep.Format + pos int + sr int + c int + Player *FFmpeg + Uri string + Base float64 + Volume float64 +} + +func NewPCM(uri string, sampleRate int, channels int) (beep.StreamSeekCloser, error) { + out := new(PCM) + + ff, err := NewFFmpeg(uri, sampleRate, channels) + if err != nil { + return nil, err + } + + out.Player = ff + format := beep.Format{ + SampleRate: beep.SampleRate(sampleRate), + NumChannels: channels, + Precision: 2, + } + + return &PCM{ + format, + 0, + sampleRate, + channels, + ff, + uri, + 2, + -2, + }, nil +} + +func (d *PCM) Stream(samples [][2]float64) (n int, ok bool) { + tmp := make([]byte, d.c+2) + + for i := range samples { + dn, err := d.Player.Out.Read(tmp) + if dn == len(tmp) { + samples[i], _ = d.f.DecodeSigned(tmp) + d.pos += dn + ok = true + } + if err == io.EOF { + ok = false + break + } + if err != nil { + ok = false + break + } + gain := math.Pow(d.Base, d.Volume) + samples[i][0] *= gain + samples[i][1] *= gain + } + + return len(samples), ok +} + +func (d PCM) Err() error { + return nil +} + +func (d PCM) Position() int { + t, _ := time.ParseDuration(fmt.Sprintf("%ds", ((d.pos / d.sr) / 4))) + return int(t.Milliseconds()) +} + +func (d PCM) Close() error { + d.Player.Close() + return nil +} + +func (d PCM) Len() int { + return int(d.Player.Len.Milliseconds()) +} + +func (d PCM) Seek(p int) error { + return nil +} diff --git a/js/Sortable.min.js b/js/Sortable.min.js new file mode 100644 index 0000000..17bb16c --- /dev/null +++ b/js/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.0 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function M(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function N(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function I(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function P(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=P(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[j]._onDragOver(o)}}var i,r,a}function Yt(t){q&&q.parentNode[j]._isOutsideThisEl(t.target)}function Bt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return It(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Bt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in K.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Pt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Mt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Et.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function Ft(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||k(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function jt(t){t.draggable=!1}function Ht(){Ct=!1}function Lt(t){return setTimeout(t,0)}function Kt(t){return clearTimeout(t)}Bt.prototype={constructor:Bt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(gt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,q):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Tt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Tt.push(o)}}(o),!q&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=N(l,t.draggable,o,!1))&&l.animated||J===l)){if(nt=B(l),it=B(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return U({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),z("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=N(s,t.trim(),o,!1))return U({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),z("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!N(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!q&&n.parentNode===r&&(o=k(n),$=r,V=(q=n).parentNode,Q=q.nextSibling,J=n,at=a.group,st={target:Bt.dragged=q,clientX:(e||t).clientX,clientY:(e||t).clientY},ht=st.clientX-o.left,ft=st.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,q.style["will-change"]="all",o=function(){z("delayEnded",i,{evt:t}),Bt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(q.draggable=!0),i._triggerDragStart(t,e),U({sortable:i,name:"choose",originalEvent:t}),I(q,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(q,t.trim(),jt)}),h(l,"dragover",Xt),h(l,"mousemove",Xt),h(l,"touchmove",Xt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,q.draggable=!0),z("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Bt.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){q&&jt(q),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(q,"dragend",this),h($,"dragstart",this._onDragStart));try{document.selection?Lt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;yt=!1,$&&q?(z("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Yt),n=this.options,t||I(q,n.dragClass,!1),I(q,n.ghostClass,!0),Bt.active=this,t&&this._appendGhost(),U({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ct){this._lastX=ct.clientX,this._lastY=ct.clientY,kt();for(var t=document.elementFromPoint(ct.clientX,ct.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ct.clientX,ct.clientY))!==e;)e=t;if(q.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j])if(e[j]._onDragOver({clientX:ct.clientX,clientY:ct.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Rt()}},_onTouchMove:function(t){if(st){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Z&&v(Z,!0),a=Z&&r&&r.a,l=Z&&r&&r.d,e=Ot&&bt&&E(bt),a=(i.clientX-st.clientX+o.x)/(a||1)+(e?e[0]-_t[0]:0)/(a||1),l=(i.clientY-st.clientY+o.y)/(l||1)+(e?e[1]-_t[1]:0)/(l||1);if(!Bt.active&&!yt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))n.right+10||t.clientX<=n.right&&t.clientY>n.bottom&&t.clientX>=n.left:t.clientX>n.right&&t.clientY>n.top||t.clientX<=n.right&&t.clientY>n.bottom+10}(n,r,this)&&!g.animated){if(g===q)return O(!1);if((l=g&&a===n.target?g:l)&&(w=k(l)),!1!==Ft($,a,q,o,l,w,n,!!l))return x(),g&&g.nextSibling?a.insertBefore(q,g.nextSibling):a.appendChild(q),V=a,A(),O(!0)}else if(g&&function(t,e,n){n=k(X(n.el,0,n.options,!0));return e?t.clientXg.maxReconnectInterval?g.maxReconnectInterval:e)}},h.onmessage=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onmessage",g.url,b.data);var c=l("message");c.data=b.data,k.dispatchEvent(c)},h.onerror=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onerror",g.url,b),k.dispatchEvent(l("error"))}},1==this.automaticOpen&&this.open(!1),this.send=function(b){if(h)return(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","send",g.url,b),h.send(b);throw"INVALID_STATE_ERR : Pausing to reconnect websocket"},this.close=function(a,b){"undefined"==typeof a&&(a=1e3),i=!0,h&&h.close(a,b)},this.refresh=function(){h&&h.close()}}return a.prototype.onopen=function(){},a.prototype.onclose=function(){},a.prototype.onconnecting=function(){},a.prototype.onmessage=function(){},a.prototype.onerror=function(){},a.debugAll=!1,a.CONNECTING=WebSocket.CONNECTING,a.OPEN=WebSocket.OPEN,a.CLOSING=WebSocket.CLOSING,a.CLOSED=WebSocket.CLOSED,a}); diff --git a/js/script.js b/js/script.js new file mode 100644 index 0000000..677ed3c --- /dev/null +++ b/js/script.js @@ -0,0 +1,204 @@ +window.onload = function() { + const ws = new ReconnectingWebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/ws"); + + const items = document.querySelector("#items"); + const amb = document.querySelector("#ambiance") + const submit = document.querySelector("#addplaylist") + const output = document.querySelector(".container2 p#output") + const info = document.querySelector("#info") + const waiting = document.querySelector("#waiting") + + document.querySelector("input#next").addEventListener("click", (e) => { + ws.send(JSON.stringify({ + "event": "next" + })) + }) + + document.querySelector("input#prev").addEventListener("click", (e) => { + ws.send(JSON.stringify({ + "event": "prev" + })) + }) + + Sortable.create(items, { + group: "dndmusicbot-playlists", + filter: ".locked", + onMove: (e) => { + if (e.related) + { + return !e.related.classList.contains('locked'); + } + }, + invertSwap: true, + store: { + get: function(sortable) { + var order = localStorage.getItem(sortable.options.group.name); + return order ? order.split('|') : []; + }, + set: function(sortable) { + var order = sortable.toArray(); + localStorage.setItem(sortable.options.group.name, order.join('|')); + } + } + }) + + Sortable.create(amb, { + group: "dndmusicbot-ambiance", + filter: ".locked", + onMove: (e) => { + if (e.related) + { + return !e.related.classList.contains('locked'); + } + }, + invertSwap: true, + store: { + get: function(sortable) { + var order = localStorage.getItem(sortable.options.group.name); + return order ? order.split('|') : []; + }, + set: function(sortable) { + var order = sortable.toArray(); + localStorage.setItem(sortable.options.group.name, order.join('|')); + } + } + }) + + ws.onopen = (e) => { + waiting.style.display = "none"; + } + + ws.onclose = (e) => { + waiting.style.display = "block"; + } + + ws.onmessage = (e) => { + data = JSON.parse(e.data) + switch (data.event) { + case "ambiance_play": + document.querySelectorAll("#ambiance > div").forEach((e) => {e.style.removeProperty("background-color")}) + document.querySelector(`#ambiance > div[data-id='${data.payload.type}']`).style.backgroundColor = "burlywood" + ambiance.style.pointerEvents = 'auto' + break + case "ambiance_stop": + document.querySelectorAll("#ambiance > div").forEach((el) => { + el.style.removeProperty("background-color") + }) + break + case "song_info": + document.querySelectorAll("#items > div").forEach((el) => { + el.style.removeProperty("background-color") + }) + if (data.payload.pause) { + info.style.display = "none" + } else { + info.style.display = "block" + info.children.link.children.channel.innerText = data.payload.channel + info.children.link.children.title.innerText = data.payload.current + info.children.link.href = "https://youtu.be/" + data.payload.song + info.children.link.target = "_blank" + info.children.time.innerText = `( ${msToTime(data.payload.position)} / ${msToTime(data.payload.len)} )` + document.querySelector(`#items > div[data-id='${data.payload.playlist}']`).style.backgroundColor = "burlywood" + } + setTimeout(() => { + items.style.pointerEvents = 'auto' + }, 1000); + break + case "song_position": + info.children.time.innerText = `( ${msToTime(data.payload.position)} / ${msToTime(data.payload.len)} )` + info.style.display = "block" + break + case "stop": + setTimeout(() => { + items.style.pointerEvents = 'auto' + }, 2000); + + info.style.display = "none" + break + case "new_playlist": + addPlaylist(data.payload); + break; + default: + } + } + + submit.addEventListener("click", (e) => { + e.preventDefault() + + output.innerText = "" + var title = document.querySelector(".container2 input[name='title'") + var url = document.querySelector(".container2 input[name='url'") + if (title.value == "" || url.value == "") { + output.innerText = "Title or Url is empty!" + return + } + + ws.send(JSON.stringify({ + "event": "add_playlist", + "payload": { + "title": title.value, + "url": url.value + } + })) + }) + + document.querySelectorAll("#items").forEach(item => item.addEventListener("click", (e) => { + e.preventDefault() + e.target.parentElement.style.pointerEvents = 'none' + const disableui = setTimeout((t) => { + t.style.pointerEvents = 'auto' + }, 3000, e.target.parentElement); + + var id = e.target.dataset.id + ws.send(JSON.stringify({ + "event": ((id === "reset") ? "stop" : "load_playlist"), + "payload": id + })) + })); + + document.querySelectorAll("#ambiance").forEach(item => item.addEventListener("click", (e) => { + e.preventDefault() + e.target.parentElement.style.pointerEvents = 'none' + const disableui = setTimeout((t) => { + t.style.pointerEvents = 'auto' + }, 3000, e.target.parentElement); + + var id = e.target.dataset.id + ws.send(JSON.stringify({ + "event": ((id === "reset") ? "ambiance_stop" : "ambiance_play"), + "payload": id + })) + })); +}; + +function addPlaylist(payload) { + const container = document.querySelector("body > div.container") + var newdiv = document.createElement('div'); + newdiv.className = "item" + newdiv.dataset.id = payload.url + newdiv.innerText = payload.title + newdiv.addEventListener("click", (e) => { + e.preventDefault() + + var id = e.target.dataset.id + ws.send(JSON.stringify({ + "event": ((id === "reset") ? "stop" : "load_playlist"), + "payload": id + })) + }) + + container.insertBefore(newdiv, document.querySelector("body > div.container div:last-child")) + + output.innerText = "New playlist was added: " + payload.title +} + +function msToTime(duration) { + var milliseconds = parseInt((duration % 1000) / 100), + seconds = Math.floor((duration / 1000) % 60), + minutes = Math.floor((duration / (1000 * 60)) % 60), + + minutes = (minutes < 10) ? "0" + minutes : minutes; + seconds = (seconds < 10) ? "0" + seconds : seconds; + + return minutes + ":" + seconds +} diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..8011b9f --- /dev/null +++ b/queue.go @@ -0,0 +1,111 @@ +package main + +import ( + "log" + + "github.com/faiface/beep" + "github.com/kataras/go-events" + + discordspeaker "dndmusicbot/speaker" +) + +type Ambiance struct { + Type string + URL string +} + +func init() { + app.ambiance = new(Queue) + app.ambiance.Events = app.events + discordspeaker.Play(app.ambiance) + + app.queue = new(Queue) + app.queue.Events = app.events + discordspeaker.Play(app.queue) + + log.Println("queue.go done.") +} + +type Queue struct { + streamers []beep.StreamSeekCloser + Events events.EventEmmiter + playing bool +} + +func (q Queue) IsPlaying() bool { + return q.playing +} + +func (q *Queue) Add(streamers ...beep.StreamSeekCloser) { + q.playing = true + q.streamers = append(q.streamers, streamers...) +} + +func (q Queue) QLen() int { + return len(q.streamers) +} + +func (q Queue) Len() int { + if len(q.streamers) > 0 { + return q.streamers[0].Len() + } else { + return 0 + } +} + +func (q Queue) Current() beep.StreamSeekCloser { + if len(q.streamers) > 0 { + return q.streamers[0] + } else { + return nil + } +} + +func (q *Queue) Reset() { + q.playing = false + for _, stream := range q.streamers { + stream.Close() + } + q.streamers = nil +} + +func (q *Queue) Stream(samples [][2]float64) (n int, ok bool) { + // We use the filled variable to track how many samples we've + // successfully filled already. We loop until all samples are filled. + filled := 0 + + for filled < len(samples) { + // There are no streamers in the queue, so we stream silence. + if len(q.streamers) == 0 { + for i := range samples[filled:] { + samples[i][0] = 0 + samples[i][1] = 0 + } + break + } + + // We stream from the first streamer in the queue. + n, ok := q.streamers[0].Stream(samples[filled:]) + // If it's drained, we pop it from the queue, thus continuing with + // the next streamer. + if !ok { + q.streamers = q.streamers[1:] + q.Events.Emit("song_over", nil) + } + // We update the number of filled samples. + filled += n + } + return len(samples), true +} + +func (q *Queue) Err() error { + return nil +} + +func (q *Queue) Position() int { + if len(q.streamers) > 0 { + return q.streamers[0].Position() + } else { + return 0 + } +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..2d8fcd7 --- /dev/null +++ b/routes.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "net/http" + "text/template" + + "github.com/google/uuid" + "github.com/julienschmidt/httprouter" +) + +func init() { + app.router = httprouter.New() + app.router.GET("/", app.Index) + app.router.GET("/play/:playlist", app.Play) + app.router.GET("/reset", app.Reset) + + app.router.ServeFiles("/js/*filepath", http.Dir("js")) + app.router.ServeFiles("/css/*filepath", http.Dir("css")) + + go func() { + log.Fatal(http.ListenAndServe(":8824", app.router)) + }() +} + +type IndexData struct { + Playlists []Playlist + Ambiance []string +} + +func (app App) Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + playlists, err := app.GetPlaylists() + if err != nil { + http.Error(w, "Unable to get playlists. "+err.Error(), http.StatusInternalServerError) + } + + amblist, err := GetAmbiance() + if err != nil { + log.Println(err) + return + } + + data := IndexData{playlists, amblist} + + t := template.Must(template.New("index.tmpl").ParseFiles("tmpl/index.tmpl")) + err = t.Execute(w, data) + if err != nil { + http.Error(w, "Unable to load template. "+err.Error(), http.StatusInternalServerError) + } +} + +func (app *App) Play(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + plname := p.ByName("playlist") + + if plname == "reset" { + app.events.Emit("stop", nil) + return + } + + plid, err := uuid.ParseBytes([]byte(plname)) + if err != nil { + http.Error(w, "Unable to parse uuid. "+err.Error(), http.StatusInternalServerError) + } + + app.events.Emit("new_playlist", plid) +} + +func (app *App) Add(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + r.ParseForm() + +} + +func (app *App) Reset(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + app.events.Emit("stop", nil) +} diff --git a/speaker/discord.go b/speaker/discord.go new file mode 100644 index 0000000..849da7f --- /dev/null +++ b/speaker/discord.go @@ -0,0 +1,139 @@ +package discordspeaker + +import ( + "bytes" + "encoding/binary" + "log" + "sync" + + "github.com/bwmarrin/discordgo" + "github.com/faiface/beep" + "github.com/pkg/errors" + "layeh.com/gopus" +) + +const bufferSize = 1000 + +var ( + mu sync.Mutex + mixer beep.Mixer + samples [][2]float64 + done chan struct{} + encoder *gopus.Encoder + voice *discordgo.VoiceConnection + frameSize int = 960 + channels int = 2 + sampleRate int = 48000 + maxBytes int = (frameSize * 2) * 2 + pcm []int16 + buf []byte + pause bool +) + +func Init(dgv *discordgo.VoiceConnection) error { + var err error + + mu.Lock() + defer mu.Unlock() + Close() + mixer = beep.Mixer{} + + buf = make([]byte, maxBytes) + pcm = make([]int16, frameSize*channels) + samples = make([][2]float64, frameSize) + + pause = true + + voice = dgv + + encoder, err = gopus.NewEncoder(sampleRate, channels, gopus.Audio) + + if err != nil { + return errors.Wrap(err, "failed to initialize speaker") + } + + go func() { + for { + select { + default: + update() + case <-done: + return + } + } + }() + + return nil +} + +func Close() { +} + +func Lock() { + mu.Lock() +} + +// Unlock unlocks the speaker. Call after modifying any currently playing Streamer. +func Unlock() { + mu.Unlock() +} + +func Play(s ...beep.Streamer) { + mu.Lock() + mixer.Add(s...) + mu.Unlock() +} + +func Clear() { + mu.Lock() + mixer.Clear() + mu.Unlock() +} + +func Pause(p bool) { + pause = p +} + +func IsPaused() bool { + return pause +} + +func update() { + if pause { + return + } + + mu.Lock() + mixer.Stream(samples) + mu.Unlock() + + for i := range samples { + for c := range samples[i] { + val := samples[i][c] + if val < -1 { + val = -1 + } + if val > +1 { + val = +1 + } + valInt16 := int16(val * (1<<15 - 1)) + low := byte(valInt16) + high := byte(valInt16 >> 8) + buf[i*4+c*2+0] = low + buf[i*4+c*2+1] = high + } + } + + binary.Read(bytes.NewReader(buf), binary.LittleEndian, &pcm) + opus, err := encoder.Encode(pcm, frameSize, maxBytes) + if err != nil { + log.Println(err) + return + } + + if voice.Ready == false || voice.OpusSend == nil { + return + } + + voice.OpusSend <- opus +} diff --git a/tmpl/index.tmpl b/tmpl/index.tmpl new file mode 100644 index 0000000..ab63d69 --- /dev/null +++ b/tmpl/index.tmpl @@ -0,0 +1,55 @@ + + + + + + + D&D Music Bot! + + + + +

Playlists

+
+ {{ range .Playlists }} +
{{ .Title }}
+ {{ end}} +
Stop
+
+
+ + + + + +
+
+

+
+ +

Ambiance

+
+ {{ range .Ambiance }} +
{{ . }}
+ {{ end}} +
Stop
+
+ + + + + + + diff --git a/ws.go b/ws.go new file mode 100644 index 0000000..75a6795 --- /dev/null +++ b/ws.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/grafov/bcast" + "github.com/kataras/go-events" +) + +func init() { + log.Println("ws.go loading..") + go ws_clients.Broadcast(0) + ws_msg = make(chan interface{}) + + go func() { + for { + select { + case msg := <-ws_msg: + ws_clients.Send(msg) + } + } + }() + + // Since httprouter seems to not like doing websocket stuff, we run a seperate server for it.. for now.. + go func() { + app.mux.HandleFunc("/ws", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("WS connection from %v\n", r.RemoteAddr) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + + err = handleWS(conn) + if err != nil { + log.Printf("WS connection closed, %v\n", r.RemoteAddr) + } + })) + }() + + log.Println("ws.go done.") +} + +type WSmsg struct { + Event string + Payload json.RawMessage +} + +var ws_clients = bcast.NewGroup() +var ws_msg chan interface{} +var WSMutex = &sync.Mutex{} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +func handleWS(c *websocket.Conn) error { + memb := ws_clients.Join() + defer memb.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + ticker := time.NewTicker(30 * time.Second) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)) + case msg := <-memb.Read: + c.SetWriteDeadline(time.Now().Add(10 * time.Second)) + c.WriteJSON(msg) + } + } + }() + + c.SetPongHandler(func(d string) error { + return nil + }) + + msg := app.songInfoEvent("song_info") + + c.SetWriteDeadline(time.Now().Add(10 * time.Second)) + c.WriteJSON(msg) + + if app.ambiance.IsPlaying() { + msg := make(map[string]interface{}) + out := make(map[string]interface{}) + msg["event"] = "ambiance_play" + out["type"] = app.curamb + msg["payload"] = out + c.WriteJSON(msg) + } else { + msg := make(map[string]interface{}) + msg["event"] = "ambiance_stop" + c.WriteJSON(msg) + } + + for { + var msg WSmsg + err := c.ReadJSON(&msg) + if err != nil { + return err + } + + app.events.Emit(events.EventName(msg.Event), msg.Payload) + } +} diff --git a/youtube.go b/youtube.go new file mode 100644 index 0000000..911a6cc --- /dev/null +++ b/youtube.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "io" + "log" + "math/big" + "time" + + mrand "math/rand" + + "github.com/faiface/beep" + "google.golang.org/api/option" + "google.golang.org/api/youtube/v3" +) + +var ( + // apikey = "AIzaSyCWO1F6n6UAtOm3L_K-kzF-4UQoS_DmJW0" + yt_url = "https://www.youtube.com/watch?v=%s" +) + +func init() { + log.Println("youtube.go loading..") + + var err error + + apikey := config.GetString("youtube.apikey") + + app.youtube, err = youtube.NewService(context.Background(), option.WithAPIKey(apikey)) + if err != nil { + log.Fatal(err) + } + log.Println("youtube.go done.") +} + +type YT struct { + dst io.WriteCloser + pcm io.ReadCloser + dur time.Duration + pos time.Duration + f beep.Format +} + +func ShufflePlaylist(list []string) ([]string, error) { + seedb := make([]byte, 32) + _, err := rand.Read(seedb) + if err != nil { + return nil, err + } + seed := binary.BigEndian.Uint64(seedb) + mrand.Seed(int64(seed)) + mrand.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] }) + + return list, nil +} + +func (app *App) GetSong(list []string) string { + return fmt.Sprintf(yt_url, list[app.plidx]) +} + +func (app *App) GetNextSong(list []string) string { + app.plidx++ + if app.plidx >= len(app.active) { + app.plidx = 0 + } + return fmt.Sprintf(yt_url, list[app.plidx]) +} + +func (app *App) GetPrevSong(list []string) string { + app.plidx-- + if app.plidx < 0 { + app.plidx = len(list) + } + return fmt.Sprintf(yt_url, list[app.plidx]) +} + +func GetRandomSong(list []string) string { + if !(len(list) > 0) { + return "" + } + + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(list)-1))) + if err != nil { + log.Println("Failed to get random int, ", err) + return "" + } + return fmt.Sprintf(yt_url, list[idx.Int64()]) +} + +func (app App) Playlist(playlist string) ([]string, error) { + call := app.youtube.PlaylistItems.List([]string{"contentDetails"}) + pageToken := "" + call = call.MaxResults(50) + call = call.PlaylistId(playlist) + if pageToken != "" { + call = call.PageToken(pageToken) + } + + var list []string + for { + response, err := call.Do() + if err != nil { + return nil, err + } + for _, item := range response.Items { + list = append(list, item.ContentDetails.VideoId) + } + pageToken = response.NextPageToken + if pageToken == "" { + break + } + } + return list, nil + + //return fmt.Sprintf(yt_url, list[rand.Intn(len(list))]) +} diff --git a/ytdl/ytdl.go b/ytdl/ytdl.go new file mode 100644 index 0000000..51bfe14 --- /dev/null +++ b/ytdl/ytdl.go @@ -0,0 +1,47 @@ +package ytdl + +import ( + "errors" + "fmt" + "os/exec" + "time" + + "github.com/tidwall/gjson" +) + +type YTdl struct { + Title string + Url string + Channel string + Len time.Duration +} + +func NewYTdl(uri string) (*YTdl, error) { + ytdl_js, err := exec.Command( + "./bin/yt-dlp_linux", + uri, + "--cookies", "./cookies.txt", + "--no-call-home", + "--no-cache-dir", + "--ignore-errors", + "--newline", + "--restrict-filenames", + "-f", "140", + "-j", + ).Output() + + if err != nil { + return nil, err + } + if !gjson.ValidBytes(ytdl_js) { + return nil, errors.New("invalid json") + } + + results := gjson.GetManyBytes(ytdl_js, "title", "url", "duration", "channel") + title := results[0].String() + geturl := results[1].String() + duration, err := time.ParseDuration(fmt.Sprintf("%ds", results[2].Int())) + channel := results[3].String() + + return &YTdl{title, geturl, channel, duration}, nil +}