diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7114a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/run.sh +/yt-dlp-telegram-bot diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7f7cc30 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "go.lintTool": "golangci-lint" +} diff --git a/LICENSE b/LICENSE index e637109..9fbb371 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Norbert Varga +Copyright (c) 2023 Norbert "Nonoo" Varga Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b6f87d --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# yt-dlp-telegram-bot + +This bot downloads videos from various supported sources +(see [yt-dlp](https://github.com/yt-dlp/yt-dlp) and then re-uploads them +to Telegram, so they can be viewed with Telegram's built-in video player. + +

+ +The bot displays the progress and further information during processing by +responding to the message with the URL. Requests are queued, only one gets +processed at a time. + +The bot uses the [Telegram MTProto API](https://github.com/gotd/td), which +supports larger video uploads than the default 50MB with the standard +Telegram bot API. Videos are not saved on disk, they are simultaneously +uploaded from the source to Telegram. Incompatible video and audio streams +are automatically converted to match those which are supported by Telegram's +built-in video player. + +The only dependencies are [yt-dlp](https://github.com/yt-dlp/yt-dlp) and +[ffmpeg](https://github.com/FFmpeg/FFmpeg). Tested on Linux, but should be +able to run on other operating systems. + +## Compiling + +You'll need Go installed on your computer. Install a recent package of `golang`. +Then: + +``` +go get github.com/nonoo/yt-dlp-telegram-bot +go install github.com/nonoo/yt-dlp-telegram-bot +``` + +This will typically install `yt-dlp-telegram-bot` into `$HOME/go/bin`. + +Or just enter `go build` in the cloned Git source repo directory. + +## Prerequisites + +1. Create a Telegram bot using [BotFather](https://t.me/BotFather) and get the + bot's `token`. +2. [Get your Telegram API Keys](https://my.telegram.org/apps) + (`api_id` and `api_hash`). You'll need to create an app if you haven't + created one already. Description is optional, set the category to "other". + If an error dialog pops up, then try creating the app using your phone's + browser. +3. Make sure `yt-dlp`, `ffprobe` and `ffmpeg` commands are available on your + system. + +## Running + +You can get the available command line arguments with `-h`. +Mandatory arguments are: + +- `-api-id`: set this to your Telegram app `api_id` +- `-api-hash`: set this to your Telegram app `api_hash` +- `-bot-token`: set this to your Telegram bot's `token` + +Set your Telegram user ID as an admin with the `-admin-user-ids` argument. +Admins will get a message when the bot starts and when a newer version of +`yt-dlp` is available (checked every 24 hours). + +Other user/group IDs can be set with the `-allowed-user-ids` and +`-allowed-group-ids` arguments. IDs should be separated by commas. + +You can get Telegram user IDs by writing a message to the bot and checking +the app's log, as it logs all incoming messages. + +All command line arguments can be set through OS environment variables. +Note that using a command line argument overwrites a setting by the environment +variable. Available OS environment variables are: + +- `APP_ID` +- `API_HASH` +- `BOT_TOKEN` +- `YTDLP_PATH` +- `ALLOWED_USERIDS` +- `ADMIN_USERIDS` +- `ALLOWED_GROUPIDS` + +## Supported commands + +- `/dlp` - Download +- `/dlpcancel` - Cancel ongoing download + +You don't need to enter the `/dlp` command if you send an URL to the bot using +a private chat. + +## Contributors + +- Norbert Varga [nonoo@nonoo.hu](mailto:nonoo@nonoo.hu) +- Akos Marton + +## Donations + +If you find this bot useful then [buy me a beer](https://paypal.me/ha2non). :) diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..80d8830 --- /dev/null +++ b/cmd.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "os/exec" + "syscall" +) + +// https://stackoverflow.com/questions/71714228/go-exec-commandcontext-is-not-being-terminated-after-context-timeout + +type Cmd struct { + ctx context.Context + *exec.Cmd +} + +// NewCommand is like exec.CommandContext but ensures that subprocesses +// are killed when the context times out, not just the top level process. +func NewCommand(ctx context.Context, command string, args ...string) *Cmd { + return &Cmd{ctx, exec.Command(command, args...)} +} + +func (c *Cmd) Start() error { + // Force-enable setpgid bit so that we can kill child processes when the + // context times out or is canceled. + if c.Cmd.SysProcAttr == nil { + c.Cmd.SysProcAttr = &syscall.SysProcAttr{} + } + c.Cmd.SysProcAttr.Setpgid = true + err := c.Cmd.Start() + if err != nil { + return err + } + go func() { + <-c.ctx.Done() + p := c.Cmd.Process + if p == nil { + return + } + // Kill by negative PID to kill the process group, which includes + // the top-level process we spawned as well as any subprocesses + // it spawned. + _ = syscall.Kill(-p.Pid, syscall.SIGKILL) + }() + return nil +} + +func (c *Cmd) Run() error { + if err := c.Start(); err != nil { + return err + } + return c.Wait() +} diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..ef6a717 --- /dev/null +++ b/convert.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + ffmpeg_go "github.com/u2takey/ffmpeg-go" + "golang.org/x/exp/slices" +) + +const probeTimeout = 10 * time.Second +const maxFFmpegProbeBytes = 20 * 1024 * 1024 + +var compatibleVideoCodecs = []string{"h264", "vp9"} +var compatibleAudioCodecs = []string{"aac", "opus", "mp3"} + +type ffmpegProbeDataStreamsStream struct { + CodecName string `json:"codec_name"` + CodecType string `json:"codec_type"` +} + +type ffmpegProbeDataFormat struct { + FormatName string `json:"format_name"` + Duration string `json:"duration"` +} + +type ffmpegProbeData struct { + Streams []ffmpegProbeDataStreamsStream `json:"streams"` + Format ffmpegProbeDataFormat `json:"format"` +} + +type Converter struct { + VideoCodecs string + VideoConvertNeeded bool + SingleVideoStreamNeeded bool + + AudioCodecs string + AudioConvertNeeded bool + SingleAudioStreamNeeded bool + + Duration float64 + + UpdateProgressPercentCallback UpdateProgressPercentCallbackFunc +} + +func (c *Converter) Probe(rr *ReReadCloser) error { + defer func() { + // Restart and replay buffer data used when probing + rr.Restarted = true + }() + + fmt.Println(" probing file...") + i, err := ffmpeg_go.ProbeReaderWithTimeout(io.LimitReader(rr, maxFFmpegProbeBytes), probeTimeout, nil) + if err != nil { + return fmt.Errorf("error probing file: %w", err) + } + + pd := ffmpegProbeData{} + err = json.Unmarshal([]byte(i), &pd) + if err != nil { + return fmt.Errorf("error decoding probe result: %w", err) + } + + c.Duration, err = strconv.ParseFloat(pd.Format.Duration, 64) + if err != nil { + fmt.Println(" error parsing duration:", err) + } + + gotVideoStream := false + gotAudioStream := false + for _, stream := range pd.Streams { + if stream.CodecType == "video" { + if c.VideoCodecs != "" { + c.VideoCodecs += ", " + } + c.VideoCodecs += stream.CodecName + + if gotVideoStream { + fmt.Println(" got additional video stream") + c.SingleVideoStreamNeeded = true + } else if !c.VideoConvertNeeded { + if !slices.Contains(compatibleVideoCodecs, stream.CodecName) { + fmt.Println(" found incompatible video codec:", stream.CodecName) + c.VideoConvertNeeded = true + } else { + fmt.Println(" found video codec:", stream.CodecName) + } + gotVideoStream = true + } + } else if stream.CodecType == "audio" { + if c.AudioCodecs != "" { + c.AudioCodecs += ", " + } + c.AudioCodecs += stream.CodecName + + if gotAudioStream { + fmt.Println(" got additional audio stream") + c.SingleAudioStreamNeeded = true + } else if !c.AudioConvertNeeded { + if !slices.Contains(compatibleAudioCodecs, stream.CodecName) { + fmt.Println(" found not compatible audio codec:", stream.CodecName) + c.AudioConvertNeeded = true + } else { + fmt.Println(" found audio codec:", stream.CodecName) + } + gotAudioStream = true + } + } + } + + if !gotVideoStream { + return fmt.Errorf("no video stream found in file") + } + + return nil +} + +func (c *Converter) ffmpegProgressSock() (sockFilename string, sock net.Listener, err error) { + sockFilename = path.Join(os.TempDir(), fmt.Sprintf("yt-dlp-telegram-bot-%d.sock", rand.Int())) + sock, err = net.Listen("unix", sockFilename) + if err != nil { + fmt.Println(" ffmpeg progress socket create error:", err) + return + } + + go func() { + re := regexp.MustCompile(`out_time_ms=(\d+)\n`) + + fd, err := sock.Accept() + if err != nil { + fmt.Println(" ffmpeg progress socket accept error:", err) + return + } + defer fd.Close() + + buf := make([]byte, 64) + data := "" + + for { + _, err := fd.Read(buf) + if err != nil { + return + } + + data += string(buf) + a := re.FindAllStringSubmatch(data, -1) + + if len(a) > 0 && len(a[len(a)-1]) > 0 { + data = "" + l, _ := strconv.Atoi(a[len(a)-1][len(a[len(a)-1])-1]) + c.UpdateProgressPercentCallback(int(100 * float64(l) / c.Duration / 1000000)) + } + + if strings.Contains(data, "progress=end") { + c.UpdateProgressPercentCallback(100) + } + } + }() + + return +} + +func (c *Converter) GetActionsNeeded() string { + var convertNeeded []string + if c.VideoConvertNeeded || c.SingleVideoStreamNeeded { + convertNeeded = append(convertNeeded, "video") + } + if c.AudioConvertNeeded || c.SingleAudioStreamNeeded { + convertNeeded = append(convertNeeded, "audio") + } + return strings.Join(convertNeeded, ", ") +} + +func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (io.ReadCloser, error) { + reader, writer := io.Pipe() + var cmd *Cmd + + fmt.Print(" converting ", c.GetActionsNeeded(), "...\n") + + args := ffmpeg_go.KwArgs{"format": "mp4", "movflags": "frag_keyframe+empty_moov+faststart"} + + if c.VideoConvertNeeded { + args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:v": "libx264", "crf": 30, "preset": "veryfast"}}) + } else { + args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:v": "copy"}}) + } + + if c.AudioConvertNeeded { + args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "mp3", "q:a": 0}}) + } else { + args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "copy"}}) + } + + if c.SingleVideoStreamNeeded || c.SingleAudioStreamNeeded { + args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"map": "0:v:0,0:a:0"}}) + } + + ff := ffmpeg_go.Input("pipe:0").Output("pipe:1", args) + + var err error + var progressSock net.Listener + if c.UpdateProgressPercentCallback != nil { + if c.Duration > 0 { + var progressSockFilename string + progressSockFilename, progressSock, err = c.ffmpegProgressSock() + if err == nil { + ff = ff.GlobalArgs("-progress", "unix:"+progressSockFilename) + } + } else { + c.UpdateProgressPercentCallback(-1) + } + } + + ffCmd := ff.WithInput(rr).WithOutput(writer).Compile() + + // Creating a new cmd with a timeout context, which will kill the cmd if it takes too long. + cmd = NewCommand(ctx, ffCmd.Args[0], ffCmd.Args[1:]...) + cmd.Stdin = ffCmd.Stdin + cmd.Stdout = ffCmd.Stdout + + // This goroutine handles copying from the input (either rr or cmd.Stdout) to writer. + go func() { + err = cmd.Run() + writer.Close() + if progressSock != nil { + progressSock.Close() + } + }() + + if err != nil { + writer.Close() + return nil, fmt.Errorf("error converting: %w", err) + } + + return reader, nil +} diff --git a/dl.go b/dl.go new file mode 100644 index 0000000..9b9ee3c --- /dev/null +++ b/dl.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/wader/goutubedl" +) + +const downloadAndConvertTimeout = 5 * time.Minute + +type ProbeStartCallbackFunc func(ctx context.Context) +type ConvertStartCallbackFunc func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string) +type UpdateProgressPercentCallbackFunc func(progressPercent int) + +type Downloader struct { + ProbeStartFunc ProbeStartCallbackFunc + ConvertStartFunc ConvertStartCallbackFunc + UpdateProgressPercentFunc UpdateProgressPercentCallbackFunc +} + +type goYouTubeDLLogger struct{} + +func (l goYouTubeDLLogger) Print(v ...interface{}) { + fmt.Print(" yt-dlp dbg:") + fmt.Println(v...) +} + +func (d *Downloader) downloadURL(dlCtx context.Context, url string) (rr *ReReadCloser, err error) { + result, err := goutubedl.New(dlCtx, url, goutubedl.Options{ + Type: goutubedl.TypeSingle, + DebugLog: goYouTubeDLLogger{}, + MergeOutputFormat: "mkv", // This handles VP9 properly. yt-dlp uses mp4 by default, which doesn't. + SortingFormat: "res:720", // Prefer videos no larger than 720p to keep their size small. + }) + if err != nil { + return nil, fmt.Errorf("preparing download %q: %w", url, err) + } + + dlResult, err := result.Download(dlCtx, "") + if err != nil { + return nil, fmt.Errorf("downloading %q: %w", url, err) + } + + return NewReReadCloser(dlResult), nil +} + +func (d *Downloader) DownloadAndConvertURL(ctx context.Context, url string) (r io.ReadCloser, err error) { + rr, err := d.downloadURL(ctx, url) + if err != nil { + return nil, err + } + + conv := Converter{ + UpdateProgressPercentCallback: d.UpdateProgressPercentFunc, + } + + if d.ProbeStartFunc != nil { + d.ProbeStartFunc(ctx) + } + + if err := conv.Probe(rr); err != nil { + return nil, err + } + + if d.ConvertStartFunc != nil { + d.ConvertStartFunc(ctx, conv.VideoCodecs, conv.AudioCodecs, conv.GetActionsNeeded()) + } + + r, err = conv.ConvertIfNeeded(ctx, rr) + if err != nil { + return nil, err + } + + return r, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3165cd6 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module github.com/nonoo/yt-dlp-telegram-bot + +go 1.20 + +replace github.com/wader/goutubedl => github.com/nonoo/goutubedl v0.0.0-20230814114826-c1dcced79138 + +require ( + github.com/google/go-github/v53 v53.2.0 + github.com/gotd/td v0.84.0 + github.com/u2takey/ffmpeg-go v0.5.0 + github.com/wader/goutubedl v0.0.0-00010101000000-000000000000 + golang.org/x/exp v0.0.0-20230116083435-1de6713980de +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/aws/aws-sdk-go v1.38.20 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/go-faster/errors v0.6.1 // indirect + github.com/go-faster/jx v1.0.1 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/gotd/ige v0.2.2 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/u2takey/go-utils v0.3.1 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/trace v1.16.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.25.0 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/net v0.12.0 // indirect + golang.org/x/oauth2 v0.8.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.10.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.28.1 // indirect + nhooyr.io/websocket v1.8.7 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..214148d --- /dev/null +++ b/go.sum @@ -0,0 +1,184 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= +github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= +github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= +github.com/go-faster/jx v1.0.1 h1:NhSJEZtqj6KmXf63On7Hg7/sjUX+gotSc/eM6bZCZ00= +github.com/go-faster/jx v1.0.1/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= +github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= +github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= +github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.84.0 h1:oWMp5HczCAFSgKWgWFCuYjELBgcRVcRpGLdQ1bP2kpg= +github.com/gotd/td v0.84.0/go.mod h1:3dQsGL9rxMcS1Z9Na3S7U8e/pLMzbLIT2jM3E5IuUk0= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/nonoo/goutubedl v0.0.0-20230814114826-c1dcced79138 h1:zS49Q/Vxi7m1iPm10Or61K4tXUWFlb/D2jh3lk0sbfw= +github.com/nonoo/goutubedl v0.0.0-20230814114826-c1dcced79138/go.mod h1:5KXd5tImdbmz4JoVhePtbIokCwAfEhUVVx3WLHmjYuw= +github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= +github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= +github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= +github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071 h1:QkrG4Zr5OVFuC9aaMPmFI0ibfhBZlAgtzDYWfu7tqQk= +github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071/go.mod h1:XD6emOFPHVzb0+qQpiNOdPL2XZ0SRUM0N5JHuq6OmXo= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= +go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= +gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0= +golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..2874796 --- /dev/null +++ b/helper.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "fmt" + + "github.com/gotd/td/tg" +) + +// Helper function to pretty-print any Telegram API object to find out which it needs to be cast to. +// https://github.com/gotd/td/blob/main/examples/pretty-print/main.go + +// func formatObject(input interface{}) string { +// o, ok := input.(tdp.Object) +// if !ok { +// // Handle tg.*Box values. +// rv := reflect.Indirect(reflect.ValueOf(input)) +// for i := 0; i < rv.NumField(); i++ { +// if v, ok := rv.Field(i).Interface().(tdp.Object); ok { +// return formatObject(v) +// } +// } + +// return fmt.Sprintf("%T (not object)", input) +// } +// return tdp.Format(o) +// } + +func getProgressbar(progressPercent, progressBarLen int) (progressBar string) { + i := 0 + for ; i < progressPercent/(100/progressBarLen); i++ { + progressBar += "▰" + } + for ; i < progressBarLen; i++ { + progressBar += "▱" + } + progressBar += " " + fmt.Sprint(progressPercent) + "%" + return +} + +func resolveMsgSrc(msg *tg.Message) (fromUser *tg.PeerUser, fromGroup *tg.PeerChat) { + fromGroup, isGroupMsg := msg.PeerID.(*tg.PeerChat) + if isGroupMsg { + fromUser = msg.FromID.(*tg.PeerUser) + } else { + fromUser = msg.PeerID.(*tg.PeerUser) + } + return +} + +func getFromUsername(entities tg.Entities, fromUID int64) string { + if fromUser, ok := entities.Users[fromUID]; ok { + if un, ok := fromUser.GetUsername(); ok { + return un + } + } + return "" +} + +func sendTextToAdmins(ctx context.Context, msg string) { + for _, id := range params.AdminUserIDs { + _, _ = telegramSender.To(&tg.InputPeerUser{ + UserID: id, + }).Text(ctx, msg) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ec863aa --- /dev/null +++ b/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/url" + "os" + "strings" + "time" + + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/telegram/uploader" + "github.com/gotd/td/tg" + "golang.org/x/exp/slices" +) + +var dlQueue DownloadQueue + +var telegramUploader *uploader.Uploader +var telegramSender *message.Sender + +func uploadFile(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, f io.ReadCloser) error { + upload, err := telegramUploader.FromReader(ctx, "yt-dlp", f) + if err != nil { + return fmt.Errorf("uploading %w", err) + } + + // Now we have uploaded file handle, sending it as styled message. First, preparing message. + document := message.UploadedDocument(upload).Video() + + // Sending message with media. + if _, err := telegramSender.Answer(entities, u).Media(ctx, document); err != nil { + return fmt.Errorf("send: %w", err) + } + + return nil +} + +func handleCmdDLP(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) { + // Check if message is an URL. + validURI := true + uri, err := url.ParseRequestURI(msg.Message) + if err != nil || (uri.Scheme != "http" && uri.Scheme != "https") { + validURI = false + } else { + _, err = net.LookupHost(uri.Host) + if err != nil { + validURI = false + } + } + if !validURI { + fmt.Println(" (not an url)") + _, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": please enter an URL to download") + return + } + + dlQueue.Add(ctx, entities, u, msg.Message) +} + +func handleCmdDLPCancel(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) { + dlQueue.CancelCurrentEntry(ctx, entities, u, msg.Message) +} + +func handleMsg(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage) error { + msg, ok := u.Message.(*tg.Message) + if !ok || msg.Out { + // Outgoing message, not interesting. + return nil + } + + fromUser, fromGroup := resolveMsgSrc(msg) + fromUsername := getFromUsername(entities, fromUser.UserID) + + fmt.Print("got message") + if fromUsername != "" { + fmt.Print(" from ", fromUsername, "#", fromUser.UserID) + } + fmt.Println(":", msg.Message) + + if fromGroup != nil { + fmt.Print(" msg from group #", -fromGroup.ChatID) + if !slices.Contains(params.AllowedGroupIDs, -fromGroup.ChatID) { + fmt.Println(", group not allowed, ignoring") + return nil + } + fmt.Println() + } else { + if !slices.Contains(params.AllowedUserIDs, fromUser.UserID) { + fmt.Println(" user not allowed, ignoring") + return nil + } + } + + // Check if message is a command. + if msg.Message[0] == '/' { + cmd := strings.Split(msg.Message, " ")[0] + if strings.Contains(cmd, "@") { + cmd = strings.Split(cmd, "@")[0] + } + msg.Message = strings.TrimPrefix(msg.Message, cmd+" ") + switch cmd { + case "/dlp": + handleCmdDLP(ctx, entities, u, msg) + return nil + case "/dlpcancel": + handleCmdDLPCancel(ctx, entities, u, msg) + return nil + default: + fmt.Println(" (invalid cmd)") + _, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": invalid command") + return nil + } + } + + handleCmdDLP(ctx, entities, u, msg) + return nil +} + +func main() { + fmt.Println("yt-dlp-telegram-bot starting...") + + if err := params.Init(); err != nil { + fmt.Println("error:", err) + os.Exit(1) + } + + // Dispatcher handles incoming updates. + dispatcher := tg.NewUpdateDispatcher() + opts := telegram.Options{ + UpdateHandler: dispatcher, + } + var err error + opts, err = telegram.OptionsFromEnvironment(opts) + if err != nil { + panic(fmt.Sprint("options from env err: ", err)) + } + + client := telegram.NewClient(params.ApiID, params.ApiHash, opts) + + if err := client.Run(context.Background(), func(ctx context.Context) error { + status, err := client.Auth().Status(ctx) + if err != nil { + panic(fmt.Sprint("auth status err: ", err)) + } + + if !status.Authorized { // Not logged in? + fmt.Println("logging in...") + if _, err := client.Auth().Bot(ctx, params.BotToken); err != nil { + panic(fmt.Sprint("login err: ", err)) + } + } + + api := client.API() + + telegramUploader = uploader.NewUploader(api) + telegramSender = message.NewSender(api).WithUploader(telegramUploader) + + dlQueue.Init(ctx) + + dispatcher.OnNewMessage(handleMsg) + + fmt.Println("telegram connection up") + + ytdlpVersionCheckStr, _ := ytdlpVersionCheckGetStr(ctx) + sendTextToAdmins(ctx, "🤖 Bot started, "+ytdlpVersionCheckStr) + + go func() { + for { + time.Sleep(24 * time.Hour) + if s, updateNeededOrError := ytdlpVersionCheckGetStr(ctx); updateNeededOrError { + sendTextToAdmins(ctx, s) + } + } + }() + + <-ctx.Done() + return nil + }); err != nil { + panic(err) + } +} diff --git a/params.go b/params.go new file mode 100644 index 0000000..f18476c --- /dev/null +++ b/params.go @@ -0,0 +1,131 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/wader/goutubedl" + "golang.org/x/exp/slices" +) + +type paramsType struct { + ApiID int + ApiHash string + BotToken string + + AllowedUserIDs []int64 + AdminUserIDs []int64 + AllowedGroupIDs []int64 +} + +var params paramsType + +func (p *paramsType) Init() error { + // Further available environment variables: + // SESSION_FILE: path to session file + // SESSION_DIR: path to session directory, if SESSION_FILE is not set + + var apiID string + flag.StringVar(&apiID, "api-id", "", "telegram api_id") + flag.StringVar(&p.ApiHash, "api-hash", "", "telegram api_hash") + flag.StringVar(&p.BotToken, "bot-token", "", "telegram bot token") + flag.StringVar(&goutubedl.Path, "yt-dlp-path", "", "yt-dlp path") + var allowedUserIDs string + flag.StringVar(&allowedUserIDs, "allowed-user-ids", "", "allowed telegram user ids") + var adminUserIDs string + flag.StringVar(&adminUserIDs, "admin-user-ids", "", "admin telegram user ids") + var allowedGroupIDs string + flag.StringVar(&allowedGroupIDs, "allowed-group-ids", "", "allowed telegram group ids") + flag.Parse() + + var err error + if apiID == "" { + apiID = os.Getenv("API_ID") + } + if apiID == "" { + return fmt.Errorf("api id not set") + } + p.ApiID, err = strconv.Atoi(apiID) + if err != nil { + return fmt.Errorf("invalid env var API_ID") + } + + if p.ApiHash == "" { + p.ApiHash = os.Getenv("API_HASH") + } + if p.ApiHash == "" { + return fmt.Errorf("api hash not set") + } + + if p.BotToken == "" { + p.BotToken = os.Getenv("BOT_TOKEN") + } + if p.BotToken == "" { + return fmt.Errorf("bot token not set") + } + + if goutubedl.Path == "" { + goutubedl.Path = os.Getenv("YTDLP_PATH") + } + if goutubedl.Path == "" { + goutubedl.Path = "yt-dlp" + } + goutubedl.Path, err = exec.LookPath(goutubedl.Path) + if err != nil { + return fmt.Errorf("yt-dlp not found") + } + + if allowedUserIDs == "" { + allowedUserIDs = os.Getenv("ALLOWED_USERIDS") + } + sa := strings.Split(allowedUserIDs, ",") + for _, idStr := range sa { + if idStr == "" { + continue + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return fmt.Errorf("env var ALLOWED_USERIDS contains invalid user ID: " + idStr) + } + p.AllowedUserIDs = append(p.AllowedUserIDs, id) + } + + if adminUserIDs == "" { + adminUserIDs = os.Getenv("ADMIN_USERIDS") + } + sa = strings.Split(adminUserIDs, ",") + for _, idStr := range sa { + if idStr == "" { + continue + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return fmt.Errorf("env var ADMIN_USERIDS contains invalid user ID: " + idStr) + } + p.AdminUserIDs = append(p.AdminUserIDs, id) + if !slices.Contains(p.AllowedUserIDs, id) { + p.AllowedUserIDs = append(p.AllowedUserIDs, id) + } + } + + if allowedGroupIDs == "" { + allowedGroupIDs = os.Getenv("ALLOWED_GROUPIDS") + } + sa = strings.Split(allowedGroupIDs, ",") + for _, idStr := range sa { + if idStr == "" { + continue + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return fmt.Errorf("env var ALLOWED_GROUPIDS contains invalid group ID: " + idStr) + } + p.AllowedGroupIDs = append(p.AllowedUserIDs, id) + } + + return nil +} diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..6cfde03 --- /dev/null +++ b/queue.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" +) + +const processStartStr = "🔍 Getting information..." +const processStr = "🔨 Processing" +const processDoneStr = "🏁 Processing" +const errorStr = "❌ Error" +const canceledStr = "❌ Canceled" + +const maxProgressPercentUpdateInterval = time.Second +const progressBarLength = 10 + +type DownloadQueueEntry struct { + URL string + + OrigEntities tg.Entities + OrigMsgUpdate *tg.UpdateNewMessage + OrigMsg *tg.Message + FromUser *tg.PeerUser + FromGroup *tg.PeerChat + + Reply *message.Builder + ReplyMsg *tg.UpdateShortSentMessage + + Ctx context.Context + CtxCancel context.CancelFunc + Canceled bool +} + +// func (e *DownloadQueueEntry) getTypingActionDst() tg.InputPeerClass { +// if e.FromGroup != nil { +// return &tg.InputPeerChat{ +// ChatID: e.FromGroup.ChatID, +// } +// } +// return &tg.InputPeerUser{ +// UserID: e.FromUser.UserID, +// } +// } + +func (e *DownloadQueueEntry) sendTypingAction(ctx context.Context) { + // _ = telegramSender.To(e.getTypingActionDst()).TypingAction().Typing(ctx) +} + +func (e *DownloadQueueEntry) sendTypingCancelAction(ctx context.Context) { + // _ = telegramSender.To(e.getTypingActionDst()).TypingAction().Cancel(ctx) +} + +func (e *DownloadQueueEntry) editReply(ctx context.Context, s string) { + _, _ = e.Reply.Edit(e.ReplyMsg.ID).Text(ctx, s) + e.sendTypingAction(ctx) +} + +type DownloadQueue struct { + mutex sync.Mutex + entries []DownloadQueueEntry + processReqChan chan bool +} + +func (e *DownloadQueue) getQueuePositionString(pos int) string { + return "👨‍👦‍👦 Request queued at position #" + fmt.Sprint(pos) +} + +func (q *DownloadQueue) Add(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url string) { + q.mutex.Lock() + + var replyStr string + if len(q.entries) == 0 { + replyStr = processStartStr + } else { + fmt.Println(" queueing request at position #", len(q.entries)) + replyStr = q.getQueuePositionString(len(q.entries)) + } + + newEntry := DownloadQueueEntry{ + URL: url, + OrigEntities: entities, + OrigMsgUpdate: u, + OrigMsg: u.Message.(*tg.Message), + } + + newEntry.Reply = telegramSender.Reply(entities, u) + replyText, _ := newEntry.Reply.Text(ctx, replyStr) + newEntry.ReplyMsg = replyText.(*tg.UpdateShortSentMessage) + + newEntry.FromUser, newEntry.FromGroup = resolveMsgSrc(newEntry.OrigMsg) + + q.entries = append(q.entries, newEntry) + q.mutex.Unlock() + + select { + case q.processReqChan <- true: + default: + } +} + +func (q *DownloadQueue) CancelCurrentEntry(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url string) { + q.mutex.Lock() + if len(q.entries) > 0 { + q.entries[0].Canceled = true + q.entries[0].CtxCancel() + } else { + fmt.Println(" no active request to cancel") + _, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": no active request to cancel") + } + q.mutex.Unlock() +} + +func (q *DownloadQueue) updateProgress(ctx context.Context, qEntry *DownloadQueueEntry, progressPercent int, sourceCodecInfo string) { + if progressPercent < 0 { + qEntry.editReply(ctx, processStr+"... (no progress available)\n"+sourceCodecInfo) + return + } + if progressPercent == 0 { + qEntry.editReply(ctx, processStr+"...\n"+sourceCodecInfo) + return + } + fmt.Print(" progress: ", progressPercent, "%\n") + qEntry.editReply(ctx, processStr+": "+getProgressbar(progressPercent, progressBarLength)+"\n"+sourceCodecInfo) +} + +func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQueueEntry) { + fromUsername := getFromUsername(qEntry.OrigEntities, qEntry.FromUser.UserID) + fmt.Print("processing request by") + if fromUsername != "" { + fmt.Print(" from ", fromUsername, "#", qEntry.FromUser.UserID) + } + fmt.Println(":", qEntry.URL) + + qEntry.editReply(ctx, processStartStr) + + var disableProgressPercentUpdate bool + var lastProgressPercentUpdateAt time.Time + var lastProgressPercent int + var progressUpdateTimer *time.Timer + var sourceCodecInfo string + downloader := Downloader{ + ProbeStartFunc: func(ctx context.Context) { + qEntry.editReply(ctx, "🎬 Getting video format...") + }, + ConvertStartFunc: func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string) { + sourceCodecInfo = "🎬 Source: " + videoCodecs + if audioCodecs == "" { + sourceCodecInfo += ", no audio" + } else { + sourceCodecInfo += " / " + audioCodecs + } + if convertActionsNeeded == "" { + sourceCodecInfo += " (no conversion needed)" + } else { + sourceCodecInfo += " (converting: " + convertActionsNeeded + ")" + } + qEntry.editReply(ctx, "🎬 Preparing download...\n"+sourceCodecInfo) + }, + UpdateProgressPercentFunc: func(progressPercent int) { + if disableProgressPercentUpdate || lastProgressPercent == progressPercent { + return + } + lastProgressPercent = progressPercent + if progressPercent < 0 { + disableProgressPercentUpdate = true + q.updateProgress(ctx, qEntry, progressPercent, sourceCodecInfo) + return + } + + if progressUpdateTimer != nil { + progressUpdateTimer.Stop() + select { + case <-progressUpdateTimer.C: + default: + } + } + + timeElapsedSinceLastUpdate := time.Since(lastProgressPercentUpdateAt) + if timeElapsedSinceLastUpdate < maxProgressPercentUpdateInterval { + progressUpdateTimer = time.AfterFunc(maxProgressPercentUpdateInterval-timeElapsedSinceLastUpdate, func() { + q.updateProgress(ctx, qEntry, progressPercent, sourceCodecInfo) + lastProgressPercentUpdateAt = time.Now() + }) + return + } + q.updateProgress(ctx, qEntry, progressPercent, sourceCodecInfo) + lastProgressPercentUpdateAt = time.Now() + }, + } + + r, err := downloader.DownloadAndConvertURL(qEntry.Ctx, qEntry.OrigMsg.Message) + if err != nil { + fmt.Println(" error downloading:", err) + qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err)) + return + } + + // Feeding the returned io.ReadCloser to the uploader. + fmt.Println(" processing...") + q.updateProgress(ctx, qEntry, lastProgressPercent, sourceCodecInfo) + err = uploadFile(ctx, qEntry.OrigEntities, qEntry.OrigMsgUpdate, r) + if err != nil { + fmt.Println(" error processing:", err) + disableProgressPercentUpdate = true + r.Close() + qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err)) + return + } + disableProgressPercentUpdate = true + r.Close() + + if qEntry.Canceled { + fmt.Print(" canceled\n") + qEntry.editReply(ctx, canceledStr+": "+getProgressbar(lastProgressPercent, progressBarLength)+"\n"+sourceCodecInfo) + } else if lastProgressPercent < 100 { + fmt.Print(" progress: 100%\n") + qEntry.editReply(ctx, processDoneStr+": "+getProgressbar(100, progressBarLength)+"\n"+sourceCodecInfo) + } + qEntry.sendTypingCancelAction(ctx) +} + +func (q *DownloadQueue) processor(ctx context.Context) { + for { + q.mutex.Lock() + if (len(q.entries)) == 0 { + q.mutex.Unlock() + <-q.processReqChan + continue + } + + // Updating queue positions for all waiting entries. + for i := 1; i < len(q.entries); i++ { + q.entries[i].editReply(ctx, q.getQueuePositionString(i)) + q.entries[i].sendTypingCancelAction(ctx) + } + + q.entries[0].Ctx, q.entries[0].CtxCancel = context.WithTimeout(ctx, downloadAndConvertTimeout) + + qEntry := &q.entries[0] + q.mutex.Unlock() + + q.processQueueEntry(ctx, qEntry) + + q.mutex.Lock() + q.entries[0].CtxCancel() + q.entries = q.entries[1:] + if len(q.entries) == 0 { + fmt.Print("finished queue processing\n") + } + q.mutex.Unlock() + } +} + +func (q *DownloadQueue) Init(ctx context.Context) { + q.processReqChan = make(chan bool) + go q.processor(ctx) +} diff --git a/rereader.go b/rereader.go new file mode 100644 index 0000000..247fdbc --- /dev/null +++ b/rereader.go @@ -0,0 +1,78 @@ +package main + +// Copyright (c) 2016 Mattias Wadman + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import ( + "bytes" + "io" +) + +type restartBuffer struct { + Buffer bytes.Buffer + Restarted bool +} + +func (rb *restartBuffer) Read(r io.Reader, p []byte) (n int, err error) { + if rb.Restarted { + if rb.Buffer.Len() > 0 { + return rb.Buffer.Read(p) + } + n, err = r.Read(p) + return n, err + } + + n, err = r.Read(p) + rb.Buffer.Write(p[:n]) + + return n, err +} + +// ReReader transparently buffers all reads from a reader until Restarted +// is set to true. When restarted buffered data will be replayed on read and +// after that normal reading from the reader continues. +type ReReader struct { + io.Reader + restartBuffer +} + +// NewReReader return a initialized ReReader +func NewReReader(r io.Reader) *ReReader { + return &ReReader{Reader: r} +} + +func (rr *ReReader) Read(p []byte) (n int, err error) { + return rr.restartBuffer.Read(rr.Reader, p) +} + +// ReReadCloser is same as ReReader but also forwards Close calls +type ReReadCloser struct { + io.ReadCloser + restartBuffer +} + +// NewReReadCloser return a initialized ReReadCloser +func NewReReadCloser(rc io.ReadCloser) *ReReadCloser { + return &ReReadCloser{ReadCloser: rc} +} + +func (rc *ReReadCloser) Read(p []byte) (n int, err error) { + return rc.restartBuffer.Read(rc.ReadCloser, p) +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..93c8fb1 Binary files /dev/null and b/screenshot.png differ diff --git a/vercheck.go b/vercheck.go new file mode 100644 index 0000000..c8dbe4a --- /dev/null +++ b/vercheck.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/google/go-github/v53/github" + "github.com/wader/goutubedl" +) + +const ytdlpVersionCheckTimeout = time.Second * 10 + +func ytdlpVersionCheck(ctx context.Context) (latestVersion, currentVersion string, err error) { + client := github.NewClient(nil) + + release, _, err := client.Repositories.GetLatestRelease(ctx, "yt-dlp", "yt-dlp") + if err != nil { + return "", "", fmt.Errorf("getting latest yt-dlp version: %w", err) + } + latestVersion = release.GetTagName() + + out, err := exec.Command(goutubedl.Path, "--version").Output() + if err != nil { + return "", "", fmt.Errorf("getting current yt-dlp version: %w", err) + } + + currentVersion = strings.TrimSpace(string(out)) + return +} + +func ytdlpVersionCheckGetStr(ctx context.Context) (res string, updateNeededOrError bool) { + verCheckCtx, verCheckCtxCancel := context.WithTimeout(ctx, ytdlpVersionCheckTimeout) + defer verCheckCtxCancel() + + var latestVersion, currentVersion string + var err error + if latestVersion, currentVersion, err = ytdlpVersionCheck(verCheckCtx); err != nil { + return errorStr + ": " + err.Error(), true + } + + updateNeededOrError = currentVersion != latestVersion + res = "yt-dlp version: " + currentVersion + if updateNeededOrError { + res = "📢 " + res + " 📢 Update needed! Latest version is " + latestVersion + " 📢" + } else { + res += " (up to date)" + } + return +} diff --git a/yt-dlp-telegram-bot.code-workspace b/yt-dlp-telegram-bot.code-workspace new file mode 100644 index 0000000..362d7c2 --- /dev/null +++ b/yt-dlp-telegram-bot.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file