Initial commit
commit
b1923fe809
|
@ -0,0 +1,10 @@
|
||||||
|
*.sh
|
||||||
|
dndmusicbot
|
||||||
|
youtube-dl
|
||||||
|
oauth2-proxy
|
||||||
|
test/*
|
||||||
|
cookies.txt
|
||||||
|
config
|
||||||
|
|
||||||
|
bin
|
||||||
|
ambiance
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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.")
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1 @@
|
||||||
|
!function(a,b){"function"==typeof define&&define.amd?define([],b):"undefined"!=typeof module&&module.exports?module.exports=b():a.ReconnectingWebSocket=b()}(this,function(){function a(b,c,d){function l(a,b){var c=document.createEvent("CustomEvent");return c.initCustomEvent(a,!1,!1,b),c}var e={debug:!1,automaticOpen:!0,reconnectInterval:1e3,maxReconnectInterval:3e4,reconnectDecay:1.5,timeoutInterval:2e3};d||(d={});for(var f in e)this[f]="undefined"!=typeof d[f]?d[f]:e[f];this.url=b,this.reconnectAttempts=0,this.readyState=WebSocket.CONNECTING,this.protocol=null;var h,g=this,i=!1,j=!1,k=document.createElement("div");k.addEventListener("open",function(a){g.onopen(a)}),k.addEventListener("close",function(a){g.onclose(a)}),k.addEventListener("connecting",function(a){g.onconnecting(a)}),k.addEventListener("message",function(a){g.onmessage(a)}),k.addEventListener("error",function(a){g.onerror(a)}),this.addEventListener=k.addEventListener.bind(k),this.removeEventListener=k.removeEventListener.bind(k),this.dispatchEvent=k.dispatchEvent.bind(k),this.open=function(b){h=new WebSocket(g.url,c||[]),b||k.dispatchEvent(l("connecting")),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","attempt-connect",g.url);var d=h,e=setTimeout(function(){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","connection-timeout",g.url),j=!0,d.close(),j=!1},g.timeoutInterval);h.onopen=function(){clearTimeout(e),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onopen",g.url),g.protocol=h.protocol,g.readyState=WebSocket.OPEN,g.reconnectAttempts=0;var d=l("open");d.isReconnect=b,b=!1,k.dispatchEvent(d)},h.onclose=function(c){if(clearTimeout(e),h=null,i)g.readyState=WebSocket.CLOSED,k.dispatchEvent(l("close"));else{g.readyState=WebSocket.CONNECTING;var d=l("connecting");d.code=c.code,d.reason=c.reason,d.wasClean=c.wasClean,k.dispatchEvent(d),b||j||((g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onclose",g.url),k.dispatchEvent(l("close")));var e=g.reconnectInterval*Math.pow(g.reconnectDecay,g.reconnectAttempts);setTimeout(function(){g.reconnectAttempts++,g.open(!0)},e>g.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});
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>D&D Music Bot!</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/solarized-dark.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div><h2 class="bot">Playlists</h2></div>
|
||||||
|
<div id="items" class="container">
|
||||||
|
{{ range .Playlists }}
|
||||||
|
<div class="item" data-id="{{ .Id }}">{{ .Title }}</div>
|
||||||
|
{{ end}}
|
||||||
|
<div class="item locked" data-id="reset">Stop</div>
|
||||||
|
</div>
|
||||||
|
<div class="container2">
|
||||||
|
<input name="title" type="text" placeholder="Enter name..">
|
||||||
|
<input name="url" type="text" placeholder="https://youtube.com/playlist?list=...">
|
||||||
|
<input id="addplaylist" name="submit" value="Add" type="submit">
|
||||||
|
<input id="prev" name="prev" type="button" value="prev">
|
||||||
|
<input id="next" name="next" type="button" value="next">
|
||||||
|
</div>
|
||||||
|
<div class="container2">
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
<div id="info" class="container2">
|
||||||
|
<a id="link" style="text-decoration: none;">
|
||||||
|
<span id="channel"></span>
|
||||||
|
<span> - </span>
|
||||||
|
<span id="title"></span>
|
||||||
|
</a>
|
||||||
|
<span id="time"></span>
|
||||||
|
</div>
|
||||||
|
<div><h2 class="bot">Ambiance</h2></div>
|
||||||
|
<div id="ambiance" class="container">
|
||||||
|
{{ range .Ambiance }}
|
||||||
|
<div class="item drag" data-id="{{ . }}">{{ . }}</div>
|
||||||
|
{{ end}}
|
||||||
|
<div class="item locked" data-id="reset">Stop</div>
|
||||||
|
</div>
|
||||||
|
<!-- The Modal -->
|
||||||
|
<div id="waiting" class="modal">
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="modal-content">
|
||||||
|
<p>Waiting for bot..</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/js/reconnecting-websocket.min.js"></script>
|
||||||
|
<script src="/js/Sortable.min.js"></script>
|
||||||
|
<script src="/js/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))])
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue