7 Commits

10 changed files with 580 additions and 225 deletions

105
app/download.go Normal file
View 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
}
}
}()
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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
View 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
View 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
View 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
}

View File

@@ -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
View File

@@ -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": {

View File

@@ -46,6 +46,8 @@
.venv/bin/pip install -r ./app/requirements.txt
source .venv/bin/activate
fi
export YTDLP_BIN=${pkgs.lib.getExe pkgs.yt-dlp}
'';
};
});