Compare commits

..

19 Commits

Author SHA1 Message Date
krau
b8a95e9362 chore: update generated code files for consistency 2026-01-19 17:39:11 +08:00
Krau
4825f0d5b9 Merge branch 'main' into gh-153 2026-01-19 17:37:55 +08:00
krau
77f1827979 refactor: use strutil to parse args 2026-01-17 21:38:21 +08:00
krau
6d5e3a4a16 fix: missing progress stats i18n 2026-01-17 21:37:57 +08:00
Krau
4d40f14b50 Update storage/webdav/webdav.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:29:00 +08:00
Krau
dceb3737f6 Update core/tasks/batchimport/execute.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:25:49 +08:00
Krau
441d944bc2 Update storage/telegram/telegram.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:25:13 +08:00
Krau
2a86e59b6f Update storage/webdav/webdav.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:24:25 +08:00
Krau
7c2a9f12fd Update core/tasks/batchimport/execute.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:24:15 +08:00
Krau
79c180d2f8 Update common/i18n/locale/en.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:23:19 +08:00
Krau
7a68c4254a Update pkg/storagetypes/fileinfo.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:22:53 +08:00
Krau
bf55f77546 Update common/i18n/locale/en.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:22:33 +08:00
Krau
221b4ee1f5 Update storage/alist/alist.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:22:13 +08:00
Krau
bb2b053fbd Update core/tasks/batchimport/execute.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:21:08 +08:00
Krau
b2c9d6612e Update core/tasks/batchimport/progress.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:19:33 +08:00
Krau
fb20fee2bc Update common/i18n/locale/zh-Hans.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 21:19:06 +08:00
krau
b277a79786 feat: implement ListFiles and OpenFile methods for WebDAV and Alist storage 2026-01-17 19:36:23 +08:00
krau
1d4aa56dd6 feat: add i18n for import command 2026-01-17 19:27:03 +08:00
krau
eda0756f0c feat: add import command and batch import functionality
- Implemented the `/import` command to allow users to import files from storage to Telegram.
- Added support for listing files in storage and filtering based on regex patterns.
- Created a batch import task to handle multiple file uploads concurrently.
- Introduced progress tracking for batch imports, providing real-time updates to users.
- Enhanced storage interfaces to support file listing and reading capabilities.
- Updated localization files for the new import command and its usage instructions.
- Added utility functions for file size formatting and speed calculation.
- Refactored Telegram storage handling to support reading from non-seekable streams.
2026-01-17 18:59:09 +08:00
51 changed files with 340 additions and 1348 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

@@ -26,16 +26,12 @@
- Multi-user support - Multi-user support
- Auto organize files based on storage rules - Auto organize files based on storage rules
- Watch specified chats and auto-save messages, with filters - Watch specified chats and auto-save messages, with filters
- Transfer files between different storage backends
- Integrate with yt-dlp to download and save media from 1000+ websites
- Aria2 integration to download files from URLs/magnets and save to storages
- Write JS parser plugins to save files from almost any website - Write JS parser plugins to save files from almost any website
- Storage backends: - Storage backends:
- Alist - Alist
- S3 - S3
- WebDAV - WebDAV
- Local filesystem - Local filesystem
- Rclone (via command line)
- Telegram (re-upload to specified chats) - Telegram (re-upload to specified chats)
## 📦 Quick Start ## 📦 Quick Start

View File

@@ -24,16 +24,12 @@
- 多用户使用 - 多用户使用
- 基于存储规则的自动整理 - 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤 - 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件 - 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持: - 存储端支持:
- Alist - Alist
- S3 - S3
- WebDAV - WebDAV
- 本地磁盘 - 本地磁盘
- Rclone
- Telegram (重传回指定聊天) - Telegram (重传回指定聊天)
## 快速开始 ## 快速开始

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

@@ -0,0 +1,182 @@
package handlers
import (
"fmt"
"regexp"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"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/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
storconfig "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/batchimport"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func handleImportCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportUsage, nil)), nil)
return dispatcher.EndGroups
}
storageName := args[1]
dirPath := args[2]
userID := update.GetUserChat().GetID()
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotFound, map[string]any{
"StorageName": storageName,
"Error": err,
})), nil)
return dispatcher.EndGroups
}
listable, ok := stor.(storage.StorageListable)
if !ok {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotListable, map[string]any{
"StorageName": storageName,
})), nil)
return dispatcher.EndGroups
}
_, ok = stor.(storage.StorageReadable)
if !ok {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorStorageNotReadable, map[string]any{
"StorageName": storageName,
})), nil)
return dispatcher.EndGroups
}
telegramStorage, err := storage.GetTelegramStorageByUserID(ctx, userID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportErrorNoTelegramStorage, map[string]any{
"Error": err,
})), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgImportInfoFetchingFiles, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
files, err := listable.ListFiles(ctx, dirPath)
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgImportErrorListFilesFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
var filter *regexp.Regexp
if len(args) >= 5 {
filter, err = regexp.Compile(args[4])
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgImportErrorInvalidRegex, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
}
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.BotMsgImportErrorNoFilesToImport, nil),
})
return dispatcher.EndGroups
}
// Get default chat_id from Telegram storage config
targetChatID := int64(0)
if telegramCfg := config.C().GetStorageByName(telegramStorage.Name()); telegramCfg != nil {
if tgCfg, ok := telegramCfg.(*storconfig.TelegramStorageConfig); ok {
targetChatID = tgCfg.ChatID
}
}
if len(args) >= 4 {
parsedChatID, err := tgutil.ParseChatID(ctx, args[3])
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgImportErrorInvalidChatId, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
targetChatID = parsedChatID
}
if targetChatID == 0 {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgImportErrorNoTargetChatId, nil),
})
return dispatcher.EndGroups
}
elems := make([]batchimport.TaskElement, 0, len(filteredFiles))
var totalSize int64
for _, file := range filteredFiles {
elem := batchimport.NewTaskElement(stor, file, telegramStorage, targetChatID)
elems = append(elems, *elem)
totalSize += file.Size
}
taskID := xid.New().String()
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := batchimport.NewBatchImportTask(
taskID,
injectCtx,
elems,
batchimport.NewProgressTracker(replied.ID, userID),
true, // IgnoreErrors
)
if err := core.AddTask(injectCtx, task); err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgImportErrorAddTaskFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgImportInfoTaskAdded, map[string]any{
"Count": len(elems),
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
"TaskID": taskID,
}),
})
return dispatcher.EndGroups
}

View File

@@ -31,7 +31,7 @@ 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}, {"import", i18nk.BotMsgCmdImport, handleImportCmd},
{"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

@@ -31,7 +31,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"
@@ -107,6 +106,20 @@ const (
BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage" BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage"
BotMsgDlUsage Key = "bot.msg.dl.usage" BotMsgDlUsage Key = "bot.msg.dl.usage"
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt" BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
BotMsgImportErrorAddTaskFailed Key = "bot.msg.import.error_add_task_failed"
BotMsgImportErrorInvalidChatId Key = "bot.msg.import.error_invalid_chat_id"
BotMsgImportErrorInvalidRegex Key = "bot.msg.import.error_invalid_regex"
BotMsgImportErrorListFilesFailed Key = "bot.msg.import.error_list_files_failed"
BotMsgImportErrorNoFilesToImport Key = "bot.msg.import.error_no_files_to_import"
BotMsgImportErrorNoTargetChatId Key = "bot.msg.import.error_no_target_chat_id"
BotMsgImportErrorNoTelegramStorage Key = "bot.msg.import.error_no_telegram_storage"
BotMsgImportErrorStorageNotFound Key = "bot.msg.import.error_storage_not_found"
BotMsgImportErrorStorageNotListable Key = "bot.msg.import.error_storage_not_listable"
BotMsgImportErrorStorageNotReadable Key = "bot.msg.import.error_storage_not_readable"
BotMsgImportInfoFetchingFiles Key = "bot.msg.import.info_fetching_files"
BotMsgImportInfoTaskAdded Key = "bot.msg.import.info_task_added"
BotMsgImportStartStats Key = "bot.msg.import.start_stats"
BotMsgImportUsage Key = "bot.msg.import.usage"
BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.media_group.error_build_storage_select_keyboard_failed" BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.media_group.error_build_storage_select_keyboard_failed"
BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage" BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage"
BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files" BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files"
@@ -151,6 +164,20 @@ const (
BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix" BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix"
BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_prefix" BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_prefix"
BotMsgProgressFileStartPrefix Key = "bot.msg.progress.file_start_prefix" BotMsgProgressFileStartPrefix Key = "bot.msg.progress.file_start_prefix"
BotMsgProgressImportAvgSpeedPrefix Key = "bot.msg.progress.import_avg_speed_prefix"
BotMsgProgressImportElapsedTimePrefix Key = "bot.msg.progress.import_elapsed_time_prefix"
BotMsgProgressImportFailedFilesPrefix Key = "bot.msg.progress.import_failed_files_prefix"
BotMsgProgressImportFailedPrefix Key = "bot.msg.progress.import_failed_prefix"
BotMsgProgressImportProcessingMore Key = "bot.msg.progress.import_processing_more"
BotMsgProgressImportProcessingPrefix Key = "bot.msg.progress.import_processing_prefix"
BotMsgProgressImportProgressPrefix Key = "bot.msg.progress.import_progress_prefix"
BotMsgProgressImportRemainingTimePrefix Key = "bot.msg.progress.import_remaining_time_prefix"
BotMsgProgressImportSpeedPrefix Key = "bot.msg.progress.import_speed_prefix"
BotMsgProgressImportStartPrefix Key = "bot.msg.progress.import_start_prefix"
BotMsgProgressImportSuccessPrefix Key = "bot.msg.progress.import_success_prefix"
BotMsgProgressImportTotalFilesPrefix Key = "bot.msg.progress.import_total_files_prefix"
BotMsgProgressImportTotalSizePrefix Key = "bot.msg.progress.import_total_size_prefix"
BotMsgProgressImportUploadedPrefix Key = "bot.msg.progress.import_uploaded_prefix"
BotMsgProgressParsedDonePrefix Key = "bot.msg.progress.parsed_done_prefix" BotMsgProgressParsedDonePrefix Key = "bot.msg.progress.parsed_done_prefix"
BotMsgProgressParsedStartPrefix Key = "bot.msg.progress.parsed_start_prefix" BotMsgProgressParsedStartPrefix Key = "bot.msg.progress.parsed_start_prefix"
BotMsgProgressProcessingListPrefix Key = "bot.msg.progress.processing_list_prefix" BotMsgProgressProcessingListPrefix Key = "bot.msg.progress.processing_list_prefix"
@@ -163,20 +190,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 +245,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

@@ -54,7 +54,6 @@ bot:
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" 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 +296,20 @@ 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: import:
usage: | usage: "Usage: /import <storage_name> <dir_path> [target_chat_id] [filter]\n\nExamples:\n/import local1 /downloads\n/import MyAlist /media/photos -1001234567890\n/import MyLocal /backup \".*[.]mp4$\""
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_found: "Storage '{{.StorageName}}' not found or access denied: {{.Error}}"
error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files" error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files"
error_storage_not_readable: "Storage '{{.StorageName}}' does not support reading 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}}" error_no_telegram_storage: "No Telegram storage found: {{.Error}}"
info_fetching_files: "Fetching file list..." info_fetching_files: "Fetching file list..."
error_list_files_failed: "Failed to list files: {{.Error}}" error_list_files_failed: "Failed to list files: {{.Error}}"
error_invalid_regex: "Invalid regular expression: {{.Error}}" error_invalid_regex: "Invalid regular expression: {{.Error}}"
error_no_files_to_transfer: "No files to transfer in directory" error_no_files_to_import: "No files to import in directory"
error_invalid_chat_id: "Invalid Chat ID: {{.Error}}"
error_no_target_chat_id: "No target channel ID specified and Telegram storage has no default chat_id configured"
error_add_task_failed: "Failed to add task: {{.Error}}" 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}}" info_task_added: "Added {{.Count}} files to import 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 +358,20 @@ 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: " import_start_prefix: "Importing: "
transfer_progress_prefix: "Transfer progress: " import_progress_prefix: "Import progress: "
transfer_uploaded_prefix: "\nUploaded: " import_uploaded_prefix: "\nUploaded: "
transfer_speed_prefix: "\nSpeed: " import_speed_prefix: "\nSpeed: "
transfer_remaining_time_prefix: "\nRemaining time: " import_remaining_time_prefix: "\nRemaining time: "
transfer_processing_prefix: "\nProcessing:\n" import_processing_prefix: "\nProcessing:\n"
transfer_processing_more: "...and {{.Count}} more files\n" import_processing_more: "...and {{.Count}} more files\n"
transfer_failed_prefix: "Transfer failed\n" import_failed_prefix: "Import failed\n"
transfer_success_prefix: "Transfer completed\n" import_success_prefix: "Import completed\n"
transfer_total_files_prefix: "\nTotal files: " import_total_files_prefix: "\nTotal files: "
transfer_total_size_prefix: "\nTotal size: " import_total_size_prefix: "\nTotal size: "
transfer_elapsed_time_prefix: "\nElapsed time: " import_elapsed_time_prefix: "\nElapsed time: "
transfer_avg_speed_prefix: "\nAverage speed: " import_avg_speed_prefix: "\nAverage speed: "
transfer_failed_files_prefix: "\nFailed files: " import_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

@@ -55,7 +55,6 @@ bot:
aria2dl: "使用 Aria2 下载给定链接的文件" aria2dl: "使用 Aria2 下载给定链接的文件"
ytdlp: "使用 yt-dlp 下载视频/音频" ytdlp: "使用 yt-dlp 下载视频/音频"
import: "从存储端导入文件到 Telegram" import: "从存储端导入文件到 Telegram"
transfer: "在存储端之间传输文件"
task: "管理任务队列" task: "管理任务队列"
cancel: "取消任务" cancel: "取消任务"
watch: "监听聊天(UserBot)" watch: "监听聊天(UserBot)"
@@ -298,28 +297,26 @@ 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: import:
usage: | usage: |
用法: /transfer <source_storage>:/<source_path> [filter] 用法: /import <storage_name> <dir_path> [target_chat_id] [filter]
示例: 示例:
/transfer local1:/downloads /import 本机1 /downloads
/transfer alist1:/media/photos /import MyAlist /media/photos -1001234567890
/transfer webdav1:/files ".*\.mp4$" /import MyLocal /backup ".*\.mp4$"
error_invalid_source: "源路径格式无效,应为: storage_name:/path"
error_invalid_target: "目标路径格式无效,应为: storage_name:/path"
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}" error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能" error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能" error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
error_target_not_found: "目标存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}" error_no_telegram_storage: "未找到可用的 Telegram 存储: {{.Error}}"
info_fetching_files: "正在获取文件列表..." info_fetching_files: "正在获取文件列表..."
error_list_files_failed: "获取文件列表失败: {{.Error}}" error_list_files_failed: "获取文件列表失败: {{.Error}}"
error_invalid_regex: "正则表达式无效: {{.Error}}" error_invalid_regex: "正则表达式无效: {{.Error}}"
error_no_files_to_transfer: "目录中没有可传输的文件" error_no_files_to_import: "目录中没有可导入的文件"
error_invalid_chat_id: "无效的 Chat ID: {{.Error}}"
error_no_target_chat_id: "未指定目标频道 ID且 Telegram 存储未配置默认 chat_id"
error_add_task_failed: "添加任务失败: {{.Error}}" error_add_task_failed: "添加任务失败: {{.Error}}"
info_task_added: "已添加 {{.Count}} 个文件到传输队列\n总大小: {{.SizeMB}} MB\n任务 ID: {{.TaskID}}" info_task_added: "已添加 {{.Count}} 个文件到导入队列\n总大小: {{.SizeMB}} MB\n任务 ID: {{.TaskID}}"
start_stats: "总文件数: {{.Count}}\n总大小: {{.SizeMB}} MB" 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 +365,20 @@ 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: "正在转存: " import_start_prefix: "正在导入: "
transfer_progress_prefix: "转存进度: " import_progress_prefix: "导入进度: "
transfer_uploaded_prefix: "\n已上传: " import_uploaded_prefix: "\n已上传: "
transfer_speed_prefix: "\n速度: " import_speed_prefix: "\n速度: "
transfer_remaining_time_prefix: "\n剩余时间: " import_remaining_time_prefix: "\n剩余时间: "
transfer_processing_prefix: "\n正在处理:\n" import_processing_prefix: "\n正在处理:\n"
transfer_processing_more: "...和其他 {{.Count}} 个文件\n" import_processing_more: "...和其他 {{.Count}} 个文件\n"
transfer_failed_prefix: "转存失败\n" import_failed_prefix: "导入失败\n"
transfer_success_prefix: "转存完成\n" import_success_prefix: "导入完成\n"
transfer_total_files_prefix: "\n总文件数: " import_total_files_prefix: "\n总文件数: "
transfer_total_size_prefix: "\n总大小: " import_total_size_prefix: "\n总大小: "
transfer_elapsed_time_prefix: "\n耗时: " import_elapsed_time_prefix: "\n耗时: "
transfer_avg_speed_prefix: "\n平均速度: " import_avg_speed_prefix: "\n平均速度: "
transfer_failed_files_prefix: "\n失败文件数: " import_failed_files_prefix: "\n失败文件数: "
syncpeers: syncpeers:
start: "正在同步对话列表..." start: "正在同步对话列表..."
success: "对话列表同步完成, 共同步 {{.Count}} 个对话" success: "对话列表同步完成, 共同步 {{.Count}} 个对话"

View File

@@ -16,7 +16,6 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
storenum.Minio: createStorageConfig(&MinioStorageConfig{}), storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.S3: createStorageConfig(&S3StorageConfig{}), storenum.S3: createStorageConfig(&S3StorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}), storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
storenum.Rclone: createStorageConfig(&RcloneStorageConfig{}),
} }
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) { func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {

View File

@@ -1,33 +0,0 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type RcloneStorageConfig struct {
BaseConfig
// The name of the remote as defined in rclone config
Remote string `toml:"remote" mapstructure:"remote" json:"remote"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
// The path to the rclone config file, if not using the default
ConfigPath string `toml:"config_path" mapstructure:"config_path" json:"config_path"`
// Additional flags to pass to rclone commands
Flags []string `toml:"flags" mapstructure:"flags" json:"flags"`
}
func (r *RcloneStorageConfig) Validate() error {
if r.Remote == "" {
return fmt.Errorf("remote is required for rclone storage")
}
return nil
}
func (r *RcloneStorageConfig) GetType() storenum.StorageType {
return storenum.Rclone
}
func (r *RcloneStorageConfig) GetName() string {
return r.Name
}

View File

@@ -1,11 +1,10 @@
package transfer package batchimport
import ( import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
@@ -17,8 +16,8 @@ import (
// Execute implements core.Executable. // Execute implements core.Executable.
func (t *Task) Execute(ctx context.Context) error { func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("transfer[%s]", t.ID)) logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("batch_import[%s]", t.ID))
logger.Info("Starting transfer task") logger.Info("Starting batch import task")
t.Progress.OnStart(ctx, t) t.Progress.OnStart(ctx, t)
workers := config.C().Workers workers := config.C().Workers
@@ -60,9 +59,9 @@ func (t *Task) Execute(ctx context.Context) error {
err := eg.Wait() err := eg.Wait()
if err != nil { if err != nil {
logger.Errorf("Error during transfer processing: %v", err) logger.Errorf("Error during batch import processing: %v", err)
} else { } else {
logger.Info("Transfer task completed successfully") logger.Info("Batch import task completed successfully")
} }
t.Progress.OnDone(ctx, t, err) t.Progress.OnDone(ctx, t, err)
@@ -85,15 +84,15 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
} }
defer reader.Close() defer reader.Close()
// Build target storage path: /target_path/filename // Build Telegram storage path: /<chat_id>/<filename>
storagePath := path.Join(elem.TargetPath, elem.FileInfo.Name) storagePath := fmt.Sprintf("/%d/%s", elem.TargetChatID, elem.FileInfo.Name)
// Inject file size into context // 注入文件大小到 context
ctx = context.WithValue(ctx, ctxkey.ContentLength, size) ctx = context.WithValue(ctx, ctxkey.ContentLength, size)
if config.C().Stream { if config.C().Stream {
if err := elem.TargetStorage.Save(ctx, reader, storagePath); err != nil { if err := elem.TargetStorage.Save(ctx, reader, storagePath); err != nil {
return fmt.Errorf("failed to upload file to storage: %w", err) return fmt.Errorf("failed to upload file to telegram: %w", err)
} }
} else { } else {
logger.Info("Downloading to temporary file for ReadSeeker support") logger.Info("Downloading to temporary file for ReadSeeker support")
@@ -108,9 +107,9 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
return fmt.Errorf("failed to seek temp file: %w", err) return fmt.Errorf("failed to seek temp file: %w", err)
} }
logger.Infof("Uploading file to storage (size: %d bytes)", size) logger.Infof("Uploading file to Telegram storage (size: %d bytes)", size)
if err := elem.TargetStorage.Save(ctx, tempFile, storagePath); err != nil { if err := elem.TargetStorage.Save(ctx, tempFile, storagePath); err != nil {
return fmt.Errorf("failed to upload file to storage: %w", err) return fmt.Errorf("failed to upload file to telegram: %w", err)
} }
} }

View File

@@ -1,4 +1,4 @@
package transfer package batchimport
import ( import (
"context" "context"
@@ -40,17 +40,17 @@ func NewProgressTracker(messageID int, chatID int64) ProgressTracker {
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) { func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
p.start = time.Now() p.start = time.Now()
p.lastUpdatePercent.Store(0) p.lastUpdatePercent.Store(0)
log.FromContext(ctx).Debugf("Transfer task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID) log.FromContext(ctx).Debugf("Batch import task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
sizeMB := float64(info.TotalSize()) / (1024 * 1024) sizeMB := float64(info.TotalSize()) / (1024 * 1024)
statsText := i18n.T(i18nk.BotMsgTransferStartStats, map[string]any{ statsText := i18n.T(i18nk.BotMsgImportStartStats, map[string]any{
"SizeMB": fmt.Sprintf("%.2f", sizeMB), "SizeMB": fmt.Sprintf("%.2f", sizeMB),
"Count": info.Count(), "Count": info.Count(),
}) })
entityBuilder := entity.Builder{} entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder, if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressTransferStartPrefix, nil)), styling.Plain(i18n.T(i18nk.BotMsgProgressImportStartPrefix, nil)),
styling.Code(statsText), styling.Code(statsText),
); err != nil { ); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err) log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -75,10 +75,7 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
ext := tgutil.ExtFromContext(ctx) ext := tgutil.ExtFromContext(ctx)
if ext != nil { if ext != nil {
_, err := ext.EditMessage(p.ChatID, req) ext.EditMessage(p.ChatID, req)
if err != nil {
log.FromContext(ctx).Errorf("Failed to send progress start message: %s", err)
}
} }
} }
@@ -97,32 +94,32 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{} entityBuilder := entity.Builder{}
var progressText strings.Builder var progressText strings.Builder
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProgressPrefix, nil)) progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportProgressPrefix, nil))
fmt.Fprintf(&progressText, "%d%%", percent) progressText.WriteString(fmt.Sprintf("%d%%", percent))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, nil)) progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportUploadedPrefix, nil))
fmt.Fprintf(&progressText, "%.2f MB / %.2f MB", progressText.WriteString(fmt.Sprintf("%.2f MB / %.2f MB",
float64(info.Uploaded())/(1024*1024), float64(info.Uploaded())/(1024*1024),
float64(info.TotalSize())/(1024*1024)) float64(info.TotalSize())/(1024*1024)))
if p.start.Unix() > 0 { if p.start.Unix() > 0 {
elapsed := time.Since(p.start) elapsed := time.Since(p.start)
speed := float64(info.Uploaded()) / elapsed.Seconds() speed := float64(info.Uploaded()) / elapsed.Seconds()
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSpeedPrefix, nil)) progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportSpeedPrefix, nil))
progressText.WriteString(dlutil.FormatSize(int64(speed)) + "/s") progressText.WriteString(dlutil.FormatSize(int64(speed)) + "/s")
if info.Uploaded() > 0 { if info.Uploaded() > 0 {
remaining := time.Duration(float64(info.TotalSize()-info.Uploaded()) / speed * float64(time.Second)) remaining := time.Duration(float64(info.TotalSize()-info.Uploaded()) / speed * float64(time.Second))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferRemainingTimePrefix, nil)) progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportRemainingTimePrefix, nil))
progressText.WriteString(formatDuration(remaining)) progressText.WriteString(formatDuration(remaining))
} }
} }
processing := info.Processing() processing := info.Processing()
if len(processing) > 0 { if len(processing) > 0 {
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingPrefix, nil)) progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportProcessingPrefix, nil))
for i, elem := range processing { for i, elem := range processing {
if i >= 3 { if i >= 3 {
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(processing) - 3})) progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportProcessingMore, map[string]any{"Count": len(processing) - 3}))
break break
} }
fmt.Fprintf(&progressText, "- %s\n", elem.FileName()) fmt.Fprintf(&progressText, "- %s\n", elem.FileName())
@@ -159,42 +156,42 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
} }
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) { 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) log.FromContext(ctx).Debugf("Batch import task progress tracking done for message %d in chat %d", p.MessageID, p.ChatID)
entityBuilder := entity.Builder{} entityBuilder := entity.Builder{}
var resultText strings.Builder var resultText strings.Builder
if err != nil { if err != nil {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportFailedPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressErrorPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressErrorPrefix, nil))
fmt.Fprintf(&resultText, "%v\n", err) fmt.Fprintf(&resultText, "%v\n", err)
} else { } else {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSuccessPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportSuccessPrefix, nil))
} }
elapsed := time.Since(p.start) elapsed := time.Since(p.start)
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferTotalFilesPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportTotalFilesPrefix, nil))
fmt.Fprintf(&resultText, "%d\n", info.Count()) fmt.Fprintf(&resultText, "%d\n", info.Count())
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferTotalSizePrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportTotalSizePrefix, nil))
fmt.Fprintf(&resultText, "%.2f MB\n", float64(info.TotalSize())/(1024*1024)) fmt.Fprintf(&resultText, "%.2f MB\n", float64(info.TotalSize())/(1024*1024))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportUploadedPrefix, nil))
fmt.Fprintf(&resultText, "%.2f MB\n", float64(info.Uploaded())/(1024*1024)) fmt.Fprintf(&resultText, "%.2f MB\n", float64(info.Uploaded())/(1024*1024))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferElapsedTimePrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportElapsedTimePrefix, nil))
fmt.Fprintf(&resultText, "%s\n", formatDuration(elapsed)) fmt.Fprintf(&resultText, "%s\n", formatDuration(elapsed))
if elapsed.Seconds() > 0 { if elapsed.Seconds() > 0 {
avgSpeed := float64(info.Uploaded()) / elapsed.Seconds() avgSpeed := float64(info.Uploaded()) / elapsed.Seconds()
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferAvgSpeedPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportAvgSpeedPrefix, nil))
fmt.Fprintf(&resultText, "%s/s\n", dlutil.FormatSize(int64(avgSpeed))) fmt.Fprintf(&resultText, "%s/s\n", dlutil.FormatSize(int64(avgSpeed)))
} }
failedFiles := info.FailedFiles() failedFiles := info.FailedFiles()
if len(failedFiles) > 0 { if len(failedFiles) > 0 {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedFilesPrefix, nil)) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportFailedFilesPrefix, nil))
fmt.Fprintf(&resultText, "%d\n", len(failedFiles)) fmt.Fprintf(&resultText, "%d\n", len(failedFiles))
for i, name := range failedFiles { for i, name := range failedFiles {
if i >= 5 { if i >= 5 {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(failedFiles) - 5})) resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportProcessingMore, map[string]any{"Count": len(failedFiles) - 5}))
break break
} }
fmt.Fprintf(&resultText, "- %s\n", name) fmt.Fprintf(&resultText, "- %s\n", name)

View File

@@ -1,4 +1,4 @@
package transfer package batchimport
import ( import (
"context" "context"
@@ -21,7 +21,7 @@ type TaskElement struct {
SourcePath string SourcePath string
FileInfo storagetypes.FileInfo FileInfo storagetypes.FileInfo
TargetStorage storage.Storage TargetStorage storage.Storage
TargetPath string TargetChatID int64
} }
type Task struct { type Task struct {
@@ -44,7 +44,7 @@ func (t *Task) Title() string {
// Type implements core.Executable. // Type implements core.Executable.
func (t *Task) Type() tasktype.TaskType { func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeTransfer return tasktype.TaskTypeBatchimport
} }
// TaskID implements core.Executable. // TaskID implements core.Executable.
@@ -56,7 +56,7 @@ func NewTaskElement(
sourceStorage storage.Storage, sourceStorage storage.Storage,
fileInfo storagetypes.FileInfo, fileInfo storagetypes.FileInfo,
targetStorage storage.Storage, targetStorage storage.Storage,
targetPath string, targetChatID int64,
) *TaskElement { ) *TaskElement {
id := xid.New().String() id := xid.New().String()
return &TaskElement{ return &TaskElement{
@@ -65,11 +65,11 @@ func NewTaskElement(
SourcePath: fileInfo.Path, SourcePath: fileInfo.Path,
FileInfo: fileInfo, FileInfo: fileInfo,
TargetStorage: targetStorage, TargetStorage: targetStorage,
TargetPath: targetPath, TargetChatID: targetChatID,
} }
} }
func NewTransferTask( func NewBatchImportTask(
id string, id string,
ctx context.Context, ctx context.Context,
elems []TaskElement, elems []TaskElement,

View File

@@ -1,4 +1,4 @@
package transfer package batchimport
type TaskElementInfo interface { type TaskElementInfo interface {
FileName() string FileName() string

View File

@@ -45,18 +45,10 @@ func (t *Task) Execute(ctx context.Context) error {
fetchedTotalBytes.Add(resp.ContentLength) fetchedTotalBytes.Add(resp.ContentLength)
file.Size = resp.ContentLength file.Size = resp.ContentLength
if name := resp.Header.Get("Content-Disposition"); name != "" { if name := resp.Header.Get("Content-Disposition"); name != "" {
// Set file name
filename := parseFilename(name) filename := parseFilename(name)
if filename != "" {
file.Name = filename file.Name = filename
} }
}
// extract filename from URL if Content-Disposition is empty or invalid
if file.Name == "" {
file.Name = parseFilenameFromURL(file.URL)
}
if file.Name == "" {
return fmt.Errorf("failed to determine filename for %s: Content-Disposition header is empty and URL does not contain a valid filename", file.URL)
}
return nil return nil
}) })

View File

@@ -76,9 +76,6 @@ func (t *Task) StorageName() string {
// StoragePath implements TaskInfo. // StoragePath implements TaskInfo.
func (t *Task) StoragePath() string { func (t *Task) StoragePath() string {
if len(t.files) == 1 {
return t.StorPath + "/" + t.files[0].Name
}
return t.StorPath return t.StorPath
} }

View File

@@ -144,41 +144,6 @@ func tryDecodeGBK(s string) string {
return "" return ""
} }
// parseFilenameFromURL extracts filename from URL path
// This is used as a fallback when Content-Disposition is not available
func parseFilenameFromURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
// Get the path part and extract the last segment
path := parsed.Path
if path == "" {
return ""
}
// URL decode the path first
decodedPath, err := url.PathUnescape(path)
if err != nil {
decodedPath = path
}
// Get the last segment of the path
lastSlash := strings.LastIndex(decodedPath, "/")
if lastSlash == -1 {
return decodedPath
}
filename := decodedPath[lastSlash+1:]
// Remove query string if somehow still present
if idx := strings.Index(filename, "?"); idx != -1 {
filename = filename[:idx]
}
return filename
}
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails // parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
func parseFilenameFallback(cd string) string { func parseFilenameFallback(cd string) string {
// Look for filename= (case-insensitive) // Look for filename= (case-insensitive)

View File

@@ -1,73 +0,0 @@
package directlinks
import (
"testing"
)
func TestParseFilenameFromURL(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "simple filename",
url: "https://example.com/files/document.pdf",
expected: "document.pdf",
},
{
name: "filename with encoded characters",
url: "https://example.com/files/%E6%B5%8B%E8%AF%95.zip",
expected: "测试.zip",
},
{
name: "filename with query string in URL",
url: "https://example.com/files/image.png?token=abc123",
expected: "image.png",
},
{
name: "nested path",
url: "https://example.com/a/b/c/file.txt",
expected: "file.txt",
},
{
name: "URL with port",
url: "https://example.com:8080/downloads/archive.tar.gz",
expected: "archive.tar.gz",
},
{
name: "empty path",
url: "https://example.com",
expected: "",
},
{
name: "root path only",
url: "https://example.com/",
expected: "",
},
{
name: "filename with spaces encoded",
url: "https://example.com/my%20file%20name.pdf",
expected: "my file name.pdf",
},
{
name: "complex encoded filename",
url: "https://example.com/downloads/%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.docx",
expected: "中文文件.docx",
},
{
name: "invalid URL",
url: "://invalid-url",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseFilenameFromURL(tt.url)
if result != tt.expected {
t.Errorf("parseFilenameFromURL(%q) = %q, want %q", tt.url, result, tt.expected)
}
})
}
}

View File

@@ -20,9 +20,6 @@ Save Any Bot is a tool that allows you to save files from Telegram to various st
- Multi-user - Multi-user
- Automatic organization based on storage rules - Automatic organization based on storage rules
- Watch specific chats and automatically save messages, with filters - Watch specific chats and automatically save messages, with filters
- Transfer files between different storage backends
- Integrate with yt-dlp to download and save media from 1000+ websites
- Aria2 integration to download files from URLs/magnets and save to storages
- Write JS parser plugins to save files from almost any website - Write JS parser plugins to save files from almost any website
- Supports multiple storage backends: - Supports multiple storage backends:
- Alist - Alist

View File

@@ -92,27 +92,6 @@ enable = false
session = "data/usersession.db" session = "data/usersession.db"
``` ```
### Aria2 Configuration
Aria2 is a powerful download manager that supports HTTP/HTTPS, FTP, BitTorrent, and other protocols. When enabled, the bot can use the `/aria2dl` command to download files via Aria2.
- `enable`: Whether to enable Aria2 support, default is `false`
- `url`: Aria2 RPC address, typically `http://localhost:6800/jsonrpc`
- `secret`: Aria2 RPC secret, if you configured `rpc-secret` in Aria2, you need to fill it in here
- `remove_after_transfer`: Whether to remove local files downloaded by Aria2 after transfer, default is `true`
{{< hint info >}}
Aria2 needs to be installed and running separately. You can refer to the [Aria2 official documentation](https://aria2.github.io/) to learn how to install and configure Aria2.
{{< /hint >}}
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret"
remove_after_transfer = true
```
### Storage Endpoints List ### Storage Endpoints List
The storage endpoints list is used to define the storage locations supported by the Bot. Each storage endpoint needs to specify a name, type, and related configuration, using the double bracket syntax `[[storages]]`. The storage endpoints list is used to define the storage locations supported by the Bot. Each storage endpoint needs to specify a name, type, and related configuration, using the double bracket syntax `[[storages]]`.

View File

@@ -81,59 +81,3 @@ force_file = false # Force sending as file, default is false
skip_large = false # Skip large files, default is false. If enabled, files exceeding Telegram's limit will not be uploaded. skip_large = false # Skip large files, default is false. If enabled, files exceeding Telegram's limit will not be uploaded.
spilt_size_mb = 2000 # Split size in MB, default is 2000 MB (2 GB). Files larger than this will be split into multiple parts (zip format). Ignored when skip_large is true. spilt_size_mb = 2000 # Split size in MB, default is 2000 MB (2 GB). Files larger than this will be split into multiple parts (zip format). Ignored when skip_large is true.
``` ```
## Rclone
`type=rclone`
Supports multiple cloud storage services through the [rclone](https://rclone.org/) command-line tool. You need to install rclone and configure remote storage first.
```toml
# Remote name configured in rclone, can be any remote defined in rclone.conf
remote = "mydrive"
# Base path in the remote storage, all files will be stored under this path
base_path = "/telegram"
# Path to rclone config file, optional, leave empty to use default path (~/.config/rclone/rclone.conf)
config_path = ""
# Additional flags to pass to rclone commands, optional
flags = ["--transfers", "4", "--checkers", "8"]
```
### Configuring rclone Remote
First, you need to configure an rclone remote. Run `rclone config` for interactive configuration, or directly edit the `rclone.conf` file.
rclone supports many cloud storage services, including but not limited to:
- Google Drive
- Dropbox
- OneDrive
- Amazon S3 and compatible services
- SFTP
- FTP
- For more services, please refer to the [rclone official documentation](https://rclone.org/overview/)
### Usage Examples
After configuring Google Drive, you can configure the storage like this:
```toml
[[storages]]
name = "GoogleDrive"
type = "rclone"
enable = true
remote = "gdrive"
base_path = "/SaveAnyBot"
```
If using a custom rclone config file:
```toml
[[storages]]
name = "MyRemote"
type = "rclone"
enable = true
remote = "myremote"
base_path = "/backup"
config_path = "/path/to/rclone.conf"
flags = ["--progress"]
```

View File

@@ -112,142 +112,6 @@ Regex-match the message text. For example:
This will watch the chat with ID `12345678`, and only save messages whose text contains `hello`. This will watch the chat with ID `12345678`, and only save messages whose text contains `hello`.
## Direct Download Links
Use the `/dl` command to directly download one or more HTTP/HTTPS files to storage.
```bash
/dl <url1> [url2] [url3] ...
```
Examples:
```bash
/dl https://example.com/file.zip
/dl https://example.com/file1.zip https://example.com/file2.zip
```
The bot will validate the link format and then ask you to select the target storage location.
## Aria2 Download
{{< hint warning >}}
This feature requires enabling Aria2 in the configuration file and configuring the RPC connection.
{{< /hint >}}
Use the `/aria2dl` command to download files via the Aria2 download manager, supporting HTTP/HTTPS, FTP, BitTorrent, and other protocols.
```bash
/aria2dl <uri1> [uri2] [uri3] ...
```
Examples:
```bash
# Download HTTP link
/aria2dl https://example.com/file.zip
# Download magnet link
/aria2dl magnet:?xt=urn:btih:...
# Download torrent file (need to upload .torrent file first)
/aria2dl https://example.com/file.torrent
```
Configure Aria2:
Add to `config.toml`:
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret" # If rpc-secret is configured
remove_after_transfer = true # Remove local files after transfer
```
## yt-dlp Video Download
{{< hint warning >}}
This feature requires the yt-dlp command-line tool installed on your system.
{{< /hint >}}
Use the `/ytdlp` command to download videos and audio from supported video websites, including YouTube, Bilibili, Twitter, and 1000+ other sites.
```bash
/ytdlp <url1> [url2] [flags...]
```
Examples:
```bash
# Basic download
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ
# Download multiple videos
/ytdlp https://www.youtube.com/watch?v=video1 https://www.youtube.com/watch?v=video2
# Use custom parameters
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ -f best
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ --extract-audio --audio-format mp3
```
Common parameters:
- `-f <format>`: Specify download format (e.g., `best`, `worst`, `bestvideo+bestaudio`)
- `--extract-audio`: Extract audio
- `--audio-format <format>`: Audio format (e.g., `mp3`, `m4a`, `wav`)
- `--write-sub`: Download subtitles
- `--write-thumbnail`: Download thumbnail
For more parameters, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp#usage-and-options).
## Storage Transfer
Use the `/transfer` command to transfer files directly between different storages without going through Telegram.
```bash
/transfer <source_storage>:/<source_path> [filter]
```
Parameters:
- `source_storage`: Source storage name
- `source_path`: Source path
- `filter`: Optional regex filter to transfer only matching files
Examples:
```bash
# Transfer entire directory
/transfer local1:/downloads
# Transfer files from specified path
/transfer alist1:/media/photos
# Transfer only mp4 files
/transfer webdav1:/videos ".*\.mp4$"
# Transfer image files
/transfer local1:/pictures "(?i)\.(jpg|png|gif)$"
```
The bot will:
1. List all files in the source path
2. Apply the filter (if provided)
3. Display file count and total size
4. Ask you to select the target storage
5. Ask you to select the target directory (if configured for that storage)
6. Start the transfer task
Notes:
- Source storage must support listing and reading
- Target storage must support writing
- Real-time progress is displayed during transfer
- Transfer tasks can be cancelled
## Save Files Outside Telegram ## Save Files Outside Telegram
Besides files on Telegram, the bot can also save files from other websites via JavaScript plugins or built-in parsers. Besides files on Telegram, the bot can also save files from other websites via JavaScript plugins or built-in parsers.

View File

@@ -20,16 +20,12 @@ title: 介绍
- 多用户使用 - 多用户使用
- 基于存储规则的自动整理 - 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤 - 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件 - 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持: - 存储端支持:
- Alist - Alist
- S3 - S3
- WebDAV - WebDAV
- 本地磁盘 - 本地磁盘
- Rclone (通过命令行调用)
- Telegram (重传回指定聊天) - Telegram (重传回指定聊天)
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors) ## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)

View File

@@ -90,27 +90,6 @@ enable = false
session = "data/usersession.db" session = "data/usersession.db"
``` ```
### Aria2 配置
Aria2 是一个强大的下载管理器,支持 HTTP/HTTPS、FTP、BitTorrent 等多种协议。启用后Bot 可以使用 `/aria2dl` 命令通过 Aria2 下载文件。
- `enable`: 是否启用 Aria2 支持,默认为 `false`
- `url`: Aria2 RPC 地址,通常为 `http://localhost:6800/jsonrpc`
- `secret`: Aria2 RPC 密钥,如果你在 Aria2 中配置了 `rpc-secret`,需要在此填写
- `remove_after_transfer`: 转存完成后是否删除 Aria2 下载的本地文件,默认为 `true`
{{< hint info >}}
Aria2 需要单独安装和运行。你可以参考 [Aria2 官方文档](https://aria2.github.io/) 了解如何安装和配置 Aria2。
{{< /hint >}}
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret"
remove_after_transfer = true
```
### 存储端列表 ### 存储端列表
存储端列表用于定义 Bot 支持的存储位置, 每个存储端需要指定名称、类型和相关配置, 使用双中括号语法 `[[storages]]` 定义. 存储端列表用于定义 Bot 支持的存储位置, 每个存储端需要指定名称、类型和相关配置, 使用双中括号语法 `[[storages]]` 定义.

View File

@@ -87,59 +87,3 @@ skip_large = false
# 当 skip_large 启用时, 该选项无效. # 当 skip_large 启用时, 该选项无效.
spilt_size_mb = 2000 spilt_size_mb = 2000
``` ```
## Rclone
`type=rclone`
通过 [rclone](https://rclone.org/) 命令行工具支持多种云存储服务. 需要先安装 rclone 并配置好远程存储.
```toml
# rclone 配置的远程名称, 可以是任何在 rclone.conf 中配置的远程
remote = "mydrive"
# 在远程存储中的基础路径, 所有文件将存储在此路径下
base_path = "/telegram"
# rclone 配置文件的路径, 可选, 留空使用默认路径 (~/.config/rclone/rclone.conf)
config_path = ""
# 传递给 rclone 命令的额外参数, 可选
flags = ["--transfers", "4", "--checkers", "8"]
```
### 配置 rclone 远程
首先需要配置 rclone 远程, 运行 `rclone config` 命令进行交互式配置, 或直接编辑 `rclone.conf` 文件.
rclone 支持多种云存储服务, 包括但不限于:
- Google Drive
- Dropbox
- OneDrive
- Amazon S3 及兼容服务
- SFTP
- FTP
- 更多服务请参考 [rclone 官方文档](https://rclone.org/overview/)
### 使用示例
配置 Google Drive 后, 可以这样配置存储:
```toml
[[storages]]
name = "GoogleDrive"
type = "rclone"
enable = true
remote = "gdrive"
base_path = "/SaveAnyBot"
```
如果使用自定义的 rclone 配置文件:
```toml
[[storages]]
name = "MyRemote"
type = "rclone"
enable = true
remote = "myremote"
base_path = "/backup"
config_path = "/path/to/rclone.conf"
flags = ["--progress"]
```

View File

@@ -112,142 +112,6 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息. 这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
## 直接下载链接
使用 `/dl` 命令可以直接下载一个或多个 HTTP/HTTPS 链接的文件到存储中.
```bash
/dl <url1> [url2] [url3] ...
```
示例:
```bash
/dl https://example.com/file.zip
/dl https://example.com/file1.zip https://example.com/file2.zip
```
Bot 会验证链接格式, 然后让你选择目标存储位置.
## Aria2 下载
{{< hint warning >}}
该功能需要在配置文件中启用 Aria2 并配置 RPC 连接.
{{< /hint >}}
使用 `/aria2dl` 命令可以通过 Aria2 下载管理器下载文件, 支持 HTTP/HTTPS、FTP、BitTorrent 等多种协议.
```bash
/aria2dl <uri1> [uri2] [uri3] ...
```
示例:
```bash
# 下载 HTTP 链接
/aria2dl https://example.com/file.zip
# 下载磁力链接
/aria2dl magnet:?xt=urn:btih:...
# 下载种子文件 (需要先上传 .torrent 文件)
/aria2dl https://example.com/file.torrent
```
配置 Aria2:
`config.toml` 中添加:
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret" # 如果配置了 rpc-secret
remove_after_transfer = true # 转存完成后删除本地文件
```
## yt-dlp 视频下载
{{< hint warning >}}
该功能需要在系统中安装 yt-dlp 命令行工具.
{{< /hint >}}
使用 `/ytdlp` 命令可以下载支持的视频网站的视频和音频, 支持 YouTube、Bilibili、Twitter 等 1000+ 个网站.
```bash
/ytdlp <url1> [url2] [flags...]
```
示例:
```bash
# 基本下载
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ
# 下载多个视频
/ytdlp https://www.youtube.com/watch?v=video1 https://www.youtube.com/watch?v=video2
# 使用自定义参数
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ -f best
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ --extract-audio --audio-format mp3
```
常用参数:
- `-f <format>`: 指定下载格式 (如 `best`, `worst`, `bestvideo+bestaudio`)
- `--extract-audio`: 提取音频
- `--audio-format <format>`: 音频格式 (如 `mp3`, `m4a`, `wav`)
- `--write-sub`: 下载字幕
- `--write-thumbnail`: 下载缩略图
更多参数请参考 [yt-dlp 文档](https://github.com/yt-dlp/yt-dlp#usage-and-options).
## 存储间传输
使用 `/transfer` 命令可以在不同存储之间直接传输文件, 无需经过 Telegram.
```bash
/transfer <source_storage>:/<source_path> [filter]
```
参数说明:
- `source_storage`: 源存储名称
- `source_path`: 源路径
- `filter`: 可选的正则表达式过滤器, 只传输匹配的文件
示例:
```bash
# 传输整个目录
/transfer local1:/downloads
# 传输指定路径的文件
/transfer alist1:/media/photos
# 只传输 mp4 文件
/transfer webdav1:/videos ".*\.mp4$"
# 传输图片文件
/transfer local1:/pictures "(?i)\.(jpg|png|gif)$"
```
Bot 会:
1. 列出源路径下的所有文件
2. 应用过滤器 (如果提供)
3. 显示文件数量和总大小
4. 让你选择目标存储
5. 让你选择目标目录 (如果该存储配置了目录)
6. 开始传输任务
注意:
- 源存储必须支持列举和读取功能
- 目标存储必须支持写入功能
- 传输过程显示实时进度
- 支持取消正在进行的传输任务
## 转存 Telegram 之外的文件 ## 转存 Telegram 之外的文件
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件. 除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.

View File

@@ -4,6 +4,6 @@ package storage
// StorageType // StorageType
/* ENUM( /* ENUM(
local, webdav, alist, minio, telegram, s3, rclone local, webdav, alist, minio, telegram, s3
) */ ) */
type StorageType string type StorageType string

View File

@@ -24,8 +24,6 @@ const (
Telegram StorageType = "telegram" Telegram StorageType = "telegram"
// S3 is a StorageType of type s3. // S3 is a StorageType of type s3.
S3 StorageType = "s3" S3 StorageType = "s3"
// Rclone is a StorageType of type rclone.
Rclone StorageType = "rclone"
) )
var ErrInvalidStorageType = fmt.Errorf("not a valid StorageType, try [%s]", strings.Join(_StorageTypeNames, ", ")) var ErrInvalidStorageType = fmt.Errorf("not a valid StorageType, try [%s]", strings.Join(_StorageTypeNames, ", "))
@@ -37,7 +35,6 @@ var _StorageTypeNames = []string{
string(Minio), string(Minio),
string(Telegram), string(Telegram),
string(S3), string(S3),
string(Rclone),
} }
// StorageTypeNames returns a list of possible string values of StorageType. // StorageTypeNames returns a list of possible string values of StorageType.
@@ -56,7 +53,6 @@ func StorageTypeValues() []StorageType {
Minio, Minio,
Telegram, Telegram,
S3, S3,
Rclone,
} }
} }
@@ -79,7 +75,6 @@ var _StorageTypeValue = map[string]StorageType{
"minio": Minio, "minio": Minio,
"telegram": Telegram, "telegram": Telegram,
"s3": S3, "s3": S3,
"rclone": Rclone,
} }
// ParseStorageType attempts to convert a string to a StorageType. // ParseStorageType attempts to convert a string to a StorageType.

View File

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

View File

@@ -24,8 +24,8 @@ 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. // TaskTypeBatchimport is a TaskType of type batchimport.
TaskTypeTransfer TaskType = "transfer" TaskTypeBatchimport TaskType = "batchimport"
) )
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 +37,7 @@ var _TaskTypeNames = []string{
string(TaskTypeDirectlinks), string(TaskTypeDirectlinks),
string(TaskTypeAria2), string(TaskTypeAria2),
string(TaskTypeYtdlp), string(TaskTypeYtdlp),
string(TaskTypeTransfer), string(TaskTypeBatchimport),
} }
// TaskTypeNames returns a list of possible string values of TaskType. // TaskTypeNames returns a list of possible string values of TaskType.
@@ -56,7 +56,7 @@ func TaskTypeValues() []TaskType {
TaskTypeDirectlinks, TaskTypeDirectlinks,
TaskTypeAria2, TaskTypeAria2,
TaskTypeYtdlp, TaskTypeYtdlp,
TaskTypeTransfer, TaskTypeBatchimport,
} }
} }
@@ -79,7 +79,7 @@ var _TaskTypeValue = map[string]TaskType{
"directlinks": TaskTypeDirectlinks, "directlinks": TaskTypeDirectlinks,
"aria2": TaskTypeAria2, "aria2": TaskTypeAria2,
"ytdlp": TaskTypeYtdlp, "ytdlp": TaskTypeYtdlp,
"transfer": TaskTypeTransfer, "batchimport": TaskTypeBatchimport,
} }
// ParseTaskType attempts to convert a string to a TaskType. // ParseTaskType attempts to convert a string to a TaskType.

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

@@ -104,7 +104,7 @@ func (a *Alist) Name() string {
func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string) error { func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string) error {
a.logger.Infof("Saving file to %s", storagePath) a.logger.Infof("Saving file to %s", storagePath)
storagePath = a.JoinStoragePath(storagePath)
ext := path.Ext(storagePath) ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext) base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath candidate := storagePath

View File

@@ -51,7 +51,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)

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

@@ -1,14 +0,0 @@
package rclone
import "errors"
var (
ErrRcloneNotFound = errors.New("rclone: rclone command not found in PATH")
ErrRemoteNotFound = errors.New("rclone: remote not found")
ErrFailedToSaveFile = errors.New("rclone: failed to save file")
ErrFailedToListFiles = errors.New("rclone: failed to list files")
ErrFailedToOpenFile = errors.New("rclone: failed to open file")
ErrFailedToCheckFile = errors.New("rclone: failed to check file exists")
ErrFailedToCreateDir = errors.New("rclone: failed to create directory")
ErrCommandFailed = errors.New("rclone: command execution failed")
)

View File

@@ -1,289 +0,0 @@
package rclone
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"path"
"strings"
"time"
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/rs/xid"
)
type Rclone struct {
config config.RcloneStorageConfig
logger *log.Logger
}
func (r *Rclone) Init(ctx context.Context, cfg config.StorageConfig) error {
rcloneConfig, ok := cfg.(*config.RcloneStorageConfig)
if !ok {
return fmt.Errorf("failed to cast rclone config")
}
if err := rcloneConfig.Validate(); err != nil {
return err
}
r.config = *rcloneConfig
r.logger = log.FromContext(ctx).WithPrefix(fmt.Sprintf("rclone[%s]", r.config.Name))
// 检查 rclone 是否安装
if _, err := exec.LookPath("rclone"); err != nil {
return ErrRcloneNotFound
}
args := r.buildBaseArgs()
args = append(args, "listremotes")
cmd := exec.CommandContext(ctx, "rclone", args...)
output, err := cmd.Output()
if err != nil {
r.logger.Errorf("Failed to list remotes: %v", err)
return fmt.Errorf("failed to verify rclone: %w", err)
}
remoteName := strings.TrimSuffix(r.config.Remote, ":")
if !strings.HasSuffix(r.config.Remote, ":") {
remoteName = r.config.Remote
}
found := false
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimSuffix(line, ":")
if line == remoteName {
found = true
break
}
}
if !found {
r.logger.Errorf("Remote %s not found in rclone config", r.config.Remote)
return ErrRemoteNotFound
}
r.logger.Infof("Initialized rclone storage with remote: %s", r.config.Remote)
return nil
}
func (r *Rclone) Type() storenum.StorageType {
return storenum.Rclone
}
func (r *Rclone) Name() string {
return r.config.Name
}
func (r *Rclone) buildBaseArgs() []string {
var args []string
if r.config.ConfigPath != "" {
args = append(args, "--config", r.config.ConfigPath)
}
args = append(args, r.config.Flags...)
return args
}
func (r *Rclone) getRemotePath(storagePath string) string {
remote := r.config.Remote
if !strings.HasSuffix(remote, ":") {
remote += ":"
}
basePath := strings.TrimPrefix(r.config.BasePath, "/")
fullPath := path.Join(basePath, storagePath)
return remote + fullPath
}
func (r *Rclone) Save(ctx context.Context, reader io.Reader, storagePath string) error {
r.logger.Infof("Saving file to %s", storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; r.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 100 {
r.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
}
}
remotePath := r.getRemotePath(candidate)
r.logger.Debugf("Remote path: %s", remotePath)
// Use rclone rcat to read from stdin and upload
args := r.buildBaseArgs()
args = append(args, "rcat", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
cmd.Stdin = reader
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
r.logger.Errorf("Failed to save file: %v, stderr: %s", err, stderr.String())
return fmt.Errorf("%w: %s", ErrFailedToSaveFile, stderr.String())
}
r.logger.Infof("Successfully saved file to %s", candidate)
return nil
}
func (r *Rclone) Exists(ctx context.Context, storagePath string) bool {
remotePath := r.getRemotePath(storagePath)
args := r.buildBaseArgs()
args = append(args, "lsf", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
err := cmd.Run()
return err == nil
}
// lsjsonItem represents a single entry in the output of `rclone lsjson`
type lsjsonItem struct {
Path string `json:"Path"`
Name string `json:"Name"`
Size int64 `json:"Size"`
MimeType string `json:"MimeType"`
ModTime string `json:"ModTime"`
IsDir bool `json:"IsDir"`
}
// ListFiles implements storage.StorageListable
func (r *Rclone) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
r.logger.Infof("Listing files in %s", dirPath)
remotePath := r.getRemotePath(dirPath)
args := r.buildBaseArgs()
args = append(args, "lsjson", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
r.logger.Errorf("Failed to list files: %v, stderr: %s", err, stderr.String())
return nil, fmt.Errorf("%w: %s", ErrFailedToListFiles, stderr.String())
}
var items []lsjsonItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
r.logger.Errorf("Failed to parse lsjson output: %v", err)
return nil, fmt.Errorf("failed to parse lsjson output: %w", err)
}
files := make([]storagetypes.FileInfo, 0, len(items))
for _, item := range items {
var modTime time.Time
if item.ModTime != "" {
parsedTime, err := time.Parse(time.RFC3339Nano, item.ModTime)
if err != nil {
r.logger.Warnf("Failed to parse mod time %q for %s: %v", item.ModTime, item.Name, err)
} 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,
})
}
r.logger.Debugf("Found %d files/directories in %s", len(files), dirPath)
return files, nil
}
// OpenFile implements storage.StorageReadable
func (r *Rclone) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
r.logger.Infof("Opening file %s", filePath)
remotePath := r.getRemotePath(filePath)
size, err := r.getFileSize(ctx, remotePath)
if err != nil {
r.logger.Errorf("Failed to get file size: %v", err)
return nil, 0, fmt.Errorf("%w: %v", ErrFailedToOpenFile, err)
}
args := r.buildBaseArgs()
args = append(args, "cat", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, 0, fmt.Errorf("failed to create stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, 0, fmt.Errorf("failed to start rclone cat: %w", err)
}
reader := &rcloneCatReader{
reader: stdout,
cmd: cmd,
logger: r.logger,
}
r.logger.Debugf("Opened file %s (size: %d bytes)", filePath, size)
return reader, size, nil
}
func (r *Rclone) getFileSize(ctx context.Context, remotePath string) (int64, error) {
args := r.buildBaseArgs()
args = append(args, "lsjson", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return 0, err
}
var items []lsjsonItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
return 0, err
}
if len(items) > 0 {
return items[0].Size, nil
}
return 0, nil
}
type rcloneCatReader struct {
reader io.ReadCloser
cmd *exec.Cmd
logger *log.Logger
}
func (r *rcloneCatReader) Read(p []byte) (n int, err error) {
return r.reader.Read(p)
}
func (r *rcloneCatReader) Close() error {
if err := r.reader.Close(); err != nil {
r.logger.Warnf("Failed to close reader: %v", err)
}
if err := r.cmd.Wait(); err != nil {
r.logger.Warnf("rclone cat process exited with error: %v", err)
}
return nil
}

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

@@ -11,7 +11,6 @@ import (
"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"
"github.com/krau/SaveAny-Bot/storage/rclone"
"github.com/krau/SaveAny-Bot/storage/s3" "github.com/krau/SaveAny-Bot/storage/s3"
"github.com/krau/SaveAny-Bot/storage/telegram" "github.com/krau/SaveAny-Bot/storage/telegram"
"github.com/krau/SaveAny-Bot/storage/webdav" "github.com/krau/SaveAny-Bot/storage/webdav"
@@ -22,6 +21,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
} }
@@ -54,7 +54,6 @@ var storageConstructors = map[storenum.StorageType]StorageConstructor{
storenum.Minio: func() Storage { return new(minio.Minio) }, storenum.Minio: func() Storage { return new(minio.Minio) },
storenum.S3: func() Storage { return new(s3.S3) }, storenum.S3: func() Storage { return new(s3.S3) },
storenum.Telegram: func() Storage { return new(telegram.Telegram) }, storenum.Telegram: func() Storage { return new(telegram.Telegram) },
storenum.Rclone: func() Storage { return new(rclone.Rclone) },
} }
// NewStorage creates a new storage instance based on the provided config and initializes it // NewStorage creates a new storage instance based on the provided config and initializes it

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")

View File

@@ -53,7 +53,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
@@ -129,12 +129,9 @@ func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.
isDir := resp.Propstat.Prop.ResourceType.IsCollection() isDir := resp.Propstat.Prop.ResourceType.IsCollection()
filePath := strings.TrimPrefix(decodedHref, path.Join("/", strings.Trim(path.Dir(fullPath), "/")))
filePath = strings.TrimPrefix(filePath, "/")
fileInfo := storagetypes.FileInfo{ fileInfo := storagetypes.FileInfo{
Name: name, Name: name,
Path: path.Join(dirPath, name), Path: strings.TrimPrefix(decodedHref, w.config.BasePath),
Size: resp.Propstat.Prop.GetContentLength, Size: resp.Propstat.Prop.GetContentLength,
IsDir: isDir, IsDir: isDir,
ModTime: modTime, ModTime: modTime,