Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ce88dc70f4 Preserve critical defaults and improve comments
Co-authored-by: krau <71133316+krau@users.noreply.github.com>
2026-01-19 04:33:39 +00:00
copilot-swe-agent[bot]
154ea47e6b Improve flag parsing logic and clarify argument order
Co-authored-by: krau <71133316+krau@users.noreply.github.com>
2026-01-19 04:31:33 +00:00
copilot-swe-agent[bot]
1b9c8cd2ad Add comprehensive tests for ytdlp parameter parsing
Co-authored-by: krau <71133316+krau@users.noreply.github.com>
2026-01-19 04:28:11 +00:00
copilot-swe-agent[bot]
9ee9972dec Implement parameter support for /ytdlp command
Co-authored-by: krau <71133316+krau@users.noreply.github.com>
2026-01-19 04:26:37 +00:00
copilot-swe-agent[bot]
bd70160555 Initial plan 2026-01-19 04:18:00 +00:00
36 changed files with 94 additions and 1522 deletions

View File

@@ -26,7 +26,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
FROM alpine:latest FROM alpine:latest
RUN apk add --no-cache curl ffmpeg yt-dlp RUN apk add --no-cache curl ffmpeg
WORKDIR /app WORKDIR /app

View File

@@ -101,8 +101,6 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
shortcut.CreateAndAddAria2TaskWithEdit(ctx, selectedStorage, dirPath, data.Aria2URIs, client, msgID, userID) shortcut.CreateAndAddAria2TaskWithEdit(ctx, selectedStorage, dirPath, data.Aria2URIs, client, msgID, userID)
case tasktype.TaskTypeYtdlp: case tasktype.TaskTypeYtdlp:
shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, data.YtdlpFlags, msgID, userID) shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, data.YtdlpFlags, msgID, userID)
case tasktype.TaskTypeTransfer:
return handleTransferCallback(ctx, userID, selectedStorage, dirPath, data, msgID)
default: default:
return fmt.Errorf("unexcept task type: %s", data.TaskType) return fmt.Errorf("unexcept task type: %s", data.TaskType)
} }

View File

@@ -31,7 +31,6 @@ var CommandHandlers = []DescCommandHandler{
{"dl", i18nk.BotMsgCmdDl, handleDlCmd}, {"dl", i18nk.BotMsgCmdDl, handleDlCmd},
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd}, {"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd}, {"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
{"transfer", i18nk.BotMsgCmdTransfer, handleTransferCmd},
{"task", i18nk.BotMsgCmdTask, handleTaskCmd}, {"task", i18nk.BotMsgCmdTask, handleTaskCmd},
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd}, {"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd}, {"config", i18nk.BotMsgCmdConfig, handleConfigCmd},

View File

@@ -1,257 +0,0 @@
package handlers
import (
"fmt"
"regexp"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/transfer"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func handleTransferCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferUsage, nil)), nil)
return dispatcher.EndGroups
}
// Parse source: storage_name:/path
sourceParts := strings.SplitN(args[1], ":", 2)
if len(sourceParts) != 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorInvalidSource, nil)), nil)
return dispatcher.EndGroups
}
sourceStorageName := sourceParts[0]
sourcePath := sourceParts[1]
userID := update.GetUserChat().GetID()
// Get source storage
sourceStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, sourceStorageName)
if err != nil {
logger.Errorf("Failed to get source storage by user ID and name: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorStorageNotFound, map[string]any{
"StorageName": sourceStorageName,
"Error": err,
})), nil)
return dispatcher.EndGroups
}
// Check if source storage supports listing
listable, ok := sourceStorage.(storage.StorageListable)
if !ok {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorStorageNotListable, map[string]any{
"StorageName": sourceStorageName,
})), nil)
return dispatcher.EndGroups
}
// Check if source storage supports reading
_, ok = sourceStorage.(storage.StorageReadable)
if !ok {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorStorageNotReadable, map[string]any{
"StorageName": sourceStorageName,
})), nil)
return dispatcher.EndGroups
}
// Fetch file list
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferInfoFetchingFiles, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
files, err := listable.ListFiles(ctx, sourcePath)
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorListFilesFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
// Optional filter
var filter *regexp.Regexp
if len(args) >= 3 {
filter, err = regexp.Compile(args[2])
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorInvalidRegex, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
}
// Filter files
filteredFiles := make([]storagetypes.FileInfo, 0)
for _, file := range files {
if file.IsDir {
continue
}
if filter != nil && !filter.MatchString(file.Name) {
continue
}
filteredFiles = append(filteredFiles, file)
}
if len(filteredFiles) == 0 {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorNoFilesToTransfer, nil),
})
return dispatcher.EndGroups
}
// Prepare file paths for callback data
filePaths := make([]string, 0, len(filteredFiles))
var totalSize int64
for _, file := range filteredFiles {
filePaths = append(filePaths, file.Path)
totalSize += file.Size
}
// Build storage selection keyboard
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
TaskType: tasktype.TaskTypeTransfer,
TransferSourceStorName: sourceStorageName,
TransferSourcePath: sourcePath,
TransferFiles: filePaths,
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorBuildStorageSelectKeyboardFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferInfoFilesSelectStorage, map[string]any{
"Count": len(filteredFiles),
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleTransferCallback(ctx *ext.Context, userID int64, targetStorage storage.Storage, dirPath string, data tcbdata.Add, msgID int) error {
logger := log.FromContext(ctx)
// Get source storage
sourceStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.TransferSourceStorName)
if err != nil {
logger.Errorf("Failed to get source storage: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorStorageNotFound, map[string]any{"StorageName": data.TransferSourceStorName, "Error": err}),
})
return dispatcher.EndGroups
}
// Check if source storage supports listing
listable, ok := sourceStorage.(storage.StorageListable)
if !ok {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorStorageNotListable, map[string]any{"StorageName": data.TransferSourceStorName}),
})
return dispatcher.EndGroups
}
// Re-fetch files to get FileInfo (since we only stored paths)
// This is necessary to get size and other metadata
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferInfoFetchingFiles, nil),
})
allFiles, err := listable.ListFiles(ctx, data.TransferSourcePath)
if err != nil {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorListFilesFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
// Create a map for quick lookup
fileMap := make(map[string]storagetypes.FileInfo)
for _, file := range allFiles {
fileMap[file.Path] = file
}
// Build task elements for the selected files
elems := make([]transfer.TaskElement, 0, len(data.TransferFiles))
var totalSize int64
for _, filePath := range data.TransferFiles {
fileInfo, ok := fileMap[filePath]
if !ok {
logger.Warnf("File not found in source storage: %s", filePath)
continue
}
elem := transfer.NewTaskElement(sourceStorage, fileInfo, targetStorage, dirPath)
elems = append(elems, *elem)
totalSize += fileInfo.Size
}
if len(elems) == 0 {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorNoFilesToTransfer, nil),
})
return dispatcher.EndGroups
}
// Create and add task
taskID := xid.New().String()
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := transfer.NewTransferTask(
taskID,
injectCtx,
elems,
transfer.NewProgressTracker(msgID, userID),
true, // IgnoreErrors
)
if err := core.AddTask(injectCtx, task); err != nil {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorAddTaskFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferInfoTaskAdded, map[string]any{
"Count": len(elems),
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
"TaskID": taskID,
}),
})
return dispatcher.EndGroups
}

View File

@@ -53,10 +53,6 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
Aria2URIs: adddata.Aria2URIs, Aria2URIs: adddata.Aria2URIs,
YtdlpURLs: adddata.YtdlpURLs, YtdlpURLs: adddata.YtdlpURLs,
YtdlpFlags: adddata.YtdlpFlags, YtdlpFlags: adddata.YtdlpFlags,
TransferSourceStorName: adddata.TransferSourceStorName,
TransferSourcePath: adddata.TransferSourcePath,
TransferFiles: adddata.TransferFiles,
} }
dataid := xid.New().String() dataid := xid.New().String()
err := cache.Set(dataid, data) err := cache.Set(dataid, data)

View File

@@ -46,7 +46,7 @@ func CreateAndAddAria2TaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPa
logger.Infof("Aria2 download added with GID: %s", gid) logger.Infof("Aria2 download added with GID: %s", gid)
// Create task with the GID // Create task with the GID
task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, dirPath, aria2dl.NewProgress(msgID, userID)) task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, stor.JoinStoragePath(dirPath), aria2dl.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil { if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add task: %s", err) logger.Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{

View File

@@ -16,7 +16,7 @@ import (
func CreateAndAddDirectTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, links []string, msgID int, userID int64) error { func CreateAndAddDirectTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, links []string, msgID int, userID int64) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, dirPath, directlinks.NewProgress(msgID, userID)) task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, stor.JoinStoragePath(dirPath), directlinks.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil { if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err) log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{

View File

@@ -18,7 +18,7 @@ import (
func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error { func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := parsed.NewTask(xid.New().String(), injectCtx, stor, dirPath, item, parsed.NewProgress(msgID, userID)) task := parsed.NewTask(xid.New().String(), injectCtx, stor, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil { if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err) log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{ ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{

View File

@@ -59,7 +59,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
} }
} }
startCreateTask: startCreateTask:
storagePath := path.Join(dirPath, file.Name()) storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String() taskid := xid.New().String()
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
@@ -151,7 +151,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
} }
} }
if !dirPath.NeedNewForAlbum() { if !dirPath.NeedNewForAlbum() {
storPath := path.Join(dirPath.String(), file.Name()) storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file) elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
if err != nil { if err != nil {
logger.Errorf("Failed to create task element: %s", err) logger.Errorf("Failed to create task element: %s", err)
@@ -188,7 +188,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name())) albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage albumStor := afiles[0].storage
for _, af := range afiles { for _, af := range afiles {
afstorPath := path.Join(dirPath, albumDir, af.file.Name()) afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file) elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
if err != nil { if err != nil {
logger.Errorf("Failed to create task element for album file: %s", err) logger.Errorf("Failed to create task element for album file: %s", err)

View File

@@ -32,7 +32,7 @@ func CreateAndAddtelegraphWithEdit(
tphpage.Path, tphpage.Path,
pics, pics,
stor, stor,
dirPath, stor.JoinStoragePath(dirPath),
tphutil.DefaultClient(), tphutil.DefaultClient(),
tphtask.NewProgress(trackMsgID, userID), tphtask.NewProgress(trackMsgID, userID),
) )

View File

@@ -38,7 +38,7 @@ func CreateAndAddYtdlpTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPa
urls, urls,
flags, flags,
stor, stor,
dirPath, stor.JoinStoragePath(dirPath),
ytdlp.NewProgress(msgID, userID), ytdlp.NewProgress(msgID, userID),
) )

View File

@@ -309,7 +309,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
} }
} }
startCreateTask: startCreateTask:
storagePath := path.Join(dirPath, file.Name()) storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx) injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String() taskid := xid.New().String()
task, err := coretfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil) task, err := coretfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
@@ -403,7 +403,7 @@ func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.
logger.Infof("Creating album folder for group %d: %s with %d files", groupID, albumDir, len(afiles)) logger.Infof("Creating album folder for group %d: %s with %d files", groupID, albumDir, len(afiles))
for _, af := range afiles { for _, af := range afiles {
afstorPath := path.Join(dirPath, albumDir, af.file.Name()) afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
taskid := xid.New().String() taskid := xid.New().String()
task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil) task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil)
if err != nil { if err != nil {

View File

@@ -90,7 +90,7 @@ func Upload(cmd *cobra.Command, args []string) error {
fileName := fileInfo.Name() fileName := fileInfo.Name()
fileSize := fileInfo.Size() fileSize := fileInfo.Size()
uploadPath := path.Join(dirPath, fileName) uploadPath := stor.JoinStoragePath(path.Join(dirPath, fileName))
ctx = context.WithValue(ctx, ctxkey.ContentLength, fileSize) ctx = context.WithValue(ctx, ctxkey.ContentLength, fileSize)
ctx = tgutil.ExtWithContext(ctx, bot.ExtContext()) ctx = tgutil.ExtWithContext(ctx, bot.ExtContext())

View File

@@ -21,7 +21,6 @@ const (
BotMsgCmdDl Key = "bot.msg.cmd.dl" BotMsgCmdDl Key = "bot.msg.cmd.dl"
BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl" BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl"
BotMsgCmdHelp Key = "bot.msg.cmd.help" BotMsgCmdHelp Key = "bot.msg.cmd.help"
BotMsgCmdImport Key = "bot.msg.cmd.import"
BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch" BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch"
BotMsgCmdParser Key = "bot.msg.cmd.parser" BotMsgCmdParser Key = "bot.msg.cmd.parser"
BotMsgCmdRule Key = "bot.msg.cmd.rule" BotMsgCmdRule Key = "bot.msg.cmd.rule"
@@ -31,7 +30,6 @@ const (
BotMsgCmdStorage Key = "bot.msg.cmd.storage" BotMsgCmdStorage Key = "bot.msg.cmd.storage"
BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers" BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers"
BotMsgCmdTask Key = "bot.msg.cmd.task" BotMsgCmdTask Key = "bot.msg.cmd.task"
BotMsgCmdTransfer Key = "bot.msg.cmd.transfer"
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch" BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
BotMsgCmdUpdate Key = "bot.msg.cmd.update" BotMsgCmdUpdate Key = "bot.msg.cmd.update"
BotMsgCmdWatch Key = "bot.msg.cmd.watch" BotMsgCmdWatch Key = "bot.msg.cmd.watch"
@@ -163,20 +161,6 @@ const (
BotMsgProgressTelegraphProgressPrefix Key = "bot.msg.progress.telegraph_progress_prefix" BotMsgProgressTelegraphProgressPrefix Key = "bot.msg.progress.telegraph_progress_prefix"
BotMsgProgressTelegraphStartPrefix Key = "bot.msg.progress.telegraph_start_prefix" BotMsgProgressTelegraphStartPrefix Key = "bot.msg.progress.telegraph_start_prefix"
BotMsgProgressTotalSizePrefix Key = "bot.msg.progress.total_size_prefix" BotMsgProgressTotalSizePrefix Key = "bot.msg.progress.total_size_prefix"
BotMsgProgressTransferAvgSpeedPrefix Key = "bot.msg.progress.transfer_avg_speed_prefix"
BotMsgProgressTransferElapsedTimePrefix Key = "bot.msg.progress.transfer_elapsed_time_prefix"
BotMsgProgressTransferFailedFilesPrefix Key = "bot.msg.progress.transfer_failed_files_prefix"
BotMsgProgressTransferFailedPrefix Key = "bot.msg.progress.transfer_failed_prefix"
BotMsgProgressTransferProcessingMore Key = "bot.msg.progress.transfer_processing_more"
BotMsgProgressTransferProcessingPrefix Key = "bot.msg.progress.transfer_processing_prefix"
BotMsgProgressTransferProgressPrefix Key = "bot.msg.progress.transfer_progress_prefix"
BotMsgProgressTransferRemainingTimePrefix Key = "bot.msg.progress.transfer_remaining_time_prefix"
BotMsgProgressTransferSpeedPrefix Key = "bot.msg.progress.transfer_speed_prefix"
BotMsgProgressTransferStartPrefix Key = "bot.msg.progress.transfer_start_prefix"
BotMsgProgressTransferSuccessPrefix Key = "bot.msg.progress.transfer_success_prefix"
BotMsgProgressTransferTotalFilesPrefix Key = "bot.msg.progress.transfer_total_files_prefix"
BotMsgProgressTransferTotalSizePrefix Key = "bot.msg.progress.transfer_total_size_prefix"
BotMsgProgressTransferUploadedPrefix Key = "bot.msg.progress.transfer_uploaded_prefix"
BotMsgProgressYtdlpDone Key = "bot.msg.progress.ytdlp_done" BotMsgProgressYtdlpDone Key = "bot.msg.progress.ytdlp_done"
BotMsgProgressYtdlpDownloading Key = "bot.msg.progress.ytdlp_downloading" BotMsgProgressYtdlpDownloading Key = "bot.msg.progress.ytdlp_downloading"
BotMsgProgressYtdlpStart Key = "bot.msg.progress.ytdlp_start" BotMsgProgressYtdlpStart Key = "bot.msg.progress.ytdlp_start"
@@ -232,22 +216,6 @@ const (
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix" BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage" BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix" BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix"
BotMsgTransferErrorAddTaskFailed Key = "bot.msg.transfer.error_add_task_failed"
BotMsgTransferErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.transfer.error_build_storage_select_keyboard_failed"
BotMsgTransferErrorInvalidRegex Key = "bot.msg.transfer.error_invalid_regex"
BotMsgTransferErrorInvalidSource Key = "bot.msg.transfer.error_invalid_source"
BotMsgTransferErrorInvalidTarget Key = "bot.msg.transfer.error_invalid_target"
BotMsgTransferErrorListFilesFailed Key = "bot.msg.transfer.error_list_files_failed"
BotMsgTransferErrorNoFilesToTransfer Key = "bot.msg.transfer.error_no_files_to_transfer"
BotMsgTransferErrorStorageNotFound Key = "bot.msg.transfer.error_storage_not_found"
BotMsgTransferErrorStorageNotListable Key = "bot.msg.transfer.error_storage_not_listable"
BotMsgTransferErrorStorageNotReadable Key = "bot.msg.transfer.error_storage_not_readable"
BotMsgTransferErrorTargetNotFound Key = "bot.msg.transfer.error_target_not_found"
BotMsgTransferInfoFetchingFiles Key = "bot.msg.transfer.info_fetching_files"
BotMsgTransferInfoFilesSelectStorage Key = "bot.msg.transfer.info_files_select_storage"
BotMsgTransferInfoTaskAdded Key = "bot.msg.transfer.info_task_added"
BotMsgTransferStartStats Key = "bot.msg.transfer.start_stats"
BotMsgTransferUsage Key = "bot.msg.transfer.usage"
BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade" BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade"
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed" BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found" BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"

View File

@@ -29,7 +29,6 @@ bot:
/silent - Toggle silent mode /silent - Toggle silent mode
/storage - Set default storage /storage - Set default storage
/save [custom filename] - Save file /save [custom filename] - Save file
/import <storage_name> <dir_path> [channel_id] [filter] - Import files from storage to Telegram
/dir - Manage storage directories /dir - Manage storage directories
/rule - Manage rules /rule - Manage rules
/config - Modify configuration /config - Modify configuration
@@ -53,8 +52,6 @@ bot:
dl: "Download files from given links" dl: "Download files from given links"
aria2dl: "Download files using Aria2" aria2dl: "Download files using Aria2"
ytdlp: "Download video/audio using yt-dlp" ytdlp: "Download video/audio using yt-dlp"
import: "Import files from storage to Telegram"
transfer: "Transfer files between storages"
task: "Manage task queue" task: "Manage task queue"
cancel: "Cancel task" cancel: "Cancel task"
watch: "Watch chats (UserBot)" watch: "Watch chats (UserBot)"
@@ -297,28 +294,6 @@ bot:
info_urls_select_storage: "Found {{.Count}} links, please select storage" info_urls_select_storage: "Found {{.Count}} links, please select storage"
info_downloading: "Downloading via yt-dlp..." info_downloading: "Downloading via yt-dlp..."
error_download_failed: "yt-dlp download failed: {{.Error}}" error_download_failed: "yt-dlp download failed: {{.Error}}"
transfer:
usage: |
Usage: /transfer <source_storage>:/<source_path> [filter]
Examples:
/transfer local1:/downloads
/transfer alist1:/media/photos
/transfer webdav1:/files ".*\.mp4$"
error_invalid_source: "Invalid source path format, should be: storage_name:/path"
error_invalid_target: "Invalid target path format, should be: storage_name:/path"
error_storage_not_found: "Storage '{{.StorageName}}' not found or access denied: {{.Error}}"
error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files"
error_storage_not_readable: "Storage '{{.StorageName}}' does not support reading files"
error_target_not_found: "Target storage '{{.StorageName}}' not found or access denied: {{.Error}}"
info_fetching_files: "Fetching file list..."
error_list_files_failed: "Failed to list files: {{.Error}}"
error_invalid_regex: "Invalid regular expression: {{.Error}}"
error_no_files_to_transfer: "No files to transfer in directory"
error_add_task_failed: "Failed to add task: {{.Error}}"
info_task_added: "Added {{.Count}} files to transfer queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
start_stats: "Total files: {{.Count}}\nTotal size: {{.SizeMB}} MB"
info_files_select_storage: "Total {{.Count}} files ({{.SizeMB}} MB), please select target storage"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
cancel: cancel:
usage: "Usage: /cancel <task_id>" usage: "Usage: /cancel <task_id>"
error_cancel_failed: "Failed to cancel task: {{.Error}}" error_cancel_failed: "Failed to cancel task: {{.Error}}"
@@ -367,20 +342,6 @@ bot:
ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n" ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n"
downloaded_prefix: "\nDownloaded: " downloaded_prefix: "\nDownloaded: "
current_speed_prefix: "\nCurrent speed: " current_speed_prefix: "\nCurrent speed: "
transfer_start_prefix: "Transfering: "
transfer_progress_prefix: "Transfer progress: "
transfer_uploaded_prefix: "\nUploaded: "
transfer_speed_prefix: "\nSpeed: "
transfer_remaining_time_prefix: "\nRemaining time: "
transfer_processing_prefix: "\nProcessing:\n"
transfer_processing_more: "...and {{.Count}} more files\n"
transfer_failed_prefix: "Transfer failed\n"
transfer_success_prefix: "Transfer completed\n"
transfer_total_files_prefix: "\nTotal files: "
transfer_total_size_prefix: "\nTotal size: "
transfer_elapsed_time_prefix: "\nElapsed time: "
transfer_avg_speed_prefix: "\nAverage speed: "
transfer_failed_files_prefix: "\nFailed files: "
syncpeers: syncpeers:
start: "Starting to sync peers..." start: "Starting to sync peers..."
done: "Peer sync completed, total {{.Count}} chats synced" done: "Peer sync completed, total {{.Count}} chats synced"

View File

@@ -30,7 +30,6 @@ bot:
/storage - 设置默认存储位置 /storage - 设置默认存储位置
/save [自定义文件名] - 保存文件 /save [自定义文件名] - 保存文件
/dl <链接1> <链接2> ... - 下载给定链接的文件 /dl <链接1> <链接2> ... - 下载给定链接的文件
/import <存储名> <目录路径> [频道ID] [过滤器] - 从存储端导入文件到 Telegram
/dir - 管理存储目录 /dir - 管理存储目录
/rule - 管理规则 /rule - 管理规则
/config - 修改配置 /config - 修改配置
@@ -54,8 +53,6 @@ bot:
dl: "下载给定链接的文件" dl: "下载给定链接的文件"
aria2dl: "使用 Aria2 下载给定链接的文件" aria2dl: "使用 Aria2 下载给定链接的文件"
ytdlp: "使用 yt-dlp 下载视频/音频" ytdlp: "使用 yt-dlp 下载视频/音频"
import: "从存储端导入文件到 Telegram"
transfer: "在存储端之间传输文件"
task: "管理任务队列" task: "管理任务队列"
cancel: "取消任务" cancel: "取消任务"
watch: "监听聊天(UserBot)" watch: "监听聊天(UserBot)"
@@ -298,28 +295,6 @@ bot:
info_urls_select_storage: "共 {{.Count}} 个链接, 请选择存储位置" info_urls_select_storage: "共 {{.Count}} 个链接, 请选择存储位置"
info_downloading: "正在通过 yt-dlp 下载..." info_downloading: "正在通过 yt-dlp 下载..."
error_download_failed: "yt-dlp 下载失败: {{.Error}}" error_download_failed: "yt-dlp 下载失败: {{.Error}}"
transfer:
usage: |
用法: /transfer <source_storage>:/<source_path> [filter]
示例:
/transfer local1:/downloads
/transfer alist1:/media/photos
/transfer webdav1:/files ".*\.mp4$"
error_invalid_source: "源路径格式无效,应为: storage_name:/path"
error_invalid_target: "目标路径格式无效,应为: storage_name:/path"
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
error_target_not_found: "目标存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
info_fetching_files: "正在获取文件列表..."
error_list_files_failed: "获取文件列表失败: {{.Error}}"
error_invalid_regex: "正则表达式无效: {{.Error}}"
error_no_files_to_transfer: "目录中没有可传输的文件"
error_add_task_failed: "添加任务失败: {{.Error}}"
info_task_added: "已添加 {{.Count}} 个文件到传输队列\n总大小: {{.SizeMB}} MB\n任务 ID: {{.TaskID}}"
start_stats: "总文件数: {{.Count}}\n总大小: {{.SizeMB}} MB"
info_files_select_storage: "共 {{.Count}} 个文件 (总大小: {{.SizeMB}} MB),请选择目标存储位置"
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
cancel: cancel:
usage: "用法: /cancel <task_id>" usage: "用法: /cancel <task_id>"
error_cancel_failed: "取消任务失败: {{.Error}}" error_cancel_failed: "取消任务失败: {{.Error}}"
@@ -368,20 +343,6 @@ bot:
ytdlp_done: "yt-dlp 下载完成并已转存 ({{.Count}} 个文件)\n" ytdlp_done: "yt-dlp 下载完成并已转存 ({{.Count}} 个文件)\n"
downloaded_prefix: "\n已下载: " downloaded_prefix: "\n已下载: "
current_speed_prefix: "\n当前速度: " current_speed_prefix: "\n当前速度: "
transfer_start_prefix: "正在转存: "
transfer_progress_prefix: "转存进度: "
transfer_uploaded_prefix: "\n已上传: "
transfer_speed_prefix: "\n速度: "
transfer_remaining_time_prefix: "\n剩余时间: "
transfer_processing_prefix: "\n正在处理:\n"
transfer_processing_more: "...和其他 {{.Count}} 个文件\n"
transfer_failed_prefix: "转存失败\n"
transfer_success_prefix: "转存完成\n"
transfer_total_files_prefix: "\n总文件数: "
transfer_total_size_prefix: "\n总大小: "
transfer_elapsed_time_prefix: "\n耗时: "
transfer_avg_speed_prefix: "\n平均速度: "
transfer_failed_files_prefix: "\n失败文件数: "
syncpeers: syncpeers:
start: "正在同步对话列表..." start: "正在同步对话列表..."
success: "对话列表同步完成, 共同步 {{.Count}} 个对话" success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
@@ -392,4 +353,4 @@ bot:
info_adding_aria2_download: "正在添加 Aria2 下载任务..." info_adding_aria2_download: "正在添加 Aria2 下载任务..."
error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}" error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}"
info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}" info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}"
info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列" info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列"

View File

@@ -1,9 +1,6 @@
package dlutil package dlutil
import ( import "time"
"fmt"
"time"
)
var threadsLevels = []struct { var threadsLevels = []struct {
threads int threads int
@@ -34,23 +31,3 @@ func GetSpeed(downloaded int64, startTime time.Time) float64 {
} }
return float64(downloaded) / elapsed return float64(downloaded) / elapsed
} }
// FormatSize formats a byte size as a human-readable string
func FormatSize(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}

View File

@@ -1,142 +0,0 @@
package transfer
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/storage"
"golang.org/x/sync/errgroup"
)
// Execute implements core.Executable.
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("transfer[%s]", t.ID))
logger.Info("Starting transfer task")
t.Progress.OnStart(ctx, t)
workers := config.C().Workers
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(workers)
for _, elem := range t.elems {
eg.Go(func() error {
t.processingMu.RLock()
if t.processing[elem.ID] != nil {
t.processingMu.RUnlock()
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
}
t.processingMu.RUnlock()
t.processingMu.Lock()
t.processing[elem.ID] = &elem
t.processingMu.Unlock()
defer func() {
t.processingMu.Lock()
delete(t.processing, elem.ID)
t.processingMu.Unlock()
}()
err := t.processElement(gctx, elem)
if err != nil && !t.IgnoreErrors {
return err
}
if err != nil {
t.processingMu.Lock()
t.failed[elem.ID] = err
t.processingMu.Unlock()
logger.Errorf("Failed to process file %s: %v", elem.FileInfo.Name, err)
}
return nil
})
}
err := eg.Wait()
if err != nil {
logger.Errorf("Error during transfer processing: %v", err)
} else {
logger.Info("Transfer task completed successfully")
}
t.Progress.OnDone(ctx, t, err)
return err
}
func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.FileInfo.Name))
// Check whether the source storage supports reading
readableStorage, ok := elem.SourceStorage.(storage.StorageReadable)
if !ok {
return fmt.Errorf("source storage %s does not support reading", elem.SourceStorage.Name())
}
logger.Info("Opening file from source storage")
reader, size, err := readableStorage.OpenFile(ctx, elem.SourcePath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer reader.Close()
// Build target storage path: /target_path/filename
storagePath := path.Join(elem.TargetPath, elem.FileInfo.Name)
// Inject file size into context
ctx = context.WithValue(ctx, ctxkey.ContentLength, size)
if config.C().Stream {
if err := elem.TargetStorage.Save(ctx, reader, storagePath); err != nil {
return fmt.Errorf("failed to upload file to storage: %w", err)
}
} else {
logger.Info("Downloading to temporary file for ReadSeeker support")
tempFile, err := t.downloadToTemp(reader, elem.FileInfo.Name)
if err != nil {
return fmt.Errorf("failed to download to temp: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek temp file: %w", err)
}
logger.Infof("Uploading file to storage (size: %d bytes)", size)
if err := elem.TargetStorage.Save(ctx, tempFile, storagePath); err != nil {
return fmt.Errorf("failed to upload file to storage: %w", err)
}
}
t.uploaded.Add(size)
t.Progress.OnProgress(ctx, t)
logger.Info("File uploaded successfully")
return nil
}
func (t *Task) downloadToTemp(reader io.Reader, filename string) (*os.File, error) {
tempDir := config.C().Temp.BasePath
if tempDir == "" {
tempDir = os.TempDir()
}
tempFile, err := os.CreateTemp(tempDir, filepath.Base(filename)+"-*.tmp")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := io.Copy(tempFile, reader); err != nil {
tempFile.Close()
os.Remove(tempFile.Name())
return nil, fmt.Errorf("failed to copy to temp file: %w", err)
}
return tempFile, nil
}

View File

@@ -1,247 +0,0 @@
package transfer
import (
"context"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
type ProgressTracker interface {
OnStart(ctx context.Context, info TaskInfo)
OnProgress(ctx context.Context, info TaskInfo)
OnDone(ctx context.Context, info TaskInfo, err error)
}
type Progress struct {
MessageID int
ChatID int64
start time.Time
lastUpdatePercent atomic.Int32
}
func NewProgressTracker(messageID int, chatID int64) ProgressTracker {
return &Progress{
MessageID: messageID,
ChatID: chatID,
}
}
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
p.start = time.Now()
p.lastUpdatePercent.Store(0)
log.FromContext(ctx).Debugf("Transfer task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
sizeMB := float64(info.TotalSize()) / (1024 * 1024)
statsText := i18n.T(i18nk.BotMsgTransferStartStats, map[string]any{
"SizeMB": fmt.Sprintf("%.2f", sizeMB),
"Count": info.Count(),
})
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressTransferStartPrefix, nil)),
styling.Code(statsText),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
},
})
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
_, err := ext.EditMessage(p.ChatID, req)
if err != nil {
log.FromContext(ctx).Errorf("Failed to send progress start message: %s", err)
}
}
}
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
if !shouldUpdateProgress(info.TotalSize(), info.Uploaded(), int(p.lastUpdatePercent.Load())) {
return
}
percent := int((info.Uploaded() * 100) / info.TotalSize())
if p.lastUpdatePercent.Load() == int32(percent) {
return
}
p.lastUpdatePercent.Store(int32(percent))
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.Uploaded(), info.TotalSize())
entityBuilder := entity.Builder{}
var progressText strings.Builder
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProgressPrefix, nil))
fmt.Fprintf(&progressText, "%d%%", percent)
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, nil))
fmt.Fprintf(&progressText, "%.2f MB / %.2f MB",
float64(info.Uploaded())/(1024*1024),
float64(info.TotalSize())/(1024*1024))
if p.start.Unix() > 0 {
elapsed := time.Since(p.start)
speed := float64(info.Uploaded()) / elapsed.Seconds()
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSpeedPrefix, nil))
progressText.WriteString(dlutil.FormatSize(int64(speed)) + "/s")
if info.Uploaded() > 0 {
remaining := time.Duration(float64(info.TotalSize()-info.Uploaded()) / speed * float64(time.Second))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferRemainingTimePrefix, nil))
progressText.WriteString(formatDuration(remaining))
}
}
processing := info.Processing()
if len(processing) > 0 {
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingPrefix, nil))
for i, elem := range processing {
if i >= 3 {
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(processing) - 3}))
break
}
fmt.Fprintf(&progressText, "- %s\n", elem.FileName())
}
}
if err := styling.Perform(&entityBuilder,
styling.Plain(progressText.String()),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
},
})
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
}
}
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
log.FromContext(ctx).Debugf("Transfer task progress tracking done for message %d in chat %d", p.MessageID, p.ChatID)
entityBuilder := entity.Builder{}
var resultText strings.Builder
if err != nil {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressErrorPrefix, nil))
fmt.Fprintf(&resultText, "%v\n", err)
} else {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSuccessPrefix, nil))
}
elapsed := time.Since(p.start)
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferTotalFilesPrefix, nil))
fmt.Fprintf(&resultText, "%d\n", info.Count())
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferTotalSizePrefix, nil))
fmt.Fprintf(&resultText, "%.2f MB\n", float64(info.TotalSize())/(1024*1024))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, nil))
fmt.Fprintf(&resultText, "%.2f MB\n", float64(info.Uploaded())/(1024*1024))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferElapsedTimePrefix, nil))
fmt.Fprintf(&resultText, "%s\n", formatDuration(elapsed))
if elapsed.Seconds() > 0 {
avgSpeed := float64(info.Uploaded()) / elapsed.Seconds()
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferAvgSpeedPrefix, nil))
fmt.Fprintf(&resultText, "%s/s\n", dlutil.FormatSize(int64(avgSpeed)))
}
failedFiles := info.FailedFiles()
if len(failedFiles) > 0 {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedFilesPrefix, nil))
fmt.Fprintf(&resultText, "%d\n", len(failedFiles))
for i, name := range failedFiles {
if i >= 5 {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(failedFiles) - 5}))
break
}
fmt.Fprintf(&resultText, "- %s\n", name)
}
}
if err := styling.Perform(&entityBuilder,
styling.Plain(resultText.String()),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.MessageID,
}
req.SetMessage(text)
req.SetEntities(entities)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, req)
}
}
func shouldUpdateProgress(total, current int64, lastPercent int) bool {
if total == 0 {
return false
}
currentPercent := int((current * 100) / total)
return currentPercent > lastPercent && currentPercent%5 == 0
}
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
if h > 0 {
return fmt.Sprintf("%dh%dm%ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm%ds", m, s)
}
return fmt.Sprintf("%ds", s)
}

View File

@@ -1,97 +0,0 @@
package transfer
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
var _ core.Executable = (*Task)(nil)
type TaskElement struct {
ID string
SourceStorage storage.Storage
SourcePath string
FileInfo storagetypes.FileInfo
TargetStorage storage.Storage
TargetPath string
}
type Task struct {
ID string
ctx context.Context
elems []TaskElement
Progress ProgressTracker
IgnoreErrors bool
uploaded atomic.Int64
totalSize int64
processing map[string]TaskElementInfo
processingMu sync.RWMutex
failed map[string]error
}
// Title implements core.Executable.
func (t *Task) Title() string {
return fmt.Sprintf("[%s](%d files/%.2fMB)", t.Type(), len(t.elems), float64(t.totalSize)/(1024*1024))
}
// Type implements core.Executable.
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeTransfer
}
// TaskID implements core.Executable.
func (t *Task) TaskID() string {
return t.ID
}
func NewTaskElement(
sourceStorage storage.Storage,
fileInfo storagetypes.FileInfo,
targetStorage storage.Storage,
targetPath string,
) *TaskElement {
id := xid.New().String()
return &TaskElement{
ID: id,
SourceStorage: sourceStorage,
SourcePath: fileInfo.Path,
FileInfo: fileInfo,
TargetStorage: targetStorage,
TargetPath: targetPath,
}
}
func NewTransferTask(
id string,
ctx context.Context,
elems []TaskElement,
progress ProgressTracker,
ignoreErrors bool,
) *Task {
task := &Task{
ID: id,
ctx: ctx,
elems: elems,
Progress: progress,
uploaded: atomic.Int64{},
totalSize: func() int64 {
var total int64
for _, elem := range elems {
total += elem.FileInfo.Size
}
return total
}(),
processing: make(map[string]TaskElementInfo),
IgnoreErrors: ignoreErrors,
failed: make(map[string]error),
}
return task
}

View File

@@ -1,73 +0,0 @@
package transfer
type TaskElementInfo interface {
FileName() string
FileSize() int64
GetSourcePath() string
SourceStorageName() string
}
func (e *TaskElement) FileName() string {
return e.FileInfo.Name
}
func (e *TaskElement) FileSize() int64 {
return e.FileInfo.Size
}
func (e *TaskElement) GetSourcePath() string {
return e.SourcePath
}
func (e *TaskElement) SourceStorageName() string {
return e.SourceStorage.Name()
}
type TaskInfo interface {
TaskID() string
TotalSize() int64
Uploaded() int64
Count() int
Processing() []TaskElementInfo
FailedFiles() []string
}
func (t *Task) TotalSize() int64 {
return t.totalSize
}
func (t *Task) Uploaded() int64 {
return t.uploaded.Load()
}
func (t *Task) Count() int {
return len(t.elems)
}
func (t *Task) Processing() []TaskElementInfo {
t.processingMu.RLock()
defer t.processingMu.RUnlock()
result := make([]TaskElementInfo, 0, len(t.processing))
for _, elem := range t.processing {
result = append(result, elem)
}
return result
}
func (t *Task) FailedFiles() []string {
t.processingMu.RLock()
defer t.processingMu.RUnlock()
result := make([]string, 0, len(t.failed))
for id := range t.failed {
// Find the element by ID
for _, elem := range t.elems {
if elem.ID == id {
result = append(result, elem.FileInfo.Name)
break
}
}
}
return result
}

View File

@@ -1,5 +1,6 @@
package tasktype package tasktype
// ENUM(tgfiles,tphpics,parseditem,directlinks,aria2,ytdlp)
//
//go:generate go-enum --values --names --flag --nocase //go:generate go-enum --values --names --flag --nocase
// ENUM(tgfiles,tphpics,parseditem,directlinks,aria2,ytdlp,transfer)
type TaskType string type TaskType string

View File

@@ -24,8 +24,6 @@ const (
TaskTypeAria2 TaskType = "aria2" TaskTypeAria2 TaskType = "aria2"
// TaskTypeYtdlp is a TaskType of type ytdlp. // TaskTypeYtdlp is a TaskType of type ytdlp.
TaskTypeYtdlp TaskType = "ytdlp" TaskTypeYtdlp TaskType = "ytdlp"
// TaskTypeTransfer is a TaskType of type transfer.
TaskTypeTransfer TaskType = "transfer"
) )
var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Join(_TaskTypeNames, ", ")) var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Join(_TaskTypeNames, ", "))
@@ -37,7 +35,6 @@ var _TaskTypeNames = []string{
string(TaskTypeDirectlinks), string(TaskTypeDirectlinks),
string(TaskTypeAria2), string(TaskTypeAria2),
string(TaskTypeYtdlp), string(TaskTypeYtdlp),
string(TaskTypeTransfer),
} }
// TaskTypeNames returns a list of possible string values of TaskType. // TaskTypeNames returns a list of possible string values of TaskType.
@@ -56,7 +53,6 @@ func TaskTypeValues() []TaskType {
TaskTypeDirectlinks, TaskTypeDirectlinks,
TaskTypeAria2, TaskTypeAria2,
TaskTypeYtdlp, TaskTypeYtdlp,
TaskTypeTransfer,
} }
} }
@@ -79,7 +75,6 @@ var _TaskTypeValue = map[string]TaskType{
"directlinks": TaskTypeDirectlinks, "directlinks": TaskTypeDirectlinks,
"aria2": TaskTypeAria2, "aria2": TaskTypeAria2,
"ytdlp": TaskTypeYtdlp, "ytdlp": TaskTypeYtdlp,
"transfer": TaskTypeTransfer,
} }
// ParseTaskType attempts to convert a string to a TaskType. // ParseTaskType attempts to convert a string to a TaskType.

View File

@@ -1,12 +0,0 @@
package storagetypes
import "time"
// FileInfo represents file metadata
type FileInfo struct {
Name string
Path string
Size int64
IsDir bool
ModTime time.Time
}

View File

@@ -50,10 +50,6 @@ type Add struct {
// ytdlp // ytdlp
YtdlpURLs []string YtdlpURLs []string
YtdlpFlags []string YtdlpFlags []string
// transfer
TransferSourceStorName string
TransferSourcePath string
TransferFiles []string // file paths relative to source storage
} }
type SetDefaultStorage struct { type SetDefaultStorage struct {

View File

@@ -16,7 +16,6 @@ import (
config "github.com/krau/SaveAny-Bot/config/storage" config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey" "github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
) )
type Alist struct { type Alist struct {
@@ -216,156 +215,3 @@ func (a *Alist) Exists(ctx context.Context, storagePath string) bool {
func (a *Alist) CannotStream() string { func (a *Alist) CannotStream() string {
return "Alist does not support chunked transfer encoding" return "Alist does not support chunked transfer encoding"
} }
// ListFiles implements StorageListable interface
func (a *Alist) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
a.logger.Debugf("Listing files in directory: %s", dirPath)
reqBody := fsListRequest{
Path: dirPath,
Password: "",
Page: 1,
PerPage: 0, // 0 means all files
Refresh: false,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/list", bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", a.token)
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to list files: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var listResp fsListResponse
if err := json.Unmarshal(data, &listResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal list response: %w", err)
}
if listResp.Code != http.StatusOK {
return nil, fmt.Errorf("failed to list files: %d, %s", listResp.Code, listResp.Message)
}
files := make([]storagetypes.FileInfo, 0, len(listResp.Data.Content))
for _, item := range listResp.Data.Content {
// Parse modified time; log failures but keep zero value on error.
var modTime time.Time
if item.Modified != "" {
parsedTime, err := time.Parse(time.RFC3339, item.Modified)
if err != nil {
a.logger.With(
"path", path.Join(dirPath, item.Name),
"modified_raw", item.Modified,
).Warnf("failed to parse modified time for file")
} else {
modTime = parsedTime
}
}
files = append(files, storagetypes.FileInfo{
Name: item.Name,
Path: path.Join(dirPath, item.Name),
Size: item.Size,
IsDir: item.IsDir,
ModTime: modTime,
})
}
a.logger.Debugf("Found %d files in directory %s", len(files), dirPath)
return files, nil
}
// OpenFile implements StorageReadable interface
func (a *Alist) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
a.logger.Debugf("Opening file: %s", filePath)
// First, get file info to get the raw_url
reqBody := map[string]any{
"path": filePath,
"password": "",
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/api/fs/get", bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", a.token)
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("failed to get file info: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("failed to read response body: %w", err)
}
var getResp fsGetResponse
if err := json.Unmarshal(data, &getResp); err != nil {
return nil, 0, fmt.Errorf("failed to unmarshal get response: %w", err)
}
if getResp.Code != http.StatusOK {
return nil, 0, fmt.Errorf("failed to get file info: %d, %s", getResp.Code, getResp.Message)
}
if getResp.Data.IsDir {
return nil, 0, fmt.Errorf("path is a directory, not a file")
}
// Download the file from raw_url
downloadURL := getResp.Data.RawURL
if downloadURL == "" {
// If no raw_url, construct download URL
downloadURL = a.baseURL + "/d" + filePath
}
downloadReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, 0, fmt.Errorf("failed to create download request: %w", err)
}
downloadResp, err := a.client.Do(downloadReq)
if err != nil {
return nil, 0, fmt.Errorf("failed to download file: %w", err)
}
if downloadResp.StatusCode != http.StatusOK {
downloadResp.Body.Close()
return nil, 0, fmt.Errorf("failed to download file: %s", downloadResp.Status)
}
a.logger.Debugf("Opened file %s, size: %d bytes", filePath, getResp.Data.Size)
return downloadResp.Body, getResp.Data.Size, nil
}

View File

@@ -46,46 +46,4 @@ type putResponse struct {
type fsGetResponse struct { type fsGetResponse struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
Modified string `json:"modified"`
Created string `json:"created"`
Sign string `json:"sign"`
Thumb string `json:"thumb"`
Type int `json:"type"`
RawURL string `json:"raw_url"`
Provider string `json:"provider"`
} `json:"data"`
}
type fsListRequest struct {
Path string `json:"path"`
Password string `json:"password"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Refresh bool `json:"refresh"`
}
type fsListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Content []struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
Modified string `json:"modified"`
Created string `json:"created"`
Sign string `json:"sign"`
Thumb string `json:"thumb"`
Type int `json:"type"`
} `json:"content"`
Total int64 `json:"total"`
Readme string `json:"readme"`
Header string `json:"header"`
Write bool `json:"write"`
Provider string `json:"provider"`
} `json:"data"`
} }

View File

@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config" "github.com/krau/SaveAny-Bot/config"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
) )
var UserStorages = make(map[int64][]Storage) var UserStorages = make(map[int64][]Storage)
@@ -80,14 +79,3 @@ func LoadStorages(ctx context.Context) {
UserStorages[int64(user)] = GetUserStorages(ctx, int64(user)) UserStorages[int64(user)] = GetUserStorages(ctx, int64(user))
} }
} }
// GetTelegramStorageByUserID returns the first enabled Telegram storage for the user
func GetTelegramStorageByUserID(ctx context.Context, chatID int64) (Storage, error) {
storages := GetUserStorages(ctx, chatID)
for _, stor := range storages {
if stor.Type() == storenum.Telegram {
return stor, nil
}
}
return nil, fmt.Errorf("no telegram storage found for user %d", chatID)
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/duke-git/lancet/v2/fileutil" "github.com/duke-git/lancet/v2/fileutil"
config "github.com/krau/SaveAny-Bot/config/storage" config "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
) )
type Local struct { type Local struct {
@@ -51,7 +50,6 @@ func (l *Local) JoinStoragePath(path string) string {
func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error { func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error {
l.logger.Infof("Saving file to %s", storagePath) l.logger.Infof("Saving file to %s", storagePath)
storagePath = l.JoinStoragePath(storagePath)
ext := filepath.Ext(storagePath) ext := filepath.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext) base := strings.TrimSuffix(storagePath, ext)
@@ -83,51 +81,3 @@ func (l *Local) Exists(ctx context.Context, storagePath string) bool {
} }
return fileutil.IsExist(absPath) return fileutil.IsExist(absPath)
} }
// ListFiles implements StorageListable interface
func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
absPath := l.JoinStoragePath(dirPath)
entries, err := os.ReadDir(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory %s: %w", absPath, err)
}
files := make([]storagetypes.FileInfo, 0, len(entries))
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
l.logger.Warnf("Failed to get file info for %s: %v", entry.Name(), err)
continue
}
filePath := filepath.Join(dirPath, entry.Name())
files = append(files, storagetypes.FileInfo{
Name: entry.Name(),
Path: filePath,
Size: info.Size(),
IsDir: entry.IsDir(),
ModTime: info.ModTime(),
})
}
return files, nil
}
// OpenFile implements StorageReadable interface
func (l *Local) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
absPath := l.JoinStoragePath(filePath)
file, err := os.Open(absPath)
if err != nil {
return nil, 0, fmt.Errorf("failed to open file %s: %w", absPath, err)
}
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, fmt.Errorf("failed to stat file %s: %w", absPath, err)
}
return file, stat.Size(), nil
}

View File

@@ -77,13 +77,13 @@ func (m *Minio) JoinStoragePath(p string) string {
func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error { func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error {
m.logger.Infof("Saving file from reader to %s", storagePath) m.logger.Infof("Saving file from reader to %s", storagePath)
storagePath = m.JoinStoragePath(storagePath)
ext := path.Ext(storagePath) ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext) base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath candidate := storagePath
for i := 1; m.Exists(ctx, candidate); i++ { for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext) candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 10 { if i > 100 {
m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath) m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break break

View File

@@ -65,7 +65,7 @@ func (m *S3) JoinStoragePath(p string) string {
func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error { func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error {
m.logger.Infof("Saving file from reader to %s", storagePath) m.logger.Infof("Saving file from reader to %s", storagePath)
storagePath = m.JoinStoragePath(storagePath)
ext := path.Ext(storagePath) ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext) base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath candidate := storagePath
@@ -73,7 +73,7 @@ func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error {
// Unique filename // Unique filename
for i := 1; m.Exists(ctx, candidate); i++ { for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext) candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 10 { if i > 100 {
m.logger.Errorf("Too many attempts for unique filename: %s", storagePath) m.logger.Errorf("Too many attempts for unique filename: %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext) candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break break

View File

@@ -7,7 +7,6 @@ import (
storcfg "github.com/krau/SaveAny-Bot/config/storage" storcfg "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/krau/SaveAny-Bot/storage/alist" "github.com/krau/SaveAny-Bot/storage/alist"
"github.com/krau/SaveAny-Bot/storage/local" "github.com/krau/SaveAny-Bot/storage/local"
"github.com/krau/SaveAny-Bot/storage/minio" "github.com/krau/SaveAny-Bot/storage/minio"
@@ -21,6 +20,7 @@ type Storage interface {
Init(ctx context.Context, cfg storcfg.StorageConfig) error Init(ctx context.Context, cfg storcfg.StorageConfig) error
Type() storenum.StorageType Type() storenum.StorageType
Name() string Name() string
JoinStoragePath(p string) string
Save(ctx context.Context, reader io.Reader, storagePath string) error Save(ctx context.Context, reader io.Reader, storagePath string) error
Exists(ctx context.Context, storagePath string) bool Exists(ctx context.Context, storagePath string) bool
} }
@@ -30,18 +30,6 @@ type StorageCannotStream interface {
CannotStream() string CannotStream() string
} }
// StorageListable 表示支持列举目录内容的存储
type StorageListable interface {
Storage
ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error)
}
// StorageReadable 表示支持读取文件内容的存储
type StorageReadable interface {
Storage
OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error)
}
var Storages = make(map[string]Storage) var Storages = make(map[string]Storage)
type StorageConstructor func() Storage type StorageConstructor func() Storage

View File

@@ -99,6 +99,12 @@ func (w *splitWriter) finalize() error {
} }
func CreateSplitZip(ctx context.Context, reader io.Reader, size int64, fileName, outputBase string, partSize int64) error { func CreateSplitZip(ctx context.Context, reader io.Reader, size int64, fileName, outputBase string, partSize int64) error {
// seek the reader if possible
if rs, ok := reader.(io.ReadSeeker); ok {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek reader: %w", err)
}
}
outputDir := filepath.Dir(outputBase) outputDir := filepath.Dir(outputBase)
if err := os.MkdirAll(outputDir, os.ModePerm); err != nil { if err := os.MkdirAll(outputDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create output directory: %w", err) return fmt.Errorf("failed to create output directory: %w", err)

View File

@@ -66,12 +66,15 @@ func (t *Telegram) Name() string {
return t.config.Name return t.config.Name
} }
func (t *Telegram) JoinStoragePath(p string) string {
return path.Clean(p)
}
func (t *Telegram) Exists(ctx context.Context, storagePath string) bool { func (t *Telegram) Exists(ctx context.Context, storagePath string) bool {
return false return false
} }
func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) error { func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) error {
storagePath = path.Clean(storagePath)
tctx := tgutil.ExtFromContext(ctx) tctx := tgutil.ExtFromContext(ctx)
if tctx == nil { if tctx == nil {
return fmt.Errorf("failed to get telegram context") return fmt.Errorf("failed to get telegram context")
@@ -89,6 +92,9 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
return nil return nil
} }
rs, seekable := r.(io.ReadSeeker) rs, seekable := r.(io.ReadSeeker)
if !seekable || rs == nil {
return fmt.Errorf("reader must implement io.ReadSeeker")
}
splitSize := t.config.SplitSizeMB * 1024 * 1024 splitSize := t.config.SplitSizeMB * 1024 * 1024
if splitSize <= 0 { if splitSize <= 0 {
splitSize = DefaultSplitSize splitSize = DefaultSplitSize
@@ -117,96 +123,88 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
} }
chatID = cid chatID = cid
} }
upler := uploader.NewUploader(tctx.Raw). mtype, err := mimetype.DetectReader(rs)
WithPartSize(tglimit.MaxUploadPartSize). if err != nil {
WithThreads(dlutil.BestThreads(size, config.C().Threads)) return fmt.Errorf("failed to detect mimetype: %w", err)
}
if filename == "" {
filename = xid.New().String() + mtype.Extension()
}
peer := tryGetInputPeer(tctx, chatID) peer := tryGetInputPeer(tctx, chatID)
if peer == nil || peer.Zero() { if peer == nil || peer.Zero() {
return fmt.Errorf("failed to get input peer for chat ID %d", chatID) return fmt.Errorf("failed to get input peer for chat ID %d", chatID)
} }
var mtype *mimetype.MIME
if seekable {
var err error
mtype, err = mimetype.DetectReader(rs)
if err != nil {
return fmt.Errorf("failed to detect mimetype: %w", err)
}
if filename == "" {
filename = xid.New().String() + mtype.Extension()
}
if _, err := rs.Seek(0, io.SeekStart); err != nil { if _, err := rs.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek reader: %w", err) return fmt.Errorf("failed to seek reader: %w", err)
}
} }
upler := uploader.NewUploader(tctx.Raw).
WithPartSize(tglimit.MaxUploadPartSize).
WithThreads(dlutil.BestThreads(size, config.C().Threads))
if size > splitSize { if size > splitSize {
// large file, use split uploader // large file, use split uploader
return t.splitUpload(tctx, r, filename, upler, peer, size, splitSize) return t.splitUpload(tctx, rs, filename, upler, peer, size, splitSize)
} }
var file tg.InputFileClass var file tg.InputFileClass
var err error if size < 0 {
if size <= 0 { file, err = upler.FromReader(ctx, filename, rs)
file, err = upler.FromReader(ctx, filename, r)
} else { } else {
file, err = upler.Upload(ctx, uploader.NewUpload(filename, r, size)) file, err = upler.Upload(ctx, uploader.NewUpload(filename, rs, size))
} }
if err != nil { if err != nil {
return fmt.Errorf("failed to upload file to telegram: %w", err) return fmt.Errorf("failed to upload file to telegram: %w", err)
} }
caption := styling.Plain(filename) caption := styling.Plain(filename)
forceFile := t.config.ForceFile forceFile := t.config.ForceFile
if strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
if mtype != nil && strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
forceFile = true forceFile = true
} }
doc := message.UploadedDocument(file, caption). doc := message.UploadedDocument(file, caption).
Filename(filename). Filename(filename).
ForceFile(forceFile) ForceFile(forceFile).
if mtype != nil { MIME(mtype.String())
doc = doc.MIME(mtype.String())
}
var media message.MediaOption = doc var media message.MediaOption = doc
if mtype != nil && rs != nil {
switch mtypeStr := mtype.String(); { switch mtypeStr := mtype.String(); {
case strings.HasPrefix(mtypeStr, "video/"): case strings.HasPrefix(mtypeStr, "video/"):
media = doc.Video().SupportsStreaming() media = doc.Video().SupportsStreaming()
thumb, err := extractThumbFrame(rs) thumb, err := extractThumbFrame(rs)
if err == nil {
thumb, err := upler.FromBytes(ctx, "thumb.jpg", thumb)
if err == nil { if err == nil {
thumb, err := upler.FromBytes(ctx, "thumb.jpg", thumb) doc = doc.Thumb(thumb)
if err == nil {
doc = doc.Thumb(thumb)
}
} }
rs.Seek(0, io.SeekStart)
switch mtypeStr {
case "video/mp4":
info, err := getMP4Meta(rs)
if err != nil {
// Fallback to ffprobe if gomedia fails (e.g., malformed MP4)
rs.Seek(0, io.SeekStart)
info, err = getVideoMetadata(rs)
}
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
default:
info, err := getVideoMetadata(rs)
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
}
case strings.HasPrefix(mtypeStr, "audio/"):
media = doc.Audio().Title(filename)
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
media = message.UploadedPhoto(file, caption)
} }
rs.Seek(0, io.SeekStart)
switch mtypeStr {
case "video/mp4":
info, err := getMP4Meta(rs)
if err != nil {
// Fallback to ffprobe if gomedia fails (e.g., malformed MP4)
rs.Seek(0, io.SeekStart)
info, err = getVideoMetadata(rs)
}
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
default:
info, err := getVideoMetadata(rs)
if err == nil {
media = doc.Video().
Duration(time.Duration(info.Duration)*time.Second).
Resolution(info.Width, info.Height).
SupportsStreaming()
}
}
case strings.HasPrefix(mtypeStr, "audio/"):
media = doc.Audio().Title(filename)
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
media = message.UploadedPhoto(file, caption)
} }
sender := tctx.Sender sender := tctx.Sender
_, err = sender.WithUploader(upler).To(peer).Media(ctx, media) _, err = sender.WithUploader(upler).To(peer).Media(ctx, media)
@@ -217,7 +215,7 @@ func (t *Telegram) CannotStream() string {
return "Telegram storage must use a ReaderSeeker" return "Telegram storage must use a ReaderSeeker"
} }
func (t *Telegram) splitUpload(ctx *ext.Context, r io.Reader, filename string, upler *uploader.Uploader, peer tg.InputPeerClass, fileSize, splitSize int64) error { func (t *Telegram) splitUpload(ctx *ext.Context, rs io.ReadSeeker, filename string, upler *uploader.Uploader, peer tg.InputPeerClass, fileSize, splitSize int64) error {
tempId := xid.New().String() tempId := xid.New().String()
outputBase := filepath.Join(config.C().Temp.BasePath, tempId, strings.Split(filename, ".")[0]) outputBase := filepath.Join(config.C().Temp.BasePath, tempId, strings.Split(filename, ".")[0])
defer func() { defer func() {
@@ -226,7 +224,7 @@ func (t *Telegram) splitUpload(ctx *ext.Context, r io.Reader, filename string, u
log.FromContext(ctx).Warnf("Failed to cleanup temp split files: %s", err) log.FromContext(ctx).Warnf("Failed to cleanup temp split files: %s", err)
} }
}() }()
if err := CreateSplitZip(ctx, r, fileSize, filename, outputBase, splitSize); err != nil { if err := CreateSplitZip(ctx, rs, fileSize, filename, outputBase, splitSize); err != nil {
return fmt.Errorf("failed to create split zip: %w", err) return fmt.Errorf("failed to create split zip: %w", err)
} }
matched, err := filepath.Glob(outputBase + ".z*") matched, err := filepath.Glob(outputBase + ".z*")

View File

@@ -2,7 +2,6 @@ package webdav
import ( import (
"context" "context"
"encoding/xml"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -26,40 +25,8 @@ const (
WebdavMethodMkcol WebdavMethod = "MKCOL" WebdavMethodMkcol WebdavMethod = "MKCOL"
WebdavMethodPropfind WebdavMethod = "PROPFIND" WebdavMethodPropfind WebdavMethod = "PROPFIND"
WebdavMethodPut WebdavMethod = "PUT" WebdavMethodPut WebdavMethod = "PUT"
WebdavMethodGet WebdavMethod = "GET"
) )
// WebDAV XML structures for PROPFIND response
type Multistatus struct {
XMLName xml.Name `xml:"multistatus"`
Responses []Response `xml:"response"`
}
type Response struct {
Href string `xml:"href"`
Propstat Propstat `xml:"propstat"`
}
type Propstat struct {
Prop Prop `xml:"prop"`
Status string `xml:"status"`
}
type Prop struct {
ResourceType ResourceType `xml:"resourcetype"`
GetContentLength int64 `xml:"getcontentlength"`
GetLastModified string `xml:"getlastmodified"`
DisplayName string `xml:"displayname"`
}
type ResourceType struct {
Collection *struct{} `xml:"collection"`
}
func (rt ResourceType) IsCollection() bool {
return rt.Collection != nil
}
func NewClient(baseURL, username, password string, httpClient *http.Client) *Client { func NewClient(baseURL, username, password string, httpClient *http.Client) *Client {
if !strings.HasSuffix(baseURL, "/") { if !strings.HasSuffix(baseURL, "/") {
baseURL += "/" baseURL += "/"
@@ -164,79 +131,5 @@ func (c *Client) WriteFile(ctx context.Context, remotePath string, content io.Re
return nil return nil
} }
return fmt.Errorf("PUT: %s", resp.Status) return fmt.Errorf("PUT: %s", resp.Status)
}
// ListDir lists files and directories in the given path
func (c *Client) ListDir(ctx context.Context, dirPath string) ([]Response, error) {
dirPath = strings.Trim(dirPath, "/")
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, dirPath)
if !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
resp, err := c.doRequest(ctx, WebdavMethodPropfind, u.String(), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
return nil, fmt.Errorf("PROPFIND: %s", resp.Status)
}
var multistatus Multistatus
if err := xml.NewDecoder(resp.Body).Decode(&multistatus); err != nil {
return nil, fmt.Errorf("failed to decode PROPFIND response: %w", err)
}
// Filter out the directory itself from results
var results []Response
basePath := u.Path
for _, r := range multistatus.Responses {
decodedHref, err := url.PathUnescape(r.Href)
if err != nil {
decodedHref = r.Href
}
// Skip the directory itself
if strings.TrimSuffix(decodedHref, "/") == strings.TrimSuffix(basePath, "/") {
continue
}
results = append(results, r)
}
return results, nil
}
// ReadFile downloads a file and returns a ReadCloser
func (c *Client) ReadFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
filePath = strings.Trim(filePath, "/")
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, 0, err
}
u.Path = path.Join(u.Path, filePath)
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, 0, err
}
if c.Username != "" && c.Password != "" {
req.SetBasicAuth(c.Username, c.Password)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, 0, fmt.Errorf("GET: %s", resp.Status)
}
return resp.Body, resp.ContentLength, nil
} }

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"path" "path"
"strings" "strings"
"time" "time"
@@ -13,7 +12,6 @@ import (
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage" config "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage" storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/rs/xid" "github.com/rs/xid"
) )
@@ -53,7 +51,7 @@ func (w *Webdav) JoinStoragePath(p string) string {
func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) error { func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) error {
w.logger.Infof("Saving file to %s", storagePath) w.logger.Infof("Saving file to %s", storagePath)
storagePath = w.JoinStoragePath(storagePath)
ext := path.Ext(storagePath) ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext) base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath candidate := storagePath
@@ -86,80 +84,3 @@ func (w *Webdav) Exists(ctx context.Context, storagePath string) bool {
} }
return exists return exists
} }
// ListFiles implements storage.StorageListable
func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
w.logger.Infof("Listing files in %s", dirPath)
// Join with base path
fullPath := path.Join(w.config.BasePath, dirPath)
responses, err := w.client.ListDir(ctx, fullPath)
if err != nil {
w.logger.Errorf("Failed to list directory %s: %v", fullPath, err)
return nil, fmt.Errorf("failed to list directory: %w", err)
}
files := make([]storagetypes.FileInfo, 0, len(responses))
for _, resp := range responses {
// Parse the href to get the file name
decodedHref, err := url.PathUnescape(resp.Href)
if err != nil {
w.logger.Warnf("Failed to unescape href %q: %v; using original value", resp.Href, err)
decodedHref = resp.Href
}
// Extract filename from href
name := path.Base(strings.TrimSuffix(decodedHref, "/"))
if name == "" || name == "." {
continue
}
// Parse modification time
var modTime time.Time
if resp.Propstat.Prop.GetLastModified != "" {
// Try RFC1123 format (standard for WebDAV)
parsedTime, err := time.Parse(time.RFC1123, resp.Propstat.Prop.GetLastModified)
if err != nil {
w.logger.Warnf("Failed to parse last modified time %q for %s: %v", resp.Propstat.Prop.GetLastModified, decodedHref, err)
} else {
modTime = parsedTime
}
}
isDir := resp.Propstat.Prop.ResourceType.IsCollection()
filePath := strings.TrimPrefix(decodedHref, path.Join("/", strings.Trim(path.Dir(fullPath), "/")))
filePath = strings.TrimPrefix(filePath, "/")
fileInfo := storagetypes.FileInfo{
Name: name,
Path: path.Join(dirPath, name),
Size: resp.Propstat.Prop.GetContentLength,
IsDir: isDir,
ModTime: modTime,
}
files = append(files, fileInfo)
}
w.logger.Debugf("Found %d files/directories in %s", len(files), dirPath)
return files, nil
}
// OpenFile implements storage.StorageReadable
func (w *Webdav) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
w.logger.Infof("Opening file %s", filePath)
// Join with base path
fullPath := path.Join(w.config.BasePath, filePath)
reader, size, err := w.client.ReadFile(ctx, fullPath)
if err != nil {
w.logger.Errorf("Failed to open file %s: %v", fullPath, err)
return nil, 0, fmt.Errorf("failed to open file: %w", err)
}
w.logger.Debugf("Opened file %s (size: %d bytes)", filePath, size)
return reader, size, nil
}