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
43 changed files with 341 additions and 886 deletions

View File

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

View File

@@ -26,9 +26,6 @@
- Multi-user support
- Auto organize files based on storage rules
- 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
- Storage backends:
- Alist

View File

@@ -24,9 +24,6 @@
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist

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)
case tasktype.TaskTypeYtdlp:
shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, data.YtdlpFlags, msgID, userID)
case tasktype.TaskTypeTransfer:
return handleTransferCallback(ctx, userID, selectedStorage, dirPath, data, msgID)
default:
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},
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
{"transfer", i18nk.BotMsgCmdTransfer, handleTransferCmd},
{"import", i18nk.BotMsgCmdImport, handleImportCmd},
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
{"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,
YtdlpURLs: adddata.YtdlpURLs,
YtdlpFlags: adddata.YtdlpFlags,
TransferSourceStorName: adddata.TransferSourceStorName,
TransferSourcePath: adddata.TransferSourcePath,
TransferFiles: adddata.TransferFiles,
}
dataid := xid.New().String()
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)
// 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 {
logger.Errorf("Failed to add task: %s", err)
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 {
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 {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
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 {
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 {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{

View File

@@ -59,7 +59,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
}
}
startCreateTask:
storagePath := path.Join(dirPath, file.Name())
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
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() {
storPath := path.Join(dirPath.String(), file.Name())
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
if err != nil {
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()))
albumStor := afiles[0].storage
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)
if err != nil {
logger.Errorf("Failed to create task element for album file: %s", err)

View File

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

View File

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

View File

@@ -309,7 +309,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
}
}
startCreateTask:
storagePath := path.Join(dirPath, file.Name())
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
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))
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()
task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil)
if err != nil {

View File

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

View File

@@ -31,7 +31,6 @@ const (
BotMsgCmdStorage Key = "bot.msg.cmd.storage"
BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers"
BotMsgCmdTask Key = "bot.msg.cmd.task"
BotMsgCmdTransfer Key = "bot.msg.cmd.transfer"
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
@@ -107,6 +106,20 @@ const (
BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage"
BotMsgDlUsage Key = "bot.msg.dl.usage"
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"
BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage"
BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files"
@@ -151,6 +164,20 @@ const (
BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix"
BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_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"
BotMsgProgressParsedStartPrefix Key = "bot.msg.progress.parsed_start_prefix"
BotMsgProgressProcessingListPrefix Key = "bot.msg.progress.processing_list_prefix"
@@ -163,20 +190,6 @@ const (
BotMsgProgressTelegraphProgressPrefix Key = "bot.msg.progress.telegraph_progress_prefix"
BotMsgProgressTelegraphStartPrefix Key = "bot.msg.progress.telegraph_start_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"
BotMsgProgressYtdlpDownloading Key = "bot.msg.progress.ytdlp_downloading"
BotMsgProgressYtdlpStart Key = "bot.msg.progress.ytdlp_start"
@@ -232,22 +245,6 @@ const (
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
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"
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"

View File

@@ -54,7 +54,6 @@ bot:
aria2dl: "Download files using Aria2"
ytdlp: "Download video/audio using yt-dlp"
import: "Import files from storage to Telegram"
transfer: "Transfer files between storages"
task: "Manage task queue"
cancel: "Cancel task"
watch: "Watch chats (UserBot)"
@@ -297,28 +296,20 @@ bot:
info_urls_select_storage: "Found {{.Count}} links, please select storage"
info_downloading: "Downloading via yt-dlp..."
error_download_failed: "yt-dlp download failed: {{.Error}}"
transfer:
usage: |
Usage: /transfer <source_storage>:/<source_path> [filter]
Examples:
/transfer local1:/downloads
/transfer alist1:/media/photos
/transfer webdav1:/files ".*\.mp4$"
error_invalid_source: "Invalid source path format, should be: storage_name:/path"
error_invalid_target: "Invalid target path format, should be: storage_name:/path"
import:
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$\""
error_storage_not_found: "Storage '{{.StorageName}}' not found or access denied: {{.Error}}"
error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files"
error_storage_not_readable: "Storage '{{.StorageName}}' does not support reading files"
error_target_not_found: "Target storage '{{.StorageName}}' not found or access denied: {{.Error}}"
error_no_telegram_storage: "No Telegram storage found: {{.Error}}"
info_fetching_files: "Fetching file list..."
error_list_files_failed: "Failed to list files: {{.Error}}"
error_invalid_regex: "Invalid regular expression: {{.Error}}"
error_no_files_to_transfer: "No files to transfer in directory"
error_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}}"
info_task_added: "Added {{.Count}} files to transfer queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
start_stats: "Total files: {{.Count}}\nTotal size: {{.SizeMB}} MB"
info_files_select_storage: "Total {{.Count}} files ({{.SizeMB}} MB), please select target storage"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
info_task_added: "Added {{.Count}} files to import queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
cancel:
usage: "Usage: /cancel <task_id>"
error_cancel_failed: "Failed to cancel task: {{.Error}}"
@@ -367,20 +358,20 @@ bot:
ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n"
downloaded_prefix: "\nDownloaded: "
current_speed_prefix: "\nCurrent speed: "
transfer_start_prefix: "Transfering: "
transfer_progress_prefix: "Transfer progress: "
transfer_uploaded_prefix: "\nUploaded: "
transfer_speed_prefix: "\nSpeed: "
transfer_remaining_time_prefix: "\nRemaining time: "
transfer_processing_prefix: "\nProcessing:\n"
transfer_processing_more: "...and {{.Count}} more files\n"
transfer_failed_prefix: "Transfer failed\n"
transfer_success_prefix: "Transfer completed\n"
transfer_total_files_prefix: "\nTotal files: "
transfer_total_size_prefix: "\nTotal size: "
transfer_elapsed_time_prefix: "\nElapsed time: "
transfer_avg_speed_prefix: "\nAverage speed: "
transfer_failed_files_prefix: "\nFailed files: "
import_start_prefix: "Importing: "
import_progress_prefix: "Import progress: "
import_uploaded_prefix: "\nUploaded: "
import_speed_prefix: "\nSpeed: "
import_remaining_time_prefix: "\nRemaining time: "
import_processing_prefix: "\nProcessing:\n"
import_processing_more: "...and {{.Count}} more files\n"
import_failed_prefix: "Import failed\n"
import_success_prefix: "Import completed\n"
import_total_files_prefix: "\nTotal files: "
import_total_size_prefix: "\nTotal size: "
import_elapsed_time_prefix: "\nElapsed time: "
import_avg_speed_prefix: "\nAverage speed: "
import_failed_files_prefix: "\nFailed files: "
syncpeers:
start: "Starting to sync peers..."
done: "Peer sync completed, total {{.Count}} chats synced"

View File

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

View File

@@ -1,11 +1,10 @@
package transfer
package batchimport
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/charmbracelet/log"
@@ -17,8 +16,8 @@ import (
// Execute implements core.Executable.
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("transfer[%s]", t.ID))
logger.Info("Starting transfer task")
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("batch_import[%s]", t.ID))
logger.Info("Starting batch import task")
t.Progress.OnStart(ctx, t)
workers := config.C().Workers
@@ -60,9 +59,9 @@ func (t *Task) Execute(ctx context.Context) error {
err := eg.Wait()
if err != nil {
logger.Errorf("Error during transfer processing: %v", err)
logger.Errorf("Error during batch import processing: %v", err)
} else {
logger.Info("Transfer task completed successfully")
logger.Info("Batch import task completed successfully")
}
t.Progress.OnDone(ctx, t, err)
@@ -85,15 +84,15 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
}
defer reader.Close()
// Build target storage path: /target_path/filename
storagePath := path.Join(elem.TargetPath, elem.FileInfo.Name)
// Build Telegram storage path: /<chat_id>/<filename>
storagePath := fmt.Sprintf("/%d/%s", elem.TargetChatID, elem.FileInfo.Name)
// Inject file size into context
// 注入文件大小到 context
ctx = context.WithValue(ctx, ctxkey.ContentLength, size)
if config.C().Stream {
if err := elem.TargetStorage.Save(ctx, reader, storagePath); err != nil {
return fmt.Errorf("failed to upload file to storage: %w", err)
return fmt.Errorf("failed to upload file to telegram: %w", err)
}
} else {
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)
}
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 {
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 (
"context"
@@ -40,17 +40,17 @@ func NewProgressTracker(messageID int, chatID int64) ProgressTracker {
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
p.start = time.Now()
p.lastUpdatePercent.Store(0)
log.FromContext(ctx).Debugf("Transfer task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
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)
statsText := i18n.T(i18nk.BotMsgTransferStartStats, map[string]any{
"SizeMB": fmt.Sprintf("%.2f", sizeMB),
"Count": info.Count(),
statsText := i18n.T(i18nk.BotMsgImportStartStats, map[string]any{
"SizeMB": fmt.Sprintf("%.2f", sizeMB),
"Count": info.Count(),
})
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressTransferStartPrefix, nil)),
styling.Plain(i18n.T(i18nk.BotMsgProgressImportStartPrefix, nil)),
styling.Code(statsText),
); err != nil {
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)
if ext != nil {
_, err := ext.EditMessage(p.ChatID, req)
if err != nil {
log.FromContext(ctx).Errorf("Failed to send progress start message: %s", err)
}
ext.EditMessage(p.ChatID, req)
}
}
@@ -97,32 +94,32 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{}
var progressText strings.Builder
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProgressPrefix, nil))
fmt.Fprintf(&progressText, "%d%%", percent)
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, nil))
fmt.Fprintf(&progressText, "%.2f MB / %.2f MB",
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportProgressPrefix, nil))
progressText.WriteString(fmt.Sprintf("%d%%", percent))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportUploadedPrefix, nil))
progressText.WriteString(fmt.Sprintf("%.2f MB / %.2f MB",
float64(info.Uploaded())/(1024*1024),
float64(info.TotalSize())/(1024*1024))
float64(info.TotalSize())/(1024*1024)))
if p.start.Unix() > 0 {
elapsed := time.Since(p.start)
speed := float64(info.Uploaded()) / elapsed.Seconds()
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSpeedPrefix, nil))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportSpeedPrefix, nil))
progressText.WriteString(dlutil.FormatSize(int64(speed)) + "/s")
if info.Uploaded() > 0 {
remaining := time.Duration(float64(info.TotalSize()-info.Uploaded()) / speed * float64(time.Second))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferRemainingTimePrefix, nil))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportRemainingTimePrefix, nil))
progressText.WriteString(formatDuration(remaining))
}
}
processing := info.Processing()
if len(processing) > 0 {
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingPrefix, nil))
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportProcessingPrefix, nil))
for i, elem := range processing {
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
}
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) {
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{}
var resultText strings.Builder
if err != nil {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportFailedPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressErrorPrefix, nil))
fmt.Fprintf(&resultText, "%v\n", err)
} else {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSuccessPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportSuccessPrefix, nil))
}
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())
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))
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))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferElapsedTimePrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportElapsedTimePrefix, nil))
fmt.Fprintf(&resultText, "%s\n", formatDuration(elapsed))
if elapsed.Seconds() > 0 {
avgSpeed := float64(info.Uploaded()) / elapsed.Seconds()
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferAvgSpeedPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportAvgSpeedPrefix, nil))
fmt.Fprintf(&resultText, "%s/s\n", dlutil.FormatSize(int64(avgSpeed)))
}
failedFiles := info.FailedFiles()
if len(failedFiles) > 0 {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedFilesPrefix, nil))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportFailedFilesPrefix, nil))
fmt.Fprintf(&resultText, "%d\n", len(failedFiles))
for i, name := range failedFiles {
if i >= 5 {
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(failedFiles) - 5}))
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportProcessingMore, map[string]any{"Count": len(failedFiles) - 5}))
break
}
fmt.Fprintf(&resultText, "- %s\n", name)

View File

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

View File

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

View File

@@ -45,17 +45,10 @@ func (t *Task) Execute(ctx context.Context) error {
fetchedTotalBytes.Add(resp.ContentLength)
file.Size = resp.ContentLength
if name := resp.Header.Get("Content-Disposition"); name != "" {
// Set file name from Content-Disposition header
// Set file name
filename := parseFilename(name)
file.Name = filename
}
// Fallback: extract filename from URL if no filename was determined from Content-Disposition
if file.Name == "" {
file.Name = filenameFromURL(file.URL)
}
if file.Name == "" {
return fmt.Errorf("could not determine filename for %s", file.URL)
}
return nil
})

View File

@@ -173,35 +173,6 @@ func parseFilenameFallback(cd string) string {
return decodeFilenameParam(value)
}
// filenameFromURL extracts filename from a URL path.
// It uses the last path segment and removes any query parameters.
// Returns empty string if the URL cannot be parsed or has no valid path.
func filenameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
// Get the path and extract the base name
path := u.Path
if path == "" || path == "/" {
return ""
}
// Find the last path segment
idx := strings.LastIndex(path, "/")
if idx >= 0 && idx < len(path)-1 {
filename := path[idx+1:]
// URL decode the filename
if decoded, err := url.QueryUnescape(filename); err == nil {
return decoded
}
return filename
}
return ""
}
var progressUpdatesLevels = []struct {
size int64 // 文件大小阈值
stepPercent int // 每多少 % 更新一次

View File

@@ -1,83 +0,0 @@
package directlinks
import (
"testing"
)
func TestFilenameFromURL(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "simple file",
url: "https://example.com/file.zip",
expected: "file.zip",
},
{
name: "file with path",
url: "https://example.com/path/to/document.pdf",
expected: "document.pdf",
},
{
name: "url with query params",
url: "https://example.com/file.mp4?token=abc123",
expected: "file.mp4",
},
{
name: "url with fragment",
url: "https://example.com/file.txt#section",
expected: "file.txt",
},
{
name: "url encoded filename",
url: "https://example.com/%E6%B5%8B%E8%AF%95.zip",
expected: "测试.zip",
},
{
name: "url encoded Chinese filename",
url: "https://example.com/10%E6%9C%8817%E6%97%A5(6).mp4",
expected: "10月17日(6).mp4",
},
{
name: "root path only",
url: "https://example.com/",
expected: "",
},
{
name: "no path",
url: "https://example.com",
expected: "",
},
{
name: "empty url",
url: "",
expected: "",
},
{
name: "file with spaces encoded",
url: "https://example.com/my%20file.txt",
expected: "my file.txt",
},
{
name: "complex path with multiple slashes",
url: "https://cdn.example.com/a/b/c/d/e/video.mkv",
expected: "video.mkv",
},
{
name: "malformed url with invalid characters",
url: "://invalid url",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := filenameFromURL(tt.url)
if result != tt.expected {
t.Errorf("filenameFromURL(%q) = %q, expected %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
- Automatic organization based on storage rules
- 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
- Supports multiple storage backends:
- Alist

View File

@@ -92,27 +92,6 @@ enable = false
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
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

@@ -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`.
## 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
Besides files on Telegram, the bot can also save files from other websites via JavaScript plugins or built-in parsers.

View File

@@ -20,9 +20,6 @@ title: 介绍
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist

View File

@@ -90,27 +90,6 @@ enable = false
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]]` 定义.

View File

@@ -112,142 +112,6 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
这将会监听 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 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.

View File

@@ -1,3 +1,5 @@
module github.com/krau/SaveAny-Bot/docs
go 1.24.4
require github.com/alex-shpak/hugo-book v0.0.0-20250530233833-f2c703e15588 // indirect

View File

@@ -0,0 +1,2 @@
github.com/alex-shpak/hugo-book v0.0.0-20250530233833-f2c703e15588 h1:pwxkzpzw/iJSxMBgQLWjYMQubhIemLG3UrNjeWoCkSM=
github.com/alex-shpak/hugo-book v0.0.0-20250530233833-f2c703e15588/go.mod h1:L4NMyzbn15fpLIpmmtDg9ZFFyTZzw87/lk7M2bMQ7ds=

View File

@@ -1,5 +1,5 @@
package tasktype
//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

View File

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

View File

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

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 {
l.logger.Infof("Saving file to %s", storagePath)
storagePath = l.JoinStoragePath(storagePath)
ext := filepath.Ext(storagePath)
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 {
m.logger.Infof("Saving file from reader to %s", storagePath)
storagePath = m.JoinStoragePath(storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; m.Exists(ctx, candidate); i++ {
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)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break

View File

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

View File

@@ -21,6 +21,7 @@ type Storage interface {
Init(ctx context.Context, cfg storcfg.StorageConfig) error
Type() storenum.StorageType
Name() string
JoinStoragePath(p string) string
Save(ctx context.Context, reader io.Reader, storagePath string) error
Exists(ctx context.Context, storagePath string) bool
}

View File

@@ -66,12 +66,15 @@ func (t *Telegram) Name() string {
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 {
return false
}
func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) error {
storagePath = path.Clean(storagePath)
tctx := tgutil.ExtFromContext(ctx)
if tctx == nil {
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 {
w.logger.Infof("Saving file to %s", storagePath)
storagePath = w.JoinStoragePath(storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
@@ -90,10 +90,10 @@ func (w *Webdav) Exists(ctx context.Context, storagePath string) bool {
// ListFiles implements storage.StorageListable
func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
w.logger.Infof("Listing files in %s", dirPath)
// Join with base path
fullPath := path.Join(w.config.BasePath, dirPath)
responses, err := w.client.ListDir(ctx, fullPath)
if err != nil {
w.logger.Errorf("Failed to list directory %s: %v", fullPath, err)
@@ -108,7 +108,7 @@ func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.
w.logger.Warnf("Failed to unescape href %q: %v; using original value", resp.Href, err)
decodedHref = resp.Href
}
// Extract filename from href
name := path.Base(strings.TrimSuffix(decodedHref, "/"))
if name == "" || name == "." {
@@ -128,18 +128,15 @@ func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.
}
isDir := resp.Propstat.Prop.ResourceType.IsCollection()
filePath := strings.TrimPrefix(decodedHref, path.Join("/", strings.Trim(path.Dir(fullPath), "/")))
filePath = strings.TrimPrefix(filePath, "/")
fileInfo := storagetypes.FileInfo{
Name: name,
Path: path.Join(dirPath, name),
Path: strings.TrimPrefix(decodedHref, w.config.BasePath),
Size: resp.Propstat.Prop.GetContentLength,
IsDir: isDir,
ModTime: modTime,
}
files = append(files, fileInfo)
}
@@ -150,10 +147,10 @@ func (w *Webdav) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.
// OpenFile implements storage.StorageReadable
func (w *Webdav) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
w.logger.Infof("Opening file %s", filePath)
// Join with base path
fullPath := path.Join(w.config.BasePath, filePath)
reader, size, err := w.client.ReadFile(ctx, fullPath)
if err != nil {
w.logger.Errorf("Failed to open file %s: %v", fullPath, err)