Compare commits

...
2 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
16 changed files with 635 additions and 130 deletions
+2
View File
@@ -1,2 +1,4 @@
/config.inc.sh
/yt-dlp-telegram-bot
/.vscode
/save
+3 -1
View File
@@ -10,5 +10,7 @@ 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=
+6 -3
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
+1
View File
@@ -5,3 +5,4 @@ ALLOWED_USERIDS=
ADMIN_USERIDS=
ALLOWED_GROUPIDS=
MAX_SIZE=
SAVE_DIR=/root/save_dir
+49 -29
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 {
@@ -45,6 +47,8 @@ type Converter struct {
VideoCodecs string
VideoConvertNeeded bool
SingleVideoStreamNeeded bool
VideoWidth int
VideoHeight int
AudioCodecs string
AudioConvertNeeded bool
@@ -55,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)
}
@@ -96,6 +95,12 @@ func (c *Converter) Probe(rr *ReReadCloser) error {
}
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
@@ -192,10 +197,7 @@ func (c *Converter) GetActionsNeeded() string {
return strings.Join(convertNeeded, ", ")
}
func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (reader io.ReadCloser, outputFormat string, err 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")
videoNeeded := true
@@ -205,7 +207,25 @@ func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (read
outputFormat = "mp3"
}
args := ffmpeg_go.KwArgs{"format": outputFormat}
// 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"}})
@@ -239,7 +259,7 @@ func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (read
}
}
ff := ffmpeg_go.Input("pipe:0").Output("pipe:1", args)
ff := ffmpeg_go.Input(inputPath).Output(outputPath, args)
var progressSock net.Listener
if c.UpdateProgressPercentCallback != nil {
@@ -254,26 +274,26 @@ func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (read
}
}
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, outputFormat, fmt.Errorf("error converting: %w", err)
return "", "", fmt.Errorf("error converting: %w", err)
}
return reader, outputFormat, nil
if progressSock != nil {
progressSock.Close()
}
return outputPath, outputFormat, nil
}
func (c *Converter) NeedConvert() bool {
return c.VideoConvertNeeded || c.AudioConvertNeeded || c.SingleVideoStreamNeeded || c.SingleAudioStreamNeeded
}
+138 -35
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,49 +32,146 @@ func (l goYouTubeDLLogger) Print(v ...interface{}) {
fmt.Println(v...)
}
func (d *Downloader) downloadURL(dlCtx context.Context, url string) (rr *ReReadCloser, title string, 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.
Type: goutubedl.TypeSingle,
DebugLog: goYouTubeDLLogger{},
MergeOutputFormat: "mkv", // This handles VP9 properly. yt-dlp uses mp4 by default, which doesn't.
SortingFormat: "res:2160", // Prefer videos up to 4K (2160p)
})
if err != nil {
return nil, "", fmt.Errorf("preparing download %q: %w", url, err)
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)
return nil, fmt.Errorf("downloading %q: %w", url, err)
}
defer dlResult.Close()
// Create file
file, err := os.Create(filePath)
if err != nil {
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)
}
return NewReReadCloser(dlResult), result.Info.Title, nil
// Create progress writer
pw := newProgressWriter(file, estimatedSize, d.UpdateProgressPercentFunc)
// Copy data to file with progress tracking
_, err = io.Copy(pw, dlResult)
if err != nil {
os.Remove(filePath)
return nil, fmt.Errorf("writing to file %q: %w", filePath, err)
}
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) (r io.ReadCloser, outputFormat, title string, err error) {
rr, title, err := d.downloadURL(ctx, url)
if err != nil {
return nil, "", "", err
}
conv := Converter{
Format: format,
UpdateProgressPercentCallback: 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, outputFormat, err = conv.ConvertIfNeeded(ctx, rr)
if err != nil {
return nil, "", "", err
}
return r, outputFormat, title, 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
+54
View File
@@ -23,6 +23,9 @@ 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, " ")
@@ -55,6 +58,27 @@ func handleCmdDLPCancel(ctx context.Context, entities tg.Entities, u *tg.UpdateN
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 {
@@ -85,6 +109,12 @@ 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] == '/' || msg.Message[0] == '!' {
cmd := strings.Split(msg.Message, " ")[0]
@@ -123,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...")
@@ -161,6 +214,7 @@ 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 {
+11 -1
View File
@@ -21,7 +21,8 @@ type paramsType struct {
AdminUserIDs []int64
AllowedGroupIDs []int64
MaxSize int64
MaxSize int64
SaveDir string
}
var params paramsType
@@ -138,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 != "" {
+274 -30
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"
)
@@ -24,6 +27,11 @@ 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
OrigMsg *tg.Message
@@ -38,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) {
@@ -89,22 +84,37 @@ func (e *DownloadQueue) getQueuePositionString(pos int) 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 {
replyStr = processStartStr
if isVideo {
replyStr = "⬇️ Downloading video..."
} else {
replyStr = processStartStr
}
} else {
fmt.Println(" queueing request at position #", len(q.entries))
replyStr = q.getQueuePositionString(len(q.entries))
}
newEntry := DownloadQueueEntry{
URL: url,
Format: format,
OrigEntities: entities,
OrigMsgUpdate: u,
OrigMsg: u.Message.(*tg.Message),
URL: url,
Format: format,
IsVideoMessage: isVideo,
VideoDocument: videoDocument,
OrigEntities: entities,
OrigMsgUpdate: u,
OrigMsg: u.Message.(*tg.Message),
}
newEntry.Reply = telegramSender.Reply(entities, u)
@@ -192,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{
@@ -217,7 +234,8 @@ func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQ
UpdateProgressPercentFunc: q.HandleProgressPercentUpdate,
}
r, outputFormat, title, err := downloader.DownloadAndConvertURL(qEntry.Ctx, qEntry.OrigMsg.Message, qEntry.Format)
// 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()
@@ -227,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, outputFormat, title)
// 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 converting:", err)
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
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 processing:", err)
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
}
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Lock()
q.currentlyDownloadedEntry.disableProgressPercentUpdate = true
q.currentlyDownloadedEntry.progressPercentUpdateMutex.Unlock()
r.Close()
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)
@@ -260,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()
+1
View File
@@ -14,5 +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
+77 -17
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"math/big"
"os"
"github.com/dustin/go-humanize"
"github.com/flytam/filenamify"
@@ -23,28 +24,34 @@ 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, format, title string) 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)
}
@@ -55,7 +62,12 @@ func (p *Uploader) UploadFile(ctx context.Context, entities tg.Entities, u *tg.U
if format == "mp3" {
document = message.UploadedDocument(upload).Filename(filename).Audio().Title(title)
} else {
document = message.UploadedDocument(upload).Filename(filename).Video()
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.
@@ -65,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
}
-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"