Compare commits

..
12 Commits
Author SHA1 Message Date
Sun Cheng e21f26de0a save video message 2026-03-02 14:19:12 +08:00
Sun Cheng a42bad7c48 save videos to save dir 2026-03-02 13:11:38 +08:00
Norbert VargaandGitHub f2c9c4e388 Merge pull request #14 from pmezhuev/patch-2
Fix "not an url" for groups
2024-08-16 07:52:23 +03:00
Norbert VargaandGitHub f9d5615320 Merge pull request #13 from pmezhuev/patch-1
Fix go version in Dockerfile
2024-08-16 07:52:07 +03:00
Pavel MezhuevandGitHub a1c66784d2 Fix "not an url" for groups 2024-08-14 00:48:40 +03:00
Pavel MezhuevandGitHub 6b67239a90 Fix go version in Dockerfile 2024-08-13 21:44:12 +03:00
Nonoo 2b3658ceed Upgrade go and modules 2024-06-26 09:13:35 +02:00
Nonoo 548e7effb7 Auto update yt-dlp 2023-10-01 11:41:15 +02:00
Nonoo 1d75df37cf Do not include yt-dlp in Dockerfile, as it will be auto downloaded 2023-10-01 11:33:06 +02:00
Nonoo f1724719ec Auto download yt-dlp if not found in path 2023-10-01 11:32:22 +02:00
Nonoo 540ddc2bad Add audio only download support 2023-09-08 17:08:44 +02:00
Nonoo d5b81a3b5a Add support for ! cmd char 2023-08-22 10:30:40 +02:00
19 changed files with 851 additions and 167 deletions
+2
View File
@@ -1,2 +1,4 @@
/config.inc.sh
/yt-dlp-telegram-bot
/.vscode
/save
+4 -4
View File
@@ -1,4 +1,4 @@
FROM golang:1.20 as builder
FROM golang:1.22 AS builder
WORKDIR /app/
COPY go.mod go.sum /app/
RUN go mod download
@@ -6,11 +6,11 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v
FROM python:alpine
RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp && \
chmod 755 /usr/local/bin/yt-dlp
RUN apk update && apk upgrade && apk add --no-cache ffmpeg
COPY --from=builder /app/yt-dlp-telegram-bot /app/yt-dlp-telegram-bot
COPY --from=builder /app/yt-dlp.conf /root/yt-dlp.conf
RUN mkdir -p /root/save
ENTRYPOINT ["/app/yt-dlp-telegram-bot"]
ENV API_ID= API_HASH= BOT_TOKEN= ALLOWED_USERIDS= ADMIN_USERIDS= ALLOWED_GROUPIDS= YTDLP_COOKIES=
ENV API_ID= API_HASH= BOT_TOKEN= ALLOWED_USERIDS= ADMIN_USERIDS= ALLOWED_GROUPIDS= SAVE_DIR=/root/save YTDLP_COOKIES=
+8 -4
View File
@@ -12,9 +12,11 @@ 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. Incompatible video and audio
streams are automatically converted to match those which are supported by
Telegram's built-in video player.
Telegram bot API. Videos are saved to disk in the configured `SAVE_DIR`
(default: `/root/save_dir`). Incompatible video and audio streams are
automatically converted to match those which are supported by Telegram's
built-in video player. Videos larger than 512MB are saved but not uploaded
to Telegram.
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
@@ -80,6 +82,7 @@ variable. Available OS environment variables are:
- `ADMIN_USERIDS`
- `ALLOWED_GROUPIDS`
- `MAX_SIZE`
- `SAVE_DIR` - Directory where downloaded videos are saved (default: `/root/save_dir`)
- `YTDLP_COOKIES`
The contents of the `YTDLP_COOKIES` environment variable will be written to the
@@ -89,7 +92,8 @@ cookie file.
## Supported commands
- `/dlp` - Download
- `/dlp` - Download given URL. If the first attribute is "mp3" then only the
audio stream will be downloaded and converted (if needed) to 320k MP3
- `/dlpcancel` - Cancel ongoing download
You don't need to enter the `/dlp` command if you send an URL to the bot using
+1
View File
@@ -5,3 +5,4 @@ ALLOWED_USERIDS=
ADMIN_USERIDS=
ALLOWED_GROUPIDS=
MAX_SIZE=
SAVE_DIR=/root/save_dir
+86 -33
View File
@@ -4,11 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -27,6 +27,8 @@ var compatibleAudioCodecs = []string{"aac", "opus", "mp3"}
type ffmpegProbeDataStreamsStream struct {
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
}
type ffmpegProbeDataFormat struct {
@@ -40,9 +42,13 @@ type ffmpegProbeData struct {
}
type Converter struct {
Format string
VideoCodecs string
VideoConvertNeeded bool
SingleVideoStreamNeeded bool
VideoWidth int
VideoHeight int
AudioCodecs string
AudioConvertNeeded bool
@@ -53,14 +59,9 @@ type Converter struct {
UpdateProgressPercentCallback UpdateProgressPercentCallbackFunc
}
func (c *Converter) Probe(rr *ReReadCloser) error {
defer func() {
// Restart and replay buffer data used when probing
rr.Restarted = true
}()
func (c *Converter) ProbeFile(filePath string) error {
fmt.Println(" probing file...")
i, err := ffmpeg_go.ProbeReaderWithTimeout(io.LimitReader(rr, maxFFmpegProbeBytes), probeTimeout, nil)
i, err := ffmpeg_go.ProbeWithTimeout(filePath, probeTimeout, nil)
if err != nil {
return fmt.Errorf("error probing file: %w", err)
}
@@ -76,15 +77,30 @@ func (c *Converter) Probe(rr *ReReadCloser) error {
fmt.Println(" error parsing duration:", err)
}
compatibleVideoCodecsCopy := compatibleVideoCodecs
if c.Format == "mp3" {
compatibleVideoCodecsCopy = []string{}
}
compatibleAudioCodecsCopy := compatibleAudioCodecs
if c.Format == "mp3" {
compatibleAudioCodecsCopy = []string{"mp3"}
}
gotVideoStream := false
gotAudioStream := false
for _, stream := range pd.Streams {
if stream.CodecType == "video" {
if stream.CodecType == "video" && len(compatibleVideoCodecsCopy) > 0 {
if c.VideoCodecs != "" {
c.VideoCodecs += ", "
}
c.VideoCodecs += stream.CodecName
// Store video dimensions for aspect ratio preservation
if stream.Width > 0 && stream.Height > 0 {
c.VideoWidth = stream.Width
c.VideoHeight = stream.Height
}
if gotVideoStream {
fmt.Println(" got additional video stream")
c.SingleVideoStreamNeeded = true
@@ -107,7 +123,7 @@ func (c *Converter) Probe(rr *ReReadCloser) error {
fmt.Println(" got additional audio stream")
c.SingleAudioStreamNeeded = true
} else if !c.AudioConvertNeeded {
if !slices.Contains(compatibleAudioCodecs, stream.CodecName) {
if !slices.Contains(compatibleAudioCodecsCopy, stream.CodecName) {
fmt.Println(" found not compatible audio codec:", stream.CodecName)
c.AudioConvertNeeded = true
} else {
@@ -118,7 +134,7 @@ func (c *Converter) Probe(rr *ReReadCloser) error {
}
}
if !gotVideoStream {
if len(compatibleVideoCodecsCopy) > 0 && !gotVideoStream {
return fmt.Errorf("no video stream found in file")
}
@@ -181,33 +197,70 @@ func (c *Converter) GetActionsNeeded() string {
return strings.Join(convertNeeded, ", ")
}
func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (io.ReadCloser, error) {
reader, writer := io.Pipe()
var cmd *Cmd
func (c *Converter) ConvertIfNeeded(ctx context.Context, inputPath, outputDir string) (outputPath string, outputFormat string, err error) {
fmt.Print(" converting ", c.GetActionsNeeded(), "...\n")
args := ffmpeg_go.KwArgs{"format": "mp4", "movflags": "frag_keyframe+empty_moov+faststart"}
videoNeeded := true
outputFormat = "mp4"
if c.Format == "mp3" {
videoNeeded = false
outputFormat = "mp3"
}
// Determine output path
ext := filepath.Ext(inputPath)
base := strings.TrimSuffix(filepath.Base(inputPath), ext)
outputPath = filepath.Join(outputDir, base+"_converted."+outputFormat)
// Check if conversion is needed
if !c.VideoConvertNeeded && !c.AudioConvertNeeded && !c.SingleVideoStreamNeeded && !c.SingleAudioStreamNeeded {
if outputFormat == "mp4" && ext == ".mkv" {
// Just remux from mkv to mp4, no encoding needed
fmt.Println(" remuxing mkv to mp4...")
} else {
fmt.Println(" no conversion needed, using original file")
return inputPath, outputFormat, nil
}
}
args := ffmpeg_go.KwArgs{
"format": outputFormat,
}
if videoNeeded {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"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"}})
}
} else {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"vn": ""}})
}
if c.AudioConvertNeeded {
if c.Format == "mp3" {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "mp3", "b:a": "320k"}})
} else {
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 videoNeeded {
if c.SingleVideoStreamNeeded || c.SingleAudioStreamNeeded {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"map": "0:v:0,0:a:0"}})
}
} else {
if c.SingleAudioStreamNeeded {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"map": "0:a:0"}})
}
}
ff := ffmpeg_go.Input("pipe:0").Output("pipe:1", args)
ff := ffmpeg_go.Input(inputPath).Output(outputPath, args)
var err error
var progressSock net.Listener
if c.UpdateProgressPercentCallback != nil {
if c.Duration > 0 {
@@ -221,26 +274,26 @@ func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (io.R
}
}
ffCmd := ff.WithInput(rr).WithOutput(writer).Compile()
// Run ffmpeg
cmd := ff.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
// Creating a new cmd with a timeout context
cmdCtx := NewCommand(ctx, cmd.Args[0], cmd.Args[1:]...)
// This goroutine handles copying from the input (either rr or cmd.Stdout) to writer.
go func() {
err = cmd.Run()
writer.Close()
if err := cmdCtx.Run(); err != nil {
if progressSock != nil {
progressSock.Close()
}
}()
if err != nil {
writer.Close()
return nil, fmt.Errorf("error converting: %w", err)
return "", "", fmt.Errorf("error converting: %w", err)
}
return reader, nil
if progressSock != nil {
progressSock.Close()
}
return outputPath, outputFormat, nil
}
func (c *Converter) NeedConvert() bool {
return c.VideoConvertNeeded || c.AudioConvertNeeded || c.SingleVideoStreamNeeded || c.SingleAudioStreamNeeded
}
+128 -24
View File
@@ -4,12 +4,18 @@ import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/dustin/go-humanize"
"github.com/wader/goutubedl"
)
const downloadAndConvertTimeout = 5 * time.Minute
const downloadAndConvertTimeout = 30 * time.Minute
const telegramUploadThreshold = 512 * 1024 * 1024 // 512MB
const downloadProgressUpdateInterval = time.Second
type ConvertStartCallbackFunc func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string)
type UpdateProgressPercentCallbackFunc func(progressStr string, progressPercent int)
@@ -26,48 +32,146 @@ func (l goYouTubeDLLogger) Print(v ...interface{}) {
fmt.Println(v...)
}
func (d *Downloader) downloadURL(dlCtx context.Context, url string) (rr *ReReadCloser, err error) {
type DownloadResult struct {
Title string
FilePath string
FileSize int64
}
// progressWriter wraps an io.Writer and tracks bytes written
type progressWriter struct {
writer io.Writer
written int64
total int64 // estimated total size, 0 if unknown
callback UpdateProgressPercentCallbackFunc
lastUpdate time.Time
updateInterval time.Duration
}
func newProgressWriter(w io.Writer, total int64, callback UpdateProgressPercentCallbackFunc) *progressWriter {
return &progressWriter{
writer: w,
total: total,
callback: callback,
lastUpdate: time.Now(),
updateInterval: downloadProgressUpdateInterval,
}
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
n, err = pw.writer.Write(p)
if n > 0 {
atomic.AddInt64(&pw.written, int64(n))
now := time.Now()
if now.Sub(pw.lastUpdate) >= pw.updateInterval {
pw.lastUpdate = now
written := atomic.LoadInt64(&pw.written)
if pw.total > 0 && pw.callback != nil {
percent := int(float64(written) * 100 / float64(pw.total))
if percent > 100 {
percent = 100
}
pw.callback("⬇️ Downloading", percent)
} else if pw.callback != nil {
// Unknown total size, just show bytes downloaded
pw.callback(fmt.Sprintf("⬇️ Downloaded %s", humanize.Bytes(uint64(written))), -1)
}
}
}
return n, err
}
func (pw *progressWriter) Written() int64 {
return atomic.LoadInt64(&pw.written)
}
func (d *Downloader) downloadURL(dlCtx context.Context, url string) (*DownloadResult, error) {
// Use 4K quality for saving, but fall back to best available
result, err := goutubedl.New(dlCtx, url, goutubedl.Options{
Type: goutubedl.TypeSingle,
DebugLog: goYouTubeDLLogger{},
// StderrFn: func(cmd *exec.Cmd) io.Writer { return io.Writer(os.Stdout) },
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.
SortingFormat: "res:2160", // Prefer videos up to 4K (2160p)
})
if err != nil {
return nil, fmt.Errorf("preparing download %q: %w", url, err)
}
// Create filename with date and timestamp format: 2025-11-22-{timestamp}.mkv
now := time.Now()
dateStr := now.Format("2006-01-02")
timestamp := now.Unix()
fileName := fmt.Sprintf("%s-%d.mkv", dateStr, timestamp)
filePath := filepath.Join(params.SaveDir, fileName)
// Check if file already exists, if so add a suffix
if _, err := os.Stat(filePath); err == nil {
for i := 1; i < 1000; i++ {
fileName = fmt.Sprintf("%s-%d-%d.mkv", dateStr, timestamp, i)
filePath = filepath.Join(params.SaveDir, fileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
break
}
}
}
dlResult, err := result.Download(dlCtx, "")
if err != nil {
return nil, fmt.Errorf("downloading %q: %w", url, err)
}
defer dlResult.Close()
return NewReReadCloser(dlResult), nil
}
func (d *Downloader) DownloadAndConvertURL(ctx context.Context, url string) (r io.ReadCloser, err error) {
rr, err := d.downloadURL(ctx, url)
// Create file
file, err := os.Create(filePath)
if err != nil {
return nil, err
return nil, fmt.Errorf("creating file %q: %w", filePath, err)
}
defer file.Close()
// Get estimated file size from format info
var estimatedSize int64
if len(result.Info.Formats) > 0 {
// Try to get filesize from the selected format
for _, f := range result.Info.Formats {
if f.Filesize > 0 {
fs := int64(f.Filesize)
if fs > estimatedSize {
estimatedSize = fs
}
} else if f.FilesizeApprox > 0 && estimatedSize == 0 {
estimatedSize = int64(f.FilesizeApprox)
}
}
}
if estimatedSize == 0 && result.Info.Filesize > 0 {
estimatedSize = int64(result.Info.Filesize)
}
if estimatedSize == 0 && result.Info.FilesizeApprox > 0 {
estimatedSize = int64(result.Info.FilesizeApprox)
}
conv := Converter{
UpdateProgressPercentCallback: d.UpdateProgressPercentFunc,
}
// Create progress writer
pw := newProgressWriter(file, estimatedSize, d.UpdateProgressPercentFunc)
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)
// Copy data to file with progress tracking
_, err = io.Copy(pw, dlResult)
if err != nil {
return nil, err
os.Remove(filePath)
return nil, fmt.Errorf("writing to file %q: %w", filePath, err)
}
return r, nil
written := pw.Written()
fmt.Printf(" saved to %s (%s)\n", filePath, humanize.Bytes(uint64(written)))
return &DownloadResult{
Title: result.Info.Title,
FilePath: filePath,
FileSize: written,
}, nil
}
func (d *Downloader) DownloadAndConvertURL(ctx context.Context, url, format string) (*DownloadResult, error) {
return d.downloadURL(ctx, url)
}
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker build -t nonoo/yt-dlp-telegram-bot:latest --network=host .
+17
View File
@@ -0,0 +1,17 @@
services:
downloader:
build: .
image: yt-dlp-telegram-bot:latest
container_name: downloader
restart: unless-stopped
volumes:
- ./yt-dlp.conf:/root/yt-dlp.conf
- /var/apps/docker-chromium/shares/chromium:/root/chromium
- ./save:/root/save
environment:
- API_ID=32195099
- API_HASH=16bd171827e9e8ee21d9e1a3192ac30b
- BOT_TOKEN=8681926392:AAEszGJxIQaslfXuWQw5eMqcuGxSL_-3xQU
- ALLOWED_USERIDS=1143940780,6073512239
- ADMIN_USERIDS=1143940780
- SAVE_DIR=/root/save
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
docker push nonoo/yt-dlp-telegram-bot:latest
+3 -2
View File
@@ -1,13 +1,14 @@
module github.com/nonoo/yt-dlp-telegram-bot
go 1.20
go 1.22
require (
github.com/dustin/go-humanize v1.0.1
github.com/flytam/filenamify v1.2.0
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-20230815212531-02ec4fe77de3
github.com/wader/goutubedl v0.0.0-20240626070646-8cef76d0c092
golang.org/x/exp v0.0.0-20230116083435-1de6713980de
)
+13 -2
View File
@@ -3,6 +3,7 @@ github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0g
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/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
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=
@@ -15,6 +16,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
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=
@@ -54,6 +57,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@@ -76,6 +80,7 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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=
@@ -85,6 +90,7 @@ 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/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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=
@@ -104,6 +110,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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=
@@ -112,8 +119,8 @@ 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/goutubedl v0.0.0-20230815212531-02ec4fe77de3 h1:JS151H65F6dF4KYtSSo2PRj7s4Gb+YhOz40e24zO/e8=
github.com/wader/goutubedl v0.0.0-20230815212531-02ec4fe77de3/go.mod h1:5KXd5tImdbmz4JoVhePtbIokCwAfEhUVVx3WLHmjYuw=
github.com/wader/goutubedl v0.0.0-20240626070646-8cef76d0c092 h1:BQ+eGEAUeSzrXx3ruK+pLM50FczmxhhtdA1UNYWRioQ=
github.com/wader/goutubedl v0.0.0-20240626070646-8cef76d0c092/go.mod h1:5KXd5tImdbmz4JoVhePtbIokCwAfEhUVVx3WLHmjYuw=
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=
@@ -123,6 +130,7 @@ go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLk
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/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
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=
@@ -162,6 +170,7 @@ 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/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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=
@@ -178,7 +187,9 @@ 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.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
+95 -8
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/url"
"os"
"os/exec"
"strings"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/gotd/td/telegram/message"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg"
"github.com/wader/goutubedl"
"golang.org/x/exp/slices"
)
@@ -21,7 +23,17 @@ var dlQueue DownloadQueue
var telegramUploader *uploader.Uploader
var telegramSender *message.Sender
// telegramClient is the global client reference for video download
var telegramClient *telegram.Client
func handleCmdDLP(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) {
format := "video"
s := strings.Split(msg.Message, " ")
if len(s) >= 2 && s[0] == "mp3" {
msg.Message = strings.Join(s[1:], " ")
format = "mp3"
}
// Check if message is an URL.
validURI := true
uri, err := url.ParseRequestURI(msg.Message)
@@ -39,13 +51,34 @@ func handleCmdDLP(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMess
return
}
dlQueue.Add(ctx, entities, u, msg.Message)
dlQueue.Add(ctx, entities, u, msg.Message, format)
}
func handleCmdDLPCancel(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) {
dlQueue.CancelCurrentEntry(ctx, entities, u, msg.Message)
}
func handleVideoMessage(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) {
fmt.Println(" (video message, queueing to save_dir)")
// Get video info from media
var videoFile *tg.Document
switch media := msg.Media.(type) {
case *tg.MessageMediaDocument:
if doc, ok := media.Document.(*tg.Document); ok {
videoFile = doc
}
}
if videoFile == nil {
_, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": could not get video info")
return
}
// Add to queue for processing
dlQueue.AddVideo(ctx, entities, u, videoFile)
}
func handleMsg(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage) error {
msg, ok := u.Message.(*tg.Message)
if !ok || msg.Out {
@@ -76,21 +109,28 @@ func handleMsg(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage
}
}
// Check if message contains a video
if isVideoMessage(msg) {
handleVideoMessage(ctx, entities, u, msg)
return nil
}
// Check if message is a command.
if msg.Message[0] == '/' {
if msg.Message[0] == '/' || msg.Message[0] == '!' {
cmd := strings.Split(msg.Message, " ")[0]
msg.Message = strings.TrimPrefix(msg.Message, cmd+" ")
if strings.Contains(cmd, "@") {
cmd = strings.Split(cmd, "@")[0]
}
msg.Message = strings.TrimPrefix(msg.Message, cmd+" ")
cmd = cmd[1:] // Cutting the command character.
switch cmd {
case "/dlp":
case "dlp":
handleCmdDLP(ctx, entities, u, msg)
return nil
case "/dlpcancel":
case "dlpcancel":
handleCmdDLPCancel(ctx, entities, u, msg)
return nil
case "/start":
case "start":
fmt.Println(" (start cmd)")
if fromGroup == nil {
_, _ = telegramSender.Reply(entities, u).Text(ctx, "🤖 Welcome! This bot downloads videos from various "+
@@ -113,6 +153,29 @@ func handleMsg(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage
return nil
}
func isVideoMessage(msg *tg.Message) bool {
if msg.Media == nil {
return false
}
switch media := msg.Media.(type) {
case *tg.MessageMediaDocument:
if doc, ok := media.Document.(*tg.Document); ok {
// Check if it's a video mime type
mimeType := doc.MimeType
if strings.HasPrefix(mimeType, "video/") {
return true
}
// Also check attributes for video
for _, attr := range doc.Attributes {
if _, ok := attr.(*tg.DocumentAttributeVideo); ok {
return true
}
}
}
}
return false
}
func main() {
fmt.Println("yt-dlp-telegram-bot starting...")
@@ -151,6 +214,15 @@ func main() {
telegramUploader = uploader.NewUploader(api).WithProgress(dlUploader)
telegramSender = message.NewSender(api).WithUploader(telegramUploader)
telegramClient = client
goutubedl.Path, err = exec.LookPath(goutubedl.Path)
if err != nil {
goutubedl.Path, err = ytdlpDownloadLatest(ctx)
if err != nil {
panic(fmt.Sprint("error: ", err))
}
}
dlQueue.Init(ctx)
@@ -158,14 +230,29 @@ func main() {
fmt.Println("telegram connection up")
ytdlpVersionCheckStr, _ := ytdlpVersionCheckGetStr(ctx)
ytdlpVersionCheckStr, updateNeeded, _ := ytdlpVersionCheckGetStr(ctx)
if updateNeeded {
goutubedl.Path, err = ytdlpDownloadLatest(ctx)
if err != nil {
panic(fmt.Sprint("error: ", err))
}
ytdlpVersionCheckStr, _, _ = ytdlpVersionCheckGetStr(ctx)
}
sendTextToAdmins(ctx, "🤖 Bot started, "+ytdlpVersionCheckStr)
go func() {
for {
time.Sleep(24 * time.Hour)
if s, updateNeededOrError := ytdlpVersionCheckGetStr(ctx); updateNeededOrError {
s, updateNeeded, gotError := ytdlpVersionCheckGetStr(ctx)
if gotError {
sendTextToAdmins(ctx, s)
} else if updateNeeded {
goutubedl.Path, err = ytdlpDownloadLatest(ctx)
if err != nil {
panic(fmt.Sprint("error: ", err))
}
ytdlpVersionCheckStr, _, _ = ytdlpVersionCheckGetStr(ctx)
sendTextToAdmins(ctx, "🤖 Bot updated, "+ytdlpVersionCheckStr)
}
}
}()
+10 -5
View File
@@ -4,7 +4,6 @@ import (
"flag"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
@@ -23,6 +22,7 @@ type paramsType struct {
AllowedGroupIDs []int64
MaxSize int64
SaveDir string
}
var params paramsType
@@ -79,10 +79,6 @@ func (p *paramsType) Init() error {
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")
@@ -143,6 +139,15 @@ func (p *paramsType) Init() error {
p.MaxSize = b.Int64()
}
p.SaveDir = os.Getenv("SAVE_DIR")
if p.SaveDir == "" {
p.SaveDir = "/root/save_dir"
}
// Create save directory if it doesn't exist
if err := os.MkdirAll(p.SaveDir, 0755); err != nil {
return fmt.Errorf("couldn't create save directory: %w", err)
}
// Writing env. var YTDLP_COOKIES contents to a file.
// In case a docker container is used, the yt-dlp.conf points yt-dlp to this cookie file.
if cookies := os.Getenv("YTDLP_COOKIES"); cookies != "" {
+272 -23
View File
@@ -3,9 +3,12 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/gotd/td/telegram/message"
"github.com/gotd/td/tg"
)
@@ -22,6 +25,12 @@ const progressBarLength = 10
type DownloadQueueEntry struct {
URL string
Format string
// IsVideoMessage is true if this entry is for a video message download
IsVideoMessage bool
// VideoDocument stores the video document info for video messages
VideoDocument *tg.Document
OrigEntities tg.Entities
OrigMsgUpdate *tg.UpdateNewMessage
@@ -37,23 +46,10 @@ type DownloadQueueEntry struct {
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) {
@@ -87,12 +83,25 @@ 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) {
func (q *DownloadQueue) Add(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url, format string) {
q.addEntry(ctx, entities, u, url, format, false, nil)
}
// AddVideo adds a video message download to the queue
func (q *DownloadQueue) AddVideo(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, videoDocument *tg.Document) {
q.addEntry(ctx, entities, u, "", "", true, videoDocument)
}
func (q *DownloadQueue) addEntry(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url, format string, isVideo bool, videoDocument *tg.Document) {
q.mutex.Lock()
var replyStr string
if len(q.entries) == 0 {
if isVideo {
replyStr = "⬇️ Downloading video..."
} else {
replyStr = processStartStr
}
} else {
fmt.Println(" queueing request at position #", len(q.entries))
replyStr = q.getQueuePositionString(len(q.entries))
@@ -100,6 +109,9 @@ func (q *DownloadQueue) Add(ctx context.Context, entities tg.Entities, u *tg.Upd
newEntry := DownloadQueueEntry{
URL: url,
Format: format,
IsVideoMessage: isVideo,
VideoDocument: videoDocument,
OrigEntities: entities,
OrigMsgUpdate: u,
OrigMsg: u.Message.(*tg.Message),
@@ -190,8 +202,15 @@ func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQ
if fromUsername != "" {
fmt.Print(" from ", fromUsername, "#", qEntry.FromUser.UserID)
}
fmt.Println(":", qEntry.URL)
// Handle video message downloads differently
if qEntry.IsVideoMessage {
fmt.Println(": [video message]")
q.processVideoMessageEntry(ctx, qEntry)
return
}
fmt.Println(":", qEntry.URL)
qEntry.editReply(ctx, processStartStr)
downloader := Downloader{
@@ -200,7 +219,10 @@ func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQ
if audioCodecs == "" {
q.currentlyDownloadedEntry.sourceCodecInfo += ", no audio"
} else {
q.currentlyDownloadedEntry.sourceCodecInfo += " / " + audioCodecs
if videoCodecs != "" {
q.currentlyDownloadedEntry.sourceCodecInfo += " / "
}
q.currentlyDownloadedEntry.sourceCodecInfo += audioCodecs
}
if convertActionsNeeded == "" {
q.currentlyDownloadedEntry.sourceCodecInfo += " (no conversion needed)"
@@ -212,7 +234,8 @@ func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQ
UpdateProgressPercentFunc: q.HandleProgressPercentUpdate,
}
r, err := downloader.DownloadAndConvertURL(qEntry.Ctx, qEntry.OrigMsg.Message)
// Download the file to SAVE_DIR
dlResult, err := downloader.DownloadAndConvertURL(qEntry.Ctx, qEntry.OrigMsg.Message, qEntry.Format)
if err != nil {
fmt.Println(" error downloading:", err)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
@@ -222,28 +245,103 @@ func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQ
return
}
// Feeding the returned io.ReadCloser to the uploader.
fmt.Println(" processing...")
// Probe the downloaded file to check codec compatibility
conv := Converter{
Format: qEntry.Format,
UpdateProgressPercentCallback: q.HandleProgressPercentUpdate,
}
if err := conv.ProbeFile(dlResult.FilePath); err != nil {
fmt.Println(" error probing file:", err)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err))
return
}
// Update codec info in UI
if downloader.ConvertStartFunc != nil {
downloader.ConvertStartFunc(ctx, conv.VideoCodecs, conv.AudioCodecs, conv.GetActionsNeeded())
}
// Check if file is small enough to upload to Telegram (<512MB)
if dlResult.FileSize >= telegramUploadThreshold {
// File too large, only save to disk
fmt.Printf(" file too large (%s >= 512MB), skipping Telegram upload\n", humanize.Bytes(uint64(dlResult.FileSize)))
qEntry.editReply(ctx, fmt.Sprintf("✅ Saved to server\n📁 %s\n💾 Size: %s\n⚠️ File too large for Telegram upload (>512MB)",
dlResult.FilePath, humanize.Bytes(uint64(dlResult.FileSize))))
qEntry.sendTypingCancelAction(ctx)
return
}
// File is small enough, process for upload
fmt.Println(" processing for upload...")
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.updateProgress(ctx, qEntry, processStr, q.currentlyDownloadedEntry.lastProgressPercent)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
err = dlUploader.UploadFile(qEntry.Ctx, qEntry.OrigEntities, qEntry.OrigMsgUpdate, r)
// Convert if needed, then upload
uploadPath := dlResult.FilePath
uploadFormat := "mkv"
if qEntry.Format == "mp3" {
uploadFormat = "mp3"
}
// For video format, determine the actual format from filename
if qEntry.Format != "mp3" {
ext := filepath.Ext(uploadPath)
if ext != "" {
uploadFormat = ext[1:] // Remove the leading dot
}
}
if conv.NeedConvert() {
// Need conversion
outputPath, outputFormat, err := conv.ConvertIfNeeded(qEntry.Ctx, dlResult.FilePath, params.SaveDir)
if err != nil {
fmt.Println(" error processing:", err)
fmt.Println(" error converting:", err)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
r.Close()
qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err))
return
}
uploadPath = outputPath
uploadFormat = outputFormat
// Keep both original and converted files
}
// Open file for upload
file, err := os.Open(uploadPath)
if err != nil {
fmt.Println(" error opening file:", err)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
r.Close()
qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err))
return
}
defer file.Close()
// Upload to Telegram with video dimensions
err = dlUploader.UploadFile(qEntry.Ctx, qEntry.OrigEntities, qEntry.OrigMsgUpdate, file, uploadFormat, dlResult.Title, conv.VideoWidth, conv.VideoHeight)
if err != nil {
fmt.Println(" error uploading:", err)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
file.Close()
qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err))
return
}
file.Close()
// Remove the uploaded file (since it's saved in SAVE_DIR, we keep it only if needed)
// Actually, we keep the file in SAVE_DIR as requested
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
if qEntry.Canceled {
fmt.Print(" canceled\n")
q.updateProgress(ctx, qEntry, canceledStr, q.currentlyDownloadedEntry.lastProgressPercent)
@@ -255,6 +353,157 @@ func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQ
qEntry.sendTypingCancelAction(ctx)
}
func (q *DownloadQueue) processVideoMessageEntry(ctx context.Context, qEntry *DownloadQueueEntry) {
videoFile := qEntry.VideoDocument
if videoFile == nil {
qEntry.editReply(ctx, errorStr+": could not get video info")
return
}
// Get file name
var fileName string
for _, attr := range videoFile.Attributes {
if docAttr, ok := attr.(*tg.DocumentAttributeFilename); ok {
fileName = docAttr.FileName
break
}
}
if fileName == "" {
fileName = fmt.Sprintf("video_%d.mp4", time.Now().Unix())
}
// Create safe filename with date prefix: YYYY-MM-DD-{timestamp}-{filename}.{ext}
now := time.Now()
dateStr := now.Format("2006-01-02")
timestamp := now.Unix()
ext := filepath.Ext(fileName)
if ext == "" {
ext = ".mp4"
}
safeFileName := fmt.Sprintf("%s-%d%s", dateStr, timestamp, ext)
filePath := filepath.Join(params.SaveDir, safeFileName)
// Check if file already exists
if _, err := os.Stat(filePath); err == nil {
for i := 1; i < 1000; i++ {
safeFileName = fmt.Sprintf("%s-%d-%d%s", dateStr, timestamp, i, ext)
filePath = filepath.Join(params.SaveDir, safeFileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
break
}
}
}
// Update progress message
qEntry.editReply(ctx, "⬇️ Downloading video...")
// Get file size
fileSize := videoFile.Size
// Download file using Telegram client
fileLoc := &tg.InputDocumentFileLocation{
ID: videoFile.ID,
AccessHash: videoFile.AccessHash,
FileReference: videoFile.FileReference,
}
fmt.Printf(" downloading video: %s (size: %s)\n", fileName, humanize.Bytes(uint64(fileSize)))
file, err := os.Create(filePath)
if err != nil {
qEntry.editReply(ctx, errorStr+": could not create file")
return
}
// Download with progress
offset := int64(0)
chunkSize := int64(1024 * 1024) // 1MB chunks
lastPercent := 0
written := int64(0)
for offset < fileSize {
select {
case <-qEntry.Ctx.Done():
file.Close()
os.Remove(filePath)
qEntry.editReply(ctx, "❌ Canceled")
qEntry.Canceled = true
return
default:
}
if offset+chunkSize > fileSize {
chunkSize = fileSize - offset
}
// Telegram API requires limit to be divisible by 1KB
if chunkSize%1024 != 0 {
// Round down to nearest 1KB boundary
chunkSize = (chunkSize / 1024) * 1024
if chunkSize == 0 {
// If remaining data is less than 1KB, round up to 1KB
// The API will return the actual remaining bytes
chunkSize = 1024
}
}
loc := &tg.InputDocumentFileLocation{
ID: videoFile.ID,
AccessHash: videoFile.AccessHash,
FileReference: fileLoc.FileReference,
}
chunk, err := telegramClient.API().UploadGetFile(qEntry.Ctx, &tg.UploadGetFileRequest{
Location: loc,
Offset: offset,
Limit: int(chunkSize),
Precise: true,
CDNSupported: false,
})
if err != nil {
file.Close()
os.Remove(filePath)
qEntry.editReply(ctx, errorStr+": failed to download chunk: "+err.Error())
return
}
chunkData, ok := chunk.(*tg.UploadFile)
if !ok {
file.Close()
os.Remove(filePath)
qEntry.editReply(ctx, errorStr+": unexpected response type")
return
}
n, err := file.Write(chunkData.Bytes)
if err != nil {
file.Close()
os.Remove(filePath)
qEntry.editReply(ctx, errorStr+": failed to write to file")
return
}
offset += int64(n)
written += int64(n)
// Update progress
if fileSize > 0 {
percent := int(float64(written) * 100 / float64(fileSize))
if percent != lastPercent && percent%10 == 0 {
lastPercent = percent
progressBar := getProgressbar(percent, progressBarLength)
qEntry.editReply(ctx, "⬇️ Downloading video...\n"+progressBar)
}
}
}
file.Close()
// Send success message
savedMsg := fmt.Sprintf("✅ Video saved\n📁 %s\n💾 Size: %s", safeFileName, humanize.Bytes(uint64(written)))
qEntry.editReply(ctx, savedMsg)
fmt.Printf(" video saved to: %s\n", filePath)
}
func (q *DownloadQueue) processor() {
for {
q.mutex.Lock()
+2
View File
@@ -14,4 +14,6 @@ ALLOWED_USERIDS=$ALLOWED_USERIDS \
ADMIN_USERIDS=$ADMIN_USERIDS \
ALLOWED_GROUPIDS=$ALLOWED_GROUPIDS \
MAX_SIZE=$MAX_SIZE \
SAVE_DIR=$SAVE_DIR \
YTDLP_PATH=$YTDLP_PATH \
$bin
+84 -17
View File
@@ -6,8 +6,10 @@ import (
"fmt"
"io"
"math/big"
"os"
"github.com/dustin/go-humanize"
"github.com/flytam/filenamify"
"github.com/gotd/td/telegram/message"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg"
@@ -22,34 +24,51 @@ func (p Uploader) Chunk(ctx context.Context, state uploader.ProgressState) error
return nil
}
func (p *Uploader) UploadFile(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, f io.ReadCloser) error {
// Reading to a buffer first, because we don't know the file size.
var buf bytes.Buffer
for {
b := make([]byte, 1024)
n, err := f.Read(b)
if err != nil && err != io.EOF {
return fmt.Errorf("reading to buffer error: %w", err)
func (p *Uploader) UploadFile(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, f io.ReadCloser, format, title string, width, height int) error {
// Get file size by seeking if it's a file
var fileSize int64
if file, ok := f.(*os.File); ok {
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("getting file stat error: %w", err)
}
if n == 0 {
break
}
if params.MaxSize > 0 && buf.Len() > int(params.MaxSize) {
fileSize = stat.Size()
if params.MaxSize > 0 && fileSize > params.MaxSize {
return fmt.Errorf("file is too big, max. allowed size is %s", humanize.BigBytes(big.NewInt(int64(params.MaxSize))))
}
buf.Write(b[:n])
} else {
// Fallback: read to buffer for non-file readers
return p.uploadFromBuffer(ctx, entities, u, f, format, title, width, height)
}
fmt.Println(" got", buf.Len(), "bytes, uploading...")
dlQueue.currentlyDownloadedEntry.progressInfo = fmt.Sprint(" (", humanize.BigBytes(big.NewInt(int64(buf.Len()))), ")")
fmt.Println(" got", fileSize, "bytes, uploading...")
dlQueue.currentlyDownloadedEntry.progressInfo = fmt.Sprint(" (", humanize.BigBytes(big.NewInt(fileSize)), ")")
upload, err := telegramUploader.FromBytes(ctx, "yt-dlp", buf.Bytes())
// Reset file pointer to beginning
if _, err := f.(*os.File).Seek(0, 0); err != nil {
return fmt.Errorf("seeking file error: %w", err)
}
// Use uploader.NewUpload with progress callback
upload, err := telegramUploader.Upload(ctx, uploader.NewUpload("yt-dlp", f, fileSize))
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()
var document message.MediaOption
filename, _ := filenamify.Filenamify(title+"."+format, filenamify.Options{Replacement: " "})
if format == "mp3" {
document = message.UploadedDocument(upload).Filename(filename).Audio().Title(title)
} else {
doc := message.UploadedDocument(upload).Filename(filename).Video()
// Set resolution to help Telegram display correct aspect ratio
if width > 0 && height > 0 {
doc = doc.Resolution(width, height)
}
document = doc
}
// Sending message with media.
if _, err := telegramSender.Answer(entities, u).Media(ctx, document); err != nil {
@@ -58,3 +77,51 @@ func (p *Uploader) UploadFile(ctx context.Context, entities tg.Entities, u *tg.U
return nil
}
func (p *Uploader) uploadFromBuffer(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, f io.ReadCloser, format, title string, width, height int) error {
// Fallback for non-file io.ReadCloser - read all to buffer
buf := make([]byte, 0)
tempBuf := make([]byte, 8192)
for {
n, err := f.Read(tempBuf)
if err != nil && err != io.EOF {
return fmt.Errorf("reading to buffer error: %w", err)
}
if n == 0 {
break
}
buf = append(buf, tempBuf[:n]...)
if params.MaxSize > 0 && len(buf) > int(params.MaxSize) {
return fmt.Errorf("file is too big, max. allowed size is %s", humanize.BigBytes(big.NewInt(int64(params.MaxSize))))
}
}
fmt.Println(" got", len(buf), "bytes, uploading...")
dlQueue.currentlyDownloadedEntry.progressInfo = fmt.Sprint(" (", humanize.BigBytes(big.NewInt(int64(len(buf)))), ")")
// Use Upload with progress for buffer too
upload, err := telegramUploader.Upload(ctx, uploader.NewUpload("yt-dlp", bytes.NewReader(buf), int64(len(buf))))
if err != nil {
return fmt.Errorf("uploading %w", err)
}
var document message.MediaOption
filename, _ := filenamify.Filenamify(title+"."+format, filenamify.Options{Replacement: " "})
if format == "mp3" {
document = message.UploadedDocument(upload).Filename(filename).Audio().Title(title)
} else {
doc := message.UploadedDocument(upload).Filename(filename).Video()
// Set resolution to help Telegram display correct aspect ratio
if width > 0 && height > 0 {
doc = doc.Resolution(width, height)
}
document = doc
}
if _, err := telegramSender.Answer(entities, u).Media(ctx, document); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
+100 -7
View File
@@ -2,7 +2,12 @@ package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/google/go-github/v53/github"
@@ -11,12 +16,100 @@ import (
const ytdlpVersionCheckTimeout = time.Second * 10
func ytdlpVersionCheck(ctx context.Context) (latestVersion, currentVersion string, err error) {
func ytdlpGetLatestRelease(ctx context.Context) (release *github.RepositoryRelease, err error) {
client := github.NewClient(nil)
release, _, err := client.Repositories.GetLatestRelease(ctx, "yt-dlp", "yt-dlp")
release, _, err = client.Repositories.GetLatestRelease(ctx, "yt-dlp", "yt-dlp")
if err != nil {
return "", "", fmt.Errorf("getting latest yt-dlp version: %w", err)
return nil, fmt.Errorf("getting latest yt-dlp version: %w", err)
}
return release, nil
}
type ytdlpGithubReleaseAsset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
func ytdlpGetLatestReleaseURL(ctx context.Context) (url string, err error) {
release, err := ytdlpGetLatestRelease(ctx)
if err != nil {
return "", err
}
assetsURL := release.GetAssetsURL()
if assetsURL == "" {
return "", fmt.Errorf("downloading latest yt-dlp: no assets url")
}
resp, err := http.Get(assetsURL)
if err != nil {
return "", fmt.Errorf("downloading latest yt-dlp: %w", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("downloading latest yt-dlp: %w", err)
}
defer resp.Body.Close()
var assets []ytdlpGithubReleaseAsset
err = json.Unmarshal(body, &assets)
if err != nil {
return "", fmt.Errorf("downloading latest yt-dlp: %w", err)
}
if len(assets) == 0 {
return "", fmt.Errorf("downloading latest yt-dlp: no release assets")
}
for _, asset := range assets {
if asset.Name == "yt-dlp" {
url = asset.URL
break
}
}
if url == "" {
return "", fmt.Errorf("downloading latest yt-dlp: no release asset url")
}
return url, nil
}
func ytdlpDownloadLatest(ctx context.Context) (path string, err error) {
url, err := ytdlpGetLatestReleaseURL(ctx)
if err != nil {
return "", err
}
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("downloading latest yt-dlp: %w", err)
}
defer resp.Body.Close()
file, err := os.Create(filepath.Join(os.TempDir(), "yt-dlp"))
if err != nil {
return "", fmt.Errorf("downloading latest yt-dlp: %w", err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return "", err
}
err = os.Chmod(file.Name(), 0755)
if err != nil {
panic(err)
}
return file.Name(), nil
}
func ytdlpVersionCheck(ctx context.Context) (latestVersion, currentVersion string, err error) {
release, err := ytdlpGetLatestRelease(ctx)
if err != nil {
return "", "", err
}
latestVersion = release.GetTagName()
@@ -27,19 +120,19 @@ func ytdlpVersionCheck(ctx context.Context) (latestVersion, currentVersion strin
return
}
func ytdlpVersionCheckGetStr(ctx context.Context) (res string, updateNeededOrError bool) {
func ytdlpVersionCheckGetStr(ctx context.Context) (res string, updateNeeded, gotError 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
return errorStr + ": " + err.Error(), false, true
}
updateNeededOrError = currentVersion != latestVersion
updateNeeded = currentVersion != latestVersion
res = "yt-dlp version: " + currentVersion
if updateNeededOrError {
if updateNeeded {
res = "📢 " + res + " 📢 Update needed! Latest version is " + latestVersion + " 📢"
} else {
res += " (up to date)"
-7
View File
@@ -1,7 +0,0 @@
{
"folders": [
{
"path": "."
}
]
}
+2 -1
View File
@@ -1 +1,2 @@
--cookies=/tmp/ytdlp-cookies.txt
--cookies-from-browser "chrome:/root/chromium/config/.config/chromium"
--user-agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"