4 Commits

Author SHA1 Message Date
williamp 509dab5395 Merge pull request 'automate management of loading emote' (#49) from automatic-loading-emote into master
Build and Push Docker Image / build-and-push (push) Successful in 40s
Build and Push Docker Image / deploy-on-green (push) Successful in 6s
Reviewed-on: #49
2026-03-10 01:00:31 +00:00
williamp 6b9934a221 automate management of loading emote
Build only (for PRs) / build-only (pull_request) Successful in 4m52s
2026-03-09 14:25:56 -04:00
williamp adefe2f177 Merge pull request 'optimize codebase' (#48) from code-cleanup into master
Build and Push Docker Image / build-and-push (push) Successful in 14s
Build and Push Docker Image / deploy-on-green (push) Successful in 6s
Reviewed-on: #48
2026-03-09 16:14:21 +00:00
williamp 084b7ed979 optimize codebase
Build only (for PRs) / build-only (pull_request) Successful in 4m43s
main.go:
- Eliminated ~160 lines of duplicate code: Extracted 3 new helper functions at the bottom of the file:
buildVideoMenuOptions([]VideoOption) — builds the Discord select menu options for video formats
buildAudioMenuOptions([]AudioOption) — same for audio
fetchAndShowFormats(s, i, url) — fetches formats, sorts them, builds menus, stores state, and edits the interaction; previously duplicated identically in both the download and download video command handlers
- Fixed time.Sleep ordering bug: The startAsyncDownload goroutine was launched before InteractionRespond with a 100ms sleep to compensate. Now the download is launched after InteractionRespond returns — no sleep needed. Removed "time" import.
- Used helpers in video_select handler: The two inline menu-building loops in that handler now call buildAudioMenuOptions / buildVideoMenuOptions

misc.go:
- Moved regexp.MustCompile(...) to a package-level var urlPattern — previously it recompiled the regex on every call to extractURLFromString
- Simplified the function body to a single return line
2026-03-09 11:10:33 -04:00
3 changed files with 193 additions and 353 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+132 -325
View File
@@ -7,7 +7,6 @@ import (
"os/signal" "os/signal"
"sort" "sort"
"syscall" "syscall"
"time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -142,54 +141,10 @@ func main() {
setInteractionState(i.Interaction.Token, state) setInteractionState(i.Interaction.Token, state)
// Build audio format options // Build audio format options
audioMenuOptions := []discordgo.SelectMenuOption{} audioMenuOptions := buildAudioMenuOptions(state.FormatOptions.AudioOptions)
for _, aOpt := range state.FormatOptions.AudioOptions {
label := aOpt.Format
if aOpt.Language != nil {
label += fmt.Sprintf(" [%s]", *aOpt.Language)
}
if aOpt.TBR != nil {
label += fmt.Sprintf(" (%.0fkbps)", *aOpt.TBR)
}
// Discord has a 100 char limit on labels
if len(label) > 100 {
label = label[:97] + "..."
}
audioMenuOptions = append(audioMenuOptions, discordgo.SelectMenuOption{
Label: label,
Value: aOpt.FormatID,
})
// Discord has a limit of 25 options per select menu
if len(audioMenuOptions) >= 25 {
break
}
}
// Build video format options (to keep them visible but disabled) // Build video format options (to keep them visible but disabled)
videoMenuOptions := []discordgo.SelectMenuOption{} videoMenuOptions := buildVideoMenuOptions(state.FormatOptions.VideoOptions)
for _, vOpt := range state.FormatOptions.VideoOptions {
label := fmt.Sprintf("%s (%s", vOpt.Resolution, vOpt.Ext)
if vOpt.TBR != nil {
label += fmt.Sprintf(", %.0fkbps", *vOpt.TBR)
}
label += ")"
if len(label) > 100 {
label = label[:97] + "..."
}
videoMenuOptions = append(videoMenuOptions, discordgo.SelectMenuOption{
Label: label,
Value: vOpt.FormatID,
})
if len(videoMenuOptions) >= 25 {
break
}
}
// Update components - disable video select, enable audio select // Update components - disable video select, enable audio select
updatedComponents := []discordgo.MessageComponent{ updatedComponents := []discordgo.MessageComponent{
@@ -292,14 +247,7 @@ func main() {
// Respond immediately to prevent timeout // Respond immediately to prevent timeout
response = fmt.Sprintf("%s **Starting download**", loading_emoji) response = fmt.Sprintf("%s **Starting download**", loading_emoji)
// Start async download after responding // Clean up state before responding
go func() {
// Small delay to ensure response is sent first
time.Sleep(100 * time.Millisecond)
startAsyncDownload(s, i, state.Requester, state.URL, state.VideoFormatID, state.VideoFormatName, state.AudioFormatID, state.AudioFormatName, out_dir, temp_dir)
}()
// Clean up state after starting download
deleteInteractionState(i.Interaction.Token) deleteInteractionState(i.Interaction.Token)
} else { } else {
response = "I don't see a video here :(" response = "I don't see a video here :("
@@ -314,6 +262,9 @@ func main() {
if err != nil { if err != nil {
log.Printf("Error: %v", err) log.Printf("Error: %v", err)
} }
if state.URL != "" {
go startAsyncDownload(s, i, state.Requester, state.URL, state.VideoFormatID, state.VideoFormatName, state.AudioFormatID, state.AudioFormatName, out_dir, temp_dir)
}
}, },
} }
@@ -353,150 +304,7 @@ func main() {
} }
// Fetch formats asynchronously // Fetch formats asynchronously
go func() { go fetchAndShowFormats(s, i, url)
formatOptions, err := GetFormats(url)
if err != nil {
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr("❌ Error fetching formats: " + err.Error()),
})
if err != nil {
log.Printf("Error updating interaction: %v", err)
}
return
}
// Sort video formats: highest resolution first, then by bitrate
sort.Slice(formatOptions.VideoOptions, func(i, j int) bool {
// Compare by height (descending)
heightI := 0
if formatOptions.VideoOptions[i].Height != nil {
heightI = *formatOptions.VideoOptions[i].Height
}
heightJ := 0
if formatOptions.VideoOptions[j].Height != nil {
heightJ = *formatOptions.VideoOptions[j].Height
}
if heightI != heightJ {
return heightI > heightJ
}
// If heights are equal, compare by TBR (descending)
tbrI := 0.0
if formatOptions.VideoOptions[i].TBR != nil {
tbrI = *formatOptions.VideoOptions[i].TBR
}
tbrJ := 0.0
if formatOptions.VideoOptions[j].TBR != nil {
tbrJ = *formatOptions.VideoOptions[j].TBR
}
return tbrI > tbrJ
})
// Sort audio formats: highest bitrate first
sort.Slice(formatOptions.AudioOptions, func(i, j int) bool {
tbrI := 0.0
if formatOptions.AudioOptions[i].TBR != nil {
tbrI = *formatOptions.AudioOptions[i].TBR
}
tbrJ := 0.0
if formatOptions.AudioOptions[j].TBR != nil {
tbrJ = *formatOptions.AudioOptions[j].TBR
}
return tbrI > tbrJ
})
// Build video format options for Discord select menu
videoMenuOptions := []discordgo.SelectMenuOption{}
for _, vOpt := range formatOptions.VideoOptions {
label := fmt.Sprintf("%s (%s", vOpt.Resolution, vOpt.Ext)
if vOpt.TBR != nil {
label += fmt.Sprintf(", %.0fkbps", *vOpt.TBR)
}
label += ")"
// Discord has a 100 char limit on labels
if len(label) > 100 {
label = label[:97] + "..."
}
videoMenuOptions = append(videoMenuOptions, discordgo.SelectMenuOption{
Label: label,
Value: vOpt.FormatID,
})
// Discord has a limit of 25 options per select menu
if len(videoMenuOptions) >= 25 {
break
}
}
// Build audio format options for Discord select menu
audioMenuOptions := []discordgo.SelectMenuOption{}
for _, aOpt := range formatOptions.AudioOptions {
label := aOpt.Format
if aOpt.Language != nil {
label += fmt.Sprintf(" [%s]", *aOpt.Language)
}
if aOpt.TBR != nil {
label += fmt.Sprintf(" (%.0fkbps)", *aOpt.TBR)
}
// Discord has a 100 char limit on labels
if len(label) > 100 {
label = label[:97] + "..."
}
audioMenuOptions = append(audioMenuOptions, discordgo.SelectMenuOption{
Label: label,
Value: aOpt.FormatID,
})
// Discord has a limit of 25 options per select menu
if len(audioMenuOptions) >= 25 {
break
}
}
// Store format options in interaction state
setInteractionState(i.Interaction.Token, &InteractionState{
URL: url,
FormatOptions: formatOptions,
})
// Update message with format selection menus
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr("Select a video format:"),
Components: &[]discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "video_select",
Placeholder: "Choose a video format...",
MaxValues: 1,
Options: videoMenuOptions,
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "audio_select",
Placeholder: "Choose an audio format...",
MaxValues: 1,
Disabled: true,
Options: audioMenuOptions,
},
},
},
},
})
if err != nil {
log.Printf("Error updating interaction: %v", err)
}
}()
}, },
"download video": func(s *discordgo.Session, i *discordgo.InteractionCreate) { "download video": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData() data := i.ApplicationCommandData()
@@ -511,10 +319,7 @@ func main() {
}) })
return return
} }
messageContent := targetMsg.Content url := extractURLFromString(targetMsg.Content)
var url string
url = extractURLFromString(messageContent)
if url == "" { if url == "" {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -540,7 +345,101 @@ func main() {
} }
// Fetch formats asynchronously // Fetch formats asynchronously
go func() { go fetchAndShowFormats(s, i, url)
},
"version": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "[yt-dlp-bot](https://git.dubyatp.xyz/williamp/yt-dlp-bot) by dubyatp",
Flags: discordgo.MessageFlagsEphemeral,
},
})
},
}
s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type {
case discordgo.InteractionApplicationCommand:
if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok {
h(s, i)
}
case discordgo.InteractionMessageComponent:
if h, ok := componentHandlers[i.MessageComponentData().CustomID]; ok {
h(s, i)
}
}
})
log.Println("Initialize loading emoji")
initLoadingEmoji(s)
log.Println("Adding commands")
registeredCommands := make([]*discordgo.ApplicationCommand, len(commands))
for i, v := range commands {
cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", v)
if err != nil {
log.Panicf("Cannot create '%v' command: %v", v.Name, err)
}
registeredCommands[i] = cmd
}
log.Println("Bot is now running. Press CTRL+C to exit")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-sc
s.Close()
}
func buildVideoMenuOptions(videoOptions []VideoOption) []discordgo.SelectMenuOption {
opts := make([]discordgo.SelectMenuOption, 0, 25)
for _, vOpt := range videoOptions {
label := fmt.Sprintf("%s (%s", vOpt.Resolution, vOpt.Ext)
if vOpt.TBR != nil {
label += fmt.Sprintf(", %.0fkbps", *vOpt.TBR)
}
label += ")"
if len(label) > 100 {
label = label[:97] + "..."
}
opts = append(opts, discordgo.SelectMenuOption{
Label: label,
Value: vOpt.FormatID,
})
if len(opts) >= 25 {
break
}
}
return opts
}
func buildAudioMenuOptions(audioOptions []AudioOption) []discordgo.SelectMenuOption {
opts := make([]discordgo.SelectMenuOption, 0, 25)
for _, aOpt := range audioOptions {
label := aOpt.Format
if aOpt.Language != nil {
label += fmt.Sprintf(" [%s]", *aOpt.Language)
}
if aOpt.TBR != nil {
label += fmt.Sprintf(" (%.0fkbps)", *aOpt.TBR)
}
if len(label) > 100 {
label = label[:97] + "..."
}
opts = append(opts, discordgo.SelectMenuOption{
Label: label,
Value: aOpt.FormatID,
})
if len(opts) >= 25 {
break
}
}
return opts
}
func fetchAndShowFormats(s *discordgo.Session, i *discordgo.InteractionCreate, url string) {
formatOptions, err := GetFormats(url) formatOptions, err := GetFormats(url)
if err != nil { if err != nil {
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
@@ -553,107 +452,57 @@ func main() {
} }
// Sort video formats: highest resolution first, then by bitrate // Sort video formats: highest resolution first, then by bitrate
sort.Slice(formatOptions.VideoOptions, func(i, j int) bool { sort.Slice(formatOptions.VideoOptions, func(x, y int) bool {
// Compare by height (descending) heightX, heightY := 0, 0
heightI := 0 if formatOptions.VideoOptions[x].Height != nil {
if formatOptions.VideoOptions[i].Height != nil { heightX = *formatOptions.VideoOptions[x].Height
heightI = *formatOptions.VideoOptions[i].Height
} }
heightJ := 0 if formatOptions.VideoOptions[y].Height != nil {
if formatOptions.VideoOptions[j].Height != nil { heightY = *formatOptions.VideoOptions[y].Height
heightJ = *formatOptions.VideoOptions[j].Height
} }
if heightX != heightY {
if heightI != heightJ { return heightX > heightY
return heightI > heightJ
} }
tbrX, tbrY := 0.0, 0.0
// If heights are equal, compare by TBR (descending) if formatOptions.VideoOptions[x].TBR != nil {
tbrI := 0.0 tbrX = *formatOptions.VideoOptions[x].TBR
if formatOptions.VideoOptions[i].TBR != nil {
tbrI = *formatOptions.VideoOptions[i].TBR
} }
tbrJ := 0.0 if formatOptions.VideoOptions[y].TBR != nil {
if formatOptions.VideoOptions[j].TBR != nil { tbrY = *formatOptions.VideoOptions[y].TBR
tbrJ = *formatOptions.VideoOptions[j].TBR
} }
return tbrX > tbrY
return tbrI > tbrJ
}) })
// Sort audio formats: highest bitrate first // Sort audio formats: highest bitrate first
sort.Slice(formatOptions.AudioOptions, func(i, j int) bool { sort.Slice(formatOptions.AudioOptions, func(x, y int) bool {
tbrI := 0.0 tbrX, tbrY := 0.0, 0.0
if formatOptions.AudioOptions[i].TBR != nil { if formatOptions.AudioOptions[x].TBR != nil {
tbrI = *formatOptions.AudioOptions[i].TBR tbrX = *formatOptions.AudioOptions[x].TBR
} }
tbrJ := 0.0 if formatOptions.AudioOptions[y].TBR != nil {
if formatOptions.AudioOptions[j].TBR != nil { tbrY = *formatOptions.AudioOptions[y].TBR
tbrJ = *formatOptions.AudioOptions[j].TBR
} }
return tbrX > tbrY
return tbrI > tbrJ
}) })
// Build video format options for Discord select menu videoMenuOptions := buildVideoMenuOptions(formatOptions.VideoOptions)
videoMenuOptions := []discordgo.SelectMenuOption{} audioMenuOptions := buildAudioMenuOptions(formatOptions.AudioOptions)
for _, vOpt := range formatOptions.VideoOptions {
label := fmt.Sprintf("%s (%s", vOpt.Resolution, vOpt.Ext)
if vOpt.TBR != nil {
label += fmt.Sprintf(", %.0fkbps", *vOpt.TBR)
}
label += ")"
// Discord has a 100 char limit on labels if len(videoMenuOptions) == 0 || len(audioMenuOptions) == 0 {
if len(label) > 100 { _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
label = label[:97] + "..." Content: ptr("❌ No separate video/audio streams found for this URL. The source may only provide combined formats."),
}
videoMenuOptions = append(videoMenuOptions, discordgo.SelectMenuOption{
Label: label,
Value: vOpt.FormatID,
}) })
if err != nil {
// Discord has a limit of 25 options per select menu log.Printf("Error updating interaction: %v", err)
if len(videoMenuOptions) >= 25 {
break
} }
return
} }
// Build audio format options for Discord select menu
audioMenuOptions := []discordgo.SelectMenuOption{}
for _, aOpt := range formatOptions.AudioOptions {
label := aOpt.Format
if aOpt.Language != nil {
label += fmt.Sprintf(" [%s]", *aOpt.Language)
}
if aOpt.TBR != nil {
label += fmt.Sprintf(" (%.0fkbps)", *aOpt.TBR)
}
// Discord has a 100 char limit on labels
if len(label) > 100 {
label = label[:97] + "..."
}
audioMenuOptions = append(audioMenuOptions, discordgo.SelectMenuOption{
Label: label,
Value: aOpt.FormatID,
})
// Discord has a limit of 25 options per select menu
if len(audioMenuOptions) >= 25 {
break
}
}
// Store format options in interaction state
setInteractionState(i.Interaction.Token, &InteractionState{ setInteractionState(i.Interaction.Token, &InteractionState{
URL: url, URL: url,
FormatOptions: formatOptions, FormatOptions: formatOptions,
}) })
// Update message with format selection menus
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr("Select a video format:"), Content: ptr("Select a video format:"),
Components: &[]discordgo.MessageComponent{ Components: &[]discordgo.MessageComponent{
@@ -683,46 +532,4 @@ func main() {
if err != nil { if err != nil {
log.Printf("Error updating interaction: %v", err) log.Printf("Error updating interaction: %v", err)
} }
}()
},
"version": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "[yt-dlp-bot](https://git.dubyatp.xyz/williamp/yt-dlp-bot) by dubyatp",
Flags: discordgo.MessageFlagsEphemeral,
},
})
},
}
s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type {
case discordgo.InteractionApplicationCommand:
if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok {
h(s, i)
}
case discordgo.InteractionMessageComponent:
if h, ok := componentHandlers[i.MessageComponentData().CustomID]; ok {
h(s, i)
}
}
})
log.Println("Adding commands")
registeredCommands := make([]*discordgo.ApplicationCommand, len(commands))
for i, v := range commands {
cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", v)
if err != nil {
log.Panicf("Cannot create '%v' command: %v", v.Name, err)
}
registeredCommands[i] = cmd
}
log.Println("Bot is now running. Press CTRL+C to exit")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-sc
s.Close()
} }
+39 -6
View File
@@ -2,17 +2,50 @@ package main
import ( import (
"fmt" "fmt"
"os" "log"
"regexp" "regexp"
_ "embed"
"encoding/base64"
"github.com/bwmarrin/discordgo"
) )
var loading_emoji = os.Getenv("LOADING_EMOJI") var loading_emoji string
//go:embed assets/loading.webp
var rawLoadingEmoji []byte
var loadingEmojiBase64 = func() string {
s := "data:image/webp;base64," + base64.StdEncoding.EncodeToString(rawLoadingEmoji)
rawLoadingEmoji = nil
return s
}()
func initLoadingEmoji(s *discordgo.Session) {
emojis, err := s.ApplicationEmojis(s.State.User.ID)
if err != nil {
log.Panic("Cannot get emojis")
}
for _, e := range emojis {
if e.Name == "loading" {
loading_emoji = fmt.Sprintf("<a:%s:%s>", e.Name, e.ID)
return
}
}
e, err := s.ApplicationEmojiCreate(s.State.User.ID, &discordgo.EmojiParams{
Name: "loading",
Image: loadingEmojiBase64,
})
if err != nil {
log.Panicf("Cannot create loading emoji: %s", err)
}
loading_emoji = fmt.Sprintf("<a:%s:%s>", e.Name, e.ID)
}
var urlPattern = regexp.MustCompile(`https?://\S+`)
func extractURLFromString(in_url string) string { func extractURLFromString(in_url string) string {
url_pattern := regexp.MustCompile(`https?://\S+`) return string(urlPattern.Find([]byte(in_url)))
var match = url_pattern.Find([]byte(in_url))
return string(match)
} }
// Helper function to create string pointer // Helper function to create string pointer