Skip to content
This repository has been archived by the owner on Nov 14, 2024. It is now read-only.

Commit

Permalink
opt: major refactor for player
Browse files Browse the repository at this point in the history
  • Loading branch information
keshon committed Dec 23, 2023
1 parent 02b3488 commit e021dc6
Show file tree
Hide file tree
Showing 16 changed files with 487 additions and 411 deletions.
File renamed without changes.
8 changes: 4 additions & 4 deletions music/discord/cmd_help.go → music/discord/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ func (d *Discord) handleHelpCommand(s *discordgo.Session, m *discordgo.MessageCr
avatarUrl := utils.InferProtocolByPort(hostname, 443) + hostname + "/avatar/random?" + fmt.Sprint(time.Now().UnixNano())
slog.Info(avatarUrl)

play := fmt.Sprintf("**Play**: `%vplay [title/url/id]` \nAliases: `%vp [title/url/id]`, `%v> [title/url/id]`\n", d.prefix, d.prefix, d.prefix)
play := fmt.Sprintf("**Play**: `%vplay [title/url/id/stream]` \nAliases: `%vp ...`, `%v> ...`\n", d.prefix, d.prefix, d.prefix)
pause := fmt.Sprintf("**Pause** / **resume**: `%vpause`, `%vplay` \nAliases: `%v!`, `%v>`\n", d.prefix, d.prefix, d.prefix, d.prefix)
queue := fmt.Sprintf("**Add track**: `%vadd [title/url/id]` \nAliases: `%va [title/url/id]`, `%v+ [title/url/id]`\n", d.prefix, d.prefix, d.prefix)
queue := fmt.Sprintf("**Add track**: `%vadd [title/url/id]` \nAliases: `%va ...`, `%v+ ...`\n", d.prefix, d.prefix, d.prefix)
skip := fmt.Sprintf("**Skip track**: `%vskip` \nAliases: `%vff`, `%v>>`\n", d.prefix, d.prefix, d.prefix)
list := fmt.Sprintf("**Show queue**: `%vlist` \nAliases: `%vqueue`, `%vl`, `%vq`\n", d.prefix, d.prefix, d.prefix, d.prefix)
history := fmt.Sprintf("**Show history**: `%vhistory`\n", d.prefix)
historyByDuration := fmt.Sprintf("**.. by duration**: `%vhistory duration`\n", d.prefix)
historyByPlaycount := fmt.Sprintf("**.. by play count**: `%vhistory count`\nAliases: `%vtime [count/duration]`, `%vt [count/duration]`", d.prefix, d.prefix, d.prefix)
historyByPlaycount := fmt.Sprintf("**.. by play count**: `%vhistory count`\nAliases: `%vtime ...`, `%vt ...`", d.prefix, d.prefix, d.prefix)
stop := fmt.Sprintf("**Stop and exit**: `%vexit` \nAliases: `%ve`, `%vx`\n", d.prefix, d.prefix, d.prefix)
help := fmt.Sprintf("**Show help**: `%vhelp` \nAliases: `%vh`, `%v?`\n", d.prefix, d.prefix, d.prefix)
about := fmt.Sprintf("**Show version**: `%vabout`", d.prefix)
Expand All @@ -48,7 +48,7 @@ func (d *Discord) handleHelpCommand(s *discordgo.Session, m *discordgo.MessageCr

embedMsg := embed.NewEmbed().
SetTitle("ℹ️ Melodix — Command Usage").
SetDescription("Some commands are aliased for shortness.\n`[title]` - track name\n`[url]` - youtube link\n`[id]` - track id from *History*.").
SetDescription("Some commands are aliased for shortness.\n`[title]` - track name\n`[url]` - YouTube URL\n`[id]` - track id from *History*\n`[stream]` - valid stream URL (radio).").
AddField("", "*Playback*\n"+play+skip+pause).
AddField("", "").
AddField("", "*Queue*\n"+queue+list).
Expand Down
File renamed without changes.
File renamed without changes.
8 changes: 5 additions & 3 deletions music/discord/cmd_play.go → music/discord/play.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,6 @@ func playOrEnqueue(d *Discord, playlist []*player.Song, s *discordgo.Session, m

func showStatusMessage(d *Discord, s *discordgo.Session, channelID, prevMessageID string, playlist []*player.Song, previousPlaylistExist int, skipFirst bool) {

d.Player.PrintPlayerState()

embedMsg := embed.NewEmbed().
SetColor(0x9f00d4).
SetFooter(version.AppFullName)
Expand All @@ -228,7 +226,11 @@ func showStatusMessage(d *Discord, s *discordgo.Session, channelID, prevMessageI
content += fmt.Sprintf("\n*[%v](%v)*\n\n", currentSong.Title, currentSong.UserURL)
embedMsg.SetThumbnail(currentSong.Thumbnail.URL)
} else {
content += "\nNo song has played yet. Use `!play <song name>` command or use `!help` to find out more\n\n"
if len(d.Player.GetSongQueue()) > 0 {
content += fmt.Sprintf("\nNo song is currently playing, but the queue is filled with songs. Use `%vplay` command to toggle the playback\n\n", d.prefix)
} else {
content += fmt.Sprintf("\nNo song is currently playing. Use the `%vplay [title/url/id/stream]` command to start. \nType `%vhelp` for more information.\n\n", d.prefix, d.prefix)
}
}

// Display playlist information
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion music/discord/cmd_resume.go → music/discord/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func (d *Discord) handleResumeCommand(s *discordgo.Session, m *discordgo.Message

var phrase string

if d.Player.GetCurrentSong() != nil {
if d.Player.GetCurrentSong() == nil {
phrase = getStartPhrase()
} else {
phrase = getContinuePhrase()
Expand Down
File renamed without changes.
File renamed without changes.
21 changes: 21 additions & 0 deletions music/player/pause.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package player

import "github.com/gookit/slog"

// Pause pauses audio playback.
func (p *Player) Pause() {
slog.Info("Pausing audio playback")

if p.VoiceConnection == nil {
return
}

if p.StreamingSession == nil {
return
}

if p.CurrentStatus == StatusPlaying {
p.StreamingSession.SetPaused(true)
p.CurrentStatus = StatusPaused
}
}
297 changes: 297 additions & 0 deletions music/player/play.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
package player

import (
"io"
"sync"
"time"

"github.com/gookit/slog"
"github.com/keshon/melodix-discord-player/internal/config"
"github.com/keshon/melodix-discord-player/music/history"
"github.com/keshon/melodix-discord-player/music/pkg/dca"
"github.com/keshon/melodix-discord-player/music/utils"
)

// Play starts playing the current or specified song.
func (p *Player) Play(startAt int, song *Song) {
var cleanupDone sync.WaitGroup

// Listen for skip signal
if p.handleSkipSignal() {
return
}

// Get current song (from queue or as arg)
p.setupCurrentSong(startAt, song)

// Setup encoding
options := p.createEncodeOptions(startAt)

// Start encoding
var encodeSessionError error
p.EncodingSession, encodeSessionError = dca.EncodeFile(p.CurrentSong.DownloadURL, options)
defer p.EncodingSession.Cleanup()

// Connect to Discord channel and be ready
p.setupVoiceConnection()

// Send encoding to Discord stream
done := make(chan error)
p.StreamingSession = dca.NewStream(p.EncodingSession, p.VoiceConnection, done)

// Set player status
p.CurrentStatus = StatusPlaying

// Setup history
h := history.NewHistory()

// Add current track to history
p.addSongToHistory(h)

p.setupPlaybackDurationStatsTicker(h)

// Done signal
p.handleDoneSignal(done, h, encodeSessionError, &cleanupDone)
}

func (p *Player) handleSkipSignal() bool {
select {
case <-p.SkipInterrupt:
slog.Info("Song is interrupted for skip, stopping playback")

if p.VoiceConnection != nil {
p.VoiceConnection.Speaking(false)
}
p.EncodingSession.Cleanup()

return true
default:
// No skip signal, continue with playback
return false
}
}

func (p *Player) setupCurrentSong(startAt int, song *Song) {
if song != nil {
p.CurrentSong = song
} else {
if len(p.GetSongQueue()) > 0 {
p.CurrentSong = p.Dequeue()
}
}

if p.CurrentSong == nil {
slog.Info("No songs in queue")
return
}
}

func (p *Player) createEncodeOptions(startAt int) *dca.EncodeOptions {
config, err := config.NewConfig()
if err != nil {
slog.Fatalf("Error loading config: %v", err)
}

return &dca.EncodeOptions{
Volume: 1.0,
FrameDuration: config.DcaFrameDuration,
Bitrate: config.DcaBitrate,
PacketLoss: config.DcaPacketLoss,
RawOutput: config.DcaRawOutput,
Application: config.DcaApplication,
CompressionLevel: config.DcaCompressionLevel,
BufferedFrames: config.DcaBufferedFrames,
VBR: config.DcaVBR,
StartTime: startAt,
ReconnectAtEOF: config.DcaReconnectAtEOF,
ReconnectStreamed: config.DcaReconnectStreamed,
ReconnectOnNetworkError: config.DcaReconnectOnNetworkError,
ReconnectOnHttpError: config.DcaReconnectOnHttpError,
ReconnectDelayMax: config.DcaReconnectDelayMax,
FfmpegBinaryPath: config.DcaFfmpegBinaryPath,
EncodingLineLog: config.DcaEncodingLineLog,
UserAgent: config.DcaUserAgent,
}
}

func (p *Player) setupEncodingSession(options *dca.EncodeOptions) error {
var errEnc error
p.EncodingSession, errEnc = dca.EncodeFile(p.CurrentSong.DownloadURL, options)
return errEnc
}

func (p *Player) setupVoiceConnection() {
for p.VoiceConnection == nil || !p.VoiceConnection.Ready {
time.Sleep(100 * time.Millisecond)
}

err := p.VoiceConnection.Speaking(true)
if err != nil {
slog.Errorf("Error connecting to Discord voice: %v", err)
p.VoiceConnection.Speaking(false)
}
}

func (p *Player) addSongToHistory(h history.IHistory) {
historySong := &history.Song{
Name: p.CurrentSong.Title,
UserURL: p.CurrentSong.UserURL,
DownloadURL: p.CurrentSong.DownloadURL,
Duration: p.CurrentSong.Duration,
ID: p.CurrentSong.ID,
Thumbnail: history.Thumbnail(p.CurrentSong.Thumbnail),
}
h.AddTrackToHistory(p.VoiceConnection.GuildID, historySong)
}

func (p *Player) setupPlaybackDurationStatsTicker(h history.IHistory) {
interval := 2 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
tickerDone := make(chan bool)

go func() {
for {
select {
case <-ticker.C:
p.addPlaybackStatsToHistory(h, interval)
case <-tickerDone:
return
}
}
}()
}

func (p *Player) addPlaybackStatsToHistory(h history.IHistory, interval time.Duration) {
if p.VoiceConnection != nil && p.StreamingSession != nil && p.CurrentSong != nil {
if !p.StreamingSession.Paused() {
err := h.AddPlaybackDurationStats(p.VoiceConnection.GuildID, p.CurrentSong.ID, float64(interval.Seconds()))
if err != nil {
slog.Warnf("Error adding playback duration stats to history: %v", err)
}
}
}
}

func (p *Player) handleDoneSignal(done chan error, h history.IHistory, errEnc error, cleanupDone *sync.WaitGroup) {
select {
case <-done:
cleanupDone.Add(1)
go func() {
// Auto-restarting logic in case of interruption
// Youtube songs checked by their current vs total duration
// Streams (radio) never stop
if p.VoiceConnection != nil && p.StreamingSession != nil && p.CurrentSong != nil {
if p.CurrentSong.Source != SourceStream {
songDuration, songPosition := p.getSongMetrics(p.EncodingSession, p.StreamingSession, p.CurrentSong)
if p.CurrentStatus == StatusPlaying {
if p.EncodingSession.Stats().Duration.Seconds() > 0 && songPosition.Seconds() > 0 {
if songPosition < songDuration {
slog.Warn("Song is done but still unfinished. Restarting from interrupted position...")

p.EncodingSession.Cleanup()
p.VoiceConnection.Speaking(false)

p.Play(int(songPosition.Seconds()), p.CurrentSong)

return
}
}
}
} else {
if p.CurrentStatus == StatusPlaying {

slog.Warn("Song is done but its a stream so it's never finished. Restarting from interrupted position...")

p.EncodingSession.Cleanup()
p.VoiceConnection.Speaking(false)

p.Play(0, p.CurrentSong)

return

}
}

err := h.AddPlaybackCountStats(p.VoiceConnection.GuildID, p.CurrentSong.ID)
if err != nil {
slog.Warnf("Error adding stats count stats to history: %v", err)
}
}

if errEnc != nil && errEnc != io.EOF {
slog.Warnf("Song is done but an unexpected error occurred: %v", errEnc)

time.Sleep(250 * time.Millisecond)
if p.VoiceConnection != nil {
p.VoiceConnection.Speaking(false)
}
p.CurrentStatus = StatusResting
p.EncodingSession.Cleanup()

return
}

slog.Info("Song is done")

if len(p.GetSongQueue()) == 0 {
slog.Info("Queue is done")

time.Sleep(250 * time.Millisecond)
p.Stop()

return
}

time.Sleep(250 * time.Millisecond)

slog.Info("Playing next song in queue")
p.Play(0, nil)
}()
}
cleanupDone.Wait()
}

// getSongMetrics calculates playback metrics for a song.
func (p *Player) getSongMetrics(encoding *dca.EncodeSession, streaming *dca.StreamingSession, song *Song) (songDuration, songPosition time.Duration) {
encodingDuration := encoding.Stats().Duration
encodingStartTime := time.Duration(encoding.Options().StartTime) * time.Second

streamingPosition := streaming.PlaybackPosition()
delay := encodingDuration - streamingPosition

params, err := utils.ParseQueryParamsFromURL(song.DownloadURL)
if err != nil {
slog.Warnf("Failed to parse download URL parameters: %v", err)
}

// Convert duration string to time.Duration
duration, err := time.ParseDuration(params["duration"])
if err != nil {
slog.Errorf("Error parsing duration:", err)
}

songDuration = time.Duration(duration) * time.Second
songPosition = encodingStartTime + streamingPosition + delay

slog.Infof("Total duration: %s, Stopped at: %s", songDuration, songPosition)
slog.Infof("Encoding ahead of streaming: %s, Encoding started time: %s", delay, encodingStartTime)

return songDuration, songPosition
}

func (p *Player) logPlayingInfo() {
slog.Warnf("Current status: %s", p.GetCurrentStatus())

if p.GetCurrentSong() != nil {
slog.Warn("Current song: %s", p.GetCurrentSong().Title)
} else {
slog.Warn("Current song is null")
}

slog.Warn("Song queue:")
for _, elem := range p.GetSongQueue() {
slog.Warn(elem.Title)
}
slog.Warn("Playlist count is ", len(p.GetSongQueue()))
}
Loading

0 comments on commit e021dc6

Please sign in to comment.