diff --git a/.travis.yml b/.travis.yml index e732098..902567e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,9 @@ jobs: - 'docker' before_install: './.travis/main.sh' install: skip - script: make docker-build + script: + - make deps check test build + - make docker-build - stage: deploy if: (branch = master) AND (NOT type = pull_request) services: @@ -28,6 +30,7 @@ jobs: before_install: './.travis/main.sh' install: skip script: + - make deps check test build - make docker-build - 'if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin; fi' - 'if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then make docker-push; fi' diff --git a/conf/config.json.sample b/conf/config.json.sample index 72da5f6..1539644 100644 --- a/conf/config.json.sample +++ b/conf/config.json.sample @@ -8,6 +8,7 @@ "ytdl_path": "", "save_videos": true, "cache_dir": "video_cache", + "cache_size_mb": 1024, "use_playlist": true, "playlist_path": "conf/playlist.json", "auto_pause": true, diff --git a/go.mod b/go.mod index 588986c..cd205c5 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/andybalholm/cascadia v0.0.0-20161224141413-349dd0209470 // indirect github.com/bwmarrin/discordgo v0.18.0 github.com/gorilla/websocket v1.2.0 // indirect - github.com/jatgam/goutils v0.1.0 + github.com/jatgam/goutils v0.1.1 github.com/jonas747/dca v0.0.0-20171004024810-01f9985f4a26 github.com/jonas747/ogg v0.0.0-20161220051205-b4f6f4cf3757 // indirect github.com/rylio/ytdl v0.5.2-0.20190315183053-1f14ef2e151a diff --git a/go.sum b/go.sum index 08a1d0d..ce1fa0c 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ= github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/jatgam/goutils v0.1.0 h1:QDjXxvarLLME8xcxls+5gsACjLiDsQfhIPkxllULEX4= -github.com/jatgam/goutils v0.1.0/go.mod h1:1z730wgc18GOvqmZf8H95/sDnYgqvZCzz5EMsmLVQVM= +github.com/jatgam/goutils v0.1.1 h1:1yyLw5oneryzJ5iiB6L9fzgRDYvG9iplQB+iRaiBA8g= +github.com/jatgam/goutils v0.1.1/go.mod h1:1z730wgc18GOvqmZf8H95/sDnYgqvZCzz5EMsmLVQVM= github.com/jonas747/dca v0.0.0-20171004024810-01f9985f4a26 h1:NKu1iYZ8bas0oQXjZYca7KpWDPyP+JZNUCt1gFoUJ5c= github.com/jonas747/dca v0.0.0-20171004024810-01f9985f4a26/go.mod h1:rxjYX9OJU81unMxQDHChU/lAiOhlY9MV+faPX/NmwLk= github.com/jonas747/ogg v0.0.0-20161220051205-b4f6f4cf3757 h1:Kyv+zTfWIGRNaz/4+lS+CxvuKVZSKFz/6G8E3BKKBRs= diff --git a/piccolo/commands.go b/piccolo/commands.go index b836e14..79e8bd5 100644 --- a/piccolo/commands.go +++ b/piccolo/commands.go @@ -32,6 +32,7 @@ func init() { cmdHandler.addCommand("skipSong", skipSong) cmdHandler.addCommand("savePlaylist", savePlaylist) cmdHandler.addCommand("showPlaylist", printPlaylist) + cmdHandler.addCommand("status", getStatus) } func (h commandHandler) addCommand(name string, c command) { @@ -90,8 +91,17 @@ func play(b *Bot, m *discordgo.MessageCreate) { b.reply(fmt.Sprintf("<@%s> - Sorry, couldn't find a result for: **%s**", m.Author.ID, song), m) return } - b.textChannelLookup[m.ChannelID].player.playlist.addSong(m.Author, m.ChannelID, result.ID.VideoID, result.Snippet.Title) - go b.textChannelLookup[m.ChannelID].player.downloadNextSong() + if result.ID.VideoID == b.textChannelLookup[m.ChannelID].player.currentSong.VideoID { + b.reply(fmt.Sprintf("<@%s> - You requested the already playing song: **%s**\nCasting your vote to the void.", m.Author.ID, result.Snippet.Title), m) + return + } + pEntry := &PlaylistEntry{ + Requester: m.Author, + RequestChannelID: m.ChannelID, + Title: result.Snippet.Title, + VideoID: result.ID.VideoID, + } + go b.textChannelLookup[m.ChannelID].player.downloadNextSong(pEntry) b.reply(fmt.Sprintf("<@%s> - Enqueued **%s** to be played.", m.Author.ID, result.Snippet.Title), m) } @@ -154,5 +164,16 @@ func printPlaylist(b *Bot, m *discordgo.MessageCreate) { }).Error("Failed to find controller from channel id") return } - b.reply(fmt.Sprintf("<@%s> - **Current Playlist**\n\n%s", m.Author.ID, b.textChannelLookup[m.ChannelID].player.playlist), m) + currentVideoID := b.textChannelLookup[m.ChannelID].player.currentSong.VideoID + b.reply(fmt.Sprintf("<@%s> - **Current Playlist**\n\n%s", m.Author.ID, b.textChannelLookup[m.ChannelID].player.playlist.printPlaylist(currentVideoID)), m) +} + +func getStatus(b *Bot, m *discordgo.MessageCreate) { + if _, ok := b.textChannelLookup[m.ChannelID]; !ok { + log.WithFields(log.Fields{ + "channel": m.ChannelID, + }).Error("Failed to find controller from channel id") + return + } + b.reply(fmt.Sprintf("<@%s> - **Current Status**\n\n%s", m.Author.ID, b.textChannelLookup[m.ChannelID].player.getCurrentStatus()), m) } diff --git a/piccolo/player.go b/piccolo/player.go index 0e8cce3..e55950a 100644 --- a/piccolo/player.go +++ b/piccolo/player.go @@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "strings" "sync" "time" @@ -34,9 +35,9 @@ type ( currentSong *songAndPath - dg *discordgo.Session - - downloadLock *sync.Mutex + dg *discordgo.Session + limitCacheSize bool + downloadLock *sync.Mutex } songAndPath struct { @@ -53,6 +54,11 @@ func newPlayer(confpointer *utils.Config, guildID string, voiceChID string, yout p := &player{conf: confpointer, guildID: guildID, voiceChannelID: voiceChID, yt: youtube, dg: discordSession} p.playlist = newPlaylist(p.conf.Bot.UsePlaylist, p.conf.Bot.PlaylistPath) p.downloadLock = downloadLock + if confpointer.Bot.CacheSizeMB > 0 { + p.limitCacheSize = true + } else { + p.limitCacheSize = false + } p.streamDoneChan = make(chan error) return p } @@ -81,7 +87,7 @@ func (p *player) JoinVoiceChannel() error { return err } p.vc = vc - p.downloadNextSong() + p.downloadNextSong(nil) go p.playLoop() return nil @@ -91,7 +97,7 @@ func (p *player) playLoop() { for { nextSong, err := p.getNextSongPath() // Download the next song in the background - go p.downloadNextSong() + go p.downloadNextSong(nil) if err == nil { reader, err := os.Open(filepath.FromSlash(nextSong.fsPath)) if err != nil { @@ -129,9 +135,28 @@ func (p *player) playLoop() { } p.vc.Speaking(false) } + if p.limitCacheSize { + currentCacheSize, err := goutils.DirSizeMB(p.yt.YTCacheDir) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Error("Error Determing Current Cache Size") + } else { + if currentCacheSize > float64(p.conf.Bot.CacheSizeMB) { + p.downloadLock.Lock() + deleteErr := goutils.DeleteOldestFileUntilDirUnderSize(p.yt.YTCacheDir, float64(p.conf.Bot.CacheSizeMB)) + if deleteErr != nil { + log.WithFields(log.Fields{ + "error": deleteErr, + }).Error("Error Deleting files from cache") + } + p.downloadLock.Unlock() + } + } + } // Check if the next song is downloaded, if not block until it is. Catches // additions to the request queue. - p.downloadNextSong() + p.downloadNextSong(nil) } } @@ -189,35 +214,94 @@ func (p *player) Skip(numListeners int, requesterID string) string { } func (p *player) skipSong() { + p.downloadLock.Lock() + defer p.downloadLock.Unlock() p.Pause() p.streamDoneChan <- errSkip } -func (p *player) downloadNextSong() { +func (p *player) getCurrentStatus() string { + var currentDuration time.Duration + var totalDuration time.Duration + var percentDur float64 + if p.stream != nil { + currentDuration = p.stream.PlaybackPosition() * 1000000 + totalDuration = p.currentSong.TrackDuration + } + if totalDuration > 0 { + percentDur = float64(currentDuration) / float64(totalDuration) * 100 + numHashes := int(50 * (percentDur / 100)) + percentLine := strings.Repeat("#", numHashes) + percentLine = fmt.Sprintf("|%s%s|", percentLine, strings.Repeat("-", 50-numHashes)) + + return fmt.Sprintf("**Playing:** %s\n**Time:** %s of %s\n```%s [%.2f%%]```", p.currentSong.Title, currentDuration, totalDuration, percentLine, percentDur) + } + return fmt.Sprintf("**Playing:** %s\n**Time:** %s of UNKNOWN", p.currentSong.Title, currentDuration) +} + +func (p *player) downloadNextSong(pEntry *PlaylistEntry) { p.downloadLock.Lock() defer p.downloadLock.Unlock() - nextSong := p.playlist.peekNextSong() + var nextSong *PlaylistEntry + if pEntry == nil { + nextSong = p.playlist.peekNextSong() + } else { + nextSong = pEntry + } + if nextSong == nil { - log.Warn("Can't download next song, playlist is empty!") + log.Warn("Can't download next song, None Available!") return } + if p.currentSong != nil { + if nextSong.VideoID == p.currentSong.VideoID { + log.WithFields(log.Fields{ + "videoID": nextSong.VideoID, + }).Warn("Attempted to download the current song.") + } + } songFilePath := path.Join(p.yt.YTCacheDir, nextSong.VideoID+".dca") - if _, err := os.Stat(filepath.FromSlash(songFilePath)); os.IsNotExist(err) { + if _, err := os.Stat(filepath.FromSlash(songFilePath)); os.IsNotExist(err) || (nextSong.Requester != nil && nextSong.TrackDuration.String() == "0s") { log.WithFields(log.Fields{ "song": filepath.FromSlash(songFilePath), }).Debug("Downloading song") - _, dlErr := p.yt.DownloadDCAAudio(nextSong.VideoID) + _, encodeDuration, dlErr := p.yt.DownloadDCAAudio(nextSong.VideoID) if dlErr != nil { log.WithFields(log.Fields{ "song": nextSong.VideoID, }).Error(dlErr) + } else { + log.WithFields(log.Fields{ + "song": filepath.FromSlash(songFilePath), + "duration": encodeDuration, + }).Debug("Downloaded Song") + // Separate operations for playlist entry vs request + if nextSong.Requester == nil { + modifyError := p.playlist.modifyPlaylistEntry(nextSong.VideoID, PlaylistEntry{Title: nextSong.Title, VideoID: nextSong.VideoID, TrackDuration: encodeDuration}) + if modifyError != nil { + log.WithFields(log.Fields{ + "song": nextSong.VideoID, + }).Error(modifyError) + } else { + p.playlist.savePlaylist() + } + } else { + nextSong.TrackDuration = encodeDuration + p.playlist.addRequestedSong(nextSong) + } } } else { log.WithFields(log.Fields{ "song": filepath.FromSlash(songFilePath), }).Debug("Song already downloaded") + dateUpdateErr := goutils.UpdateFileModifiedTime(filepath.FromSlash(songFilePath), time.Now()) + if dateUpdateErr != nil { + log.WithFields(log.Fields{ + "song": filepath.FromSlash(songFilePath), + }).Warn("Failed to update song modified time") + } } } diff --git a/piccolo/playlist.go b/piccolo/playlist.go index 9a070f2..adb19f4 100644 --- a/piccolo/playlist.go +++ b/piccolo/playlist.go @@ -5,6 +5,8 @@ import ( "fmt" "io/ioutil" "path/filepath" + "sync" + "time" "github.com/bwmarrin/discordgo" @@ -19,14 +21,16 @@ type ( RequestChannelID string `json:"-"` Title string `json:"title"` VideoID string `json:"videoID"` + TrackDuration time.Duration `json:"duration,omitempty"` } playlist struct { - requestQueue *goutils.Queue - list *goutils.DoubleLinkedList - current *goutils.Node - usePlaylist bool - playlistPath string + requestQueue *goutils.Queue + list *goutils.DoubleLinkedList + current *goutils.Node + usePlaylist bool + playlistPath string + readWritePlaylistLock *sync.Mutex } // PlaylistJSON is used to handled marshalling and unmarshalling a playlist @@ -39,13 +43,17 @@ type ( func newPlaylist(usePlaylist bool, playlistPath string) *playlist { p := &playlist{requestQueue: goutils.NewQueue(), list: goutils.NewDoubleLinkedList(), usePlaylist: usePlaylist, playlistPath: playlistPath} + p.readWritePlaylistLock = &sync.Mutex{} p.loadPlaylist() p.current = p.list.First() + return p } func (p *playlist) loadPlaylist() error { if p.usePlaylist { + p.readWritePlaylistLock.Lock() + defer p.readWritePlaylistLock.Unlock() playlistFileContents, err := ioutil.ReadFile(filepath.FromSlash(p.playlistPath)) if err != nil { log.WithFields(log.Fields{ @@ -76,6 +84,8 @@ func (p *playlist) loadPlaylist() error { func (p *playlist) savePlaylist() error { if p.usePlaylist { + p.readWritePlaylistLock.Lock() + defer p.readWritePlaylistLock.Unlock() currentPlaylist := &PlaylistJSON{Entries: []PlaylistEntry{}} currentNode := p.list.First() for { @@ -106,12 +116,16 @@ func (p *playlist) savePlaylist() error { } func (p *playlist) String() string { + return p.printPlaylist("") +} + +func (p *playlist) printPlaylist(currentVideoID string) string { var queueString string var playlistString string queueItem := p.requestQueue.First() count := 1 if queueItem == nil { - queueString = "\tEmpty" + queueString = " Empty" } else { for { if queueItem == nil { @@ -121,7 +135,7 @@ func (p *playlist) String() string { if !ok { continue } - queueString = queueString + fmt.Sprintf("\t%d. %s - Requester: %s\n", + queueString = queueString + fmt.Sprintf(" %d. %s - Requester: %s\n", count, song.Title, song.Requester.Username) queueItem = queueItem.Next() count++ @@ -136,14 +150,19 @@ func (p *playlist) String() string { } _, value := currentNode.GetData() if song, ok := value.(PlaylistEntry); ok { - playlistString = playlistString + fmt.Sprintf("\t%d. %s\n", - count, song.Title) + if currentVideoID == song.VideoID { + playlistString = playlistString + fmt.Sprintf("→ %d. %s\n", + count, song.Title) + } else { + playlistString = playlistString + fmt.Sprintf(" %d. %s\n", + count, song.Title) + } count++ } currentNode = currentNode.Next() } } else { - playlistString = "\tDisabled" + playlistString = " Disabled" } return fmt.Sprintf("**Request Queue:**\n```%s```\n**Playlist:**\n```%s```", @@ -159,6 +178,16 @@ func (p *playlist) addSong(requester *discordgo.User, channelID string, id strin }) } +func (p *playlist) addRequestedSong(pEntry *PlaylistEntry) { + p.requestQueue.Push(PlaylistEntry{ + Requester: pEntry.Requester, + RequestChannelID: pEntry.RequestChannelID, + Title: pEntry.Title, + VideoID: pEntry.VideoID, + TrackDuration: pEntry.TrackDuration, + }) +} + func (p *playlist) nextSong() *PlaylistEntry { for { if p.requestQueue.Length() <= 0 { @@ -219,3 +248,21 @@ func (p *playlist) peekNextSong() *PlaylistEntry { } return nil } + +func (p *playlist) modifyPlaylistEntry(videoID string, dataContents PlaylistEntry) error { + p.readWritePlaylistLock.Lock() + defer p.readWritePlaylistLock.Unlock() + playlistEntry := p.list.Find(videoID) + if playlistEntry != nil { + playlistEntry.SetData(videoID, dataContents) + } else { + return fmt.Errorf("Couldn't find playlist entry to modify: %s", videoID) + } + return nil +} + +func (p *playlist) removePlaylistEntry(videoID string) { + p.readWritePlaylistLock.Lock() + defer p.readWritePlaylistLock.Unlock() + p.list.Delete(videoID) +} diff --git a/utils/config.go b/utils/config.go index 09ce434..3d03e0a 100644 --- a/utils/config.go +++ b/utils/config.go @@ -13,6 +13,7 @@ type BotConfig struct { YtDlPath string `json:"ytdl_path"` SaveVideos bool `json:"save_videos"` CacheDir string `json:"cache_dir"` + CacheSizeMB int64 `json:"cache_size_mb"` UsePlaylist bool `json:"use_playlist"` PlaylistPath string `json:"playlist_path"` AutoPause bool `json:"auto_pause"` @@ -44,6 +45,7 @@ var ( Volume: 0.35, SaveVideos: true, CacheDir: "video_cache", + CacheSizeMB: 1024, UsePlaylist: true, PlaylistPath: "conf/playlist.json", AutoPause: true, @@ -89,8 +91,5 @@ func LoadConfig(filename string) (*Config, error) { func DumpConfigFormat(filename string) error { jsonConf, _ := json.MarshalIndent(defaultConfig, "", " ") err := ioutil.WriteFile(filepath.FromSlash(filename), jsonConf, 0644) - if err != nil { - return err - } - return nil + return err } diff --git a/youtube/download.go b/youtube/download.go index befb524..20831bb 100644 --- a/youtube/download.go +++ b/youtube/download.go @@ -1,10 +1,12 @@ package youtube import ( + "fmt" "io" "os" "path" "path/filepath" + "time" "github.com/jonas747/dca" "github.com/rylio/ytdl" @@ -12,14 +14,14 @@ import ( // DownloadDCAAudio takes a youtube video id, downloads the audio and then // converts the song to DCA format to be compatible with discordgo. -func (yt Manager) DownloadDCAAudio(videoID string) (string, error) { +func (yt Manager) DownloadDCAAudio(videoID string) (string, time.Duration, error) { cacheDir := filepath.ToSlash(yt.YTCacheDir) outputFilePath := path.Join(cacheDir, "/", videoID+".dca") if _, err := os.Stat(filepath.FromSlash(cacheDir)); os.IsNotExist(err) { err := os.MkdirAll(filepath.FromSlash(cacheDir), os.ModeDir) if err != nil { - return "", err + return "", time.Duration(0), err } } @@ -31,27 +33,34 @@ func (yt Manager) DownloadDCAAudio(videoID string) (string, error) { videoInfo, err := ytdl.GetVideoInfo(videoID) if err != nil { - return "", err + return "", time.Duration(0), err } - format := videoInfo.Formats.Extremes(ytdl.FormatAudioBitrateKey, true)[0] + formats := videoInfo.Formats.Extremes(ytdl.FormatAudioBitrateKey, true) + if len(formats) < 1 { + return "", time.Duration(0), fmt.Errorf("Couldn't Find Audiot Formats") + } + format := formats[0] downloadURL, err := videoInfo.GetDownloadURL(format) if err != nil { - return "", err + return "", time.Duration(0), err } encodingSession, err := dca.EncodeFile(downloadURL.String(), options) if err != nil { - return "", err + return "", time.Duration(0), err } defer encodingSession.Cleanup() output, err := os.Create(filepath.FromSlash(outputFilePath)) if err != nil { - return "", err + return "", time.Duration(0), err } - io.Copy(output, encodingSession) + _, err = io.Copy(output, encodingSession) + if err != nil { + return "", time.Duration(0), err + } - return filepath.FromSlash(outputFilePath), nil + return filepath.FromSlash(outputFilePath), encodingSession.Stats().Duration, nil }