Compare commits
16 Commits
master
...
v1-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
600fde2e71
|
|||
|
a2a0aabb3a
|
|||
|
bd643344ef
|
|||
|
63a6c38079
|
|||
|
4f34872f10
|
|||
|
6ae35ec636
|
|||
|
725c650deb
|
|||
|
7bd9ef1230
|
|||
|
aa9dc58259
|
|||
|
4bad5f72da
|
|||
|
99c6bd379a
|
|||
|
b5642456c2
|
|||
|
f569f382f5
|
|||
|
9345a941fb
|
|||
|
1b9437fea9
|
|||
|
39dcad6bcc
|
@@ -75,13 +75,8 @@ jobs:
|
|||||||
git config --local user.signingkey ~/.ssh/id_ed25519
|
git config --local user.signingkey ~/.ssh/id_ed25519
|
||||||
git config --local gpg.format ssh
|
git config --local gpg.format ssh
|
||||||
git config --local commit.gpgsign true
|
git config --local commit.gpgsign true
|
||||||
|
git commit -a -m "yt-dlp-bot: deploy update to ${{ needs.build-and-push.outputs.sha_short }}"
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
git commit -a -m "yt-dlp-bot: deploy update to ${{ needs.build-and-push.outputs.sha_short }}"
|
|
||||||
else
|
|
||||||
echo "No changes to commit, skipping..."
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
- name: Push changes
|
- name: Push changes
|
||||||
uses: ad-m/github-push-action@v1.0.0
|
uses: ad-m/github-push-action@v1.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -3,4 +3,28 @@ __pycache__/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
out/
|
out/
|
||||||
|
app_legacy/
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Code coverage profiles and other test artifacts
|
||||||
|
*.out
|
||||||
|
coverage.*
|
||||||
|
*.coverprofile
|
||||||
|
profile.cov
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14.3-alpine3.22
|
FROM python:3.14.2-alpine3.22
|
||||||
COPY ./app /app
|
COPY ./app /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add ffmpeg deno
|
RUN apk add ffmpeg deno
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
21
app/go.mod
Normal file
21
app/go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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/cloudflare/circl v1.6.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.4.2 // 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
|
||||||
|
)
|
||||||
30
app/go.sum
Normal file
30
app/go.sum
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||||
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
|
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/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=
|
||||||
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
|
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=
|
||||||
495
app/main.go
Normal file
495
app/main.go
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error opening connection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultMemberPermissions int64 = discordgo.PermissionAllText
|
||||||
|
var interactionPrivateChannel = discordgo.InteractionContextPrivateChannel
|
||||||
|
|
||||||
|
var commands = []*discordgo.ApplicationCommand{
|
||||||
|
{
|
||||||
|
Name: "download",
|
||||||
|
Description: "Download video and save it to 'youtube-vids",
|
||||||
|
DefaultMemberPermissions: &defaultMemberPermissions,
|
||||||
|
Contexts: &[]discordgo.InteractionContextType{interactionPrivateChannel},
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Name: "url",
|
||||||
|
Description: "URL",
|
||||||
|
Type: discordgo.ApplicationCommandOptionString,
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
|
"video_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
// 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{
|
||||||
|
discordgo.SelectMenu{
|
||||||
|
CustomID: "video_select",
|
||||||
|
Placeholder: "Choose a video format...",
|
||||||
|
MaxValues: 1,
|
||||||
|
Disabled: true,
|
||||||
|
Options: videoMenuOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
discordgo.ActionsRow{
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.SelectMenu{
|
||||||
|
CustomID: "audio_select",
|
||||||
|
Placeholder: "Choose an audio format...",
|
||||||
|
MaxValues: 1,
|
||||||
|
Disabled: false,
|
||||||
|
Options: audioMenuOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseUpdateMessage,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Now select an audio format:",
|
||||||
|
Components: updatedComponents,
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audio_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
// Get URL from 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.URL != "" {
|
||||||
|
// 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 {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error: No video URL found. Please start over with /download.",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selected audio format
|
||||||
|
selectedValues := i.MessageComponentData().Values
|
||||||
|
audioFormatID := ""
|
||||||
|
if len(selectedValues) > 0 {
|
||||||
|
audioFormatID = selectedValues[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store selected audio format ID
|
||||||
|
state.AudioFormatID = audioFormatID
|
||||||
|
|
||||||
|
response := ""
|
||||||
|
if state.URL != "" {
|
||||||
|
// Respond immediately to prevent timeout
|
||||||
|
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, state.VideoFormatID, state.AudioFormatID, out_dir, temp_dir)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Clean up state after starting download
|
||||||
|
deleteInteractionState(i.Interaction.Token)
|
||||||
|
} else {
|
||||||
|
response = "I don't see a video here :("
|
||||||
|
}
|
||||||
|
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: response,
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
|
"download": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
options := i.ApplicationCommandData().Options
|
||||||
|
optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options))
|
||||||
|
for _, opt := range options {
|
||||||
|
optionMap[opt.Name] = opt
|
||||||
|
}
|
||||||
|
|
||||||
|
var url string
|
||||||
|
if option, ok := optionMap["url"]; ok {
|
||||||
|
url = option.StringValue()
|
||||||
|
} else {
|
||||||
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "Error: No URL provided",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial "fetching formats" response
|
||||||
|
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: "🔍 Fetching available formats...",
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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: 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if h, ok := componentHandlers[i.ApplicationCommandData().Name]; ok {
|
||||||
|
h(s, i)
|
||||||
|
}
|
||||||
|
case discordgo.InteractionMessageComponent:
|
||||||
|
if h, ok := commandHandlers[i.MessageComponentData().CustomID]; ok {
|
||||||
|
h(s, i)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
216
app/main.py
216
app/main.py
@@ -1,216 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from discord import Intents, Client, Interaction, SelectOption, Message, app_commands, ui
|
|
||||||
|
|
||||||
from misc import extract_url_from_string, render_progress_bar
|
|
||||||
import ytdlp
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
discord_token = os.getenv("DISCORD_TOKEN")
|
|
||||||
out_path = os.getenv("OUT_PATH")
|
|
||||||
temp_path = os.getenv("TEMP_PATH")
|
|
||||||
|
|
||||||
class DownloadVideo(ui.View):
|
|
||||||
def __init__(self, url: str):
|
|
||||||
super().__init__()
|
|
||||||
self.url = url
|
|
||||||
self.video_options = []
|
|
||||||
self.audio_options = []
|
|
||||||
self.selected_video = None
|
|
||||||
self.selected_audio = None
|
|
||||||
self._populate_format_options()
|
|
||||||
|
|
||||||
self.video_select = ui.Select(
|
|
||||||
placeholder="Choose a video format...",
|
|
||||||
options=self.video_options,
|
|
||||||
min_values=1,
|
|
||||||
max_values=1,
|
|
||||||
custom_id="video_select"
|
|
||||||
)
|
|
||||||
self.video_select.callback = self.on_video_select
|
|
||||||
self.add_item(self.video_select)
|
|
||||||
|
|
||||||
self.audio_select = ui.Select(
|
|
||||||
placeholder="Choose an audio format...",
|
|
||||||
options=self.audio_options,
|
|
||||||
min_values=1,
|
|
||||||
max_values=1,
|
|
||||||
custom_id="audio_select",
|
|
||||||
disabled=True # Start disabled
|
|
||||||
)
|
|
||||||
self.audio_select.callback = self.on_audio_select
|
|
||||||
self.add_item(self.audio_select)
|
|
||||||
|
|
||||||
def _populate_format_options(self):
|
|
||||||
try:
|
|
||||||
formats = ytdlp.get_formats(self.url)
|
|
||||||
|
|
||||||
# Sort video options by height and bitrate
|
|
||||||
sorted_videos = sorted(
|
|
||||||
formats['video_options'],
|
|
||||||
key=lambda v: (v.get('height', 0), v.get('tbr', 0)),
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
self.video_options = [
|
|
||||||
SelectOption(
|
|
||||||
label=f"{v['resolution']} {v['height']}p ({v['ext']}, {int(v['tbr'] or 0)}kbps)",
|
|
||||||
value=v['format_id']
|
|
||||||
)
|
|
||||||
for v in sorted_videos[:25] # limit
|
|
||||||
]
|
|
||||||
self.audio_options = [
|
|
||||||
SelectOption(
|
|
||||||
label=f"{a['format']} ({a['ext']}, {int(a['tbr'] or 0)}kbps"
|
|
||||||
+ (f", {a['language']}" if a.get('language') else "") + ")",
|
|
||||||
value=a['format_id']
|
|
||||||
)
|
|
||||||
for a in formats['audio_options'][:25] # limit
|
|
||||||
]
|
|
||||||
# Ensure at least one option for each select
|
|
||||||
if not self.video_options:
|
|
||||||
self.video_options = [SelectOption(label="No video formats found", value="none", default=True, description="No video formats available", disabled=True)]
|
|
||||||
if not self.audio_options:
|
|
||||||
self.audio_options = [SelectOption(label="No audio formats found", value="none", default=True, description="No audio formats available", disabled=True)]
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching formats: {e}")
|
|
||||||
self.video_options = [SelectOption(label="Error loading formats", value="none", default=True, description="Error loading formats", disabled=True)]
|
|
||||||
self.audio_options = [SelectOption(label="Error loading formats", value="none", default=True, description="Error loading formats", disabled=True)]
|
|
||||||
|
|
||||||
async def on_video_select(self, interaction: Interaction):
|
|
||||||
self.selected_video = self.video_select.values[0]
|
|
||||||
# Disable video select, enable audio select
|
|
||||||
self.video_select.disabled = True
|
|
||||||
self.audio_select.disabled = False
|
|
||||||
await interaction.response.edit_message(
|
|
||||||
content="Now select an audio format:",
|
|
||||||
view=self
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_audio_select(self, interaction: Interaction):
|
|
||||||
self.selected_audio = self.audio_select.values[0]
|
|
||||||
format_string = f"{self.selected_video}+{self.selected_audio}"
|
|
||||||
# Optionally, disable both selects after selection
|
|
||||||
self.audio_select.disabled = True
|
|
||||||
|
|
||||||
# Get labels for summary
|
|
||||||
video_label = next((o.label for o in self.video_options if o.value == self.selected_video), self.selected_video)
|
|
||||||
audio_label = next((o.label for o in self.audio_options if o.value == self.selected_audio), self.selected_audio)
|
|
||||||
format_string = f"{self.selected_video}+{self.selected_audio}"
|
|
||||||
|
|
||||||
# Show progress bar
|
|
||||||
await interaction.response.edit_message(
|
|
||||||
content=f"Downloading video: {render_progress_bar(0)}",
|
|
||||||
view=self
|
|
||||||
)
|
|
||||||
|
|
||||||
progress_queue = asyncio.Queue()
|
|
||||||
stop_event = asyncio.Event()
|
|
||||||
|
|
||||||
async def progress_worker(msg):
|
|
||||||
last_percent = 0
|
|
||||||
while not stop_event.is_set():
|
|
||||||
try:
|
|
||||||
percent = await asyncio.wait_for(progress_queue.get(), timeout=2)
|
|
||||||
# Only update every 10% or when at 100%
|
|
||||||
if percent // 10 > last_percent // 10 or percent == 100:
|
|
||||||
await msg.edit(content=f"Downloading video: {render_progress_bar(percent)}")
|
|
||||||
last_percent = percent
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get message to edit
|
|
||||||
msg = await interaction.original_response()
|
|
||||||
|
|
||||||
worker_task = asyncio.create_task(progress_worker(msg))
|
|
||||||
|
|
||||||
error = None
|
|
||||||
|
|
||||||
async def progress_callback(percent):
|
|
||||||
await progress_queue.put(percent)
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
def threaded_download():
|
|
||||||
nonlocal error
|
|
||||||
try:
|
|
||||||
ytdlp.download_video(
|
|
||||||
self.url, format_string, out_path, temp_path, progress_callback, loop
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
error = e
|
|
||||||
await asyncio.to_thread(threaded_download)
|
|
||||||
|
|
||||||
stop_event.set()
|
|
||||||
await worker_task
|
|
||||||
|
|
||||||
if error:
|
|
||||||
await msg.edit(content=f"❌ Download failed: {error}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Show completion and summary
|
|
||||||
await msg.edit(content="✅ Download complete!\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send public message
|
|
||||||
await interaction.followup.send(
|
|
||||||
f"## Video Downloaded\n"
|
|
||||||
f"**URL**: {self.url}\n"
|
|
||||||
f"**Quality**: {video_label} + {audio_label}\n"
|
|
||||||
f"**Requested By**: <@{interaction.user.id}>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Bot setup
|
|
||||||
intents = Intents.default()
|
|
||||||
intents.message_content = True
|
|
||||||
client = Client(intents=intents)
|
|
||||||
tree = app_commands.CommandTree(client)
|
|
||||||
|
|
||||||
@client.event
|
|
||||||
async def on_ready() -> None:
|
|
||||||
"""Called when the bot is ready."""
|
|
||||||
print(f"Logged in as {client.user}")
|
|
||||||
await tree.sync()
|
|
||||||
|
|
||||||
# /download command
|
|
||||||
@app_commands.allowed_contexts(dms=True, private_channels=True)
|
|
||||||
@app_commands.allowed_installs(users=True)
|
|
||||||
@tree.command(name="download", description="Download video and save it to 'youtube-vids'")
|
|
||||||
async def download_command(interaction: Interaction, url: str) -> None:
|
|
||||||
"""Handles the /download command."""
|
|
||||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
|
||||||
|
|
||||||
view = DownloadVideo(url)
|
|
||||||
await interaction.followup.send(
|
|
||||||
content="Select a video format:",
|
|
||||||
view=view,
|
|
||||||
ephemeral=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# direct URL download from message context
|
|
||||||
@app_commands.allowed_contexts(dms=True, private_channels=True)
|
|
||||||
@app_commands.allowed_installs(users=True)
|
|
||||||
@tree.context_menu(name="download video")
|
|
||||||
async def download_context(interaction: Interaction, message: Message) -> None:
|
|
||||||
"""Handles requests to download a URL from an already posted message"""
|
|
||||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
|
||||||
|
|
||||||
url = extract_url_from_string(message.clean_content)
|
|
||||||
if url is not None:
|
|
||||||
view = DownloadVideo(url)
|
|
||||||
await interaction.followup.send(
|
|
||||||
content="Select a video format:",
|
|
||||||
view=view,
|
|
||||||
ephemeral=True
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await interaction.followup.send(
|
|
||||||
content="No URL was found in message, sorry :-(",
|
|
||||||
ephemeral=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
client.run(discord_token)
|
|
||||||
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
|
||||||
|
}
|
||||||
23
app/misc.py
23
app/misc.py
@@ -1,23 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
PROGRESS_BAR_LENGTH = 10
|
|
||||||
|
|
||||||
def extract_url_from_string(text: str):
|
|
||||||
"""
|
|
||||||
Extracts the first URL from a string
|
|
||||||
"""
|
|
||||||
|
|
||||||
url_pattern = re.compile(r'https?://\S+')
|
|
||||||
match = url_pattern.search(text)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
return match.group(0)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def render_progress_bar(percent: int, length: int = PROGRESS_BAR_LENGTH) -> str:
|
|
||||||
"""Renders a progress bar with the given percentage."""
|
|
||||||
filled = int(length * percent // 100)
|
|
||||||
bar = "█" * filled + " " * (length - filled)
|
|
||||||
return f"[{bar}] {percent}%"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
discord.py==2.7.0
|
|
||||||
python-dotenv==1.2.1
|
|
||||||
yt-dlp==2026.2.21
|
|
||||||
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
|
||||||
|
}
|
||||||
164
app/ytdlp.go
Normal file
164
app/ytdlp.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lrstanley/go-ytdlp"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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().
|
||||||
|
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
|
||||||
|
// and "post processing" when the file name changes, preventing it from appearing "reset"
|
||||||
|
phase := "downloading"
|
||||||
|
if prog.Status == ytdlp.ProgressStatusDownloading && prog.Percent() == 0.0 {
|
||||||
|
// If we already had progress, it's likely post-processing
|
||||||
|
if lastPhase == "downloading" {
|
||||||
|
phase = "post-processing"
|
||||||
|
}
|
||||||
|
} else if prog.Status != ytdlp.ProgressStatusDownloading {
|
||||||
|
phase = "post-processing"
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPhase = phase
|
||||||
|
|
||||||
|
progressChan <- ProgressUpdate{
|
||||||
|
Status: prog.Status,
|
||||||
|
Percent: prog.PercentString(),
|
||||||
|
ETA: prog.ETA(),
|
||||||
|
Filename: prog.Filename,
|
||||||
|
Phase: phase,
|
||||||
|
}
|
||||||
|
}).
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.IncludeSubtitles {
|
||||||
|
dl = dl.CompatOptions("no-keep-subs")
|
||||||
|
dl = dl.EmbedSubs()
|
||||||
|
dl = dl.SubLangs("en,en*")
|
||||||
|
dl = dl.WriteAutoSubs()
|
||||||
|
dl = dl.WriteSubs()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := dl.Run(context.TODO(), url)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/ytdlp.py
65
app/ytdlp.py
@@ -1,65 +0,0 @@
|
|||||||
import yt_dlp
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
def get_formats(url: str):
|
|
||||||
ydl = yt_dlp.YoutubeDL(params={
|
|
||||||
'remote_components': ['ejs:github']
|
|
||||||
})
|
|
||||||
info = ydl.extract_info(url, download=False)
|
|
||||||
|
|
||||||
video_options = []
|
|
||||||
audio_options = []
|
|
||||||
|
|
||||||
for fmt in info['formats']:
|
|
||||||
# Video-only
|
|
||||||
if fmt.get('vcodec') != 'none' and fmt.get('acodec') == 'none' and fmt.get('__needs_testing') == None:
|
|
||||||
video_options.append({
|
|
||||||
'height': fmt.get('height'),
|
|
||||||
'resolution': fmt.get('resolution'),
|
|
||||||
'format_id': fmt.get('format_id'),
|
|
||||||
'ext': fmt.get('ext'),
|
|
||||||
'tbr': fmt.get('tbr'),
|
|
||||||
})
|
|
||||||
# Audio-only
|
|
||||||
elif fmt.get('acodec') != 'none' and fmt.get('vcodec') == 'none' and fmt.get('__needs_testing') == None:
|
|
||||||
audio_options.append({
|
|
||||||
'format': fmt.get('format'),
|
|
||||||
'format_id': fmt.get('format_id'),
|
|
||||||
'ext': fmt.get('ext'),
|
|
||||||
'tbr': fmt.get('tbr'),
|
|
||||||
'language': fmt.get('language') or fmt.get('language_preference'),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'video_options': video_options,
|
|
||||||
'audio_options': audio_options,
|
|
||||||
}
|
|
||||||
|
|
||||||
def download_video(url: str, format: str, out_path: str, temp_path: str, progress_callback, loop):
|
|
||||||
"""
|
|
||||||
Downloads a video and sends a progress callback as it downloads
|
|
||||||
Returns the format used
|
|
||||||
"""
|
|
||||||
progress = {'percent': 0}
|
|
||||||
|
|
||||||
def hook(d):
|
|
||||||
if d['status'] == 'downloading':
|
|
||||||
total = d.get('total_bytes') or d.get('total_bytes_estimate')
|
|
||||||
downloaded = d.get('downloaded_bytes')
|
|
||||||
if total and downloaded:
|
|
||||||
percent = int(downloaded / total * 100)
|
|
||||||
if percent != progress['percent']:
|
|
||||||
progress['percent'] = percent
|
|
||||||
asyncio.run_coroutine_threadsafe(progress_callback(percent), loop)
|
|
||||||
elif d['status'] == 'finished':
|
|
||||||
asyncio.run_coroutine_threadsafe(progress_callback(100), loop)
|
|
||||||
|
|
||||||
opts = {
|
|
||||||
'format': format,
|
|
||||||
'paths': {'home': out_path, 'temp': temp_path},
|
|
||||||
'progress_hooks': [hook]
|
|
||||||
}
|
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
||||||
ydl.download([url])
|
|
||||||
|
|
||||||
return format
|
|
||||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1768875095,
|
"lastModified": 1770537093,
|
||||||
"narHash": "sha256-dYP3DjiL7oIiiq3H65tGIXXIT1Waiadmv93JS0sS+8A=",
|
"narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ed142ab1b3a092c4d149245d0c4126a5d7ea00b0",
|
"rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
pkgs.go
|
pkgs.go
|
||||||
pkgs.yt-dlp
|
pkgs.yt-dlp
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
set -a
|
set -a
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
.venv/bin/pip install -r ./app/requirements.txt
|
.venv/bin/pip install -r ./app/requirements.txt
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
export YTDLP_BIN=${pkgs.lib.getExe pkgs.yt-dlp}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user