Compare commits
7 Commits
7bd9ef1230
...
v1-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
600fde2e71
|
|||
|
a2a0aabb3a
|
|||
|
bd643344ef
|
|||
|
63a6c38079
|
|||
|
4f34872f10
|
|||
|
6ae35ec636
|
|||
|
725c650deb
|
105
app/download.go
Normal file
105
app/download.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// startAsyncDownload initiates a download in a goroutine and handles progress updates
|
||||
func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir, tempDir string) {
|
||||
progressChan := make(chan ProgressUpdate, 1)
|
||||
resultChan := make(chan DownloadResult, 1)
|
||||
|
||||
// Start download in goroutine
|
||||
go func() {
|
||||
defer close(resultChan)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Handle panic from DownloadVideo
|
||||
resultChan <- DownloadResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Download failed: %v", r),
|
||||
URL: url,
|
||||
Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID),
|
||||
Error: fmt.Errorf("%v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Call DownloadVideo (it panics on error instead of returning error)
|
||||
DownloadVideo(outputDir, tempDir, url, DownloadOptions{
|
||||
EmbedThumbnail: true,
|
||||
IncludeSubtitles: true,
|
||||
VideoFormatID: videoFormatID,
|
||||
AudioFormatID: audioFormatID,
|
||||
}, progressChan)
|
||||
|
||||
// If we reach here, download was successful
|
||||
resultChan <- DownloadResult{
|
||||
Success: true,
|
||||
Message: "Video Downloaded Successfully!",
|
||||
URL: url,
|
||||
Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID),
|
||||
Error: nil,
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle progress and results asynchronously
|
||||
go func() {
|
||||
// First update the original ephemeral message with "Processing..."
|
||||
_, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("🔄 Processing download...\nURL: %s\nVideo: %s\nAudio: %s", url, videoFormatID, audioFormatID)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case prog, ok := <-progressChan:
|
||||
if !ok {
|
||||
progressChan = nil
|
||||
continue
|
||||
}
|
||||
// Update message w/ phase and real time progress
|
||||
phaseEmoji := "⏬"
|
||||
if prog.Phase == "post-processing" {
|
||||
phaseEmoji = "⚙️"
|
||||
}
|
||||
content := fmt.Sprintf("%s %s\n%s @ %s [eta: %s]\n📄 %s",
|
||||
phaseEmoji,
|
||||
prog.Phase,
|
||||
prog.Status,
|
||||
prog.Percent,
|
||||
prog.ETA,
|
||||
prog.Filename)
|
||||
_, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(content),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating progress: %v", err)
|
||||
}
|
||||
case result := <-resultChan:
|
||||
// Handle completion
|
||||
if result.Success {
|
||||
_, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{
|
||||
Content: "📥 Video downloaded: " + result.URL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
} else {
|
||||
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("❌ Download failed: " + result.Message),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
11
app/go.mod
11
app/go.mod
@@ -2,12 +2,19 @@ module git.dubyatp.xyz/williamp/yt-dlp-bot
|
||||
|
||||
go 1.25.2
|
||||
|
||||
//replace github.com/lrstanley/go-ytdlp => /home/williamp/go-ytdlp
|
||||
|
||||
replace github.com/lrstanley/go-ytdlp => github.com/dubyatp/go-ytdlp v0.0.0-20260213041320-010f95f0f1d3
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/lrstanley/go-ytdlp v1.2.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/bwmarrin/discordgo v0.29.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/lrstanley/go-ytdlp v1.2.7 // indirect
|
||||
github.com/ulikunitz/xz v0.5.13 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
|
||||
12
app/go.sum
12
app/go.sum
@@ -4,10 +4,16 @@ github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+Eg
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
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/dubyatp/go-ytdlp v0.0.0-20260213041320-010f95f0f1d3 h1:tGzvzV6fSzybhIpwU+wjvjOSq8RBNtHENm+G03E+mgo=
|
||||
github.com/dubyatp/go-ytdlp v0.0.0-20260213041320-010f95f0f1d3/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/lrstanley/go-ytdlp v1.2.7 h1:YNDvKkd0OCJSZLZePZvJwcirBCfL8Yw3eCwrTCE5w7Q=
|
||||
github.com/lrstanley/go-ytdlp v1.2.7/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA=
|
||||
github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
@@ -20,3 +26,5 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
457
app/main.go
457
app/main.go
@@ -5,157 +5,27 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sort"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// InteractionState holds the state for a specific interaction
|
||||
type InteractionState struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// DownloadResult represents the result of an async download operation
|
||||
type DownloadResult struct {
|
||||
Success bool
|
||||
Message string
|
||||
URL string
|
||||
Format string
|
||||
Error error
|
||||
}
|
||||
|
||||
// startAsyncDownload initiates a download in a goroutine and handles progress updates
|
||||
func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, audioFormat, outputDir string) {
|
||||
progressChan := make(chan ProgressUpdate, 1)
|
||||
resultChan := make(chan DownloadResult, 1)
|
||||
|
||||
// Start download in goroutine
|
||||
go func() {
|
||||
defer close(resultChan)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Handle panic from downloadVideo
|
||||
resultChan <- DownloadResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Download failed: %v", r),
|
||||
URL: url,
|
||||
Format: audioFormat,
|
||||
Error: fmt.Errorf("%v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Call downloadVideo (it panics on error instead of returning error)
|
||||
downloadVideo(outputDir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}, progressChan)
|
||||
|
||||
// If we reach here, download was successful
|
||||
resultChan <- DownloadResult{
|
||||
Success: true,
|
||||
Message: "Video Downloaded Successfully!",
|
||||
URL: url,
|
||||
Format: audioFormat,
|
||||
Error: nil,
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle progress and results asynchronously
|
||||
go func() {
|
||||
// First update the original ephemeral message with "Processing..."
|
||||
_, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("🔄 Processing download...\nURL: " + url + "\nAudio: " + audioFormat),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case prog, ok := <-progressChan:
|
||||
if !ok {
|
||||
progressChan = nil
|
||||
continue
|
||||
}
|
||||
// Update message w/ phase and real time progress
|
||||
phaseEmoji := "⏬"
|
||||
if prog.Phase == "post-processing" {
|
||||
phaseEmoji = "⚙️"
|
||||
}
|
||||
content := fmt.Sprintf("%s %s\n%s @ %s [eta: %s]\n📄 %s",
|
||||
phaseEmoji,
|
||||
prog.Phase,
|
||||
prog.Status,
|
||||
prog.Percent,
|
||||
prog.ETA,
|
||||
prog.Filename)
|
||||
_, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(content),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating progress: %v", err)
|
||||
}
|
||||
case result := <-resultChan:
|
||||
// Handle completion
|
||||
if result.Success {
|
||||
_, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{
|
||||
Content: "📥 Video downloaded: " + result.URL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
} else {
|
||||
_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("❌ Download failed: " + result.Message),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Helper function to create string pointer
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// Global state management
|
||||
var (
|
||||
interactionStates = make(map[string]*InteractionState)
|
||||
interactionStatesMutex = sync.RWMutex{}
|
||||
)
|
||||
|
||||
func getInteractionState(token string) *InteractionState {
|
||||
interactionStatesMutex.RLock()
|
||||
defer interactionStatesMutex.RUnlock()
|
||||
return interactionStates[token]
|
||||
}
|
||||
|
||||
func setInteractionState(token string, state *InteractionState) {
|
||||
interactionStatesMutex.Lock()
|
||||
defer interactionStatesMutex.Unlock()
|
||||
interactionStates[token] = state
|
||||
}
|
||||
|
||||
func deleteInteractionState(token string) {
|
||||
interactionStatesMutex.Lock()
|
||||
defer interactionStatesMutex.Unlock()
|
||||
delete(interactionStates, token)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
out_dir := os.Getenv("OUT_PATH")
|
||||
temp_dir := os.Getenv("TEMP_PATH")
|
||||
bot_token := os.Getenv("DISCORD_TOKEN")
|
||||
|
||||
if out_dir == "" {
|
||||
panic("No output dir specified")
|
||||
}
|
||||
|
||||
if temp_dir == "" {
|
||||
panic("No temp dir specified")
|
||||
}
|
||||
|
||||
s, err := discordgo.New("Bot " + bot_token)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid bot parameters: %v", err)
|
||||
@@ -188,7 +58,109 @@ func main() {
|
||||
|
||||
var componentHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||
"video_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
// Update components
|
||||
// Get interaction state with fallback search
|
||||
var state *InteractionState
|
||||
|
||||
// First try current token
|
||||
state = getInteractionState(i.Interaction.Token)
|
||||
|
||||
// If not found, try to find state associated with this interaction context
|
||||
if state == nil {
|
||||
interactionStatesMutex.RLock()
|
||||
for token, s := range interactionStates {
|
||||
if s != nil && s.FormatOptions != nil {
|
||||
// Found a state, transfer it to current token
|
||||
state = s
|
||||
// Copy to current token and clean up old one
|
||||
interactionStatesMutex.RUnlock()
|
||||
setInteractionState(i.Interaction.Token, state)
|
||||
deleteInteractionState(token)
|
||||
break
|
||||
}
|
||||
}
|
||||
if state == nil {
|
||||
interactionStatesMutex.RUnlock()
|
||||
}
|
||||
}
|
||||
if state == nil || state.FormatOptions == nil {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error: Session expired. Please start over with /download.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get selected video format
|
||||
selectedValues := i.MessageComponentData().Values
|
||||
if len(selectedValues) == 0 {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Error: No video format selected",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store selected video format ID
|
||||
state.VideoFormatID = selectedValues[0]
|
||||
setInteractionState(i.Interaction.Token, state)
|
||||
|
||||
// Build audio format options
|
||||
audioMenuOptions := []discordgo.SelectMenuOption{}
|
||||
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)
|
||||
videoMenuOptions := []discordgo.SelectMenuOption{}
|
||||
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
|
||||
updatedComponents := []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
@@ -197,20 +169,7 @@ func main() {
|
||||
Placeholder: "Choose a video format...",
|
||||
MaxValues: 1,
|
||||
Disabled: true,
|
||||
Options: []discordgo.SelectMenuOption{
|
||||
{
|
||||
Label: "1920x1080 1080p (mp4, 1910kbps)",
|
||||
Value: "1080p",
|
||||
},
|
||||
{
|
||||
Label: "1280x720 720p (mp4, 700kbps)",
|
||||
Value: "720p",
|
||||
},
|
||||
{
|
||||
Label: "640x480 480p (mp4, 300kbps)",
|
||||
Value: "480p",
|
||||
},
|
||||
},
|
||||
Options: videoMenuOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -221,16 +180,7 @@ func main() {
|
||||
Placeholder: "Choose an audio format...",
|
||||
MaxValues: 1,
|
||||
Disabled: false,
|
||||
Options: []discordgo.SelectMenuOption{
|
||||
{
|
||||
Label: "Medium",
|
||||
Value: "medium",
|
||||
},
|
||||
{
|
||||
Label: "Low",
|
||||
Value: "low",
|
||||
},
|
||||
},
|
||||
Options: audioMenuOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -285,21 +235,25 @@ func main() {
|
||||
|
||||
// Get selected audio format
|
||||
selectedValues := i.MessageComponentData().Values
|
||||
audioFormat := "medium" // default
|
||||
audioFormatID := ""
|
||||
if len(selectedValues) > 0 {
|
||||
audioFormat = selectedValues[0]
|
||||
audioFormatID = selectedValues[0]
|
||||
}
|
||||
|
||||
// Store selected audio format ID
|
||||
state.AudioFormatID = audioFormatID
|
||||
|
||||
response := ""
|
||||
if state.URL != "" {
|
||||
// Respond immediately to prevent timeout
|
||||
response = "🚀 Starting download...\nURL: " + state.URL + "\nAudio: " + audioFormat + "\n\nYou'll receive an update when the download completes!"
|
||||
response = fmt.Sprintf("🚀 Starting download...\nURL: %s\nVideo: %s\nAudio: %s\n\nYou'll receive an update when the download completes!",
|
||||
state.URL, state.VideoFormatID, state.AudioFormatID)
|
||||
|
||||
// Start async download after responding
|
||||
go func() {
|
||||
// Small delay to ensure response is sent first
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
startAsyncDownload(s, i, state.URL, audioFormat, out_dir)
|
||||
startAsyncDownload(s, i, state.URL, state.VideoFormatID, state.AudioFormatID, out_dir, temp_dir)
|
||||
}()
|
||||
|
||||
// Clean up state after starting download
|
||||
@@ -342,36 +296,144 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL in interaction state (we'll use the response message ID later)
|
||||
// For now, store with the current token, then update after getting message ID
|
||||
setInteractionState(i.Interaction.Token, &InteractionState{URL: url})
|
||||
|
||||
// Send initial "fetching formats" response
|
||||
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Select a video format:",
|
||||
Content: "🔍 Fetching available formats...",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
Components: []discordgo.MessageComponent{
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch formats asynchronously
|
||||
go func() {
|
||||
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: []discordgo.SelectMenuOption{
|
||||
{
|
||||
Label: "1920x1080 1080p (mp4, 1910kbps)",
|
||||
Value: "1080p",
|
||||
},
|
||||
{
|
||||
Label: "1280x720 720p (mp4, 700kbps)",
|
||||
Value: "720p",
|
||||
},
|
||||
{
|
||||
Label: "640x480 480p (mp4, 300kbps)",
|
||||
Value: "480p",
|
||||
},
|
||||
},
|
||||
Options: videoMenuOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -382,25 +444,16 @@ func main() {
|
||||
Placeholder: "Choose an audio format...",
|
||||
MaxValues: 1,
|
||||
Disabled: true,
|
||||
Options: []discordgo.SelectMenuOption{
|
||||
{
|
||||
Label: "Medium",
|
||||
Value: "medium",
|
||||
},
|
||||
{
|
||||
Label: "Low",
|
||||
Value: "low",
|
||||
},
|
||||
},
|
||||
Options: audioMenuOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error updating interaction: %v", err)
|
||||
}
|
||||
}()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -439,8 +492,4 @@ func main() {
|
||||
<-sc
|
||||
|
||||
s.Close()
|
||||
|
||||
//var url string = "https://www.youtube.com/watch?v=WpBWSFF03eI"
|
||||
|
||||
//downloadVideo(out_dir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true})
|
||||
}
|
||||
|
||||
6
app/misc.go
Normal file
6
app/misc.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
// Helper function to create string pointer
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
27
app/state.go
Normal file
27
app/state.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import "sync"
|
||||
|
||||
// Global state management
|
||||
var (
|
||||
interactionStates = make(map[string]*InteractionState)
|
||||
interactionStatesMutex = sync.RWMutex{}
|
||||
)
|
||||
|
||||
func getInteractionState(token string) *InteractionState {
|
||||
interactionStatesMutex.RLock()
|
||||
defer interactionStatesMutex.RUnlock()
|
||||
return interactionStates[token]
|
||||
}
|
||||
|
||||
func setInteractionState(token string, state *InteractionState) {
|
||||
interactionStatesMutex.Lock()
|
||||
defer interactionStatesMutex.Unlock()
|
||||
interactionStates[token] = state
|
||||
}
|
||||
|
||||
func deleteInteractionState(token string) {
|
||||
interactionStatesMutex.Lock()
|
||||
defer interactionStatesMutex.Unlock()
|
||||
delete(interactionStates, token)
|
||||
}
|
||||
60
app/types.go
Normal file
60
app/types.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lrstanley/go-ytdlp"
|
||||
)
|
||||
|
||||
type DownloadOptions struct {
|
||||
EmbedThumbnail bool
|
||||
IncludeSubtitles bool
|
||||
VideoFormatID string
|
||||
AudioFormatID string
|
||||
}
|
||||
|
||||
type VideoOption struct {
|
||||
Height *int `json:"height,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
FormatID string `json:"format_id"`
|
||||
Ext string `json:"ext"`
|
||||
TBR *float64 `json:"tbr,omitempty"`
|
||||
}
|
||||
|
||||
type AudioOption struct {
|
||||
Format string `json:"format"`
|
||||
FormatID string `json:"format_id"`
|
||||
Ext string `json:"ext"`
|
||||
TBR *float64 `json:"tbr,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
type FormatOptions struct {
|
||||
VideoOptions []VideoOption `json:"video_options"`
|
||||
AudioOptions []AudioOption `json:"audio_options"`
|
||||
}
|
||||
|
||||
type ProgressUpdate struct {
|
||||
Status ytdlp.ProgressStatus
|
||||
Percent string
|
||||
ETA time.Duration
|
||||
Filename string
|
||||
Phase string
|
||||
}
|
||||
|
||||
// InteractionState holds the state for a specific interaction
|
||||
type InteractionState struct {
|
||||
URL string
|
||||
FormatOptions *FormatOptions
|
||||
VideoFormatID string
|
||||
AudioFormatID string
|
||||
}
|
||||
|
||||
// DownloadResult represents the result of an async download operation
|
||||
type DownloadResult struct {
|
||||
Success bool
|
||||
Message string
|
||||
URL string
|
||||
Format string
|
||||
Error error
|
||||
}
|
||||
119
app/ytdlp.go
119
app/ytdlp.go
@@ -2,32 +2,111 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lrstanley/go-ytdlp"
|
||||
)
|
||||
|
||||
type DownloadOptions struct {
|
||||
EmbedThumbnail bool
|
||||
IncludeSubtitles bool
|
||||
var ytdlpBinary = os.Getenv("YTDLP_BIN")
|
||||
|
||||
func GetFormats(url string) (*FormatOptions, error) {
|
||||
dl := ytdlp.New().
|
||||
SetExecutable(ytdlpBinary).
|
||||
SkipDownload().
|
||||
DumpJSON()
|
||||
|
||||
result, err := dl.Run(context.TODO(), url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the JSON output
|
||||
var info struct {
|
||||
Formats []struct {
|
||||
VCodec *string `json:"vcodec"`
|
||||
ACodec *string `json:"acodec"`
|
||||
NeedsTesting *bool `json:"__needs_testing"`
|
||||
Height *int `json:"height"`
|
||||
Resolution string `json:"resolution"`
|
||||
FormatID string `json:"format_id"`
|
||||
Format string `json:"format"`
|
||||
Ext string `json:"ext"`
|
||||
TBR *float64 `json:"tbr"`
|
||||
Language *string `json:"language"`
|
||||
LanguagePref *int `json:"language_preference"`
|
||||
URL *string `json:"url"`
|
||||
Protocol *string `json:"protocol"`
|
||||
} `json:"formats"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(result.Stdout), &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
formatOpts := &FormatOptions{
|
||||
VideoOptions: []VideoOption{},
|
||||
AudioOptions: []AudioOption{},
|
||||
}
|
||||
|
||||
for _, fmt := range info.Formats {
|
||||
// Skip formats that need testing
|
||||
if fmt.NeedsTesting != nil && *fmt.NeedsTesting {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip SABR formats (https://github.com/yt-dlp/yt-dlp/issues/12482)
|
||||
if fmt.URL == nil || *fmt.URL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Video-only: has video codec but no audio codec
|
||||
if fmt.VCodec != nil && *fmt.VCodec != "none" &&
|
||||
fmt.ACodec != nil && *fmt.ACodec == "none" {
|
||||
formatOpts.VideoOptions = append(formatOpts.VideoOptions, VideoOption{
|
||||
Height: fmt.Height,
|
||||
Resolution: fmt.Resolution,
|
||||
FormatID: fmt.FormatID,
|
||||
Ext: fmt.Ext,
|
||||
TBR: fmt.TBR,
|
||||
})
|
||||
}
|
||||
|
||||
// Audio-only: has audio codec but no video codec
|
||||
if fmt.ACodec != nil && *fmt.ACodec != "none" &&
|
||||
fmt.VCodec != nil && *fmt.VCodec == "none" {
|
||||
audioOpt := AudioOption{
|
||||
Format: fmt.Format,
|
||||
FormatID: fmt.FormatID,
|
||||
Ext: fmt.Ext,
|
||||
TBR: fmt.TBR,
|
||||
}
|
||||
|
||||
// Use language if available, otherwise use language_preference
|
||||
if fmt.Language != nil {
|
||||
audioOpt.Language = fmt.Language
|
||||
}
|
||||
|
||||
formatOpts.AudioOptions = append(formatOpts.AudioOptions, audioOpt)
|
||||
}
|
||||
}
|
||||
|
||||
return formatOpts, nil
|
||||
}
|
||||
|
||||
type ProgressUpdate struct {
|
||||
Status ytdlp.ProgressStatus
|
||||
Percent string
|
||||
ETA time.Duration
|
||||
Filename string
|
||||
Phase string
|
||||
}
|
||||
|
||||
func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) {
|
||||
func DownloadVideo(out_dir, temp_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) {
|
||||
defer close(progressChan)
|
||||
|
||||
homePath := "home:" + out_dir
|
||||
tempPath := "temp:" + temp_dir
|
||||
|
||||
var lastPhase string
|
||||
|
||||
dl := ytdlp.New().
|
||||
SetWorkDir(out_dir).
|
||||
FormatSort("res,ext:mp4:m4a").
|
||||
SetExecutable(ytdlpBinary).
|
||||
Paths(homePath).
|
||||
Paths(tempPath).
|
||||
RecodeVideo("mp4").
|
||||
ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) {
|
||||
// Detect phase transition -- differentiate "downloading" as the main download
|
||||
@@ -54,6 +133,18 @@ func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<
|
||||
}).
|
||||
Output("%(title)s.%(ext)s")
|
||||
|
||||
// Set format if both video and audio format IDs are provided
|
||||
if opts.VideoFormatID != "" && opts.AudioFormatID != "" {
|
||||
dl = dl.Format(opts.VideoFormatID + "+" + opts.AudioFormatID)
|
||||
} else if opts.VideoFormatID != "" {
|
||||
dl = dl.Format(opts.VideoFormatID)
|
||||
} else if opts.AudioFormatID != "" {
|
||||
dl = dl.Format(opts.AudioFormatID)
|
||||
} else {
|
||||
// Default format selection if none specified
|
||||
dl = dl.FormatSort("res,ext:mp4:m4a")
|
||||
}
|
||||
|
||||
if opts.EmbedThumbnail {
|
||||
dl = dl.EmbedThumbnail()
|
||||
}
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768875095,
|
||||
"narHash": "sha256-dYP3DjiL7oIiiq3H65tGIXXIT1Waiadmv93JS0sS+8A=",
|
||||
"lastModified": 1770537093,
|
||||
"narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ed142ab1b3a092c4d149245d0c4126a5d7ea00b0",
|
||||
"rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
Reference in New Issue
Block a user