├── yt-dlp.conf ├── .gitignore ├── .vscode └── settings.json ├── demo.gif ├── docker-push.sh ├── yt-dlp-telegram-bot.code-workspace ├── docker-build.sh ├── config.inc.sh-example ├── run.sh ├── Dockerfile ├── LICENSE ├── cmd.go ├── helper.go ├── go.mod ├── upload.go ├── dl.go ├── rereader.go ├── vercheck.go ├── README.md ├── params.go ├── main.go ├── convert.go ├── queue.go └── go.sum /yt-dlp.conf: -------------------------------------------------------------------------------- 1 | --cookies=/tmp/ytdlp-cookies.txt 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config.inc.sh 2 | /yt-dlp-telegram-bot 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.lintTool": "golangci-lint" 3 | } 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonoo/yt-dlp-telegram-bot/HEAD/demo.gif -------------------------------------------------------------------------------- /docker-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker push nonoo/yt-dlp-telegram-bot:latest 4 | -------------------------------------------------------------------------------- /yt-dlp-telegram-bot.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ] 7 | } -------------------------------------------------------------------------------- /docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker build -t nonoo/yt-dlp-telegram-bot:latest --network=host . 4 | -------------------------------------------------------------------------------- /config.inc.sh-example: -------------------------------------------------------------------------------- 1 | API_ID= 2 | API_HASH= 3 | BOT_TOKEN= 4 | ALLOWED_USERIDS= 5 | ADMIN_USERIDS= 6 | ALLOWED_GROUPIDS= 7 | MAX_SIZE= 8 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . config.inc.sh 4 | 5 | bin=./yt-dlp-telegram-bot 6 | if [ ! -x "$bin" ]; then 7 | bin="go run *.go" 8 | fi 9 | 10 | API_ID=$API_ID \ 11 | API_HASH=$API_HASH \ 12 | BOT_TOKEN=$BOT_TOKEN \ 13 | ALLOWED_USERIDS=$ALLOWED_USERIDS \ 14 | ADMIN_USERIDS=$ADMIN_USERIDS \ 15 | ALLOWED_GROUPIDS=$ALLOWED_GROUPIDS \ 16 | MAX_SIZE=$MAX_SIZE \ 17 | YTDLP_PATH=$YTDLP_PATH \ 18 | $bin 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 AS builder 2 | WORKDIR /app/ 3 | COPY go.mod go.sum /app/ 4 | RUN go mod download 5 | COPY . . 6 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v 7 | 8 | FROM python:alpine 9 | RUN apk update && apk upgrade && apk add --no-cache ffmpeg 10 | COPY --from=builder /app/yt-dlp-telegram-bot /app/yt-dlp-telegram-bot 11 | COPY --from=builder /app/yt-dlp.conf /root/yt-dlp.conf 12 | 13 | ENTRYPOINT ["/app/yt-dlp-telegram-bot"] 14 | ENV API_ID= API_HASH= BOT_TOKEN= ALLOWED_USERIDS= ADMIN_USERIDS= ALLOWED_GROUPIDS= YTDLP_COOKIES= 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Norbert "Nonoo" Varga 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os/exec" 6 | "syscall" 7 | ) 8 | 9 | // https://stackoverflow.com/questions/71714228/go-exec-commandcontext-is-not-being-terminated-after-context-timeout 10 | 11 | type Cmd struct { 12 | ctx context.Context 13 | *exec.Cmd 14 | } 15 | 16 | // NewCommand is like exec.CommandContext but ensures that subprocesses 17 | // are killed when the context times out, not just the top level process. 18 | func NewCommand(ctx context.Context, command string, args ...string) *Cmd { 19 | return &Cmd{ctx, exec.Command(command, args...)} 20 | } 21 | 22 | func (c *Cmd) Start() error { 23 | // Force-enable setpgid bit so that we can kill child processes when the 24 | // context times out or is canceled. 25 | if c.Cmd.SysProcAttr == nil { 26 | c.Cmd.SysProcAttr = &syscall.SysProcAttr{} 27 | } 28 | c.Cmd.SysProcAttr.Setpgid = true 29 | err := c.Cmd.Start() 30 | if err != nil { 31 | return err 32 | } 33 | go func() { 34 | <-c.ctx.Done() 35 | p := c.Cmd.Process 36 | if p == nil { 37 | return 38 | } 39 | // Kill by negative PID to kill the process group, which includes 40 | // the top-level process we spawned as well as any subprocesses 41 | // it spawned. 42 | _ = syscall.Kill(-p.Pid, syscall.SIGKILL) 43 | }() 44 | return nil 45 | } 46 | 47 | func (c *Cmd) Run() error { 48 | if err := c.Start(); err != nil { 49 | return err 50 | } 51 | return c.Wait() 52 | } 53 | -------------------------------------------------------------------------------- /helper.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/gotd/td/tg" 8 | ) 9 | 10 | // Helper function to pretty-print any Telegram API object to find out which it needs to be cast to. 11 | // https://github.com/gotd/td/blob/main/examples/pretty-print/main.go 12 | 13 | // func formatObject(input interface{}) string { 14 | // o, ok := input.(tdp.Object) 15 | // if !ok { 16 | // // Handle tg.*Box values. 17 | // rv := reflect.Indirect(reflect.ValueOf(input)) 18 | // for i := 0; i < rv.NumField(); i++ { 19 | // if v, ok := rv.Field(i).Interface().(tdp.Object); ok { 20 | // return formatObject(v) 21 | // } 22 | // } 23 | 24 | // return fmt.Sprintf("%T (not object)", input) 25 | // } 26 | // return tdp.Format(o) 27 | // } 28 | 29 | func getProgressbar(progressPercent, progressBarLen int) (progressBar string) { 30 | i := 0 31 | for ; i < progressPercent/(100/progressBarLen); i++ { 32 | progressBar += "▰" 33 | } 34 | for ; i < progressBarLen; i++ { 35 | progressBar += "▱" 36 | } 37 | progressBar += " " + fmt.Sprint(progressPercent) + "%" 38 | return 39 | } 40 | 41 | func resolveMsgSrc(msg *tg.Message) (fromUser *tg.PeerUser, fromGroup *tg.PeerChat) { 42 | fromGroup, isGroupMsg := msg.PeerID.(*tg.PeerChat) 43 | if isGroupMsg { 44 | fromUser = msg.FromID.(*tg.PeerUser) 45 | } else { 46 | fromUser = msg.PeerID.(*tg.PeerUser) 47 | } 48 | return 49 | } 50 | 51 | func getFromUsername(entities tg.Entities, fromUID int64) string { 52 | if fromUser, ok := entities.Users[fromUID]; ok { 53 | if un, ok := fromUser.GetUsername(); ok { 54 | return un 55 | } 56 | } 57 | return "" 58 | } 59 | 60 | func sendTextToAdmins(ctx context.Context, msg string) { 61 | for _, id := range params.AdminUserIDs { 62 | _, _ = telegramSender.To(&tg.InputPeerUser{ 63 | UserID: id, 64 | }).Text(ctx, msg) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nonoo/yt-dlp-telegram-bot 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/dustin/go-humanize v1.0.1 7 | github.com/flytam/filenamify v1.2.0 8 | github.com/google/go-github/v53 v53.2.0 9 | github.com/gotd/td v0.84.0 10 | github.com/u2takey/ffmpeg-go v0.5.0 11 | github.com/wader/goutubedl v0.0.0-20240626070646-8cef76d0c092 12 | golang.org/x/exp v0.0.0-20230116083435-1de6713980de 13 | ) 14 | 15 | require ( 16 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 17 | github.com/aws/aws-sdk-go v1.38.20 // indirect 18 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 19 | github.com/cloudflare/circl v1.3.3 // indirect 20 | github.com/go-faster/errors v0.6.1 // indirect 21 | github.com/go-faster/jx v1.0.1 // indirect 22 | github.com/go-faster/xor v1.0.0 // indirect 23 | github.com/golang/protobuf v1.5.2 // indirect 24 | github.com/google/go-querystring v1.1.0 // indirect 25 | github.com/gotd/ige v0.2.2 // indirect 26 | github.com/gotd/neo v0.1.5 // indirect 27 | github.com/jmespath/go-jmespath v0.4.0 // indirect 28 | github.com/klauspost/compress v1.16.7 // indirect 29 | github.com/segmentio/asm v1.2.0 // indirect 30 | github.com/u2takey/go-utils v0.3.1 // indirect 31 | go.opentelemetry.io/otel v1.16.0 // indirect 32 | go.opentelemetry.io/otel/trace v1.16.0 // indirect 33 | go.uber.org/atomic v1.11.0 // indirect 34 | go.uber.org/multierr v1.11.0 // indirect 35 | go.uber.org/zap v1.25.0 // indirect 36 | golang.org/x/crypto v0.11.0 // indirect 37 | golang.org/x/net v0.12.0 // indirect 38 | golang.org/x/oauth2 v0.8.0 // indirect 39 | golang.org/x/sync v0.3.0 // indirect 40 | golang.org/x/sys v0.10.0 // indirect 41 | google.golang.org/appengine v1.6.7 // indirect 42 | google.golang.org/protobuf v1.28.1 // indirect 43 | nhooyr.io/websocket v1.8.7 // indirect 44 | rsc.io/qr v0.2.0 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "math/big" 9 | 10 | "github.com/dustin/go-humanize" 11 | "github.com/flytam/filenamify" 12 | "github.com/gotd/td/telegram/message" 13 | "github.com/gotd/td/telegram/uploader" 14 | "github.com/gotd/td/tg" 15 | ) 16 | 17 | type Uploader struct{} 18 | 19 | var dlUploader Uploader 20 | 21 | func (p Uploader) Chunk(ctx context.Context, state uploader.ProgressState) error { 22 | dlQueue.HandleProgressPercentUpdate(uploadStr, int(state.Uploaded*100/state.Total)) 23 | return nil 24 | } 25 | 26 | func (p *Uploader) UploadFile(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, f io.ReadCloser, format, title string) error { 27 | // Reading to a buffer first, because we don't know the file size. 28 | var buf bytes.Buffer 29 | for { 30 | b := make([]byte, 1024) 31 | n, err := f.Read(b) 32 | if err != nil && err != io.EOF { 33 | return fmt.Errorf("reading to buffer error: %w", err) 34 | } 35 | if n == 0 { 36 | break 37 | } 38 | if params.MaxSize > 0 && buf.Len() > int(params.MaxSize) { 39 | return fmt.Errorf("file is too big, max. allowed size is %s", humanize.BigBytes(big.NewInt(int64(params.MaxSize)))) 40 | } 41 | buf.Write(b[:n]) 42 | } 43 | 44 | fmt.Println(" got", buf.Len(), "bytes, uploading...") 45 | dlQueue.currentlyDownloadedEntry.progressInfo = fmt.Sprint(" (", humanize.BigBytes(big.NewInt(int64(buf.Len()))), ")") 46 | 47 | upload, err := telegramUploader.FromBytes(ctx, "yt-dlp", buf.Bytes()) 48 | if err != nil { 49 | return fmt.Errorf("uploading %w", err) 50 | } 51 | 52 | // Now we have uploaded file handle, sending it as styled message. First, preparing message. 53 | var document message.MediaOption 54 | filename, _ := filenamify.Filenamify(title+"."+format, filenamify.Options{Replacement: " "}) 55 | if format == "mp3" { 56 | document = message.UploadedDocument(upload).Filename(filename).Audio().Title(title) 57 | } else { 58 | document = message.UploadedDocument(upload).Filename(filename).Video() 59 | } 60 | 61 | // Sending message with media. 62 | if _, err := telegramSender.Answer(entities, u).Media(ctx, document); err != nil { 63 | return fmt.Errorf("send: %w", err) 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /dl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "github.com/wader/goutubedl" 10 | ) 11 | 12 | const downloadAndConvertTimeout = 5 * time.Minute 13 | 14 | type ConvertStartCallbackFunc func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string) 15 | type UpdateProgressPercentCallbackFunc func(progressStr string, progressPercent int) 16 | 17 | type Downloader struct { 18 | ConvertStartFunc ConvertStartCallbackFunc 19 | UpdateProgressPercentFunc UpdateProgressPercentCallbackFunc 20 | } 21 | 22 | type goYouTubeDLLogger struct{} 23 | 24 | func (l goYouTubeDLLogger) Print(v ...interface{}) { 25 | fmt.Print(" yt-dlp dbg:") 26 | fmt.Println(v...) 27 | } 28 | 29 | func (d *Downloader) downloadURL(dlCtx context.Context, url string) (rr *ReReadCloser, title string, err error) { 30 | result, err := goutubedl.New(dlCtx, url, goutubedl.Options{ 31 | Type: goutubedl.TypeSingle, 32 | DebugLog: goYouTubeDLLogger{}, 33 | // StderrFn: func(cmd *exec.Cmd) io.Writer { return io.Writer(os.Stdout) }, 34 | MergeOutputFormat: "mkv", // This handles VP9 properly. yt-dlp uses mp4 by default, which doesn't. 35 | SortingFormat: "res:720", // Prefer videos no larger than 720p to keep their size small. 36 | }) 37 | if err != nil { 38 | return nil, "", fmt.Errorf("preparing download %q: %w", url, err) 39 | } 40 | 41 | dlResult, err := result.Download(dlCtx, "") 42 | if err != nil { 43 | return nil, "", fmt.Errorf("downloading %q: %w", url, err) 44 | } 45 | 46 | return NewReReadCloser(dlResult), result.Info.Title, nil 47 | } 48 | 49 | func (d *Downloader) DownloadAndConvertURL(ctx context.Context, url, format string) (r io.ReadCloser, outputFormat, title string, err error) { 50 | rr, title, err := d.downloadURL(ctx, url) 51 | if err != nil { 52 | return nil, "", "", err 53 | } 54 | 55 | conv := Converter{ 56 | Format: format, 57 | UpdateProgressPercentCallback: d.UpdateProgressPercentFunc, 58 | } 59 | 60 | if err := conv.Probe(rr); err != nil { 61 | return nil, "", "", err 62 | } 63 | 64 | if d.ConvertStartFunc != nil { 65 | d.ConvertStartFunc(ctx, conv.VideoCodecs, conv.AudioCodecs, conv.GetActionsNeeded()) 66 | } 67 | 68 | r, outputFormat, err = conv.ConvertIfNeeded(ctx, rr) 69 | if err != nil { 70 | return nil, "", "", err 71 | } 72 | 73 | return r, outputFormat, title, nil 74 | } 75 | -------------------------------------------------------------------------------- /rereader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Copyright (c) 2016 Mattias Wadman 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | // this software and associated documentation files (the "Software"), to deal in 7 | // the Software without restriction, including without limitation the rights to 8 | // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | // of the Software, and to permit persons to whom the Software is furnished to do 10 | // so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import ( 24 | "bytes" 25 | "io" 26 | ) 27 | 28 | type restartBuffer struct { 29 | Buffer bytes.Buffer 30 | Restarted bool 31 | } 32 | 33 | func (rb *restartBuffer) Read(r io.Reader, p []byte) (n int, err error) { 34 | if rb.Restarted { 35 | if rb.Buffer.Len() > 0 { 36 | return rb.Buffer.Read(p) 37 | } 38 | n, err = r.Read(p) 39 | return n, err 40 | } 41 | 42 | n, err = r.Read(p) 43 | rb.Buffer.Write(p[:n]) 44 | 45 | return n, err 46 | } 47 | 48 | // ReReader transparently buffers all reads from a reader until Restarted 49 | // is set to true. When restarted buffered data will be replayed on read and 50 | // after that normal reading from the reader continues. 51 | type ReReader struct { 52 | io.Reader 53 | restartBuffer 54 | } 55 | 56 | // NewReReader return a initialized ReReader 57 | func NewReReader(r io.Reader) *ReReader { 58 | return &ReReader{Reader: r} 59 | } 60 | 61 | func (rr *ReReader) Read(p []byte) (n int, err error) { 62 | return rr.restartBuffer.Read(rr.Reader, p) 63 | } 64 | 65 | // ReReadCloser is same as ReReader but also forwards Close calls 66 | type ReReadCloser struct { 67 | io.ReadCloser 68 | restartBuffer 69 | } 70 | 71 | // NewReReadCloser return a initialized ReReadCloser 72 | func NewReReadCloser(rc io.ReadCloser) *ReReadCloser { 73 | return &ReReadCloser{ReadCloser: rc} 74 | } 75 | 76 | func (rc *ReReadCloser) Read(p []byte) (n int, err error) { 77 | return rc.restartBuffer.Read(rc.ReadCloser, p) 78 | } 79 | -------------------------------------------------------------------------------- /vercheck.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "time" 12 | 13 | "github.com/google/go-github/v53/github" 14 | "github.com/wader/goutubedl" 15 | ) 16 | 17 | const ytdlpVersionCheckTimeout = time.Second * 10 18 | 19 | func ytdlpGetLatestRelease(ctx context.Context) (release *github.RepositoryRelease, err error) { 20 | client := github.NewClient(nil) 21 | 22 | release, _, err = client.Repositories.GetLatestRelease(ctx, "yt-dlp", "yt-dlp") 23 | if err != nil { 24 | return nil, fmt.Errorf("getting latest yt-dlp version: %w", err) 25 | } 26 | return release, nil 27 | } 28 | 29 | type ytdlpGithubReleaseAsset struct { 30 | Name string `json:"name"` 31 | URL string `json:"browser_download_url"` 32 | } 33 | 34 | func ytdlpGetLatestReleaseURL(ctx context.Context) (url string, err error) { 35 | release, err := ytdlpGetLatestRelease(ctx) 36 | if err != nil { 37 | return "", err 38 | } 39 | 40 | assetsURL := release.GetAssetsURL() 41 | if assetsURL == "" { 42 | return "", fmt.Errorf("downloading latest yt-dlp: no assets url") 43 | } 44 | 45 | resp, err := http.Get(assetsURL) 46 | if err != nil { 47 | return "", fmt.Errorf("downloading latest yt-dlp: %w", err) 48 | } 49 | 50 | body, err := io.ReadAll(resp.Body) 51 | if err != nil { 52 | return "", fmt.Errorf("downloading latest yt-dlp: %w", err) 53 | } 54 | defer resp.Body.Close() 55 | 56 | var assets []ytdlpGithubReleaseAsset 57 | err = json.Unmarshal(body, &assets) 58 | if err != nil { 59 | return "", fmt.Errorf("downloading latest yt-dlp: %w", err) 60 | } 61 | 62 | if len(assets) == 0 { 63 | return "", fmt.Errorf("downloading latest yt-dlp: no release assets") 64 | } 65 | 66 | for _, asset := range assets { 67 | if asset.Name == "yt-dlp" { 68 | url = asset.URL 69 | break 70 | } 71 | } 72 | if url == "" { 73 | return "", fmt.Errorf("downloading latest yt-dlp: no release asset url") 74 | } 75 | return url, nil 76 | } 77 | 78 | func ytdlpDownloadLatest(ctx context.Context) (path string, err error) { 79 | url, err := ytdlpGetLatestReleaseURL(ctx) 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | resp, err := http.Get(url) 85 | if err != nil { 86 | return "", fmt.Errorf("downloading latest yt-dlp: %w", err) 87 | } 88 | defer resp.Body.Close() 89 | 90 | file, err := os.Create(filepath.Join(os.TempDir(), "yt-dlp")) 91 | if err != nil { 92 | return "", fmt.Errorf("downloading latest yt-dlp: %w", err) 93 | } 94 | defer file.Close() 95 | 96 | _, err = io.Copy(file, resp.Body) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | err = os.Chmod(file.Name(), 0755) 102 | if err != nil { 103 | panic(err) 104 | } 105 | 106 | return file.Name(), nil 107 | } 108 | 109 | func ytdlpVersionCheck(ctx context.Context) (latestVersion, currentVersion string, err error) { 110 | release, err := ytdlpGetLatestRelease(ctx) 111 | if err != nil { 112 | return "", "", err 113 | } 114 | latestVersion = release.GetTagName() 115 | 116 | currentVersion, err = goutubedl.Version(ctx) 117 | if err != nil { 118 | return "", "", fmt.Errorf("getting current yt-dlp version: %w", err) 119 | } 120 | return 121 | } 122 | 123 | func ytdlpVersionCheckGetStr(ctx context.Context) (res string, updateNeeded, gotError bool) { 124 | verCheckCtx, verCheckCtxCancel := context.WithTimeout(ctx, ytdlpVersionCheckTimeout) 125 | defer verCheckCtxCancel() 126 | 127 | var latestVersion, currentVersion string 128 | var err error 129 | if latestVersion, currentVersion, err = ytdlpVersionCheck(verCheckCtx); err != nil { 130 | return errorStr + ": " + err.Error(), false, true 131 | } 132 | 133 | updateNeeded = currentVersion != latestVersion 134 | res = "yt-dlp version: " + currentVersion 135 | if updateNeeded { 136 | res = "📢 " + res + " 📢 Update needed! Latest version is " + latestVersion + " 📢" 137 | } else { 138 | res += " (up to date)" 139 | } 140 | return 141 | } 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yt-dlp-telegram-bot 2 | 3 | This bot downloads videos from various supported sources 4 | (see [yt-dlp](https://github.com/yt-dlp/yt-dlp)) and then re-uploads them 5 | to Telegram, so they can be viewed with Telegram's built-in video player. 6 | 7 |

8 | 9 | The bot displays the progress and further information during processing by 10 | responding to the message with the URL. Requests are queued, only one gets 11 | processed at a time. 12 | 13 | The bot uses the [Telegram MTProto API](https://github.com/gotd/td), which 14 | supports larger video uploads than the default 50MB with the standard 15 | Telegram bot API. Videos are not saved on disk. Incompatible video and audio 16 | streams are automatically converted to match those which are supported by 17 | Telegram's built-in video player. 18 | 19 | The only dependencies are [yt-dlp](https://github.com/yt-dlp/yt-dlp) and 20 | [ffmpeg](https://github.com/FFmpeg/FFmpeg). Tested on Linux, but should be 21 | able to run on other operating systems. 22 | 23 | ## Compiling 24 | 25 | You'll need Go installed on your computer. Install a recent package of `golang`. 26 | Then: 27 | 28 | ``` 29 | go get github.com/nonoo/yt-dlp-telegram-bot 30 | go install github.com/nonoo/yt-dlp-telegram-bot 31 | ``` 32 | 33 | This will typically install `yt-dlp-telegram-bot` into `$HOME/go/bin`. 34 | 35 | Or just enter `go build` in the cloned Git source repo directory. 36 | 37 | ## Prerequisites 38 | 39 | 1. Create a Telegram bot using [BotFather](https://t.me/BotFather) and get the 40 | bot's `token`. 41 | 2. [Get your Telegram API Keys](https://my.telegram.org/apps) 42 | (`api_id` and `api_hash`). You'll need to create an app if you haven't 43 | created one already. Description is optional, set the category to "other". 44 | If an error dialog pops up, then try creating the app using your phone's 45 | browser. 46 | 3. Make sure `yt-dlp`, `ffprobe` and `ffmpeg` commands are available on your 47 | system. 48 | 49 | ## Running 50 | 51 | You can get the available command line arguments with `-h`. 52 | Mandatory arguments are: 53 | 54 | - `-api-id`: set this to your Telegram app `api_id` 55 | - `-api-hash`: set this to your Telegram app `api_hash` 56 | - `-bot-token`: set this to your Telegram bot's `token` 57 | 58 | Set your Telegram user ID as an admin with the `-admin-user-ids` argument. 59 | Admins will get a message when the bot starts and when a newer version of 60 | `yt-dlp` is available (checked every 24 hours). 61 | 62 | Other user/group IDs can be set with the `-allowed-user-ids` and 63 | `-allowed-group-ids` arguments. IDs should be separated by commas. 64 | 65 | You can get Telegram user IDs by writing a message to the bot and checking 66 | the app's log, as it logs all incoming messages. 67 | 68 | You can set a max. upload file size limit with the `-max-size` argument. 69 | Example: `-max-size 512MB` 70 | 71 | All command line arguments can be set through OS environment variables. 72 | Note that using a command line argument overwrites a setting by the environment 73 | variable. Available OS environment variables are: 74 | 75 | - `API_ID` 76 | - `API_HASH` 77 | - `BOT_TOKEN` 78 | - `YTDLP_PATH` 79 | - `ALLOWED_USERIDS` 80 | - `ADMIN_USERIDS` 81 | - `ALLOWED_GROUPIDS` 82 | - `MAX_SIZE` 83 | - `YTDLP_COOKIES` 84 | 85 | The contents of the `YTDLP_COOKIES` environment variable will be written to the 86 | file `/tmp/ytdlp-cookies.txt`. This will be used by `yt-dlp` if it is running 87 | in a docker container, as the `yt-dlp.conf` file in the container points to this 88 | cookie file. 89 | 90 | ## Supported commands 91 | 92 | - `/dlp` - Download given URL. If the first attribute is "mp3" then only the 93 | audio stream will be downloaded and converted (if needed) to 320k MP3 94 | - `/dlpcancel` - Cancel ongoing download 95 | 96 | You don't need to enter the `/dlp` command if you send an URL to the bot using 97 | a private chat. 98 | 99 | ## Contributors 100 | 101 | - Norbert Varga [nonoo@nonoo.hu](mailto:nonoo@nonoo.hu) 102 | - Akos Marton 103 | 104 | ## Donations 105 | 106 | If you find this bot useful then [buy me a beer](https://paypal.me/ha2non). :) 107 | -------------------------------------------------------------------------------- /params.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/dustin/go-humanize" 11 | "github.com/wader/goutubedl" 12 | "golang.org/x/exp/slices" 13 | ) 14 | 15 | type paramsType struct { 16 | ApiID int 17 | ApiHash string 18 | BotToken string 19 | 20 | AllowedUserIDs []int64 21 | AdminUserIDs []int64 22 | AllowedGroupIDs []int64 23 | 24 | MaxSize int64 25 | } 26 | 27 | var params paramsType 28 | 29 | func (p *paramsType) Init() error { 30 | // Further available environment variables: 31 | // SESSION_FILE: path to session file 32 | // SESSION_DIR: path to session directory, if SESSION_FILE is not set 33 | 34 | var apiID string 35 | flag.StringVar(&apiID, "api-id", "", "telegram api_id") 36 | flag.StringVar(&p.ApiHash, "api-hash", "", "telegram api_hash") 37 | flag.StringVar(&p.BotToken, "bot-token", "", "telegram bot token") 38 | flag.StringVar(&goutubedl.Path, "yt-dlp-path", "", "yt-dlp path") 39 | var allowedUserIDs string 40 | flag.StringVar(&allowedUserIDs, "allowed-user-ids", "", "allowed telegram user ids") 41 | var adminUserIDs string 42 | flag.StringVar(&adminUserIDs, "admin-user-ids", "", "admin telegram user ids") 43 | var allowedGroupIDs string 44 | flag.StringVar(&allowedGroupIDs, "allowed-group-ids", "", "allowed telegram group ids") 45 | var maxSize string 46 | flag.StringVar(&maxSize, "max-size", "", "allowed max size of video files") 47 | flag.Parse() 48 | 49 | var err error 50 | if apiID == "" { 51 | apiID = os.Getenv("API_ID") 52 | } 53 | if apiID == "" { 54 | return fmt.Errorf("api id not set") 55 | } 56 | p.ApiID, err = strconv.Atoi(apiID) 57 | if err != nil { 58 | return fmt.Errorf("invalid api_id") 59 | } 60 | 61 | if p.ApiHash == "" { 62 | p.ApiHash = os.Getenv("API_HASH") 63 | } 64 | if p.ApiHash == "" { 65 | return fmt.Errorf("api hash not set") 66 | } 67 | 68 | if p.BotToken == "" { 69 | p.BotToken = os.Getenv("BOT_TOKEN") 70 | } 71 | if p.BotToken == "" { 72 | return fmt.Errorf("bot token not set") 73 | } 74 | 75 | if goutubedl.Path == "" { 76 | goutubedl.Path = os.Getenv("YTDLP_PATH") 77 | } 78 | if goutubedl.Path == "" { 79 | goutubedl.Path = "yt-dlp" 80 | } 81 | 82 | if allowedUserIDs == "" { 83 | allowedUserIDs = os.Getenv("ALLOWED_USERIDS") 84 | } 85 | sa := strings.Split(allowedUserIDs, ",") 86 | for _, idStr := range sa { 87 | if idStr == "" { 88 | continue 89 | } 90 | id, err := strconv.ParseInt(idStr, 10, 64) 91 | if err != nil { 92 | return fmt.Errorf("allowed user ids contains invalid user ID: " + idStr) 93 | } 94 | p.AllowedUserIDs = append(p.AllowedUserIDs, id) 95 | } 96 | 97 | if adminUserIDs == "" { 98 | adminUserIDs = os.Getenv("ADMIN_USERIDS") 99 | } 100 | sa = strings.Split(adminUserIDs, ",") 101 | for _, idStr := range sa { 102 | if idStr == "" { 103 | continue 104 | } 105 | id, err := strconv.ParseInt(idStr, 10, 64) 106 | if err != nil { 107 | return fmt.Errorf("admin ids contains invalid user ID: " + idStr) 108 | } 109 | p.AdminUserIDs = append(p.AdminUserIDs, id) 110 | if !slices.Contains(p.AllowedUserIDs, id) { 111 | p.AllowedUserIDs = append(p.AllowedUserIDs, id) 112 | } 113 | } 114 | 115 | if allowedGroupIDs == "" { 116 | allowedGroupIDs = os.Getenv("ALLOWED_GROUPIDS") 117 | } 118 | sa = strings.Split(allowedGroupIDs, ",") 119 | for _, idStr := range sa { 120 | if idStr == "" { 121 | continue 122 | } 123 | id, err := strconv.ParseInt(idStr, 10, 64) 124 | if err != nil { 125 | return fmt.Errorf("allowed group ids contains invalid group ID: " + idStr) 126 | } 127 | p.AllowedGroupIDs = append(p.AllowedGroupIDs, id) 128 | } 129 | 130 | if maxSize == "" { 131 | maxSize = os.Getenv("MAX_SIZE") 132 | } 133 | if maxSize != "" { 134 | b, err := humanize.ParseBigBytes(maxSize) 135 | if err != nil { 136 | return fmt.Errorf("invalid max size: %w", err) 137 | } 138 | p.MaxSize = b.Int64() 139 | } 140 | 141 | // Writing env. var YTDLP_COOKIES contents to a file. 142 | // In case a docker container is used, the yt-dlp.conf points yt-dlp to this cookie file. 143 | if cookies := os.Getenv("YTDLP_COOKIES"); cookies != "" { 144 | f, err := os.Create("/tmp/ytdlp-cookies.txt") 145 | if err != nil { 146 | return fmt.Errorf("couldn't create cookies file: %w", err) 147 | } 148 | _, err = f.WriteString(cookies) 149 | if err != nil { 150 | return fmt.Errorf("couldn't write cookies file: %w", err) 151 | } 152 | f.Close() 153 | } 154 | 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | "time" 12 | 13 | "github.com/gotd/td/telegram" 14 | "github.com/gotd/td/telegram/message" 15 | "github.com/gotd/td/telegram/uploader" 16 | "github.com/gotd/td/tg" 17 | "github.com/wader/goutubedl" 18 | "golang.org/x/exp/slices" 19 | ) 20 | 21 | var dlQueue DownloadQueue 22 | 23 | var telegramUploader *uploader.Uploader 24 | var telegramSender *message.Sender 25 | 26 | func handleCmdDLP(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) { 27 | format := "video" 28 | s := strings.Split(msg.Message, " ") 29 | if len(s) >= 2 && s[0] == "mp3" { 30 | msg.Message = strings.Join(s[1:], " ") 31 | format = "mp3" 32 | } 33 | 34 | // Check if message is an URL. 35 | validURI := true 36 | uri, err := url.ParseRequestURI(msg.Message) 37 | if err != nil || (uri.Scheme != "http" && uri.Scheme != "https") { 38 | validURI = false 39 | } else { 40 | _, err = net.LookupHost(uri.Host) 41 | if err != nil { 42 | validURI = false 43 | } 44 | } 45 | if !validURI { 46 | fmt.Println(" (not an url)") 47 | _, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": please enter an URL to download") 48 | return 49 | } 50 | 51 | dlQueue.Add(ctx, entities, u, msg.Message, format) 52 | } 53 | 54 | func handleCmdDLPCancel(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) { 55 | dlQueue.CancelCurrentEntry(ctx, entities, u, msg.Message) 56 | } 57 | 58 | func handleMsg(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage) error { 59 | msg, ok := u.Message.(*tg.Message) 60 | if !ok || msg.Out { 61 | // Outgoing message, not interesting. 62 | return nil 63 | } 64 | 65 | fromUser, fromGroup := resolveMsgSrc(msg) 66 | fromUsername := getFromUsername(entities, fromUser.UserID) 67 | 68 | fmt.Print("got message") 69 | if fromUsername != "" { 70 | fmt.Print(" from ", fromUsername, "#", fromUser.UserID) 71 | } 72 | fmt.Println(":", msg.Message) 73 | 74 | if fromGroup != nil { 75 | fmt.Print(" msg from group #", -fromGroup.ChatID) 76 | if !slices.Contains(params.AllowedGroupIDs, -fromGroup.ChatID) { 77 | fmt.Println(", group not allowed, ignoring") 78 | return nil 79 | } 80 | fmt.Println() 81 | } else { 82 | if !slices.Contains(params.AllowedUserIDs, fromUser.UserID) { 83 | fmt.Println(" user not allowed, ignoring") 84 | return nil 85 | } 86 | } 87 | 88 | // Check if message is a command. 89 | if msg.Message[0] == '/' || msg.Message[0] == '!' { 90 | cmd := strings.Split(msg.Message, " ")[0] 91 | msg.Message = strings.TrimPrefix(msg.Message, cmd+" ") 92 | if strings.Contains(cmd, "@") { 93 | cmd = strings.Split(cmd, "@")[0] 94 | } 95 | cmd = cmd[1:] // Cutting the command character. 96 | switch cmd { 97 | case "dlp": 98 | handleCmdDLP(ctx, entities, u, msg) 99 | return nil 100 | case "dlpcancel": 101 | handleCmdDLPCancel(ctx, entities, u, msg) 102 | return nil 103 | case "start": 104 | fmt.Println(" (start cmd)") 105 | if fromGroup == nil { 106 | _, _ = telegramSender.Reply(entities, u).Text(ctx, "🤖 Welcome! This bot downloads videos from various "+ 107 | "supported sources and then re-uploads them to Telegram, so they can be viewed with Telegram's built-in "+ 108 | "video player.\n\nMore info: https://github.com/nonoo/yt-dlp-telegram-bot") 109 | } 110 | return nil 111 | default: 112 | fmt.Println(" (invalid cmd)") 113 | if fromGroup == nil { 114 | _, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": invalid command") 115 | } 116 | return nil 117 | } 118 | } 119 | 120 | if fromGroup == nil { 121 | handleCmdDLP(ctx, entities, u, msg) 122 | } 123 | return nil 124 | } 125 | 126 | func main() { 127 | fmt.Println("yt-dlp-telegram-bot starting...") 128 | 129 | if err := params.Init(); err != nil { 130 | fmt.Println("error:", err) 131 | os.Exit(1) 132 | } 133 | 134 | // Dispatcher handles incoming updates. 135 | dispatcher := tg.NewUpdateDispatcher() 136 | opts := telegram.Options{ 137 | UpdateHandler: dispatcher, 138 | } 139 | var err error 140 | opts, err = telegram.OptionsFromEnvironment(opts) 141 | if err != nil { 142 | panic(fmt.Sprint("options from env err: ", err)) 143 | } 144 | 145 | client := telegram.NewClient(params.ApiID, params.ApiHash, opts) 146 | 147 | if err := client.Run(context.Background(), func(ctx context.Context) error { 148 | status, err := client.Auth().Status(ctx) 149 | if err != nil { 150 | panic(fmt.Sprint("auth status err: ", err)) 151 | } 152 | 153 | if !status.Authorized { // Not logged in? 154 | fmt.Println("logging in...") 155 | if _, err := client.Auth().Bot(ctx, params.BotToken); err != nil { 156 | panic(fmt.Sprint("login err: ", err)) 157 | } 158 | } 159 | 160 | api := client.API() 161 | 162 | telegramUploader = uploader.NewUploader(api).WithProgress(dlUploader) 163 | telegramSender = message.NewSender(api).WithUploader(telegramUploader) 164 | 165 | goutubedl.Path, err = exec.LookPath(goutubedl.Path) 166 | if err != nil { 167 | goutubedl.Path, err = ytdlpDownloadLatest(ctx) 168 | if err != nil { 169 | panic(fmt.Sprint("error: ", err)) 170 | } 171 | } 172 | 173 | dlQueue.Init(ctx) 174 | 175 | dispatcher.OnNewMessage(handleMsg) 176 | 177 | fmt.Println("telegram connection up") 178 | 179 | ytdlpVersionCheckStr, updateNeeded, _ := ytdlpVersionCheckGetStr(ctx) 180 | if updateNeeded { 181 | goutubedl.Path, err = ytdlpDownloadLatest(ctx) 182 | if err != nil { 183 | panic(fmt.Sprint("error: ", err)) 184 | } 185 | ytdlpVersionCheckStr, _, _ = ytdlpVersionCheckGetStr(ctx) 186 | } 187 | sendTextToAdmins(ctx, "🤖 Bot started, "+ytdlpVersionCheckStr) 188 | 189 | go func() { 190 | for { 191 | time.Sleep(24 * time.Hour) 192 | s, updateNeeded, gotError := ytdlpVersionCheckGetStr(ctx) 193 | if gotError { 194 | sendTextToAdmins(ctx, s) 195 | } else if updateNeeded { 196 | goutubedl.Path, err = ytdlpDownloadLatest(ctx) 197 | if err != nil { 198 | panic(fmt.Sprint("error: ", err)) 199 | } 200 | ytdlpVersionCheckStr, _, _ = ytdlpVersionCheckGetStr(ctx) 201 | sendTextToAdmins(ctx, "🤖 Bot updated, "+ytdlpVersionCheckStr) 202 | } 203 | } 204 | }() 205 | 206 | <-ctx.Done() 207 | return nil 208 | }); err != nil { 209 | panic(err) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /convert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "math/rand" 9 | "net" 10 | "os" 11 | "path" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | ffmpeg_go "github.com/u2takey/ffmpeg-go" 18 | "golang.org/x/exp/slices" 19 | ) 20 | 21 | const probeTimeout = 10 * time.Second 22 | const maxFFmpegProbeBytes = 20 * 1024 * 1024 23 | 24 | var compatibleVideoCodecs = []string{"h264", "vp9", "hevc"} 25 | var compatibleAudioCodecs = []string{"aac", "opus", "mp3"} 26 | 27 | type ffmpegProbeDataStreamsStream struct { 28 | CodecName string `json:"codec_name"` 29 | CodecType string `json:"codec_type"` 30 | } 31 | 32 | type ffmpegProbeDataFormat struct { 33 | FormatName string `json:"format_name"` 34 | Duration string `json:"duration"` 35 | } 36 | 37 | type ffmpegProbeData struct { 38 | Streams []ffmpegProbeDataStreamsStream `json:"streams"` 39 | Format ffmpegProbeDataFormat `json:"format"` 40 | } 41 | 42 | type Converter struct { 43 | Format string 44 | 45 | VideoCodecs string 46 | VideoConvertNeeded bool 47 | SingleVideoStreamNeeded bool 48 | 49 | AudioCodecs string 50 | AudioConvertNeeded bool 51 | SingleAudioStreamNeeded bool 52 | 53 | Duration float64 54 | 55 | UpdateProgressPercentCallback UpdateProgressPercentCallbackFunc 56 | } 57 | 58 | func (c *Converter) Probe(rr *ReReadCloser) error { 59 | defer func() { 60 | // Restart and replay buffer data used when probing 61 | rr.Restarted = true 62 | }() 63 | 64 | fmt.Println(" probing file...") 65 | i, err := ffmpeg_go.ProbeReaderWithTimeout(io.LimitReader(rr, maxFFmpegProbeBytes), probeTimeout, nil) 66 | if err != nil { 67 | return fmt.Errorf("error probing file: %w", err) 68 | } 69 | 70 | pd := ffmpegProbeData{} 71 | err = json.Unmarshal([]byte(i), &pd) 72 | if err != nil { 73 | return fmt.Errorf("error decoding probe result: %w", err) 74 | } 75 | 76 | c.Duration, err = strconv.ParseFloat(pd.Format.Duration, 64) 77 | if err != nil { 78 | fmt.Println(" error parsing duration:", err) 79 | } 80 | 81 | compatibleVideoCodecsCopy := compatibleVideoCodecs 82 | if c.Format == "mp3" { 83 | compatibleVideoCodecsCopy = []string{} 84 | } 85 | compatibleAudioCodecsCopy := compatibleAudioCodecs 86 | if c.Format == "mp3" { 87 | compatibleAudioCodecsCopy = []string{"mp3"} 88 | } 89 | 90 | gotVideoStream := false 91 | gotAudioStream := false 92 | for _, stream := range pd.Streams { 93 | if stream.CodecType == "video" && len(compatibleVideoCodecsCopy) > 0 { 94 | if c.VideoCodecs != "" { 95 | c.VideoCodecs += ", " 96 | } 97 | c.VideoCodecs += stream.CodecName 98 | 99 | if gotVideoStream { 100 | fmt.Println(" got additional video stream") 101 | c.SingleVideoStreamNeeded = true 102 | } else if !c.VideoConvertNeeded { 103 | if !slices.Contains(compatibleVideoCodecs, stream.CodecName) { 104 | fmt.Println(" found incompatible video codec:", stream.CodecName) 105 | c.VideoConvertNeeded = true 106 | } else { 107 | fmt.Println(" found video codec:", stream.CodecName) 108 | } 109 | gotVideoStream = true 110 | } 111 | } else if stream.CodecType == "audio" { 112 | if c.AudioCodecs != "" { 113 | c.AudioCodecs += ", " 114 | } 115 | c.AudioCodecs += stream.CodecName 116 | 117 | if gotAudioStream { 118 | fmt.Println(" got additional audio stream") 119 | c.SingleAudioStreamNeeded = true 120 | } else if !c.AudioConvertNeeded { 121 | if !slices.Contains(compatibleAudioCodecsCopy, stream.CodecName) { 122 | fmt.Println(" found not compatible audio codec:", stream.CodecName) 123 | c.AudioConvertNeeded = true 124 | } else { 125 | fmt.Println(" found audio codec:", stream.CodecName) 126 | } 127 | gotAudioStream = true 128 | } 129 | } 130 | } 131 | 132 | if len(compatibleVideoCodecsCopy) > 0 && !gotVideoStream { 133 | return fmt.Errorf("no video stream found in file") 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func (c *Converter) ffmpegProgressSock() (sockFilename string, sock net.Listener, err error) { 140 | sockFilename = path.Join(os.TempDir(), fmt.Sprintf("yt-dlp-telegram-bot-%d.sock", rand.Int())) 141 | sock, err = net.Listen("unix", sockFilename) 142 | if err != nil { 143 | fmt.Println(" ffmpeg progress socket create error:", err) 144 | return 145 | } 146 | 147 | go func() { 148 | re := regexp.MustCompile(`out_time_ms=(\d+)\n`) 149 | 150 | fd, err := sock.Accept() 151 | if err != nil { 152 | fmt.Println(" ffmpeg progress socket accept error:", err) 153 | return 154 | } 155 | defer fd.Close() 156 | 157 | buf := make([]byte, 64) 158 | data := "" 159 | 160 | for { 161 | _, err := fd.Read(buf) 162 | if err != nil { 163 | return 164 | } 165 | 166 | data += string(buf) 167 | a := re.FindAllStringSubmatch(data, -1) 168 | 169 | if len(a) > 0 && len(a[len(a)-1]) > 0 { 170 | data = "" 171 | l, _ := strconv.Atoi(a[len(a)-1][len(a[len(a)-1])-1]) 172 | c.UpdateProgressPercentCallback(processStr, int(100*float64(l)/c.Duration/1000000)) 173 | } 174 | 175 | if strings.Contains(data, "progress=end") { 176 | c.UpdateProgressPercentCallback(processStr, 100) 177 | } 178 | } 179 | }() 180 | 181 | return 182 | } 183 | 184 | func (c *Converter) GetActionsNeeded() string { 185 | var convertNeeded []string 186 | if c.VideoConvertNeeded || c.SingleVideoStreamNeeded { 187 | convertNeeded = append(convertNeeded, "video") 188 | } 189 | if c.AudioConvertNeeded || c.SingleAudioStreamNeeded { 190 | convertNeeded = append(convertNeeded, "audio") 191 | } 192 | return strings.Join(convertNeeded, ", ") 193 | } 194 | 195 | func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (reader io.ReadCloser, outputFormat string, err error) { 196 | reader, writer := io.Pipe() 197 | var cmd *Cmd 198 | 199 | fmt.Print(" converting ", c.GetActionsNeeded(), "...\n") 200 | 201 | videoNeeded := true 202 | outputFormat = "mp4" 203 | if c.Format == "mp3" { 204 | videoNeeded = false 205 | outputFormat = "mp3" 206 | } 207 | 208 | args := ffmpeg_go.KwArgs{"format": outputFormat} 209 | 210 | if videoNeeded { 211 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"movflags": "frag_keyframe+empty_moov+faststart"}}) 212 | 213 | if c.VideoConvertNeeded { 214 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:v": "libx264", "crf": 30, "preset": "veryfast"}}) 215 | } else { 216 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:v": "copy"}}) 217 | } 218 | } else { 219 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"vn": ""}}) 220 | } 221 | 222 | if c.AudioConvertNeeded { 223 | if c.Format == "mp3" { 224 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "mp3", "b:a": "320k"}}) 225 | } else { 226 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "mp3", "q:a": 0}}) 227 | } 228 | } else { 229 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "copy"}}) 230 | } 231 | 232 | if videoNeeded { 233 | if c.SingleVideoStreamNeeded || c.SingleAudioStreamNeeded { 234 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"map": "0:v:0,0:a:0"}}) 235 | } 236 | } else { 237 | if c.SingleAudioStreamNeeded { 238 | args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"map": "0:a:0"}}) 239 | } 240 | } 241 | 242 | ff := ffmpeg_go.Input("pipe:0").Output("pipe:1", args) 243 | 244 | var progressSock net.Listener 245 | if c.UpdateProgressPercentCallback != nil { 246 | if c.Duration > 0 { 247 | var progressSockFilename string 248 | progressSockFilename, progressSock, err = c.ffmpegProgressSock() 249 | if err == nil { 250 | ff = ff.GlobalArgs("-progress", "unix:"+progressSockFilename) 251 | } 252 | } else { 253 | c.UpdateProgressPercentCallback(processStr, -1) 254 | } 255 | } 256 | 257 | ffCmd := ff.WithInput(rr).WithOutput(writer).Compile() 258 | 259 | // Creating a new cmd with a timeout context, which will kill the cmd if it takes too long. 260 | cmd = NewCommand(ctx, ffCmd.Args[0], ffCmd.Args[1:]...) 261 | cmd.Stdin = ffCmd.Stdin 262 | cmd.Stdout = ffCmd.Stdout 263 | 264 | // This goroutine handles copying from the input (either rr or cmd.Stdout) to writer. 265 | go func() { 266 | err = cmd.Run() 267 | writer.Close() 268 | if progressSock != nil { 269 | progressSock.Close() 270 | } 271 | }() 272 | 273 | if err != nil { 274 | writer.Close() 275 | return nil, outputFormat, fmt.Errorf("error converting: %w", err) 276 | } 277 | 278 | return reader, outputFormat, nil 279 | } 280 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gotd/td/telegram/message" 10 | "github.com/gotd/td/tg" 11 | ) 12 | 13 | const processStartStr = "🔍 Getting information..." 14 | const processStr = "🔨 Processing" 15 | const uploadStr = "☁️ Uploading" 16 | const uploadDoneStr = "🏁 Uploading" 17 | const errorStr = "❌ Error" 18 | const canceledStr = "❌ Canceled" 19 | 20 | const maxProgressPercentUpdateInterval = time.Second 21 | const progressBarLength = 10 22 | 23 | type DownloadQueueEntry struct { 24 | URL string 25 | Format string 26 | 27 | OrigEntities tg.Entities 28 | OrigMsgUpdate *tg.UpdateNewMessage 29 | OrigMsg *tg.Message 30 | FromUser *tg.PeerUser 31 | FromGroup *tg.PeerChat 32 | 33 | Reply *message.Builder 34 | ReplyMsg *tg.UpdateShortSentMessage 35 | 36 | Ctx context.Context 37 | CtxCancel context.CancelFunc 38 | Canceled bool 39 | } 40 | 41 | // func (e *DownloadQueueEntry) getTypingActionDst() tg.InputPeerClass { 42 | // if e.FromGroup != nil { 43 | // return &tg.InputPeerChat{ 44 | // ChatID: e.FromGroup.ChatID, 45 | // } 46 | // } 47 | // return &tg.InputPeerUser{ 48 | // UserID: e.FromUser.UserID, 49 | // } 50 | // } 51 | 52 | func (e *DownloadQueueEntry) sendTypingAction(ctx context.Context) { 53 | // _ = telegramSender.To(e.getTypingActionDst()).TypingAction().Typing(ctx) 54 | } 55 | 56 | func (e *DownloadQueueEntry) sendTypingCancelAction(ctx context.Context) { 57 | // _ = telegramSender.To(e.getTypingActionDst()).TypingAction().Cancel(ctx) 58 | } 59 | 60 | func (e *DownloadQueueEntry) editReply(ctx context.Context, s string) { 61 | _, _ = e.Reply.Edit(e.ReplyMsg.ID).Text(ctx, s) 62 | e.sendTypingAction(ctx) 63 | } 64 | 65 | type currentlyDownloadedEntryType struct { 66 | disableProgressPercentUpdate bool 67 | progressPercentUpdateMutex sync.Mutex 68 | lastProgressPercentUpdateAt time.Time 69 | lastProgressPercent int 70 | lastDisplayedProgressPercent int 71 | progressUpdateTimer *time.Timer 72 | 73 | sourceCodecInfo string 74 | progressInfo string 75 | } 76 | 77 | type DownloadQueue struct { 78 | ctx context.Context 79 | 80 | mutex sync.Mutex 81 | entries []DownloadQueueEntry 82 | processReqChan chan bool 83 | 84 | currentlyDownloadedEntry currentlyDownloadedEntryType 85 | } 86 | 87 | func (e *DownloadQueue) getQueuePositionString(pos int) string { 88 | return "👨‍👦‍👦 Request queued at position #" + fmt.Sprint(pos) 89 | } 90 | 91 | func (q *DownloadQueue) Add(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url, format string) { 92 | q.mutex.Lock() 93 | 94 | var replyStr string 95 | if len(q.entries) == 0 { 96 | replyStr = processStartStr 97 | } else { 98 | fmt.Println(" queueing request at position #", len(q.entries)) 99 | replyStr = q.getQueuePositionString(len(q.entries)) 100 | } 101 | 102 | newEntry := DownloadQueueEntry{ 103 | URL: url, 104 | Format: format, 105 | OrigEntities: entities, 106 | OrigMsgUpdate: u, 107 | OrigMsg: u.Message.(*tg.Message), 108 | } 109 | 110 | newEntry.Reply = telegramSender.Reply(entities, u) 111 | replyText, _ := newEntry.Reply.Text(ctx, replyStr) 112 | newEntry.ReplyMsg = replyText.(*tg.UpdateShortSentMessage) 113 | 114 | newEntry.FromUser, newEntry.FromGroup = resolveMsgSrc(newEntry.OrigMsg) 115 | 116 | q.entries = append(q.entries, newEntry) 117 | q.mutex.Unlock() 118 | 119 | select { 120 | case q.processReqChan <- true: 121 | default: 122 | } 123 | } 124 | 125 | func (q *DownloadQueue) CancelCurrentEntry(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url string) { 126 | q.mutex.Lock() 127 | if len(q.entries) > 0 { 128 | q.entries[0].Canceled = true 129 | q.entries[0].CtxCancel() 130 | } else { 131 | fmt.Println(" no active request to cancel") 132 | _, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": no active request to cancel") 133 | } 134 | q.mutex.Unlock() 135 | } 136 | 137 | func (q *DownloadQueue) updateProgress(ctx context.Context, qEntry *DownloadQueueEntry, progressStr string, progressPercent int) { 138 | if progressPercent < 0 { 139 | qEntry.editReply(ctx, progressStr+"... (no progress available)\n"+q.currentlyDownloadedEntry.sourceCodecInfo) 140 | return 141 | } 142 | if progressPercent == 0 { 143 | qEntry.editReply(ctx, progressStr+"..."+q.currentlyDownloadedEntry.progressInfo+"\n"+q.currentlyDownloadedEntry.sourceCodecInfo) 144 | return 145 | } 146 | fmt.Print(" progress: ", progressPercent, "%\n") 147 | qEntry.editReply(ctx, progressStr+": "+getProgressbar(progressPercent, progressBarLength)+q.currentlyDownloadedEntry.progressInfo+"\n"+q.currentlyDownloadedEntry.sourceCodecInfo) 148 | q.currentlyDownloadedEntry.lastDisplayedProgressPercent = progressPercent 149 | } 150 | 151 | func (q *DownloadQueue) HandleProgressPercentUpdate(progressStr string, progressPercent int) { 152 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 153 | defer q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 154 | 155 | if q.currentlyDownloadedEntry.disableProgressPercentUpdate || q.currentlyDownloadedEntry.lastProgressPercent == progressPercent { 156 | return 157 | } 158 | q.currentlyDownloadedEntry.lastProgressPercent = progressPercent 159 | if progressPercent < 0 { 160 | q.currentlyDownloadedEntry.disableProgressPercentUpdate = true 161 | q.updateProgress(q.ctx, &q.entries[0], progressStr, progressPercent) 162 | return 163 | } 164 | 165 | if q.currentlyDownloadedEntry.progressUpdateTimer != nil { 166 | q.currentlyDownloadedEntry.progressUpdateTimer.Stop() 167 | select { 168 | case <-q.currentlyDownloadedEntry.progressUpdateTimer.C: 169 | default: 170 | } 171 | } 172 | 173 | timeElapsedSinceLastUpdate := time.Since(q.currentlyDownloadedEntry.lastProgressPercentUpdateAt) 174 | if timeElapsedSinceLastUpdate < maxProgressPercentUpdateInterval { 175 | q.currentlyDownloadedEntry.progressUpdateTimer = time.AfterFunc(maxProgressPercentUpdateInterval-timeElapsedSinceLastUpdate, func() { 176 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 177 | if !q.currentlyDownloadedEntry.disableProgressPercentUpdate { 178 | q.updateProgress(q.ctx, &q.entries[0], progressStr, progressPercent) 179 | q.currentlyDownloadedEntry.lastProgressPercentUpdateAt = time.Now() 180 | } 181 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 182 | }) 183 | return 184 | } 185 | q.updateProgress(q.ctx, &q.entries[0], progressStr, progressPercent) 186 | q.currentlyDownloadedEntry.lastProgressPercentUpdateAt = time.Now() 187 | } 188 | 189 | func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQueueEntry) { 190 | fromUsername := getFromUsername(qEntry.OrigEntities, qEntry.FromUser.UserID) 191 | fmt.Print("processing request by") 192 | if fromUsername != "" { 193 | fmt.Print(" from ", fromUsername, "#", qEntry.FromUser.UserID) 194 | } 195 | fmt.Println(":", qEntry.URL) 196 | 197 | qEntry.editReply(ctx, processStartStr) 198 | 199 | downloader := Downloader{ 200 | ConvertStartFunc: func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string) { 201 | q.currentlyDownloadedEntry.sourceCodecInfo = "🎬 Source: " + videoCodecs 202 | if audioCodecs == "" { 203 | q.currentlyDownloadedEntry.sourceCodecInfo += ", no audio" 204 | } else { 205 | if videoCodecs != "" { 206 | q.currentlyDownloadedEntry.sourceCodecInfo += " / " 207 | } 208 | q.currentlyDownloadedEntry.sourceCodecInfo += audioCodecs 209 | } 210 | if convertActionsNeeded == "" { 211 | q.currentlyDownloadedEntry.sourceCodecInfo += " (no conversion needed)" 212 | } else { 213 | q.currentlyDownloadedEntry.sourceCodecInfo += " (converting: " + convertActionsNeeded + ")" 214 | } 215 | qEntry.editReply(ctx, "🎬 Preparing download...\n"+q.currentlyDownloadedEntry.sourceCodecInfo) 216 | }, 217 | UpdateProgressPercentFunc: q.HandleProgressPercentUpdate, 218 | } 219 | 220 | r, outputFormat, title, err := downloader.DownloadAndConvertURL(qEntry.Ctx, qEntry.OrigMsg.Message, qEntry.Format) 221 | if err != nil { 222 | fmt.Println(" error downloading:", err) 223 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 224 | q.currentlyDownloadedEntry.disableProgressPercentUpdate = true 225 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 226 | qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err)) 227 | return 228 | } 229 | 230 | // Feeding the returned io.ReadCloser to the uploader. 231 | fmt.Println(" processing...") 232 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 233 | q.updateProgress(ctx, qEntry, processStr, q.currentlyDownloadedEntry.lastProgressPercent) 234 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 235 | 236 | err = dlUploader.UploadFile(qEntry.Ctx, qEntry.OrigEntities, qEntry.OrigMsgUpdate, r, outputFormat, title) 237 | if err != nil { 238 | fmt.Println(" error processing:", err) 239 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 240 | q.currentlyDownloadedEntry.disableProgressPercentUpdate = true 241 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 242 | r.Close() 243 | qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err)) 244 | return 245 | } 246 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 247 | q.currentlyDownloadedEntry.disableProgressPercentUpdate = true 248 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 249 | r.Close() 250 | 251 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock() 252 | if qEntry.Canceled { 253 | fmt.Print(" canceled\n") 254 | q.updateProgress(ctx, qEntry, canceledStr, q.currentlyDownloadedEntry.lastProgressPercent) 255 | } else if q.currentlyDownloadedEntry.lastDisplayedProgressPercent < 100 { 256 | fmt.Print(" progress: 100%\n") 257 | q.updateProgress(ctx, qEntry, uploadDoneStr, 100) 258 | } 259 | q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock() 260 | qEntry.sendTypingCancelAction(ctx) 261 | } 262 | 263 | func (q *DownloadQueue) processor() { 264 | for { 265 | q.mutex.Lock() 266 | if (len(q.entries)) == 0 { 267 | q.mutex.Unlock() 268 | <-q.processReqChan 269 | continue 270 | } 271 | 272 | // Updating queue positions for all waiting entries. 273 | for i := 1; i < len(q.entries); i++ { 274 | q.entries[i].editReply(q.ctx, q.getQueuePositionString(i)) 275 | q.entries[i].sendTypingCancelAction(q.ctx) 276 | } 277 | 278 | q.entries[0].Ctx, q.entries[0].CtxCancel = context.WithTimeout(q.ctx, downloadAndConvertTimeout) 279 | 280 | qEntry := &q.entries[0] 281 | q.mutex.Unlock() 282 | 283 | q.currentlyDownloadedEntry = currentlyDownloadedEntryType{} 284 | 285 | q.processQueueEntry(q.ctx, qEntry) 286 | 287 | q.mutex.Lock() 288 | q.entries[0].CtxCancel() 289 | q.entries = q.entries[1:] 290 | if len(q.entries) == 0 { 291 | fmt.Print("finished queue processing\n") 292 | } 293 | q.mutex.Unlock() 294 | } 295 | } 296 | 297 | func (q *DownloadQueue) Init(ctx context.Context) { 298 | q.ctx = ctx 299 | q.processReqChan = make(chan bool) 300 | go q.processor() 301 | } 302 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 3 | github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= 4 | github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= 5 | github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 6 | github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 7 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 8 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 9 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 10 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 11 | github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= 12 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 17 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 18 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 19 | github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI= 20 | github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= 21 | github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= 22 | github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= 23 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 24 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 25 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 26 | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= 27 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 28 | github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= 29 | github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= 30 | github.com/go-faster/jx v1.0.1 h1:NhSJEZtqj6KmXf63On7Hg7/sjUX+gotSc/eM6bZCZ00= 31 | github.com/go-faster/jx v1.0.1/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= 32 | github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= 33 | github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= 34 | github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= 35 | github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= 36 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 37 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 38 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 39 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 40 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 41 | github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= 42 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= 43 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= 44 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= 45 | github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= 46 | github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 47 | github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= 48 | github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= 49 | github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= 50 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 51 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 52 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 53 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 54 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 55 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 56 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 57 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 58 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 59 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 60 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 61 | github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= 62 | github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= 63 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 64 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 65 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 66 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 67 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= 68 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 69 | github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= 70 | github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= 71 | github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= 72 | github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= 73 | github.com/gotd/td v0.84.0 h1:oWMp5HczCAFSgKWgWFCuYjELBgcRVcRpGLdQ1bP2kpg= 74 | github.com/gotd/td v0.84.0/go.mod h1:3dQsGL9rxMcS1Z9Na3S7U8e/pLMzbLIT2jM3E5IuUk0= 75 | github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 76 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 77 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 78 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 79 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 80 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 81 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 82 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 83 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 84 | github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 85 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 86 | github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 87 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 88 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 89 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 90 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 91 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 92 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 93 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 94 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 95 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 96 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 97 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 98 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 99 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 100 | github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= 101 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 102 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 103 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 104 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 105 | github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 106 | github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 107 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 108 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 109 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 110 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 111 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 112 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 113 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 114 | github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= 115 | github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= 116 | github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= 117 | github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= 118 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 119 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 120 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 121 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 122 | github.com/wader/goutubedl v0.0.0-20240626070646-8cef76d0c092 h1:BQ+eGEAUeSzrXx3ruK+pLM50FczmxhhtdA1UNYWRioQ= 123 | github.com/wader/goutubedl v0.0.0-20240626070646-8cef76d0c092/go.mod h1:5KXd5tImdbmz4JoVhePtbIokCwAfEhUVVx3WLHmjYuw= 124 | github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071 h1:QkrG4Zr5OVFuC9aaMPmFI0ibfhBZlAgtzDYWfu7tqQk= 125 | github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071/go.mod h1:XD6emOFPHVzb0+qQpiNOdPL2XZ0SRUM0N5JHuq6OmXo= 126 | go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= 127 | go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= 128 | go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= 129 | go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= 130 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 131 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 132 | go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 133 | go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 134 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 135 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 136 | go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= 137 | go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= 138 | gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= 139 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 140 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 141 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 142 | golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= 143 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= 144 | golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0= 145 | golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 146 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 147 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 148 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 149 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 150 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 151 | golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= 152 | golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= 153 | golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= 154 | golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= 155 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 156 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 157 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 158 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 159 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 162 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 164 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 165 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 166 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 167 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 168 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 169 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 170 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 171 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 172 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 173 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 174 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 175 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 176 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 177 | golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 178 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 179 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 180 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 181 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 182 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 183 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 184 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 185 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 186 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 187 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 188 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 189 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 190 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 191 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 192 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 193 | nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= 194 | nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= 195 | rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= 196 | rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= 197 | sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= 198 | --------------------------------------------------------------------------------