Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159dba6224 | ||
|
|
22d773da10 | ||
|
|
69ccfa664f | ||
|
|
38355dfd14 | ||
|
|
0940258b4d | ||
|
|
602fc251d8 | ||
|
|
af28738235 | ||
|
|
3eb3b6e3c8 | ||
|
|
f377ee3ca4 | ||
|
|
70f7172162 | ||
|
|
091f581881 | ||
|
|
8b86330f5c | ||
|
|
b431fa08e2 | ||
|
|
a02e8a8d90 | ||
|
|
4d2c345003 | ||
|
|
33a886fac9 | ||
|
|
57539ec3da | ||
|
|
82e1efb518 | ||
|
|
9b52a3e0ce | ||
|
|
6990543c9f | ||
|
|
dd0dea8cb5 | ||
|
|
3d20fbd0fe | ||
|
|
e6d8cc775a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,4 +11,5 @@ temp/
|
|||||||
playwright/
|
playwright/
|
||||||
testplugins/
|
testplugins/
|
||||||
*.exe
|
*.exe
|
||||||
tmp-*
|
tmp-*
|
||||||
|
saveany-bot
|
||||||
@@ -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
|
RUN apk add --no-cache curl ffmpeg yt-dlp
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,16 @@
|
|||||||
- 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
|
||||||
|
|||||||
@@ -24,12 +24,16 @@
|
|||||||
- 多用户使用
|
- 多用户使用
|
||||||
- 基于存储规则的自动整理
|
- 基于存储规则的自动整理
|
||||||
- 监听并自动转存指定聊天的消息, 支持过滤
|
- 监听并自动转存指定聊天的消息, 支持过滤
|
||||||
|
- 在不同存储端之间转存文件
|
||||||
|
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
|
||||||
|
- 集成 Aria2, 支持直链/磁力下载和转存
|
||||||
- 使用 js 编写解析器插件以转存任意网站的文件
|
- 使用 js 编写解析器插件以转存任意网站的文件
|
||||||
- 存储端支持:
|
- 存储端支持:
|
||||||
- Alist
|
- Alist
|
||||||
- S3
|
- S3
|
||||||
- WebDAV
|
- WebDAV
|
||||||
- 本地磁盘
|
- 本地磁盘
|
||||||
|
- Rclone
|
||||||
- Telegram (重传回指定聊天)
|
- Telegram (重传回指定聊天)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
48
api/auth.go
Normal file
48
api/auth.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenContextKey 用于在 context 中存储 token
|
||||||
|
type tokenContextKey struct{}
|
||||||
|
|
||||||
|
// AuthMiddleware 返回认证中间件
|
||||||
|
func AuthMiddleware() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.C().API
|
||||||
|
|
||||||
|
// 从请求头获取 token
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthorized", "missing authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 Bearer token
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid authorization header format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := parts[1]
|
||||||
|
|
||||||
|
// 验证 token
|
||||||
|
if subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) != 1 {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 token 添加到 context
|
||||||
|
ctx := context.WithValue(r.Context(), tokenContextKey{}, token)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
355
api/factory.go
Normal file
355
api/factory.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/directlinks"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
|
||||||
|
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/transfer"
|
||||||
|
"github.com/krau/SaveAny-Bot/core/tasks/ytdlp"
|
||||||
|
"github.com/krau/SaveAny-Bot/parsers/parsers"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/aria2"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/parser"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskFactory 任务工厂
|
||||||
|
type TaskFactory struct {
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskFactory 创建任务工厂
|
||||||
|
func NewTaskFactory(ctx context.Context) *TaskFactory {
|
||||||
|
return &TaskFactory{ctx: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTask 创建任务
|
||||||
|
func (f *TaskFactory) CreateTask(req *CreateTaskRequest) (*CreateTaskResponse, error) {
|
||||||
|
// 验证存储
|
||||||
|
stor, ok := storage.Storages[req.Storage]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("storage not found: %s", req.Storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := xid.New().String()
|
||||||
|
createdAt := time.Now()
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case tasktype.TaskTypeDirectlinks:
|
||||||
|
return f.createDirectLinksTask(taskID, createdAt, req, stor)
|
||||||
|
case tasktype.TaskTypeYtdlp:
|
||||||
|
return f.createYTDLPTask(taskID, createdAt, req, stor)
|
||||||
|
case tasktype.TaskTypeAria2:
|
||||||
|
return f.createAria2Task(taskID, createdAt, req, stor)
|
||||||
|
case tasktype.TaskTypeParseditem:
|
||||||
|
return f.createParsedTask(taskID, createdAt, req, stor)
|
||||||
|
case tasktype.TaskTypeTgfiles:
|
||||||
|
return f.createTGFilesTask(taskID, createdAt, req, stor)
|
||||||
|
case tasktype.TaskTypeTphpics:
|
||||||
|
return f.createTPHPicsTask(taskID, createdAt, req, stor)
|
||||||
|
case tasktype.TaskTypeTransfer:
|
||||||
|
return f.createTransferTask(taskID, createdAt, req)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported task type: %s", req.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDirectLinksTask 创建直链下载任务
|
||||||
|
func (f *TaskFactory) createDirectLinksTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
|
||||||
|
var params DirectLinksParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params.URLs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no URLs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
task := directlinks.NewTask(taskID, f.ctx, params.URLs, stor, req.Path, nil)
|
||||||
|
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeDirectlinks,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createYTDLPTask 创建 yt-dlp 任务
|
||||||
|
func (f *TaskFactory) createYTDLPTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
|
||||||
|
var params YTDLPParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params.URLs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no URLs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
task := ytdlp.NewTask(taskID, f.ctx, params.URLs, params.Flags, stor, req.Path, nil)
|
||||||
|
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeYtdlp,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAria2Task 创建 Aria2 任务
|
||||||
|
func (f *TaskFactory) createAria2Task(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
|
||||||
|
var params Aria2Params
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params.URLs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no URLs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Aria2 是否启用
|
||||||
|
cfg := config.C().Aria2
|
||||||
|
if !cfg.Enable {
|
||||||
|
return nil, fmt.Errorf("aria2 is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
aria2Client, err := aria2.NewClient(cfg.Url, cfg.Secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create aria2 client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加下载任务到 Aria2
|
||||||
|
gid, err := aria2Client.AddURI(f.ctx, params.URLs, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add aria2 task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task := aria2dl.NewTask(taskID, f.ctx, gid, params.URLs, aria2Client, stor, req.Path, nil)
|
||||||
|
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeAria2,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createParsedTask 创建解析任务
|
||||||
|
func (f *TaskFactory) createParsedTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
|
||||||
|
var params ParsedParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.URL == "" {
|
||||||
|
return nil, fmt.Errorf("no URL provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找合适的解析器
|
||||||
|
var p parser.Parser
|
||||||
|
for _, parserItem := range parsers.Get() {
|
||||||
|
if parserItem.CanHandle(params.URL) {
|
||||||
|
p = parserItem
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p == nil {
|
||||||
|
return nil, fmt.Errorf("no parser found for URL: %s", params.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 URL
|
||||||
|
item, err := p.Parse(f.ctx, params.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task := parsed.NewTask(taskID, f.ctx, stor, req.Path, item, nil)
|
||||||
|
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeParseditem,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTGFilesTask 创建 Telegram 文件下载任务
|
||||||
|
func (f *TaskFactory) createTGFilesTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
|
||||||
|
var params TGFilesParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params.MessageLinks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no message links provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取文件
|
||||||
|
files, err := ExtractFilesFromLinks(f.ctx, params.MessageLinks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, fmt.Errorf("no files found in provided links")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 1 {
|
||||||
|
// 单个文件任务
|
||||||
|
tfileTask, err := tfile.NewTGFileTask(taskID, f.ctx, files[0], stor, req.Path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create tfile task: %w", err)
|
||||||
|
}
|
||||||
|
if err := core.AddTask(f.ctx, tfileTask); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 批量文件任务
|
||||||
|
elems := make([]batchtfile.TaskElement, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
elem, err := batchtfile.NewTaskElement(stor, req.Path, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create task element: %w", err)
|
||||||
|
}
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
}
|
||||||
|
|
||||||
|
task := batchtfile.NewBatchTGFileTask(taskID, f.ctx, elems, nil, true)
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeTgfiles,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTPHPicsTask 创建 Telegraph 图片下载任务
|
||||||
|
func (f *TaskFactory) createTPHPicsTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
|
||||||
|
var params TPHPicsParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.TelegraphURL == "" {
|
||||||
|
return nil, fmt.Errorf("no telegraph URL provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取图片
|
||||||
|
pics, phPath, err := ExtractTelegraphImages(f.ctx, params.TelegraphURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract telegraph images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pics) == 0 {
|
||||||
|
return nil, fmt.Errorf("no images found in telegraph page")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := telegraph.NewClient()
|
||||||
|
task := tphtask.NewTask(taskID, f.ctx, phPath, pics, stor, req.Path, client, nil)
|
||||||
|
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeTphpics,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTransferTask 创建存储间传输任务
|
||||||
|
func (f *TaskFactory) createTransferTask(taskID string, createdAt time.Time, req *CreateTaskRequest) (*CreateTaskResponse, error) {
|
||||||
|
var params TransferParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证源存储和目标存储
|
||||||
|
sourceStor, ok := storage.Storages[params.SourceStorage]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("source storage not found: %s", params.SourceStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStor, ok := storage.Storages[params.TargetStorage]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("target storage not found: %s", params.TargetStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查源存储是否可读
|
||||||
|
sourceReadable, ok := sourceStor.(storage.StorageReadable)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("source storage does not support reading: %s", params.SourceStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查源存储是否可列
|
||||||
|
sourceListable, ok := sourceStor.(storage.StorageListable)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("source storage does not support listing: %s", params.SourceStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出源文件
|
||||||
|
files, err := sourceListable.ListFiles(f.ctx, params.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list source files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, fmt.Errorf("no files found at source path: %s", params.SourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建传输元素
|
||||||
|
elems := make([]transfer.TaskElement, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
elem := transfer.NewTaskElement(sourceReadable, file, targetStor, params.TargetPath)
|
||||||
|
elems = append(elems, *elem)
|
||||||
|
}
|
||||||
|
|
||||||
|
task := transfer.NewTransferTask(taskID, f.ctx, elems, nil, true)
|
||||||
|
|
||||||
|
if err := core.AddTask(f.ctx, task); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateTaskResponse{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: tasktype.TaskTypeTransfer,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
222
api/handlers.go
Normal file
222
api/handlers.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/core"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
"github.com/krau/SaveAny-Bot/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handlers 处理器结构体
|
||||||
|
type Handlers struct {
|
||||||
|
factory *TaskFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers 创建处理器
|
||||||
|
func NewHandlers(factory *TaskFactory) *Handlers {
|
||||||
|
return &Handlers{factory: factory}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskHandler 创建任务处理器
|
||||||
|
func (h *Handlers) CreateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only POST method is allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateTaskRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
WriteError(w, http.StatusBadRequest, "invalid_request", "failed to decode request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证请求
|
||||||
|
if req.Type == "" {
|
||||||
|
WriteError(w, http.StatusBadRequest, "invalid_request", "task type is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Storage == "" {
|
||||||
|
WriteError(w, http.StatusBadRequest, "invalid_request", "storage is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建任务
|
||||||
|
resp, err := h.factory.CreateTask(&req)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusBadRequest, "task_creation_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteJSON(w, http.StatusCreated, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasksHandler 列出任务处理器
|
||||||
|
func (h *Handlers) ListTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := GetAllTasks()
|
||||||
|
response := make([]TaskInfoResponse, 0, len(tasks))
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
info := convertTaskProgressToResponse(task)
|
||||||
|
response = append(response, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteJSON(w, http.StatusOK, TasksListResponse{
|
||||||
|
Tasks: response,
|
||||||
|
Total: len(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskHandler 获取单个任务处理器
|
||||||
|
func (h *Handlers) GetTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := extractTaskIDFromPath(r.URL.Path)
|
||||||
|
if taskID == "" {
|
||||||
|
WriteError(w, http.StatusBadRequest, "invalid_request", "task ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, ok := GetTask(taskID)
|
||||||
|
if !ok {
|
||||||
|
WriteError(w, http.StatusNotFound, "task_not_found", "task not found: "+taskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := convertTaskProgressToResponse(task)
|
||||||
|
WriteJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelTaskHandler 取消任务处理器
|
||||||
|
func (h *Handlers) CancelTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only DELETE method is allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := extractTaskIDFromPath(r.URL.Path)
|
||||||
|
if taskID == "" {
|
||||||
|
WriteError(w, http.StatusBadRequest, "invalid_request", "task ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, ok := GetTask(taskID)
|
||||||
|
if !ok {
|
||||||
|
WriteError(w, http.StatusNotFound, "task_not_found", "task not found: "+taskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消任务
|
||||||
|
if err := core.CancelTask(r.Context(), taskID); err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "cancel_failed", "failed to cancel task: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task.UpdateStatus(TaskStatusCancelled)
|
||||||
|
WriteJSON(w, http.StatusOK, map[string]string{"message": "task cancelled successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStoragesHandler 列出存储处理器
|
||||||
|
func (h *Handlers) ListStoragesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storages := make([]StorageInfo, 0, len(storage.Storages))
|
||||||
|
for name, stor := range storage.Storages {
|
||||||
|
storages = append(storages, StorageInfo{
|
||||||
|
Name: name,
|
||||||
|
Type: string(stor.Type()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteJSON(w, http.StatusOK, StoragesResponse{Storages: storages})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskTypesHandler 获取支持的任务类型
|
||||||
|
func (h *Handlers) GetTaskTypesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
types := []tasktype.TaskType{
|
||||||
|
tasktype.TaskTypeDirectlinks,
|
||||||
|
tasktype.TaskTypeYtdlp,
|
||||||
|
tasktype.TaskTypeAria2,
|
||||||
|
tasktype.TaskTypeParseditem,
|
||||||
|
tasktype.TaskTypeTgfiles,
|
||||||
|
tasktype.TaskTypeTphpics,
|
||||||
|
tasktype.TaskTypeTransfer,
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"types": types,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheckHandler 健康检查处理器
|
||||||
|
func (h *Handlers) HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
WriteJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTaskIDFromPath 从路径中提取任务 ID
|
||||||
|
// 路径格式: /api/v1/tasks/:id
|
||||||
|
func extractTaskIDFromPath(path string) string {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertTaskProgressToResponse 将任务进度转换为响应格式
|
||||||
|
func convertTaskProgressToResponse(task *TaskProgressInfo) TaskInfoResponse {
|
||||||
|
resp := TaskInfoResponse{
|
||||||
|
TaskID: task.TaskID,
|
||||||
|
Type: tasktype.TaskType(task.Type),
|
||||||
|
Status: task.Status,
|
||||||
|
Title: task.Title,
|
||||||
|
Storage: task.Storage,
|
||||||
|
Path: task.Path,
|
||||||
|
Error: task.Error,
|
||||||
|
CreatedAt: task.CreatedAt,
|
||||||
|
UpdatedAt: task.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算进度
|
||||||
|
if task.TotalBytes > 0 {
|
||||||
|
percent := float64(task.DownloadedBytes) * 100 / float64(task.TotalBytes)
|
||||||
|
resp.Progress = &TaskProgress{
|
||||||
|
TotalBytes: task.TotalBytes,
|
||||||
|
DownloadedBytes: task.DownloadedBytes,
|
||||||
|
Percent: percent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFoundHandler 404 处理器
|
||||||
|
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", "endpoint not found: "+r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodNotAllowedHandler 405 处理器
|
||||||
|
func MethodNotAllowedHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed: "+r.Method)
|
||||||
|
}
|
||||||
689
api/handlers_test.go
Normal file
689
api/handlers_test.go
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestServer creates a test server with handlers
|
||||||
|
func setupTestServer(t *testing.T) (*Handlers, *TaskFactory) {
|
||||||
|
factory := NewTaskFactory(t.Context())
|
||||||
|
handlers := NewHandlers(factory)
|
||||||
|
return handlers, factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateTaskHandler tests the create task endpoint
|
||||||
|
func TestCreateTaskHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
body any
|
||||||
|
wantStatus int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Method not allowed",
|
||||||
|
method: http.MethodGet,
|
||||||
|
body: nil,
|
||||||
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid JSON body",
|
||||||
|
method: http.MethodPost,
|
||||||
|
body: "invalid json",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty request body",
|
||||||
|
method: http.MethodPost,
|
||||||
|
body: CreateTaskRequest{},
|
||||||
|
// Will fail validation for missing type
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing type",
|
||||||
|
method: http.MethodPost,
|
||||||
|
body: CreateTaskRequest{
|
||||||
|
Storage: "test-storage",
|
||||||
|
Path: "downloads",
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing storage",
|
||||||
|
method: http.MethodPost,
|
||||||
|
body: CreateTaskRequest{
|
||||||
|
Type: tasktype.TaskTypeDirectlinks,
|
||||||
|
Path: "downloads",
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Storage not found",
|
||||||
|
method: http.MethodPost,
|
||||||
|
body: CreateTaskRequest{
|
||||||
|
Type: tasktype.TaskTypeDirectlinks,
|
||||||
|
Storage: "non-existent-storage",
|
||||||
|
Path: "downloads",
|
||||||
|
Params: json.RawMessage(`{"urls":["https://example.com/file.zip"]}`),
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var bodyBytes []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if tt.body != nil {
|
||||||
|
switch v := tt.body.(type) {
|
||||||
|
case string:
|
||||||
|
bodyBytes = []byte(v)
|
||||||
|
default:
|
||||||
|
bodyBytes, err = json.Marshal(tt.body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal body: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(tt.method, "/api/v1/tasks", bytes.NewReader(bodyBytes))
|
||||||
|
if tt.body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.CreateTaskHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.wantStatus {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &errResp); err != nil {
|
||||||
|
t.Errorf("expected error response, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListTasksHandler tests the list tasks endpoint
|
||||||
|
func TestListTasksHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Method not allowed",
|
||||||
|
method: http.MethodPost,
|
||||||
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Success",
|
||||||
|
method: http.MethodGet,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, "/api/v1/tasks", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.ListTasksHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.wantStatus {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.method == http.MethodGet {
|
||||||
|
var resp TasksListResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Errorf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Tasks == nil {
|
||||||
|
t.Error("expected non-nil tasks array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetTaskHandler tests the get task endpoint
|
||||||
|
func TestGetTaskHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
// Register a test task
|
||||||
|
testTaskID := "test-get-task"
|
||||||
|
RegisterTask(testTaskID, "directlinks", "local", "downloads", "Test", "")
|
||||||
|
defer DeleteTask(testTaskID)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
wantStatus int
|
||||||
|
wantFound bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Method not allowed",
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: "/api/v1/tasks/test-id",
|
||||||
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing task ID",
|
||||||
|
method: http.MethodGet,
|
||||||
|
path: "/api/v1/tasks",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Task not found",
|
||||||
|
method: http.MethodGet,
|
||||||
|
path: "/api/v1/tasks/non-existent-task",
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Task found",
|
||||||
|
method: http.MethodGet,
|
||||||
|
path: "/api/v1/tasks/" + testTaskID,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantFound: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.GetTaskHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.wantStatus {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantFound {
|
||||||
|
var resp TaskInfoResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Errorf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.TaskID != testTaskID {
|
||||||
|
t.Errorf("expected task ID %s, got %s", testTaskID, resp.TaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCancelTaskHandler tests the cancel task endpoint
|
||||||
|
func TestCancelTaskHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
// Register a test task
|
||||||
|
testTaskID := "test-cancel-task"
|
||||||
|
RegisterTask(testTaskID, "directlinks", "local", "downloads", "Test", "")
|
||||||
|
defer DeleteTask(testTaskID)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
wantStatus int
|
||||||
|
skipCore bool // Skip if core is not initialized
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Method not allowed",
|
||||||
|
method: http.MethodGet,
|
||||||
|
path: "/api/v1/tasks/test-id",
|
||||||
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing task ID",
|
||||||
|
method: http.MethodDelete,
|
||||||
|
path: "/api/v1/tasks",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Task not found",
|
||||||
|
method: http.MethodDelete,
|
||||||
|
path: "/api/v1/tasks/non-existent-task",
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cancel task",
|
||||||
|
method: http.MethodDelete,
|
||||||
|
path: "/api/v1/tasks/" + testTaskID,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
skipCore: true, // Requires initialized core task queue
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.skipCore {
|
||||||
|
t.Skip("Skipping test: requires initialized core")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.CancelTaskHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.wantStatus {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListStoragesHandler tests the list storages endpoint
|
||||||
|
func TestListStoragesHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Method not allowed",
|
||||||
|
method: http.MethodPost,
|
||||||
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Success",
|
||||||
|
method: http.MethodGet,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, "/api/v1/storages", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.ListStoragesHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.wantStatus {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.method == http.MethodGet {
|
||||||
|
var resp StoragesResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Errorf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Storages == nil {
|
||||||
|
t.Error("expected non-nil storages array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentProgressStore tests concurrent access to progress store
|
||||||
|
func TestConcurrentProgressStore(t *testing.T) {
|
||||||
|
// Clear store before test
|
||||||
|
t.Cleanup(func() {
|
||||||
|
tasks := GetAllTasks()
|
||||||
|
for _, task := range tasks {
|
||||||
|
if strings.HasPrefix(task.TaskID, "concurrent-test-") {
|
||||||
|
DeleteTask(task.TaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numGoroutines := 100
|
||||||
|
|
||||||
|
// Concurrent registrations
|
||||||
|
for i := range numGoroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
taskID := fmt.Sprintf("concurrent-test-%d", id)
|
||||||
|
RegisterTask(taskID, "directlinks", "local", "downloads", "Test", "")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent reads
|
||||||
|
for i := range numGoroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
taskID := fmt.Sprintf("concurrent-test-%d", id)
|
||||||
|
GetTask(taskID)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent updates
|
||||||
|
for i := range numGoroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
taskID := fmt.Sprintf("concurrent-test-%d", id)
|
||||||
|
info, ok := GetTask(taskID)
|
||||||
|
if ok {
|
||||||
|
info.UpdateStatus(TaskStatusRunning)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify all tasks exist
|
||||||
|
for i := range numGoroutines {
|
||||||
|
taskID := fmt.Sprintf("concurrent-test-%d", i)
|
||||||
|
if _, ok := GetTask(taskID); !ok {
|
||||||
|
t.Errorf("task %s not found after concurrent operations", taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProgressTrackerConcurrentUpdates tests concurrent progress updates
|
||||||
|
func TestProgressTrackerConcurrentUpdates(t *testing.T) {
|
||||||
|
tracker := NewProgressTracker("concurrent-progress", "directlinks", "local", "downloads", "Test", "")
|
||||||
|
tracker.OnStart(10000, 10)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numGoroutines := 50
|
||||||
|
updatesPerGoroutine := 100
|
||||||
|
|
||||||
|
// Concurrent progress updates
|
||||||
|
for i := range numGoroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range updatesPerGoroutine {
|
||||||
|
tracker.OnProgress(int64(id*updatesPerGoroutine+j), j)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
info := tracker.GetInfo()
|
||||||
|
if info.Status != TaskStatusRunning {
|
||||||
|
t.Errorf("expected status Running after concurrent updates, got %s", info.Status)
|
||||||
|
}
|
||||||
|
// Note: Due to race conditions in the simple implementation,
|
||||||
|
// we can't reliably check exact values without proper synchronization
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTaskFactoryValidation tests TaskFactory parameter validation
|
||||||
|
func TestTaskFactoryValidation(t *testing.T) {
|
||||||
|
factory := NewTaskFactory(context.Background())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
request *CreateTaskRequest
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Storage not found",
|
||||||
|
request: &CreateTaskRequest{
|
||||||
|
Type: tasktype.TaskTypeDirectlinks,
|
||||||
|
Storage: "non-existent",
|
||||||
|
Path: "downloads",
|
||||||
|
Params: json.RawMessage(`{"urls":["https://example.com/file.zip"]}`),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "storage not found",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := factory.CreateTask(tt.request)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("CreateTask() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil && tt.errMsg != "" {
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), tt.errMsg) {
|
||||||
|
t.Errorf("error message %q does not contain %q", err.Error(), tt.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEdgeCases tests various edge cases
|
||||||
|
func TestEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fn func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty request body",
|
||||||
|
fn: func(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.CreateTaskHandler(rr, req)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected %d, got %d", http.StatusBadRequest, rr.Code)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Very long task ID in path",
|
||||||
|
fn: func(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
longID := strings.Repeat("a", 1000)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+longID, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.GetTaskHandler(rr, req)
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected %d, got %d", http.StatusNotFound, rr.Code)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Path with special characters",
|
||||||
|
fn: func(t *testing.T) {
|
||||||
|
path := "/api/v1/tasks/test%20id/with/slashes"
|
||||||
|
got := extractTaskIDFromPath(path)
|
||||||
|
expected := "test%20id"
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("expected %q, got %q", expected, got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Double slashes in path",
|
||||||
|
fn: func(t *testing.T) {
|
||||||
|
path := "/api/v1/tasks//task-id"
|
||||||
|
got := extractTaskIDFromPath(path)
|
||||||
|
expected := ""
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("expected %q, got %q", expected, got)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Progress tracker with empty webhook",
|
||||||
|
fn: func(t *testing.T) {
|
||||||
|
tracker := NewProgressTracker("test", "type", "storage", "path", "title", "")
|
||||||
|
info := tracker.GetInfo()
|
||||||
|
if info.Webhook != "" {
|
||||||
|
t.Error("expected empty webhook")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, tt.fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHealthCheckHandler tests the health check endpoint
|
||||||
|
func TestHealthCheckHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.HealthCheckHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Errorf("expected status 'ok', got %q", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetTaskTypesHandler tests the task types endpoint
|
||||||
|
func TestGetTaskTypesHandler(t *testing.T) {
|
||||||
|
handlers, _ := setupTestServer(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Method not allowed",
|
||||||
|
method: http.MethodPost,
|
||||||
|
wantStatus: http.StatusMethodNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Success",
|
||||||
|
method: http.MethodGet,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, "/api/v1/task-types", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handlers.GetTaskTypesHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != tt.wantStatus {
|
||||||
|
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.method == http.MethodGet {
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Errorf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := resp["types"]; !ok {
|
||||||
|
t.Error("expected 'types' field in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNotFoundHandler tests the 404 handler
|
||||||
|
func TestNotFoundHandler(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/non-existent-path", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
NotFoundHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ErrorResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Error != "not_found" {
|
||||||
|
t.Errorf("expected error 'not_found', got %q", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMethodNotAllowedHandler tests the 405 handler
|
||||||
|
func TestMethodNotAllowedHandler(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
MethodNotAllowedHandler(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ErrorResponse
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Error != "method_not_allowed" {
|
||||||
|
t.Errorf("expected error 'method_not_allowed', got %q", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTaskProgressInfoTimeUpdate tests that timestamps are updated correctly
|
||||||
|
func TestTaskProgressInfoTimeUpdate(t *testing.T) {
|
||||||
|
info := RegisterTask("time-test", "directlinks", "local", "downloads", "Test", "")
|
||||||
|
defer DeleteTask("time-test")
|
||||||
|
|
||||||
|
originalTime := info.UpdatedAt
|
||||||
|
time.Sleep(10 * time.Millisecond) // Ensure time difference
|
||||||
|
|
||||||
|
info.UpdateStatus(TaskStatusRunning)
|
||||||
|
if !info.UpdatedAt.After(originalTime) {
|
||||||
|
t.Error("expected UpdatedAt to be updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookPayloadWithNilCompletedAt tests webhook payload with nil completed_at
|
||||||
|
func TestWebhookPayloadWithNilCompletedAt(t *testing.T) {
|
||||||
|
payload := WebhookPayload{
|
||||||
|
TaskID: "test-id",
|
||||||
|
Type: "directlinks",
|
||||||
|
Status: TaskStatusRunning,
|
||||||
|
Storage: "local",
|
||||||
|
Path: "downloads/file.zip",
|
||||||
|
CompletedAt: nil,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// completed_at should be omitted when nil
|
||||||
|
if _, ok := decoded["completed_at"]; ok {
|
||||||
|
t.Error("expected completed_at to be omitted when nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
150
api/progress.go
Normal file
150
api/progress.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskProgressInfo 存储任务的进度信息
|
||||||
|
type TaskProgressInfo struct {
|
||||||
|
TaskID string
|
||||||
|
Type string
|
||||||
|
Status TaskStatus
|
||||||
|
Title string
|
||||||
|
TotalBytes int64
|
||||||
|
DownloadedBytes int64
|
||||||
|
TotalFiles int
|
||||||
|
DownloadedFiles int
|
||||||
|
Storage string
|
||||||
|
Path string
|
||||||
|
Error string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Webhook string
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressStore 存储所有 API 任务的进度信息
|
||||||
|
type progressStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tasks map[string]*TaskProgressInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
var store = &progressStore{
|
||||||
|
tasks: make(map[string]*TaskProgressInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTask 注册一个新的 API 任务
|
||||||
|
func RegisterTask(taskID, taskType, storage, path, title, webhook string) *TaskProgressInfo {
|
||||||
|
info := &TaskProgressInfo{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: taskType,
|
||||||
|
Status: TaskStatusQueued,
|
||||||
|
Title: title,
|
||||||
|
Storage: storage,
|
||||||
|
Path: path,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Webhook: webhook,
|
||||||
|
}
|
||||||
|
|
||||||
|
store.mu.Lock()
|
||||||
|
store.tasks[taskID] = info
|
||||||
|
store.mu.Unlock()
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTask 获取任务进度信息
|
||||||
|
func GetTask(taskID string) (*TaskProgressInfo, bool) {
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
info, ok := store.tasks[taskID]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllTasks 获取所有任务
|
||||||
|
func GetAllTasks() []*TaskProgressInfo {
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
|
||||||
|
tasks := make([]*TaskProgressInfo, 0, len(store.tasks))
|
||||||
|
for _, info := range store.tasks {
|
||||||
|
tasks = append(tasks, info)
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTask 删除任务记录
|
||||||
|
func DeleteTask(taskID string) {
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
delete(store.tasks, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 更新任务状态
|
||||||
|
func (t *TaskProgressInfo) UpdateStatus(status TaskStatus) {
|
||||||
|
t.Status = status
|
||||||
|
t.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetError 设置错误信息
|
||||||
|
func (t *TaskProgressInfo) SetError(err string) {
|
||||||
|
t.Error = err
|
||||||
|
t.Status = TaskStatusFailed
|
||||||
|
t.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressTracker 用于 API 任务的进度追踪
|
||||||
|
type ProgressTracker struct {
|
||||||
|
info *TaskProgressInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProgressTracker 创建新的进度追踪器
|
||||||
|
func NewProgressTracker(taskID, taskType, storage, path, title, webhook string) *ProgressTracker {
|
||||||
|
info := RegisterTask(taskID, taskType, storage, path, title, webhook)
|
||||||
|
return &ProgressTracker{info: info}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStart 任务开始
|
||||||
|
func (p *ProgressTracker) OnStart(totalBytes int64, totalFiles int) {
|
||||||
|
p.info.Status = TaskStatusRunning
|
||||||
|
p.info.TotalBytes = totalBytes
|
||||||
|
p.info.TotalFiles = totalFiles
|
||||||
|
p.info.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnProgress 进度更新
|
||||||
|
func (p *ProgressTracker) OnProgress(downloadedBytes int64, downloadedFiles int) {
|
||||||
|
atomic.StoreInt64(&p.info.DownloadedBytes, downloadedBytes)
|
||||||
|
p.info.DownloadedFiles = downloadedFiles
|
||||||
|
p.info.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDone 任务完成
|
||||||
|
func (p *ProgressTracker) OnDone(err error) {
|
||||||
|
if err != nil {
|
||||||
|
p.info.Status = TaskStatusFailed
|
||||||
|
p.info.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
p.info.Status = TaskStatusCompleted
|
||||||
|
}
|
||||||
|
p.info.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo 获取任务信息
|
||||||
|
func (p *ProgressTracker) GetInfo() *TaskProgressInfo {
|
||||||
|
return p.info
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgressBytes 更新下载字节数
|
||||||
|
func (p *ProgressTracker) UpdateProgressBytes(bytes int64) {
|
||||||
|
atomic.StoreInt64(&p.info.DownloadedBytes, bytes)
|
||||||
|
p.info.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProgressFiles 更新下载文件数
|
||||||
|
func (p *ProgressTracker) UpdateProgressFiles(files int) {
|
||||||
|
p.info.DownloadedFiles = files
|
||||||
|
p.info.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
163
api/server.go
Normal file
163
api/server.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server API 服务器
|
||||||
|
type Server struct {
|
||||||
|
httpServer *http.Server
|
||||||
|
factory *TaskFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer 创建新的 API 服务器
|
||||||
|
func NewServer(ctx context.Context) *Server {
|
||||||
|
cfg := config.C().API
|
||||||
|
|
||||||
|
factory := NewTaskFactory(ctx)
|
||||||
|
handlers := NewHandlers(factory)
|
||||||
|
|
||||||
|
// 设置路由
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
mux.HandleFunc("/health", handlers.HealthCheckHandler)
|
||||||
|
|
||||||
|
// API v1 路由
|
||||||
|
mux.HandleFunc("/api/v1/tasks", handlers.CreateTaskHandler)
|
||||||
|
mux.HandleFunc("/api/v1/tasks/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 根据方法和路径分发
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if r.URL.Path == "/api/v1/tasks" {
|
||||||
|
handlers.ListTasksHandler(w, r)
|
||||||
|
} else {
|
||||||
|
handlers.GetTaskHandler(w, r)
|
||||||
|
}
|
||||||
|
case http.MethodDelete:
|
||||||
|
handlers.CancelTaskHandler(w, r)
|
||||||
|
default:
|
||||||
|
MethodNotAllowedHandler(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/v1/storages", handlers.ListStoragesHandler)
|
||||||
|
mux.HandleFunc("/api/v1/task-types", handlers.GetTaskTypesHandler)
|
||||||
|
|
||||||
|
// 404 处理
|
||||||
|
mux.HandleFunc("/", NotFoundHandler)
|
||||||
|
|
||||||
|
// 应用中间件
|
||||||
|
var handler http.Handler = mux
|
||||||
|
|
||||||
|
// 添加认证中间件
|
||||||
|
token := cfg.Token
|
||||||
|
if token == "" {
|
||||||
|
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
handler = AuthMiddleware()(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加日志中间件
|
||||||
|
handler = loggingMiddleware(handler)
|
||||||
|
|
||||||
|
// 添加恢复中间件
|
||||||
|
handler = recoveryMiddleware(handler)
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
httpServer: &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
},
|
||||||
|
factory: factory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 启动服务器
|
||||||
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
|
logger := log.FromContext(ctx).With("module", "api")
|
||||||
|
|
||||||
|
logger.Infof("Starting API server on %s", s.httpServer.Addr)
|
||||||
|
|
||||||
|
// 在 goroutine 中启动服务器
|
||||||
|
go func() {
|
||||||
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Errorf("API server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 监听 context 取消
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Errorf("API server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware 日志中间件
|
||||||
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// 包装 ResponseWriter 以获取状态码
|
||||||
|
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
|
||||||
|
next.ServeHTTP(wrapped, r)
|
||||||
|
|
||||||
|
log.Infof("%s %s %d %s", r.Method, r.URL.Path, wrapped.statusCode, time.Since(start))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryMiddleware 恢复中间件
|
||||||
|
func recoveryMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Errorf("Panic recovered: %v", err)
|
||||||
|
WriteError(w, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseWriter 包装 http.ResponseWriter 以捕获状态码
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(code int) {
|
||||||
|
rw.statusCode = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 初始化并启动 API 服务器
|
||||||
|
func Start(ctx context.Context) error {
|
||||||
|
cfg := config.C().API
|
||||||
|
|
||||||
|
if !cfg.Enable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Token == "" {
|
||||||
|
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
|
||||||
|
}
|
||||||
|
|
||||||
|
server := NewServer(ctx)
|
||||||
|
return server.Start(ctx)
|
||||||
|
}
|
||||||
272
api/tgfiles.go
Normal file
272
api/tgfiles.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/celestix/gotgproto/ext"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/krau/SaveAny-Bot/client/bot"
|
||||||
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageContext 保存消息和获取它所用的 context
|
||||||
|
type MessageContext struct {
|
||||||
|
Message *tg.Message
|
||||||
|
Client *ext.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientContext 获取可用的客户端上下文
|
||||||
|
// 优先使用 Bot,失败后回退到 Userbot
|
||||||
|
func getClientContext() (*ext.Context, error) {
|
||||||
|
// 首先尝试获取 Bot context
|
||||||
|
if botCtx := bot.ExtContext(); botCtx != nil {
|
||||||
|
return botCtx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到 Userbot
|
||||||
|
if uc := userclient.GetCtx(); uc != nil {
|
||||||
|
return uc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no client available (bot and userbot are not initialized)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveChatID 解析聊天 ID
|
||||||
|
func resolveChatID(_ context.Context, idOrUsername string) (int64, error) {
|
||||||
|
// 如果是数字 ID
|
||||||
|
if id, err := strconv.ParseInt(idOrUsername, 10, 64); err == nil {
|
||||||
|
// 私有频道 ID 需要加上 -100 前缀
|
||||||
|
if id > 0 {
|
||||||
|
return -1000000000000 - id, nil
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用的客户端上下文
|
||||||
|
clientCtx, err := getClientContext()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 tgutil 的 ParseChatID
|
||||||
|
return tgutil.ParseChatID(clientCtx, idOrUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMessageLink 解析 Telegram 消息链接
|
||||||
|
// 支持格式:
|
||||||
|
// - https://t.me/username/123
|
||||||
|
// - https://t.me/c/123456789/123
|
||||||
|
// - https://t.me/c/123456789/111/456 (topic id)
|
||||||
|
// - https://t.me/username/123?comment=2 (评论)
|
||||||
|
func ParseMessageLink(ctx context.Context, link string) (int64, int, error) {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||||
|
|
||||||
|
if cmt := u.Query().Get("comment"); cmt != "" {
|
||||||
|
// 频道评论的消息链接
|
||||||
|
if len(paths) < 1 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||||
|
}
|
||||||
|
// 简化处理:返回错误,提示不支持评论链接
|
||||||
|
return 0, 0, fmt.Errorf("comment links are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(paths) {
|
||||||
|
case 2: // https://t.me/username/123
|
||||||
|
chatID, err := resolveChatID(ctx, paths[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(paths[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||||
|
}
|
||||||
|
return chatID, msgID, nil
|
||||||
|
case 3:
|
||||||
|
// https://t.me/c/123456789/123
|
||||||
|
// https://t.me/username/123/456 , 123: topic id
|
||||||
|
chatPart, msgPart := paths[1], paths[2]
|
||||||
|
if paths[0] != "c" {
|
||||||
|
chatPart = paths[0]
|
||||||
|
}
|
||||||
|
chatID, err := resolveChatID(ctx, chatPart)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(msgPart)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||||
|
}
|
||||||
|
return chatID, msgID, nil
|
||||||
|
case 4:
|
||||||
|
// https://t.me/c/123456789/111/456 111: topic id
|
||||||
|
if paths[0] != "c" {
|
||||||
|
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||||
|
}
|
||||||
|
chatID, err := resolveChatID(ctx, paths[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
|
||||||
|
}
|
||||||
|
msgID, err := strconv.Atoi(paths[3])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
|
||||||
|
}
|
||||||
|
return chatID, msgID, nil
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessageWithContext 通过 ID 获取消息,返回消息和使用的 context
|
||||||
|
// 确保消息获取和后续文件创建使用同一个 context
|
||||||
|
func getMessageWithContext(_ context.Context, chatID int64, msgID int) (*MessageContext, error) {
|
||||||
|
// 首先尝试使用 Bot
|
||||||
|
if botCtx := bot.ExtContext(); botCtx != nil {
|
||||||
|
msg, err := tgutil.GetMessageByID(botCtx, chatID, msgID)
|
||||||
|
if err == nil {
|
||||||
|
return &MessageContext{Message: msg, Client: botCtx}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到 Userbot
|
||||||
|
uc := userclient.GetCtx()
|
||||||
|
if uc == nil {
|
||||||
|
return nil, fmt.Errorf("userbot not initialized and bot cannot access this message")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := tgutil.GetMessageByID(uc, chatID, msgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MessageContext{Message: msg, Client: uc}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getGroupedMessagesWithContext 获取媒体组消息,返回消息列表和使用的 context
|
||||||
|
// 确保消息获取和后续文件创建使用同一个 context
|
||||||
|
func getGroupedMessagesWithContext(ctx *MessageContext, chatID int64) ([]*tg.Message, error) {
|
||||||
|
msg := ctx.Message
|
||||||
|
clientCtx := ctx.Client
|
||||||
|
|
||||||
|
groupID, ok := msg.GetGroupedID()
|
||||||
|
if !ok || groupID == 0 {
|
||||||
|
return []*tg.Message{msg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用获取原始消息的同一个 client 获取媒体组
|
||||||
|
msgs, err := tgutil.GetGroupedMessages(clientCtx, chatID, msg)
|
||||||
|
if err != nil || len(msgs) == 0 {
|
||||||
|
// 如果获取失败,至少返回原始消息
|
||||||
|
return []*tg.Message{msg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFilesFromLinks 从消息链接中提取文件
|
||||||
|
// 每个文件的处理流程:解析链接 -> 获取消息 -> 获取媒体组 -> 创建文件对象
|
||||||
|
// 对于单个文件,全程使用同一个 client context,不会交叉
|
||||||
|
func ExtractFilesFromLinks(ctx context.Context, links []string) ([]tfile.TGFileMessage, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
var files []tfile.TGFileMessage
|
||||||
|
|
||||||
|
for _, link := range links {
|
||||||
|
link = strings.TrimSpace(link)
|
||||||
|
if link == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证链接格式
|
||||||
|
if !isValidMessageLink(link) {
|
||||||
|
logger.Errorf("Invalid message link format: %s", link)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID, msgID, err := ParseMessageLink(ctx, link)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to parse message link %s: %v", link, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析链接 URL 检查是否有 single 参数
|
||||||
|
u, _ := url.Parse(link)
|
||||||
|
single := u != nil && u.Query().Has("single")
|
||||||
|
|
||||||
|
// 获取消息和使用的 context(Bot 优先,失败回退 Userbot)
|
||||||
|
msgCtx, err := getMessageWithContext(ctx, chatID, msgID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get message %d from chat %d: %v", msgID, chatID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := msgCtx.Message
|
||||||
|
clientCtx := msgCtx.Client
|
||||||
|
|
||||||
|
if msg.Media == nil {
|
||||||
|
logger.Warnf("Message %d has no media", msgID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
media, ok := msg.GetMedia()
|
||||||
|
if !ok {
|
||||||
|
logger.Warnf("Failed to get media from message %d", msgID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是媒体组
|
||||||
|
groupID, isGroup := msg.GetGroupedID()
|
||||||
|
if isGroup && groupID != 0 && !single {
|
||||||
|
// 使用同一个 client context 获取媒体组
|
||||||
|
groupMsgs, err := getGroupedMessagesWithContext(msgCtx, chatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get grouped messages: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, gmsg := range groupMsgs {
|
||||||
|
if gmsg.Media == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gmedia, ok := gmsg.GetMedia()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 使用获取消息时使用的同一个 client context 创建文件
|
||||||
|
file, err := tfile.FromMediaMessage(gmedia, clientCtx.Raw, gmsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create file from media: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个文件 - 使用获取消息时使用的同一个 client context 创建文件
|
||||||
|
file, err := tfile.FromMediaMessage(media, clientCtx.Raw, msg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create file from media: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, fmt.Errorf("no files found in provided links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidMessageLink 检查是否是有效的 Telegram 消息链接
|
||||||
|
func isValidMessageLink(link string) bool {
|
||||||
|
return strings.HasPrefix(link, "https://t.me/") || strings.HasPrefix(link, "http://t.me/")
|
||||||
|
}
|
||||||
80
api/tphpics.go
Normal file
80
api/tphpics.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/telegraph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractTelegraphImages 从 Telegraph URL 提取图片
|
||||||
|
func ExtractTelegraphImages(ctx context.Context, pageURL string) ([]string, string, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
|
||||||
|
// 验证 URL 格式
|
||||||
|
if !isValidTelegraphURL(pageURL) {
|
||||||
|
return nil, "", fmt.Errorf("invalid telegraph URL format: %s", pageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 URL 获取页面路径
|
||||||
|
pagepath, err := parseTelegraphPath(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Fetching telegraph page: %s", pagepath)
|
||||||
|
|
||||||
|
client := telegraph.NewClient()
|
||||||
|
page, err := client.GetPage(ctx, pagepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to get telegraph page: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgs []string
|
||||||
|
for _, elem := range page.Content {
|
||||||
|
imgs = append(imgs, tphutil.GetNodeImages(elem)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("no images found in telegraph page")
|
||||||
|
}
|
||||||
|
|
||||||
|
return imgs, pagepath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTelegraphPath 解析 Telegraph URL 获取页面路径
|
||||||
|
func parseTelegraphPath(pageURL string) (string, error) {
|
||||||
|
u, err := url.Parse(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid telegraph URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(u.Host, "telegra.ph") && !strings.HasSuffix(u.Host, "telegraph.co") {
|
||||||
|
return "", fmt.Errorf("invalid telegraph URL host: %s", u.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
|
||||||
|
if len(paths) == 0 || paths[0] == "" {
|
||||||
|
return "", fmt.Errorf("invalid telegraph URL path: %s", u.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pagepath := paths[len(paths)-1]
|
||||||
|
pagepath, err = url.PathUnescape(pagepath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unescape telegraph path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(pagepath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidTelegraphURL 检查是否是有效的 Telegraph URL
|
||||||
|
func isValidTelegraphURL(url string) bool {
|
||||||
|
return strings.HasPrefix(url, "https://telegra.ph/") ||
|
||||||
|
strings.HasPrefix(url, "http://telegra.ph/") ||
|
||||||
|
strings.HasPrefix(url, "https://telegraph.co/") ||
|
||||||
|
strings.HasPrefix(url, "http://telegraph.co/")
|
||||||
|
}
|
||||||
161
api/types.go
Normal file
161
api/types.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskStatus 表示任务状态
|
||||||
|
type TaskStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskStatusQueued TaskStatus = "queued"
|
||||||
|
TaskStatusRunning TaskStatus = "running"
|
||||||
|
TaskStatusCompleted TaskStatus = "completed"
|
||||||
|
TaskStatusFailed TaskStatus = "failed"
|
||||||
|
TaskStatusCancelled TaskStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTaskRequest 创建任务请求
|
||||||
|
type CreateTaskRequest struct {
|
||||||
|
Type tasktype.TaskType `json:"type"`
|
||||||
|
Storage string `json:"storage"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Webhook string `json:"webhook,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskResponse 创建任务响应
|
||||||
|
type CreateTaskResponse struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Type tasktype.TaskType `json:"type"`
|
||||||
|
Status TaskStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskProgress 任务进度
|
||||||
|
type TaskProgress struct {
|
||||||
|
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||||
|
DownloadedBytes int64 `json:"downloaded_bytes,omitempty"`
|
||||||
|
Percent float64 `json:"percent,omitempty"`
|
||||||
|
SpeedMBPS float64 `json:"speed_mbps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskInfoResponse 任务信息响应
|
||||||
|
type TaskInfoResponse struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Type tasktype.TaskType `json:"type"`
|
||||||
|
Status TaskStatus `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Progress *TaskProgress `json:"progress,omitempty"`
|
||||||
|
Storage string `json:"storage"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksListResponse 任务列表响应
|
||||||
|
type TasksListResponse struct {
|
||||||
|
Tasks []TaskInfoResponse `json:"tasks"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoragesResponse 存储列表响应
|
||||||
|
type StoragesResponse struct {
|
||||||
|
Storages []StorageInfo `json:"storages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageInfo 存储信息
|
||||||
|
type StorageInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookPayload Webhook 回调负载
|
||||||
|
type WebhookPayload struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status TaskStatus `json:"status"`
|
||||||
|
Storage string `json:"storage"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse 错误响应
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError API 错误
|
||||||
|
type APIError struct {
|
||||||
|
StatusCode int
|
||||||
|
ErrorCode string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteJSON 写入 JSON 响应
|
||||||
|
func WriteJSON(w http.ResponseWriter, statusCode int, data any) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
return json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError 写入错误响应
|
||||||
|
func WriteError(w http.ResponseWriter, statusCode int, errCode, message string) error {
|
||||||
|
return WriteJSON(w, statusCode, ErrorResponse{
|
||||||
|
Error: errCode,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 参数结构体
|
||||||
|
|
||||||
|
// DirectLinksParams directlinks 任务参数
|
||||||
|
type DirectLinksParams struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YTDLPParams ytdlp 任务参数
|
||||||
|
type YTDLPParams struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Flags []string `json:"flags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aria2Params aria2 任务参数
|
||||||
|
type Aria2Params struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
Options map[string]string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedParams parsed 任务参数
|
||||||
|
type ParsedParams struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferParams transfer 任务参数
|
||||||
|
type TransferParams struct {
|
||||||
|
SourceStorage string `json:"source_storage"`
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
TargetStorage string `json:"target_storage"`
|
||||||
|
TargetPath string `json:"target_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TGFilesParams tgfiles 任务参数
|
||||||
|
type TGFilesParams struct {
|
||||||
|
MessageLinks []string `json:"message_links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TPHPicsParams tphpics 任务参数
|
||||||
|
type TPHPicsParams struct {
|
||||||
|
TelegraphURL string `json:"telegraph_url"`
|
||||||
|
}
|
||||||
130
api/webhook.go
Normal file
130
api/webhook.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webhookClient Webhook 客户端
|
||||||
|
var webhookClient = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendWebhook 发送 Webhook 回调
|
||||||
|
func SendWebhook(ctx context.Context, payload *WebhookPayload) {
|
||||||
|
if payload == nil || payload.TaskID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取任务信息以获取 webhook URL
|
||||||
|
info, ok := GetTask(payload.TaskID)
|
||||||
|
if !ok || info.Webhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookURL := info.Webhook
|
||||||
|
|
||||||
|
// 异步发送 webhook
|
||||||
|
go func() {
|
||||||
|
logger := log.FromContext(ctx).With("task_id", payload.TaskID)
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to marshal webhook payload: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重试 3 次
|
||||||
|
for i := range 3 {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to create webhook request: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SaveAny-Bot/1.0")
|
||||||
|
|
||||||
|
resp, err := webhookClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Webhook request failed (attempt %d/3): %v", i+1, err)
|
||||||
|
time.Sleep(time.Second * time.Duration(i+1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
logger.Debugf("Webhook sent successfully: %s", webhookURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warnf("Webhook returned non-2xx status (attempt %d/3): %d", i+1, resp.StatusCode)
|
||||||
|
time.Sleep(time.Second * time.Duration(i+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Errorf("Failed to send webhook after 3 attempts")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWebhookPayload 创建 Webhook 负载
|
||||||
|
func CreateWebhookPayload(taskID string, taskType string, status TaskStatus, storage, path string, err error) *WebhookPayload {
|
||||||
|
payload := &WebhookPayload{
|
||||||
|
TaskID: taskID,
|
||||||
|
Type: taskType,
|
||||||
|
Status: status,
|
||||||
|
Storage: storage,
|
||||||
|
Path: path,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == TaskStatusCompleted || status == TaskStatusFailed {
|
||||||
|
now := time.Now()
|
||||||
|
payload.CompletedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
payload.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapTaskWithWebhook 包装任务执行,添加 webhook 回调
|
||||||
|
func WrapTaskWithWebhook(ctx context.Context, taskID string, fn func() error) error {
|
||||||
|
info, ok := GetTask(taskID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("task not found: %s", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fn()
|
||||||
|
|
||||||
|
// 确定任务状态
|
||||||
|
status := TaskStatusCompleted
|
||||||
|
if err != nil {
|
||||||
|
if err == context.Canceled {
|
||||||
|
status = TaskStatusCancelled
|
||||||
|
} else {
|
||||||
|
status = TaskStatusFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新任务状态
|
||||||
|
if err != nil {
|
||||||
|
info.SetError(err.Error())
|
||||||
|
} else {
|
||||||
|
info.UpdateStatus(TaskStatusCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送 webhook
|
||||||
|
if info.Webhook != "" {
|
||||||
|
payload := CreateWebhookPayload(taskID, info.Type, status, info.Storage, info.Path, err)
|
||||||
|
SendWebhook(ctx, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -101,6 +101,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -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},
|
||||||
{"import", i18nk.BotMsgCmdImport, handleImportCmd},
|
{"transfer", i18nk.BotMsgCmdTransfer, handleTransferCmd},
|
||||||
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
|
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
|
||||||
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
|
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
|
||||||
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
|
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
|
||||||
|
|||||||
257
client/bot/handlers/transfer.go
Normal file
257
client/bot/handlers/transfer.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@ 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)
|
||||||
|
|||||||
@@ -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, stor.JoinStoragePath(dirPath), aria2dl.NewProgress(msgID, userID))
|
task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, 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{
|
||||||
|
|||||||
@@ -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, stor.JoinStoragePath(dirPath), directlinks.NewProgress(msgID, userID))
|
task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, 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{
|
||||||
|
|||||||
@@ -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, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
|
task := parsed.NewTask(xid.New().String(), injectCtx, stor, 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{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
startCreateTask:
|
startCreateTask:
|
||||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
storagePath := 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 := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
|
storPath := 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 := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
afstorPath := 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)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func CreateAndAddtelegraphWithEdit(
|
|||||||
tphpage.Path,
|
tphpage.Path,
|
||||||
pics,
|
pics,
|
||||||
stor,
|
stor,
|
||||||
stor.JoinStoragePath(dirPath),
|
dirPath,
|
||||||
tphutil.DefaultClient(),
|
tphutil.DefaultClient(),
|
||||||
tphtask.NewProgress(trackMsgID, userID),
|
tphtask.NewProgress(trackMsgID, userID),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func CreateAndAddYtdlpTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPa
|
|||||||
urls,
|
urls,
|
||||||
flags,
|
flags,
|
||||||
stor,
|
stor,
|
||||||
stor.JoinStoragePath(dirPath),
|
dirPath,
|
||||||
ytdlp.NewProgress(msgID, userID),
|
ytdlp.NewProgress(msgID, userID),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,16 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Resolve the default directory path from user.DefaultDir
|
||||||
|
var defaultDirPath string
|
||||||
|
if user.DefaultDir != 0 {
|
||||||
|
dir, err := database.GetDirByID(ctx, user.DefaultDir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to get default dir for user %d: %v, using root", user.ChatID, err)
|
||||||
|
} else {
|
||||||
|
defaultDirPath = dir.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
switch user.FilenameStrategy {
|
switch user.FilenameStrategy {
|
||||||
case fnamest.Message.String():
|
case fnamest.Message.String():
|
||||||
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
|
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
|
||||||
@@ -286,14 +296,14 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
|
|
||||||
if needAlbumHandling {
|
if needAlbumHandling {
|
||||||
// For media groups with NEW-FOR-ALBUM rule, collect all files of the same group
|
// For media groups with NEW-FOR-ALBUM rule, collect all files of the same group
|
||||||
watchMediaGroupMgr.addFile(event.ChatID, user.ID, file, time.Duration(config.C().Telegram.MediaGroupTimeout)*time.Second, func(files []tfile.TGFileMessage) {
|
watchMediaGroupMgr.addFile(event.ChatID, user.ID, file, time.Duration(max(config.C().Telegram.MediaGroupTimeout, 1))*time.Second, func(files []tfile.TGFileMessage) {
|
||||||
processWatchMediaGroup(ctx, user, stor, "", files)
|
processWatchMediaGroup(ctx, user, stor, defaultDirPath, files)
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process single file or media group without album folder creation
|
// Process single file or media group without album folder creation
|
||||||
var dirPath string
|
dirPath := defaultDirPath
|
||||||
if user.ApplyRule && user.Rules != nil {
|
if user.ApplyRule && user.Rules != nil {
|
||||||
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
|
||||||
if !matched {
|
if !matched {
|
||||||
@@ -309,7 +319,7 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
startCreateTask:
|
startCreateTask:
|
||||||
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
|
storagePath := 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)
|
||||||
@@ -352,6 +362,7 @@ func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.
|
|||||||
type albumFile struct {
|
type albumFile struct {
|
||||||
file tfile.TGFileMessage
|
file tfile.TGFileMessage
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
|
dirPath string
|
||||||
}
|
}
|
||||||
albumFiles := make(map[int64][]albumFile)
|
albumFiles := make(map[int64][]albumFile)
|
||||||
|
|
||||||
@@ -374,9 +385,11 @@ func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ruleDirPath.NeedNewForAlbum() {
|
// Use the effective dirPath: if rule returns NEW-FOR-ALBUM sentinel, fall back to the
|
||||||
logger.Warnf("File %s does not need album folder, skipping", file.Name())
|
// base dirPath passed in (which is defaultDirPath from the caller).
|
||||||
continue
|
effectiveDirPath := string(ruleDirPath)
|
||||||
|
if ruleDirPath.NeedNewForAlbum() {
|
||||||
|
effectiveDirPath = dirPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := albumFiles[groupId]; !ok {
|
if _, ok := albumFiles[groupId]; !ok {
|
||||||
@@ -385,6 +398,7 @@ func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.
|
|||||||
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
|
||||||
file: file,
|
file: file,
|
||||||
storage: fileStor,
|
storage: fileStor,
|
||||||
|
dirPath: effectiveDirPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +417,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 := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
|
afstorPath := path.Join(af.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 {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/krau/SaveAny-Bot/api"
|
||||||
"github.com/krau/SaveAny-Bot/client/bot"
|
"github.com/krau/SaveAny-Bot/client/bot"
|
||||||
userclient "github.com/krau/SaveAny-Bot/client/user"
|
userclient "github.com/krau/SaveAny-Bot/client/user"
|
||||||
"github.com/krau/SaveAny-Bot/common/cache"
|
"github.com/krau/SaveAny-Bot/common/cache"
|
||||||
@@ -76,6 +77,9 @@ func initAll(ctx context.Context, cmd *cobra.Command) (<-chan struct{}, error) {
|
|||||||
logger.Fatal("User login failed", "error", err)
|
logger.Fatal("User login failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := api.Start(ctx); err != nil {
|
||||||
|
logger.Error("Failed to start API server", "error", err)
|
||||||
|
}
|
||||||
return bot.Init(ctx), nil
|
return bot.Init(ctx), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 := stor.JoinStoragePath(path.Join(dirPath, fileName))
|
uploadPath := 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())
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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"
|
||||||
@@ -106,20 +107,6 @@ 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"
|
||||||
@@ -164,20 +151,6 @@ 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"
|
||||||
@@ -190,6 +163,20 @@ 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"
|
||||||
@@ -245,6 +232,22 @@ 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"
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ 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)"
|
||||||
@@ -296,20 +297,28 @@ 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}}"
|
||||||
import:
|
transfer:
|
||||||
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: |
|
||||||
|
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_no_telegram_storage: "No Telegram storage found: {{.Error}}"
|
error_target_not_found: "Target storage '{{.StorageName}}' not found or access denied: {{.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_import: "No files to import in directory"
|
error_no_files_to_transfer: "No files to transfer 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 import queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
|
info_task_added: "Added {{.Count}} files to transfer queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
|
||||||
|
start_stats: "Total files: {{.Count}}\nTotal size: {{.SizeMB}} MB"
|
||||||
|
info_files_select_storage: "Total {{.Count}} files ({{.SizeMB}} MB), please select target storage"
|
||||||
|
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
|
||||||
cancel:
|
cancel:
|
||||||
usage: "Usage: /cancel <task_id>"
|
usage: "Usage: /cancel <task_id>"
|
||||||
error_cancel_failed: "Failed to cancel task: {{.Error}}"
|
error_cancel_failed: "Failed to cancel task: {{.Error}}"
|
||||||
@@ -358,20 +367,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: "
|
||||||
import_start_prefix: "Importing: "
|
transfer_start_prefix: "Transfering: "
|
||||||
import_progress_prefix: "Import progress: "
|
transfer_progress_prefix: "Transfer progress: "
|
||||||
import_uploaded_prefix: "\nUploaded: "
|
transfer_uploaded_prefix: "\nUploaded: "
|
||||||
import_speed_prefix: "\nSpeed: "
|
transfer_speed_prefix: "\nSpeed: "
|
||||||
import_remaining_time_prefix: "\nRemaining time: "
|
transfer_remaining_time_prefix: "\nRemaining time: "
|
||||||
import_processing_prefix: "\nProcessing:\n"
|
transfer_processing_prefix: "\nProcessing:\n"
|
||||||
import_processing_more: "...and {{.Count}} more files\n"
|
transfer_processing_more: "...and {{.Count}} more files\n"
|
||||||
import_failed_prefix: "Import failed\n"
|
transfer_failed_prefix: "Transfer failed\n"
|
||||||
import_success_prefix: "Import completed\n"
|
transfer_success_prefix: "Transfer completed\n"
|
||||||
import_total_files_prefix: "\nTotal files: "
|
transfer_total_files_prefix: "\nTotal files: "
|
||||||
import_total_size_prefix: "\nTotal size: "
|
transfer_total_size_prefix: "\nTotal size: "
|
||||||
import_elapsed_time_prefix: "\nElapsed time: "
|
transfer_elapsed_time_prefix: "\nElapsed time: "
|
||||||
import_avg_speed_prefix: "\nAverage speed: "
|
transfer_avg_speed_prefix: "\nAverage speed: "
|
||||||
import_failed_files_prefix: "\nFailed files: "
|
transfer_failed_files_prefix: "\nFailed files: "
|
||||||
syncpeers:
|
syncpeers:
|
||||||
start: "Starting to sync peers..."
|
start: "Starting to sync peers..."
|
||||||
done: "Peer sync completed, total {{.Count}} chats synced"
|
done: "Peer sync completed, total {{.Count}} chats synced"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ bot:
|
|||||||
aria2dl: "使用 Aria2 下载给定链接的文件"
|
aria2dl: "使用 Aria2 下载给定链接的文件"
|
||||||
ytdlp: "使用 yt-dlp 下载视频/音频"
|
ytdlp: "使用 yt-dlp 下载视频/音频"
|
||||||
import: "从存储端导入文件到 Telegram"
|
import: "从存储端导入文件到 Telegram"
|
||||||
|
transfer: "在存储端之间传输文件"
|
||||||
task: "管理任务队列"
|
task: "管理任务队列"
|
||||||
cancel: "取消任务"
|
cancel: "取消任务"
|
||||||
watch: "监听聊天(UserBot)"
|
watch: "监听聊天(UserBot)"
|
||||||
@@ -297,26 +298,28 @@ 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}}"
|
||||||
import:
|
transfer:
|
||||||
usage: |
|
usage: |
|
||||||
用法: /import <storage_name> <dir_path> [target_chat_id] [filter]
|
用法: /transfer <source_storage>:/<source_path> [filter]
|
||||||
示例:
|
示例:
|
||||||
/import 本机1 /downloads
|
/transfer local1:/downloads
|
||||||
/import MyAlist /media/photos -1001234567890
|
/transfer alist1:/media/photos
|
||||||
/import MyLocal /backup ".*\.mp4$"
|
/transfer webdav1:/files ".*\.mp4$"
|
||||||
|
error_invalid_source: "源路径格式无效,应为: storage_name:/path"
|
||||||
|
error_invalid_target: "目标路径格式无效,应为: storage_name:/path"
|
||||||
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
|
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
|
||||||
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
|
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
|
||||||
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
|
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
|
||||||
error_no_telegram_storage: "未找到可用的 Telegram 存储: {{.Error}}"
|
error_target_not_found: "目标存储端 '{{.StorageName}}' 不存在或您无权访问: {{.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_import: "目录中没有可导入的文件"
|
error_no_files_to_transfer: "目录中没有可传输的文件"
|
||||||
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}}"
|
||||||
@@ -365,20 +368,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当前速度: "
|
||||||
import_start_prefix: "正在导入: "
|
transfer_start_prefix: "正在转存: "
|
||||||
import_progress_prefix: "导入进度: "
|
transfer_progress_prefix: "转存进度: "
|
||||||
import_uploaded_prefix: "\n已上传: "
|
transfer_uploaded_prefix: "\n已上传: "
|
||||||
import_speed_prefix: "\n速度: "
|
transfer_speed_prefix: "\n速度: "
|
||||||
import_remaining_time_prefix: "\n剩余时间: "
|
transfer_remaining_time_prefix: "\n剩余时间: "
|
||||||
import_processing_prefix: "\n正在处理:\n"
|
transfer_processing_prefix: "\n正在处理:\n"
|
||||||
import_processing_more: "...和其他 {{.Count}} 个文件\n"
|
transfer_processing_more: "...和其他 {{.Count}} 个文件\n"
|
||||||
import_failed_prefix: "导入失败\n"
|
transfer_failed_prefix: "转存失败\n"
|
||||||
import_success_prefix: "导入完成\n"
|
transfer_success_prefix: "转存完成\n"
|
||||||
import_total_files_prefix: "\n总文件数: "
|
transfer_total_files_prefix: "\n总文件数: "
|
||||||
import_total_size_prefix: "\n总大小: "
|
transfer_total_size_prefix: "\n总大小: "
|
||||||
import_elapsed_time_prefix: "\n耗时: "
|
transfer_elapsed_time_prefix: "\n耗时: "
|
||||||
import_avg_speed_prefix: "\n平均速度: "
|
transfer_avg_speed_prefix: "\n平均速度: "
|
||||||
import_failed_files_prefix: "\n失败文件数: "
|
transfer_failed_files_prefix: "\n失败文件数: "
|
||||||
syncpeers:
|
syncpeers:
|
||||||
start: "正在同步对话列表..."
|
start: "正在同步对话列表..."
|
||||||
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
|
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func FormatSize(bytes int64) string {
|
|||||||
MB = KB * 1024
|
MB = KB * 1024
|
||||||
GB = MB * 1024
|
GB = MB * 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case bytes >= GB:
|
case bytes >= GB:
|
||||||
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ secret = ""
|
|||||||
# 转存完成后删除 Aria2 下载的本地文件
|
# 转存完成后删除 Aria2 下载的本地文件
|
||||||
remove_after_transfer = true
|
remove_after_transfer = true
|
||||||
|
|
||||||
|
# HTTP API 配置
|
||||||
|
[api]
|
||||||
|
# 启用 HTTP API
|
||||||
|
enable = false
|
||||||
|
# 监听地址
|
||||||
|
host = "0.0.0.0"
|
||||||
|
# 监听端口
|
||||||
|
port = 8080
|
||||||
|
# 认证 Token (必需)
|
||||||
|
token = ""
|
||||||
|
|
||||||
# 存储列表
|
# 存储列表
|
||||||
[[storages]]
|
[[storages]]
|
||||||
# 标识名, 需要唯一
|
# 标识名, 需要唯一
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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) {
|
||||||
|
|||||||
33
config/storage/rclone.go
Normal file
33
config/storage/rclone.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type Config struct {
|
|||||||
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
|
||||||
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
|
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
|
||||||
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
|
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
|
||||||
|
API apiConfig `toml:"api" mapstructure:"api" json:"api"`
|
||||||
|
|
||||||
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
|
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
|
||||||
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
|
||||||
@@ -42,6 +43,13 @@ type aria2Config struct {
|
|||||||
KeepFile bool `toml:"keep_file" mapstructure:"keep_file" json:"keep_file"`
|
KeepFile bool `toml:"keep_file" mapstructure:"keep_file" json:"keep_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiConfig struct {
|
||||||
|
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
|
||||||
|
Host string `toml:"host" mapstructure:"host" json:"host"`
|
||||||
|
Port int `toml:"port" mapstructure:"port" json:"port"`
|
||||||
|
Token string `toml:"token" mapstructure:"token" json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
var cfg = &Config{}
|
var cfg = &Config{}
|
||||||
|
|
||||||
func C() Config {
|
func C() Config {
|
||||||
@@ -115,18 +123,18 @@ func Init(ctx context.Context, configFile ...string) error {
|
|||||||
// 数据库
|
// 数据库
|
||||||
"db.path": "data/saveany.db",
|
"db.path": "data/saveany.db",
|
||||||
"db.session": "data/session.db",
|
"db.session": "data/session.db",
|
||||||
|
|
||||||
|
// API
|
||||||
|
"api.enable": false,
|
||||||
|
"api.host": "0.0.0.0",
|
||||||
|
"api.port": 8080,
|
||||||
|
"api.token": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range defaultConfigs {
|
for key, value := range defaultConfigs {
|
||||||
viper.SetDefault(key, value)
|
viper.SetDefault(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
|
|
||||||
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
|
|
||||||
return fmt.Errorf("error saving default config: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
fmt.Println("Error reading config file, ", err)
|
fmt.Println("Error reading config file, ", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -45,9 +45,17 @@ 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)
|
||||||
file.Name = filename
|
if 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
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,41 @@ 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)
|
||||||
|
|||||||
73
core/tasks/directlinks/util_test.go
Normal file
73
core/tasks/directlinks/util_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package batchimport
|
package transfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/charmbracelet/log"
|
"github.com/charmbracelet/log"
|
||||||
@@ -16,8 +17,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("batch_import[%s]", t.ID))
|
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("transfer[%s]", t.ID))
|
||||||
logger.Info("Starting batch import task")
|
logger.Info("Starting transfer task")
|
||||||
t.Progress.OnStart(ctx, t)
|
t.Progress.OnStart(ctx, t)
|
||||||
|
|
||||||
workers := config.C().Workers
|
workers := config.C().Workers
|
||||||
@@ -59,9 +60,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 batch import processing: %v", err)
|
logger.Errorf("Error during transfer processing: %v", err)
|
||||||
} else {
|
} else {
|
||||||
logger.Info("Batch import task completed successfully")
|
logger.Info("Transfer task completed successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Progress.OnDone(ctx, t, err)
|
t.Progress.OnDone(ctx, t, err)
|
||||||
@@ -84,15 +85,15 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
|||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
// Build Telegram storage path: /<chat_id>/<filename>
|
// Build target storage path: /target_path/filename
|
||||||
storagePath := fmt.Sprintf("/%d/%s", elem.TargetChatID, elem.FileInfo.Name)
|
storagePath := path.Join(elem.TargetPath, elem.FileInfo.Name)
|
||||||
|
|
||||||
// 注入文件大小到 context
|
// Inject file size into 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 telegram: %w", err)
|
return fmt.Errorf("failed to upload file to storage: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Info("Downloading to temporary file for ReadSeeker support")
|
logger.Info("Downloading to temporary file for ReadSeeker support")
|
||||||
@@ -107,9 +108,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 Telegram storage (size: %d bytes)", size)
|
logger.Infof("Uploading file to 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 telegram: %w", err)
|
return fmt.Errorf("failed to upload file to storage: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchimport
|
package transfer
|
||||||
|
|
||||||
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("Batch import task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
|
log.FromContext(ctx).Debugf("Transfer task progress tracking started for message %d in chat %d", p.MessageID, p.ChatID)
|
||||||
|
|
||||||
sizeMB := float64(info.TotalSize()) / (1024 * 1024)
|
sizeMB := float64(info.TotalSize()) / (1024 * 1024)
|
||||||
statsText := i18n.T(i18nk.BotMsgImportStartStats, map[string]any{
|
statsText := i18n.T(i18nk.BotMsgTransferStartStats, 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.BotMsgProgressImportStartPrefix, nil)),
|
styling.Plain(i18n.T(i18nk.BotMsgProgressTransferStartPrefix, 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,7 +75,10 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
|
|||||||
|
|
||||||
ext := tgutil.ExtFromContext(ctx)
|
ext := tgutil.ExtFromContext(ctx)
|
||||||
if ext != nil {
|
if ext != nil {
|
||||||
ext.EditMessage(p.ChatID, req)
|
_, err := ext.EditMessage(p.ChatID, req)
|
||||||
|
if err != nil {
|
||||||
|
log.FromContext(ctx).Errorf("Failed to send progress start message: %s", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,32 +97,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.BotMsgProgressImportProgressPrefix, nil))
|
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProgressPrefix, nil))
|
||||||
progressText.WriteString(fmt.Sprintf("%d%%", percent))
|
fmt.Fprintf(&progressText, "%d%%", percent)
|
||||||
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportUploadedPrefix, nil))
|
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, nil))
|
||||||
progressText.WriteString(fmt.Sprintf("%.2f MB / %.2f MB",
|
fmt.Fprintf(&progressText, "%.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.BotMsgProgressImportSpeedPrefix, nil))
|
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSpeedPrefix, 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.BotMsgProgressImportRemainingTimePrefix, nil))
|
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferRemainingTimePrefix, 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.BotMsgProgressImportProcessingPrefix, nil))
|
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingPrefix, nil))
|
||||||
for i, elem := range processing {
|
for i, elem := range processing {
|
||||||
if i >= 3 {
|
if i >= 3 {
|
||||||
progressText.WriteString(i18n.T(i18nk.BotMsgProgressImportProcessingMore, map[string]any{"Count": len(processing) - 3}))
|
progressText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(processing) - 3}))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&progressText, "- %s\n", elem.FileName())
|
fmt.Fprintf(&progressText, "- %s\n", elem.FileName())
|
||||||
@@ -156,42 +159,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("Batch import task progress tracking done for message %d in chat %d", p.MessageID, p.ChatID)
|
log.FromContext(ctx).Debugf("Transfer 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.BotMsgProgressImportFailedPrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedPrefix, 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.BotMsgProgressImportSuccessPrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferSuccessPrefix, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(p.start)
|
elapsed := time.Since(p.start)
|
||||||
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportTotalFilesPrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferTotalFilesPrefix, nil))
|
||||||
fmt.Fprintf(&resultText, "%d\n", info.Count())
|
fmt.Fprintf(&resultText, "%d\n", info.Count())
|
||||||
resultText.WriteString(i18n.T(i18nk.BotMsgProgressImportTotalSizePrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferTotalSizePrefix, 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.BotMsgProgressImportUploadedPrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferUploadedPrefix, 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.BotMsgProgressImportElapsedTimePrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferElapsedTimePrefix, 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.BotMsgProgressImportAvgSpeedPrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferAvgSpeedPrefix, 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.BotMsgProgressImportFailedFilesPrefix, nil))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferFailedFilesPrefix, 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.BotMsgProgressImportProcessingMore, map[string]any{"Count": len(failedFiles) - 5}))
|
resultText.WriteString(i18n.T(i18nk.BotMsgProgressTransferProcessingMore, map[string]any{"Count": len(failedFiles) - 5}))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&resultText, "- %s\n", name)
|
fmt.Fprintf(&resultText, "- %s\n", name)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchimport
|
package transfer
|
||||||
|
|
||||||
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
|
||||||
TargetChatID int64
|
TargetPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
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.TaskTypeBatchimport
|
return tasktype.TaskTypeTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
targetChatID int64,
|
targetPath string,
|
||||||
) *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,
|
||||||
TargetChatID: targetChatID,
|
TargetPath: targetPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBatchImportTask(
|
func NewTransferTask(
|
||||||
id string,
|
id string,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
elems []TaskElement,
|
elems []TaskElement,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package batchimport
|
package transfer
|
||||||
|
|
||||||
type TaskElementInfo interface {
|
type TaskElementInfo interface {
|
||||||
FileName() string
|
FileName() string
|
||||||
@@ -20,6 +20,9 @@ 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
|
||||||
|
|||||||
@@ -92,6 +92,27 @@ 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]]`.
|
||||||
@@ -105,6 +126,7 @@ Each storage endpoint requires at least the following fields:
|
|||||||
- `alist`: Alist
|
- `alist`: Alist
|
||||||
- `webdav`: WebDAV
|
- `webdav`: WebDAV
|
||||||
- `s3`: aws S3 and other S3 compatible services
|
- `s3`: aws S3 and other S3 compatible services
|
||||||
|
- `rclone`: Uses rclone to implement uploads
|
||||||
- `telegram`: Upload to Telegram
|
- `telegram`: Upload to Telegram
|
||||||
|
|
||||||
Example, this is a configuration that includes local storage and webdav storage:
|
Example, this is a configuration that includes local storage and webdav storage:
|
||||||
|
|||||||
@@ -80,4 +80,60 @@ chat_id = "123456789" # Telegram chat ID, the bot will send files to this chat
|
|||||||
force_file = false # Force sending as file, default is false
|
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"]
|
||||||
```
|
```
|
||||||
@@ -13,112 +13,4 @@ To use the bot's Telegram file saving feature, you need to send or forward the f
|
|||||||
|
|
||||||
1. File or media messages, such as images, videos, documents, etc.
|
1. File or media messages, such as images, videos, documents, etc.
|
||||||
2. Telegram message links, for example: `https://t.me/acherkrau/1097`. **Even if the channel prohibits forwarding and saving, the bot can still download its files.**
|
2. Telegram message links, for example: `https://t.me/acherkrau/1097`. **Even if the channel prohibits forwarding and saving, the bot can still download its files.**
|
||||||
3. Telegra.ph article links. The bot will download all images in the article.
|
3. Telegra.ph article links. The bot will download all images in the article.
|
||||||
|
|
||||||
## Silent Mode (silent)
|
|
||||||
|
|
||||||
Use the `/silent` command to toggle silent mode.
|
|
||||||
|
|
||||||
By default, silent mode is off, and the bot will ask you for the save location of each file.
|
|
||||||
|
|
||||||
When silent mode is enabled, the bot will save files directly to the default location without confirmation.
|
|
||||||
|
|
||||||
Before enabling silent mode, you need to set the default save location using the `/storage` command.
|
|
||||||
|
|
||||||
|
|
||||||
## Storage Rules
|
|
||||||
|
|
||||||
Storage rules allow you to define redirection rules when the bot uploads files to storage, so that saved files are automatically organized.
|
|
||||||
|
|
||||||
See: <a href="https://github.com/krau/SaveAny-Bot/issues/28" target="_blank">#28</a>
|
|
||||||
|
|
||||||
Currently supported rule types:
|
|
||||||
|
|
||||||
1. FILENAME-REGEX
|
|
||||||
2. MESSAGE-REGEX
|
|
||||||
3. IS-ALBUM
|
|
||||||
|
|
||||||
Basic syntax for adding rules:
|
|
||||||
|
|
||||||
"RuleType RuleContent StorageName Path"
|
|
||||||
|
|
||||||
Pay attention to spaces; the bot can only parse correctly formatted syntax. Below is an example of a valid rule command:
|
|
||||||
|
|
||||||
```
|
|
||||||
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /videos
|
|
||||||
```
|
|
||||||
|
|
||||||
In addition, if `CHOSEN` is used as the storage name in the rule, it means files will be stored under the path of the storage you selected by clicking the inline button.
|
|
||||||
|
|
||||||
Rule types:
|
|
||||||
|
|
||||||
### FILENAME-REGEX
|
|
||||||
|
|
||||||
Matches based on filename regex. The rule content must be a valid regular expression, such as:
|
|
||||||
|
|
||||||
```
|
|
||||||
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /videos
|
|
||||||
```
|
|
||||||
|
|
||||||
This means files with extensions mp4, mkv, ts, avi, flv will be saved to the `/videos` directory in the storage named `MyAlist` (also affected by the `base_path` in the configuration file).
|
|
||||||
|
|
||||||
### MESSAGE-REGEX
|
|
||||||
|
|
||||||
Similar to the above, but matches based on the text content of the message itself.
|
|
||||||
|
|
||||||
### IS-ALBUM
|
|
||||||
|
|
||||||
Matches album messages (media groups). Rule content can only be `true` or `false`.
|
|
||||||
|
|
||||||
If the path in the rule uses `NEW-FOR-ALBUM`, the bot will create a new folder for each media group and store all files of that group there. See: https://github.com/krau/SaveAny-Bot/issues/87
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
|
||||||
```
|
|
||||||
|
|
||||||
This will save media-group messages to the storage named `MyWebdav`, creating a new folder (generated from the first file) for each album.
|
|
||||||
|
|
||||||
## Watch Chats
|
|
||||||
|
|
||||||
{{< hint warning >}}
|
|
||||||
This feature requires enabling UserBot integration.
|
|
||||||
{{< /hint >}}
|
|
||||||
|
|
||||||
You can watch messages in a specific chat and automatically save them to the default storage, following storage rules. You can also add filters so that only matching messages are saved.
|
|
||||||
|
|
||||||
Watch a chat:
|
|
||||||
|
|
||||||
```
|
|
||||||
/watch <chat_id/username> [filter]
|
|
||||||
```
|
|
||||||
|
|
||||||
Stop watching:
|
|
||||||
|
|
||||||
```
|
|
||||||
/unwatch <chat_id/username>
|
|
||||||
```
|
|
||||||
|
|
||||||
Filter types:
|
|
||||||
|
|
||||||
### msgre
|
|
||||||
|
|
||||||
Regex-match the message text. For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
/watch 12345678 msgre:.*hello.*
|
|
||||||
```
|
|
||||||
|
|
||||||
This will watch the chat with ID `12345678`, and only save messages whose text contains `hello`.
|
|
||||||
|
|
||||||
## Save Files Outside Telegram
|
|
||||||
|
|
||||||
Besides files on Telegram, the bot can also save files from other websites via JavaScript plugins or built-in parsers.
|
|
||||||
|
|
||||||
> See the [Contributing Parsers](../contribute) document for details.
|
|
||||||
|
|
||||||
Just send links that match the requirements of a parser to the bot. Currently built-in parsers include:
|
|
||||||
|
|
||||||
- Twitter
|
|
||||||
- Kemono
|
|
||||||
442
docs/content/en/usage/api.md
Normal file
442
docs/content/en/usage/api.md
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
---
|
||||||
|
title: "HTTP API"
|
||||||
|
weight: 20
|
||||||
|
---
|
||||||
|
|
||||||
|
# HTTP API
|
||||||
|
|
||||||
|
SaveAny-Bot provides an HTTP API that allows you to programmatically create download/transfer tasks, query task status, cancel tasks, and more — without going through Telegram.
|
||||||
|
|
||||||
|
## Enabling the API
|
||||||
|
|
||||||
|
Add or modify the following section in `config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[api]
|
||||||
|
enable = true
|
||||||
|
host = "0.0.0.0" # Bind address, default 0.0.0.0
|
||||||
|
port = 8080 # Listen port, default 8080
|
||||||
|
token = "your-token" # Auth token — strongly recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also override these settings with environment variables (prefix `SAVEANY_`):
|
||||||
|
|
||||||
|
| Environment Variable | Config Key |
|
||||||
|
|---|---|
|
||||||
|
| `SAVEANY_API_ENABLE` | `api.enable` |
|
||||||
|
| `SAVEANY_API_HOST` | `api.host` |
|
||||||
|
| `SAVEANY_API_PORT` | `api.port` |
|
||||||
|
| `SAVEANY_API_TOKEN` | `api.token` |
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
If `token` is empty, the API server will be accessible **without any authentication**, which is a security risk.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
When `token` is configured, all API requests must include a Bearer token in the HTTP header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
On authentication failure, the server returns `401`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "unauthorized", "message": "invalid token" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Response Format
|
||||||
|
|
||||||
|
All errors use a consistent JSON format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "error_code",
|
||||||
|
"message": "human readable description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common error codes:
|
||||||
|
|
||||||
|
| Error Code | HTTP Status | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `unauthorized` | 401 | Authentication failed |
|
||||||
|
| `method_not_allowed` | 405 | Wrong HTTP method |
|
||||||
|
| `invalid_request` | 400 | Malformed request body or parameters |
|
||||||
|
| `task_creation_failed` | 400 | Failed to create task |
|
||||||
|
| `task_not_found` | 404 | Task ID does not exist |
|
||||||
|
| `cancel_failed` | 500 | Failed to cancel task |
|
||||||
|
| `internal_error` | 500 | Internal server error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /health — Health Check
|
||||||
|
|
||||||
|
No authentication required.
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/storages — List Storages
|
||||||
|
|
||||||
|
Returns all currently loaded storage backends.
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"storages": [
|
||||||
|
{ "name": "local", "type": "local" },
|
||||||
|
{ "name": "MyMinio", "type": "s3" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/task-types — List Supported Task Types
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"types": [
|
||||||
|
"directlinks",
|
||||||
|
"ytdlp",
|
||||||
|
"aria2",
|
||||||
|
"parseditem",
|
||||||
|
"tgfiles",
|
||||||
|
"tphpics",
|
||||||
|
"transfer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/v1/tasks — Create Task
|
||||||
|
|
||||||
|
**Request headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "<task_type>",
|
||||||
|
"storage": "<storage_name>",
|
||||||
|
"path": "<subpath>",
|
||||||
|
"webhook": "<callback_url>",
|
||||||
|
"params": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | string | Yes | Task type — see below |
|
||||||
|
| `storage` | string | Yes | Target storage name, must match a name in your config |
|
||||||
|
| `path` | string | No | Subdirectory path within the storage |
|
||||||
|
| `webhook` | string | No | Callback URL invoked when the task reaches a terminal state |
|
||||||
|
| `params` | object | Yes | Type-specific parameters — see below |
|
||||||
|
|
||||||
|
**Response `201 Created`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "abc123xyz",
|
||||||
|
"type": "directlinks",
|
||||||
|
"status": "queued",
|
||||||
|
"created_at": "2026-03-11T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task Types and params
|
||||||
|
|
||||||
|
##### directlinks — Direct URL Download
|
||||||
|
|
||||||
|
Download one or more files from direct HTTP/HTTPS URLs.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "directlinks",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"params": {
|
||||||
|
"urls": [
|
||||||
|
"https://example.com/file.zip",
|
||||||
|
"https://example.com/other.zip"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urls` | []string | Yes | List of download URLs, at least 1 |
|
||||||
|
|
||||||
|
##### ytdlp — yt-dlp Media Download
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
Requires yt-dlp to be installed on the system.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
Download videos or audio via yt-dlp, supporting YouTube, Bilibili, and 1000+ other sites.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ytdlp",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "videos",
|
||||||
|
"params": {
|
||||||
|
"urls": ["https://www.youtube.com/watch?v=xxx"],
|
||||||
|
"flags": ["--extract-audio", "--audio-format", "mp3"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urls` | []string | Yes | List of media URLs, at least 1 |
|
||||||
|
| `flags` | []string | No | Extra yt-dlp command-line flags |
|
||||||
|
|
||||||
|
##### aria2 — Aria2 Download
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
Requires Aria2 to be enabled and configured (RPC) in the config file.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
Download files via the Aria2 download manager, supporting HTTP/HTTPS, FTP, BitTorrent (magnet links, torrent files), and more.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "aria2",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"params": {
|
||||||
|
"urls": ["magnet:?xt=urn:btih:..."],
|
||||||
|
"options": { "split": "4" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urls` | []string | Yes | List of download URIs, at least 1 |
|
||||||
|
| `options` | map[string]string | No | Aria2 download options |
|
||||||
|
|
||||||
|
##### parseditem — Parser Plugin Download
|
||||||
|
|
||||||
|
Hand a URL off to a registered JS plugin or built-in parser for processing and downloading.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "parseditem",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "parsed",
|
||||||
|
"params": {
|
||||||
|
"url": "https://some-site.com/page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `url` | string | Yes | The URL to parse |
|
||||||
|
|
||||||
|
Returns `400 task_creation_failed` if no parser is able to handle the URL.
|
||||||
|
|
||||||
|
##### tgfiles — Telegram Message File Download
|
||||||
|
|
||||||
|
Download files from Telegram messages via message links. Supported link formats:
|
||||||
|
|
||||||
|
- `https://t.me/username/123` — public channel or group
|
||||||
|
- `https://t.me/c/123456789/123` — private channel by numeric ID
|
||||||
|
- `https://t.me/c/123456789/111/456` — topic message (thread ID / message ID)
|
||||||
|
- `https://t.me/username/111/456` — topic under a username-based chat
|
||||||
|
|
||||||
|
If the message is part of a media group (album), all files in the group are downloaded by default. Append `?single` to the link to force downloading only the single specified message.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tgfiles",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "telegram",
|
||||||
|
"params": {
|
||||||
|
"message_links": [
|
||||||
|
"https://t.me/username/123",
|
||||||
|
"https://t.me/c/1234567890/456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `message_links` | []string | Yes | List of Telegram message links, at least 1 |
|
||||||
|
|
||||||
|
##### tphpics — Telegraph Article Images
|
||||||
|
|
||||||
|
Download all images from a Telegra.ph article.
|
||||||
|
|
||||||
|
Supported URL prefixes: `https://telegra.ph/`, `http://telegra.ph/`, `https://telegraph.co/`, `http://telegraph.co/`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tphpics",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "telegraph",
|
||||||
|
"params": {
|
||||||
|
"telegraph_url": "https://telegra.ph/Some-Article-01-01"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `telegraph_url` | string | Yes | URL of the Telegra.ph article |
|
||||||
|
|
||||||
|
##### transfer — Storage-to-Storage Transfer
|
||||||
|
|
||||||
|
Transfer files directly between two storage backends without going through Telegram. The source storage must support both listing and reading.
|
||||||
|
|
||||||
|
{{< hint info >}}
|
||||||
|
For `transfer` tasks, the top-level `storage` field is still required for validation, but the actual storages used are determined by `source_storage` and `target_storage` inside `params`.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "transfer",
|
||||||
|
"storage": "local",
|
||||||
|
"params": {
|
||||||
|
"source_storage": "MyS3",
|
||||||
|
"source_path": "backups/",
|
||||||
|
"target_storage": "LocalDisk",
|
||||||
|
"target_path": "restored/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `source_storage` | string | Yes | Source storage name |
|
||||||
|
| `source_path` | string | Yes | Path within the source storage; must contain at least one file |
|
||||||
|
| `target_storage` | string | Yes | Target storage name |
|
||||||
|
| `target_path` | string | Yes | Destination path within the target storage |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/tasks — List All Tasks
|
||||||
|
|
||||||
|
Returns all tasks created via the API. Task records are stored in memory only and are cleared on restart.
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"task_id": "abc123xyz",
|
||||||
|
"type": "directlinks",
|
||||||
|
"status": "running",
|
||||||
|
"title": "file.zip",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"error": "",
|
||||||
|
"created_at": "2026-03-11T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-11T10:00:05Z",
|
||||||
|
"progress": {
|
||||||
|
"total_bytes": 10485760,
|
||||||
|
"downloaded_bytes": 5242880,
|
||||||
|
"percent": 50.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `progress` field is only included when `total_bytes > 0`. The `error` field is only included when non-empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/tasks/{task_id} — Get Task
|
||||||
|
|
||||||
|
**Path parameter:** `task_id` — the ID returned when the task was created.
|
||||||
|
|
||||||
|
**Response `200 OK`:** Same structure as a single task object from the list above.
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
- `400 invalid_request` — no task ID in path
|
||||||
|
- `404 task_not_found` — task does not exist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/v1/tasks/{task_id} — Cancel Task
|
||||||
|
|
||||||
|
**Path parameter:** `task_id`
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "task cancelled successfully" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
- `400 invalid_request` — no task ID in path
|
||||||
|
- `404 task_not_found` — task does not exist
|
||||||
|
- `500 cancel_failed` — cancellation failed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Statuses
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `queued` | Task is queued and waiting to run |
|
||||||
|
| `running` | Task is currently executing |
|
||||||
|
| `completed` | Task finished successfully |
|
||||||
|
| `failed` | Task encountered an error |
|
||||||
|
| `cancelled` | Task was cancelled via the DELETE endpoint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Callbacks
|
||||||
|
|
||||||
|
When a `webhook` URL is provided in the create request, SaveAny-Bot sends a `POST` request to that URL when the task reaches a terminal state (`completed`, `failed`, or `cancelled`).
|
||||||
|
|
||||||
|
**Callback request headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
User-Agent: SaveAny-Bot/1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "abc123xyz",
|
||||||
|
"type": "directlinks",
|
||||||
|
"status": "completed",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"completed_at": "2026-03-11T10:01:00Z",
|
||||||
|
"error": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`completed_at` is only present when status is `completed` or `failed`. `error` is only present when non-empty.
|
||||||
|
|
||||||
|
**Retry policy:** Up to 3 attempts, with delays of 1s, 2s, and 3s between retries. Each request has a 30-second timeout.
|
||||||
41
docs/content/en/usage/aria2.md
Normal file
41
docs/content/en/usage/aria2.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: "Aria2 Download"
|
||||||
|
weight: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
21
docs/content/en/usage/directlinks.md
Normal file
21
docs/content/en/usage/directlinks.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
title: "Direct Download Links"
|
||||||
|
weight: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
15
docs/content/en/usage/parsers.md
Normal file
15
docs/content/en/usage/parsers.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: "Save Files Outside Telegram"
|
||||||
|
weight: 9
|
||||||
|
---
|
||||||
|
|
||||||
|
# Save Files Outside Telegram
|
||||||
|
|
||||||
|
Besides files on Telegram, the bot can also save files from other websites via JavaScript plugins or built-in parsers.
|
||||||
|
|
||||||
|
> See the [Contributing Parsers](../contribute) document for details.
|
||||||
|
|
||||||
|
Just send links that match the requirements of a parser to the bot. Currently built-in parsers include:
|
||||||
|
|
||||||
|
- Twitter
|
||||||
|
- Kemono
|
||||||
58
docs/content/en/usage/rules.md
Normal file
58
docs/content/en/usage/rules.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
title: "Storage Rules"
|
||||||
|
weight: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Storage Rules
|
||||||
|
|
||||||
|
Storage rules allow you to define redirection rules when the bot uploads files to storage, so that saved files are automatically organized.
|
||||||
|
|
||||||
|
See: <a href="https://github.com/krau/SaveAny-Bot/issues/28" target="_blank">#28</a>
|
||||||
|
|
||||||
|
Currently supported rule types:
|
||||||
|
|
||||||
|
1. FILENAME-REGEX
|
||||||
|
2. MESSAGE-REGEX
|
||||||
|
3. IS-ALBUM
|
||||||
|
|
||||||
|
Basic syntax for adding rules:
|
||||||
|
|
||||||
|
"RuleType RuleContent StorageName Path"
|
||||||
|
|
||||||
|
Pay attention to spaces; the bot can only parse correctly formatted syntax. Below is an example of a valid rule command:
|
||||||
|
|
||||||
|
```
|
||||||
|
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /videos
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition, if `CHOSEN` is used as the storage name in the rule, it means files will be stored under the path of the storage you selected by clicking the inline button.
|
||||||
|
|
||||||
|
Rule types:
|
||||||
|
|
||||||
|
## FILENAME-REGEX
|
||||||
|
|
||||||
|
Matches based on filename regex. The rule content must be a valid regular expression, such as:
|
||||||
|
|
||||||
|
```
|
||||||
|
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /videos
|
||||||
|
```
|
||||||
|
|
||||||
|
This means files with extensions mp4, mkv, ts, avi, flv will be saved to the `/videos` directory in the storage named `MyAlist` (also affected by the `base_path` in the configuration file).
|
||||||
|
|
||||||
|
## MESSAGE-REGEX
|
||||||
|
|
||||||
|
Similar to the above, but matches based on the text content of the message itself.
|
||||||
|
|
||||||
|
## IS-ALBUM
|
||||||
|
|
||||||
|
Matches album messages (media groups). Rule content can only be `true` or `false`.
|
||||||
|
|
||||||
|
If the path in the rule uses `NEW-FOR-ALBUM`, the bot will create a new folder for each media group and store all files of that group there. See: https://github.com/krau/SaveAny-Bot/issues/87
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
||||||
|
```
|
||||||
|
|
||||||
|
This will save media-group messages to the storage named `MyWebdav`, creating a new folder (generated from the first file) for each album.
|
||||||
14
docs/content/en/usage/silent.md
Normal file
14
docs/content/en/usage/silent.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Silent Mode"
|
||||||
|
weight: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Silent Mode (silent)
|
||||||
|
|
||||||
|
Use the `/silent` command to toggle silent mode.
|
||||||
|
|
||||||
|
By default, silent mode is off, and the bot will ask you for the save location of each file.
|
||||||
|
|
||||||
|
When silent mode is enabled, the bot will save files directly to the default location without confirmation.
|
||||||
|
|
||||||
|
Before enabling silent mode, you need to set the default save location using the `/storage` command.
|
||||||
50
docs/content/en/usage/transfer.md
Normal file
50
docs/content/en/usage/transfer.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: "Storage Transfer"
|
||||||
|
weight: 8
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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
|
||||||
36
docs/content/en/usage/watch.md
Normal file
36
docs/content/en/usage/watch.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: "Watch Chats"
|
||||||
|
weight: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Watch Chats
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
This feature requires enabling UserBot integration.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
You can watch messages in a specific chat and automatically save them to the default storage, following storage rules. You can also add filters so that only matching messages are saved.
|
||||||
|
|
||||||
|
Watch a chat:
|
||||||
|
|
||||||
|
```
|
||||||
|
/watch <chat_id/username> [filter]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop watching:
|
||||||
|
|
||||||
|
```
|
||||||
|
/unwatch <chat_id/username>
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter types:
|
||||||
|
|
||||||
|
## msgre
|
||||||
|
|
||||||
|
Regex-match the message text. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
/watch 12345678 msgre:.*hello.*
|
||||||
|
```
|
||||||
|
|
||||||
|
This will watch the chat with ID `12345678`, and only save messages whose text contains `hello`.
|
||||||
40
docs/content/en/usage/ytdlp.md
Normal file
40
docs/content/en/usage/ytdlp.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: "yt-dlp Video Download"
|
||||||
|
weight: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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).
|
||||||
@@ -20,12 +20,16 @@ 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)
|
||||||
|
|||||||
@@ -90,6 +90,27 @@ 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]]` 定义.
|
||||||
@@ -103,6 +124,7 @@ session = "data/usersession.db"
|
|||||||
- `alist`: Alist
|
- `alist`: Alist
|
||||||
- `webdav`: WebDAV
|
- `webdav`: WebDAV
|
||||||
- `s3`: aws S3 及其他兼容 S3 的服务
|
- `s3`: aws S3 及其他兼容 S3 的服务
|
||||||
|
- `rclone`: 调用 rclone 实现上传
|
||||||
- `telegram`: 上传到 Telegram
|
- `telegram`: 上传到 Telegram
|
||||||
|
|
||||||
示例, 这是一个包含本地存储和 webdav 存储的配置:
|
示例, 这是一个包含本地存储和 webdav 存储的配置:
|
||||||
|
|||||||
@@ -86,4 +86,60 @@ skip_large = false
|
|||||||
# 超过该大小的文件将被分割成多个部分上传.(使用 zip 格式)
|
# 超过该大小的文件将被分割成多个部分上传.(使用 zip 格式)
|
||||||
# 当 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"]
|
||||||
```
|
```
|
||||||
@@ -13,112 +13,4 @@ weight: 10
|
|||||||
|
|
||||||
1. 文件或媒体消息, 如图片, 视频, 文档等
|
1. 文件或媒体消息, 如图片, 视频, 文档等
|
||||||
2. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
|
2. Telegram 消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.**
|
||||||
3. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
|
3. Telegra.ph 的文章链接, Bot 将下载其中的所有图片
|
||||||
|
|
||||||
## 静默模式 (silent)
|
|
||||||
|
|
||||||
使用 `/silent` 命令可以开关静默模式.
|
|
||||||
|
|
||||||
默认情况下不开启静默模式, Bot 会询问你每个文件的保存位置.
|
|
||||||
|
|
||||||
开启静默模式后, Bot 会直接保存文件到默认位置, 无需确认.
|
|
||||||
|
|
||||||
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
|
|
||||||
|
|
||||||
## 存储规则
|
|
||||||
|
|
||||||
允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件.
|
|
||||||
|
|
||||||
见: <a href="https://github.com/krau/SaveAny-Bot/issues/28" target="_blank">#28</a>
|
|
||||||
|
|
||||||
目前支持的规则类型:
|
|
||||||
|
|
||||||
1. FILENAME-REGEX
|
|
||||||
2. MESSAGE-REGEX
|
|
||||||
3. IS-ALBUM
|
|
||||||
|
|
||||||
添加规则的基本语法:
|
|
||||||
|
|
||||||
"规则类型 规则内容 存储名 路径"
|
|
||||||
|
|
||||||
注意空格的使用, 语法正确 bot 才能解析, 以下是一条合法的添加规则命令:
|
|
||||||
|
|
||||||
```
|
|
||||||
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
|
|
||||||
```
|
|
||||||
|
|
||||||
此外, 规则中的存储名若使用 "CHOSEN" , 则表示存储到点击按钮选择的存储端的路径下
|
|
||||||
|
|
||||||
规则类型:
|
|
||||||
|
|
||||||
### FILENAME-REGEX
|
|
||||||
|
|
||||||
根据文件名正则匹配, 规则内容要求为一个合法的正则表达式, 如
|
|
||||||
|
|
||||||
```
|
|
||||||
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
|
|
||||||
```
|
|
||||||
|
|
||||||
表示将文件名后缀为 mp4,mkv,ts,avi,flv 的文件放到名为 MyAlist 存储下的 /视频 目录内 (同时受配置文件中的 `base_path` 影响)
|
|
||||||
|
|
||||||
### MESSAGE-REGEX
|
|
||||||
|
|
||||||
同上, 但是是根据消息本身的文本内容正则匹配
|
|
||||||
|
|
||||||
### IS-ALBUM
|
|
||||||
|
|
||||||
匹配相册消息 (media group), 规则内容只能为 `true` 或 `false`.
|
|
||||||
|
|
||||||
规则中的路径若使用 "NEW-FOR-ALBUM" , 则表示为该组消息新建一个文件夹来存储它们. 见: https://github.com/krau/SaveAny-Bot/issues/87
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
```
|
|
||||||
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
|
||||||
```
|
|
||||||
|
|
||||||
这将会把以 media group 形式发送的消息保存到名为 MyWebdav 的存储下, 并为每个相册新建一个文件夹(由第一个文件生成)来存储它们.
|
|
||||||
|
|
||||||
|
|
||||||
## 监听聊天
|
|
||||||
|
|
||||||
{{< hint warning >}}
|
|
||||||
该功能需开启 UserBot 集成.
|
|
||||||
{{< /hint >}}
|
|
||||||
|
|
||||||
监听指定聊天的消息, 并自动保存到默认存储中, 遵从存储规则, 并且可以设置过滤器来只保存匹配的消息.
|
|
||||||
|
|
||||||
监听聊天:
|
|
||||||
|
|
||||||
```
|
|
||||||
/watch <chat_id/username> [filter]
|
|
||||||
```
|
|
||||||
|
|
||||||
取消监听:
|
|
||||||
|
|
||||||
```
|
|
||||||
/unwatch <chat_id/username>
|
|
||||||
```
|
|
||||||
|
|
||||||
过滤器类型:
|
|
||||||
|
|
||||||
### msgre
|
|
||||||
|
|
||||||
正则匹配消息文本, 例如:
|
|
||||||
|
|
||||||
```
|
|
||||||
/watch 12345678 msgre:.*hello.*
|
|
||||||
```
|
|
||||||
|
|
||||||
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
|
||||||
|
|
||||||
## 转存 Telegram 之外的文件
|
|
||||||
|
|
||||||
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.
|
|
||||||
|
|
||||||
> 查看[贡献解析器](../contribute)文档了解详情
|
|
||||||
|
|
||||||
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
|
||||||
|
|
||||||
- Twitter
|
|
||||||
- Kemono
|
|
||||||
442
docs/content/zh/usage/api.md
Normal file
442
docs/content/zh/usage/api.md
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
---
|
||||||
|
title: "HTTP API"
|
||||||
|
weight: 20
|
||||||
|
---
|
||||||
|
|
||||||
|
# HTTP API
|
||||||
|
|
||||||
|
SaveAny-Bot 提供了一套 HTTP API,允许你通过程序化方式创建下载/转存任务、查询任务状态、取消任务等,无需通过 Telegram 操作。
|
||||||
|
|
||||||
|
## 启用 API
|
||||||
|
|
||||||
|
在 `config.toml` 中添加或修改以下配置:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[api]
|
||||||
|
enable = true
|
||||||
|
host = "0.0.0.0" # 监听地址,默认 0.0.0.0
|
||||||
|
port = 8080 # 监听端口,默认 8080
|
||||||
|
token = "your-token" # 鉴权 Token,强烈建议设置
|
||||||
|
```
|
||||||
|
|
||||||
|
也可通过环境变量覆盖(前缀 `SAVEANY_`):
|
||||||
|
|
||||||
|
| 环境变量 | 对应配置项 |
|
||||||
|
|---|---|
|
||||||
|
| `SAVEANY_API_ENABLE` | `api.enable` |
|
||||||
|
| `SAVEANY_API_HOST` | `api.host` |
|
||||||
|
| `SAVEANY_API_PORT` | `api.port` |
|
||||||
|
| `SAVEANY_API_TOKEN` | `api.token` |
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
若 `token` 为空,API 服务将**不进行任何鉴权**即可访问,存在安全风险。
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
## 鉴权
|
||||||
|
|
||||||
|
当配置了 `token` 时,所有 API 请求均需在 HTTP 请求头中携带 Bearer Token:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
鉴权失败时返回 `401`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "unauthorized", "message": "invalid token" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误响应格式
|
||||||
|
|
||||||
|
所有错误均使用统一的 JSON 格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "error_code",
|
||||||
|
"message": "错误说明"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
常见错误码:
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `unauthorized` | 401 | 鉴权失败 |
|
||||||
|
| `method_not_allowed` | 405 | HTTP 方法不正确 |
|
||||||
|
| `invalid_request` | 400 | 请求体/参数非法 |
|
||||||
|
| `task_creation_failed` | 400 | 任务创建失败 |
|
||||||
|
| `task_not_found` | 404 | 任务 ID 不存在 |
|
||||||
|
| `cancel_failed` | 500 | 取消任务失败 |
|
||||||
|
| `internal_error` | 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
### GET /health — 健康检查
|
||||||
|
|
||||||
|
无需鉴权。
|
||||||
|
|
||||||
|
**响应 `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/storages — 列出存储
|
||||||
|
|
||||||
|
返回当前所有已加载的存储后端。
|
||||||
|
|
||||||
|
**响应 `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"storages": [
|
||||||
|
{ "name": "local", "type": "local" },
|
||||||
|
{ "name": "MyMinio", "type": "s3" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/task-types — 列出支持的任务类型
|
||||||
|
|
||||||
|
**响应 `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"types": [
|
||||||
|
"directlinks",
|
||||||
|
"ytdlp",
|
||||||
|
"aria2",
|
||||||
|
"parseditem",
|
||||||
|
"tgfiles",
|
||||||
|
"tphpics",
|
||||||
|
"transfer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/v1/tasks — 创建任务
|
||||||
|
|
||||||
|
**请求头:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "<任务类型>",
|
||||||
|
"storage": "<存储名>",
|
||||||
|
"path": "<子目录>",
|
||||||
|
"webhook": "<回调URL>",
|
||||||
|
"params": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | string | 是 | 任务类型,见下文 |
|
||||||
|
| `storage` | string | 是 | 目标存储名,须与配置中的存储名一致 |
|
||||||
|
| `path` | string | 否 | 存储内的子目录路径 |
|
||||||
|
| `webhook` | string | 否 | 任务完成/失败时的回调地址 |
|
||||||
|
| `params` | object | 是 | 各任务类型的专属参数,见下文 |
|
||||||
|
|
||||||
|
**响应 `201 Created`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "abc123xyz",
|
||||||
|
"type": "directlinks",
|
||||||
|
"status": "queued",
|
||||||
|
"created_at": "2026-03-11T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 任务类型与 params
|
||||||
|
|
||||||
|
##### directlinks — 直接下载链接
|
||||||
|
|
||||||
|
下载一个或多个 HTTP/HTTPS 直链文件。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "directlinks",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"params": {
|
||||||
|
"urls": [
|
||||||
|
"https://example.com/file.zip",
|
||||||
|
"https://example.com/other.zip"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urls` | []string | 是 | 下载地址列表,至少 1 条 |
|
||||||
|
|
||||||
|
##### ytdlp — yt-dlp 视频下载
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
需要在系统中安装 yt-dlp。
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
通过 yt-dlp 下载视频/音频,支持 YouTube、Bilibili 等 1000+ 网站。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ytdlp",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "videos",
|
||||||
|
"params": {
|
||||||
|
"urls": ["https://www.youtube.com/watch?v=xxx"],
|
||||||
|
"flags": ["--extract-audio", "--audio-format", "mp3"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urls` | []string | 是 | 媒体链接列表,至少 1 条 |
|
||||||
|
| `flags` | []string | 否 | 额外的 yt-dlp 命令行参数 |
|
||||||
|
|
||||||
|
##### aria2 — Aria2 下载
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
需要在配置文件中启用并配置 Aria2 RPC。
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
通过 Aria2 下载管理器下载文件,支持 HTTP/HTTPS、FTP、BitTorrent(磁力链接、种子)等协议。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "aria2",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"params": {
|
||||||
|
"urls": ["magnet:?xt=urn:btih:..."],
|
||||||
|
"options": { "split": "4" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `urls` | []string | 是 | 下载地址列表,至少 1 条 |
|
||||||
|
| `options` | map[string]string | 否 | Aria2 下载选项 |
|
||||||
|
|
||||||
|
##### parseditem — 解析器下载
|
||||||
|
|
||||||
|
将 URL 交由已注册的 JS 插件或内置解析器处理后下载。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "parseditem",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "parsed",
|
||||||
|
"params": {
|
||||||
|
"url": "https://some-site.com/page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `url` | string | 是 | 待解析的页面 URL |
|
||||||
|
|
||||||
|
若没有任何解析器能处理该 URL,则返回 `400 task_creation_failed`。
|
||||||
|
|
||||||
|
##### tgfiles — Telegram 消息文件下载
|
||||||
|
|
||||||
|
通过 Telegram 消息链接下载文件。支持以下链接格式:
|
||||||
|
|
||||||
|
- `https://t.me/username/123` — 公开频道/群组
|
||||||
|
- `https://t.me/c/123456789/123` — 私有频道(数字 ID)
|
||||||
|
- `https://t.me/c/123456789/111/456` — 话题消息
|
||||||
|
- `https://t.me/username/111/456` — 用户名频道下的话题消息
|
||||||
|
|
||||||
|
若消息属于媒体组(相册),默认下载整组文件。在链接末尾追加 `?single` 可强制只下载单条消息的文件。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tgfiles",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "telegram",
|
||||||
|
"params": {
|
||||||
|
"message_links": [
|
||||||
|
"https://t.me/username/123",
|
||||||
|
"https://t.me/c/1234567890/456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `message_links` | []string | 是 | Telegram 消息链接列表,至少 1 条 |
|
||||||
|
|
||||||
|
##### tphpics — Telegraph 文章图片下载
|
||||||
|
|
||||||
|
下载 Telegra.ph 文章中的所有图片。
|
||||||
|
|
||||||
|
支持的链接前缀:`https://telegra.ph/`、`http://telegra.ph/`、`https://telegraph.co/`、`http://telegraph.co/`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tphpics",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "telegraph",
|
||||||
|
"params": {
|
||||||
|
"telegraph_url": "https://telegra.ph/Some-Article-01-01"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `telegraph_url` | string | 是 | Telegra.ph 文章 URL |
|
||||||
|
|
||||||
|
##### transfer — 存储间文件传输
|
||||||
|
|
||||||
|
在两个存储后端之间直接传输文件,无需经过 Telegram。源存储须支持列举(list)和读取(read)操作。
|
||||||
|
|
||||||
|
{{< hint info >}}
|
||||||
|
`transfer` 任务中,顶层的 `storage` 字段仍然必须填写(用于通过参数校验),但实际使用的存储由 `params` 中的 `source_storage` 和 `target_storage` 决定。
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "transfer",
|
||||||
|
"storage": "local",
|
||||||
|
"params": {
|
||||||
|
"source_storage": "MyS3",
|
||||||
|
"source_path": "backups/",
|
||||||
|
"target_storage": "LocalDisk",
|
||||||
|
"target_path": "restored/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| params 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `source_storage` | string | 是 | 源存储名 |
|
||||||
|
| `source_path` | string | 是 | 源存储中的路径,须包含至少一个文件 |
|
||||||
|
| `target_storage` | string | 是 | 目标存储名 |
|
||||||
|
| `target_path` | string | 是 | 目标存储中的路径 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/tasks — 列出所有任务
|
||||||
|
|
||||||
|
返回所有 API 创建的任务(仅在内存中保留,重启后清空)。
|
||||||
|
|
||||||
|
**响应 `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"task_id": "abc123xyz",
|
||||||
|
"type": "directlinks",
|
||||||
|
"status": "running",
|
||||||
|
"title": "file.zip",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"error": "",
|
||||||
|
"created_at": "2026-03-11T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-11T10:00:05Z",
|
||||||
|
"progress": {
|
||||||
|
"total_bytes": 10485760,
|
||||||
|
"downloaded_bytes": 5242880,
|
||||||
|
"percent": 50.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`progress` 字段仅在 `total_bytes > 0` 时出现。`error` 字段仅在有错误时出现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/v1/tasks/{task_id} — 查询任务
|
||||||
|
|
||||||
|
**路径参数:** `task_id` — 创建任务时返回的 ID。
|
||||||
|
|
||||||
|
**响应 `200 OK`:** 同上列表中的单个任务对象。
|
||||||
|
|
||||||
|
**错误响应:**
|
||||||
|
- `400 invalid_request` — 路径中未提供 task_id
|
||||||
|
- `404 task_not_found` — 任务不存在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/v1/tasks/{task_id} — 取消任务
|
||||||
|
|
||||||
|
**路径参数:** `task_id`
|
||||||
|
|
||||||
|
**响应 `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "task cancelled successfully" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误响应:**
|
||||||
|
- `400 invalid_request` — 路径中未提供 task_id
|
||||||
|
- `404 task_not_found` — 任务不存在
|
||||||
|
- `500 cancel_failed` — 取消操作失败
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务状态
|
||||||
|
|
||||||
|
| 状态值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `queued` | 已入队,等待执行 |
|
||||||
|
| `running` | 正在执行 |
|
||||||
|
| `completed` | 已成功完成 |
|
||||||
|
| `failed` | 执行失败 |
|
||||||
|
| `cancelled` | 已通过 DELETE 接口取消 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook 回调
|
||||||
|
|
||||||
|
创建任务时可设置 `webhook` 字段。当任务进入终态(`completed`、`failed`、`cancelled`)时,Bot 会向该地址发送一个 `POST` 请求。
|
||||||
|
|
||||||
|
**回调请求头:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
User-Agent: SaveAny-Bot/1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**回调请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "abc123xyz",
|
||||||
|
"type": "directlinks",
|
||||||
|
"status": "completed",
|
||||||
|
"storage": "local",
|
||||||
|
"path": "downloads",
|
||||||
|
"completed_at": "2026-03-11T10:01:00Z",
|
||||||
|
"error": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`completed_at` 仅在状态为 `completed` 或 `failed` 时出现。`error` 仅在有错误时出现。
|
||||||
|
|
||||||
|
**重试机制:** 最多重试 3 次,重试间隔依次为 1 秒、2 秒、3 秒。每次请求超时为 30 秒。
|
||||||
41
docs/content/zh/usage/aria2.md
Normal file
41
docs/content/zh/usage/aria2.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: "Aria2 下载"
|
||||||
|
weight: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 # 转存完成后删除本地文件
|
||||||
|
```
|
||||||
21
docs/content/zh/usage/directlinks.md
Normal file
21
docs/content/zh/usage/directlinks.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
title: "直接下载链接"
|
||||||
|
weight: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# 直接下载链接
|
||||||
|
|
||||||
|
使用 `/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 会验证链接格式, 然后让你选择目标存储位置.
|
||||||
15
docs/content/zh/usage/parsers.md
Normal file
15
docs/content/zh/usage/parsers.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: "转存 Telegram 之外的文件"
|
||||||
|
weight: 9
|
||||||
|
---
|
||||||
|
|
||||||
|
# 转存 Telegram 之外的文件
|
||||||
|
|
||||||
|
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.
|
||||||
|
|
||||||
|
> 查看[贡献解析器](../contribute)文档了解详情
|
||||||
|
|
||||||
|
只需向 Bot 发送符合解析器要求的链接即可使用, 当前内置的解析器:
|
||||||
|
|
||||||
|
- Twitter
|
||||||
|
- Kemono
|
||||||
58
docs/content/zh/usage/rules.md
Normal file
58
docs/content/zh/usage/rules.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
title: "存储规则"
|
||||||
|
weight: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# 存储规则
|
||||||
|
|
||||||
|
允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件.
|
||||||
|
|
||||||
|
见: <a href="https://github.com/krau/SaveAny-Bot/issues/28" target="_blank">#28</a>
|
||||||
|
|
||||||
|
目前支持的规则类型:
|
||||||
|
|
||||||
|
1. FILENAME-REGEX
|
||||||
|
2. MESSAGE-REGEX
|
||||||
|
3. IS-ALBUM
|
||||||
|
|
||||||
|
添加规则的基本语法:
|
||||||
|
|
||||||
|
"规则类型 规则内容 存储名 路径"
|
||||||
|
|
||||||
|
注意空格的使用, 语法正确 bot 才能解析, 以下是一条合法的添加规则命令:
|
||||||
|
|
||||||
|
```
|
||||||
|
/rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
|
||||||
|
```
|
||||||
|
|
||||||
|
此外, 规则中的存储名若使用 "CHOSEN" , 则表示存储到点击按钮选择的存储端的路径下
|
||||||
|
|
||||||
|
规则类型:
|
||||||
|
|
||||||
|
## FILENAME-REGEX
|
||||||
|
|
||||||
|
根据文件名正则匹配, 规则内容要求为一个合法的正则表达式, 如
|
||||||
|
|
||||||
|
```
|
||||||
|
FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频
|
||||||
|
```
|
||||||
|
|
||||||
|
表示将文件名后缀为 mp4,mkv,ts,avi,flv 的文件放到名为 MyAlist 存储下的 /视频 目录内 (同时受配置文件中的 `base_path` 影响)
|
||||||
|
|
||||||
|
## MESSAGE-REGEX
|
||||||
|
|
||||||
|
同上, 但是是根据消息本身的文本内容正则匹配
|
||||||
|
|
||||||
|
## IS-ALBUM
|
||||||
|
|
||||||
|
匹配相册消息 (media group), 规则内容只能为 `true` 或 `false`.
|
||||||
|
|
||||||
|
规则中的路径若使用 "NEW-FOR-ALBUM" , 则表示为该组消息新建一个文件夹来存储它们. 见: https://github.com/krau/SaveAny-Bot/issues/87
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
IS-ALBUM true MyWebdav NEW-FOR-ALBUM
|
||||||
|
```
|
||||||
|
|
||||||
|
这将会把以 media group 形式发送的消息保存到名为 MyWebdav 的存储下, 并为每个相册新建一个文件夹(由第一个文件生成)来存储它们.
|
||||||
14
docs/content/zh/usage/silent.md
Normal file
14
docs/content/zh/usage/silent.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "静默模式"
|
||||||
|
weight: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# 静默模式 (silent)
|
||||||
|
|
||||||
|
使用 `/silent` 命令可以开关静默模式.
|
||||||
|
|
||||||
|
默认情况下不开启静默模式, Bot 会询问你每个文件的保存位置.
|
||||||
|
|
||||||
|
开启静默模式后, Bot 会直接保存文件到默认位置, 无需确认.
|
||||||
|
|
||||||
|
在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置.
|
||||||
50
docs/content/zh/usage/transfer.md
Normal file
50
docs/content/zh/usage/transfer.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: "存储间传输"
|
||||||
|
weight: 8
|
||||||
|
---
|
||||||
|
|
||||||
|
# 存储间传输
|
||||||
|
|
||||||
|
使用 `/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. 开始传输任务
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 源存储必须支持列举和读取功能
|
||||||
|
- 目标存储必须支持写入功能
|
||||||
|
- 传输过程显示实时进度
|
||||||
|
- 支持取消正在进行的传输任务
|
||||||
36
docs/content/zh/usage/watch.md
Normal file
36
docs/content/zh/usage/watch.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: "监听聊天"
|
||||||
|
weight: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# 监听聊天
|
||||||
|
|
||||||
|
{{< hint warning >}}
|
||||||
|
该功能需开启 UserBot 集成.
|
||||||
|
{{< /hint >}}
|
||||||
|
|
||||||
|
监听指定聊天的消息, 并自动保存到默认存储中, 遵从存储规则, 并且可以设置过滤器来只保存匹配的消息.
|
||||||
|
|
||||||
|
监听聊天:
|
||||||
|
|
||||||
|
```
|
||||||
|
/watch <chat_id/username> [filter]
|
||||||
|
```
|
||||||
|
|
||||||
|
取消监听:
|
||||||
|
|
||||||
|
```
|
||||||
|
/unwatch <chat_id/username>
|
||||||
|
```
|
||||||
|
|
||||||
|
过滤器类型:
|
||||||
|
|
||||||
|
## msgre
|
||||||
|
|
||||||
|
正则匹配消息文本, 例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
/watch 12345678 msgre:.*hello.*
|
||||||
|
```
|
||||||
|
|
||||||
|
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
|
||||||
40
docs/content/zh/usage/ytdlp.md
Normal file
40
docs/content/zh/usage/ytdlp.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: "yt-dlp 视频下载"
|
||||||
|
weight: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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).
|
||||||
@@ -9,10 +9,5 @@ if [ -n "$CONFIG_URL" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f /app/config.toml ]; then
|
|
||||||
echo "[ERROR] Missing config.toml: Please provide the configuration file via mounting or CONFIG_URL"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec /app/saveany-bot
|
exec /app/saveany-bot
|
||||||
69
go.mod
69
go.mod
@@ -1,50 +1,49 @@
|
|||||||
module github.com/krau/SaveAny-Bot
|
module github.com/krau/SaveAny-Bot
|
||||||
|
|
||||||
go 1.24.2
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/celestix/gotgproto v1.0.0-beta22
|
github.com/celestix/gotgproto v1.0.0-beta22
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/charmbracelet/log v0.4.2
|
github.com/charmbracelet/log v0.4.2
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12
|
github.com/gabriel-vasile/mimetype v1.4.13
|
||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/gotd/contrib v0.21.1
|
github.com/gotd/contrib v0.21.1
|
||||||
github.com/gotd/td v0.137.0
|
github.com/gotd/td v0.142.0
|
||||||
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3
|
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3
|
||||||
github.com/krau/ffmpeg-go v0.6.0
|
github.com/krau/ffmpeg-go v0.6.0
|
||||||
github.com/lrstanley/go-ytdlp v1.2.7
|
github.com/lrstanley/go-ytdlp v1.3.2
|
||||||
github.com/minio/minio-go/v7 v7.0.98
|
github.com/minio/minio-go/v7 v7.0.98
|
||||||
github.com/playwright-community/playwright-go v0.5200.1
|
github.com/playwright-community/playwright-go v0.5700.1
|
||||||
github.com/rs/xid v1.6.0
|
github.com/rs/xid v1.6.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/unvgo/ghselfupdate v1.0.1
|
github.com/unvgo/ghselfupdate v1.0.1
|
||||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
|
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/term v0.39.0
|
golang.org/x/term v0.41.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
|
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
||||||
github.com/aws/smithy-go v1.24.0 // indirect
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.4 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.7.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
@@ -64,7 +63,7 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.2.0 // indirect
|
github.com/google/go-querystring v1.2.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gotd/ige v0.2.2 // indirect
|
github.com/gotd/ige v0.2.2 // indirect
|
||||||
github.com/gotd/neo v0.1.5 // indirect
|
github.com/gotd/neo v0.1.5 // indirect
|
||||||
@@ -77,7 +76,7 @@ require (
|
|||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
@@ -85,7 +84,7 @@ require (
|
|||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/ncruces/julianday v1.0.0 // indirect
|
github.com/ncruces/julianday v1.0.0 // indirect
|
||||||
github.com/ogen-go/ogen v1.18.0 // indirect
|
github.com/ogen-go/ogen v1.20.1 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -96,34 +95,34 @@ require (
|
|||||||
github.com/tetratelabs/wazero v1.11.0 // indirect
|
github.com/tetratelabs/wazero v1.11.0 // indirect
|
||||||
github.com/tinylib/msgp v1.6.3 // indirect
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.1 // indirect
|
go.uber.org/zap v1.27.1 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.69.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.44.1 // indirect
|
modernc.org/sqlite v1.46.1 // indirect
|
||||||
rsc.io/qr v0.2.0 // indirect
|
rsc.io/qr v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
github.com/dgraph-io/ristretto/v2 v2.4.0
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
|
||||||
github.com/duke-git/lancet/v2 v2.3.8
|
github.com/duke-git/lancet/v2 v2.3.8
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.3 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/ncruces/go-sqlite3 v0.30.4
|
github.com/ncruces/go-sqlite3 v0.30.5
|
||||||
github.com/ncruces/go-sqlite3/gormlite v0.30.2
|
github.com/ncruces/go-sqlite3/gormlite v0.30.2
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
@@ -134,9 +133,9 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.33.0
|
golang.org/x/text v0.35.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|||||||
162
go.sum
162
go.sum
@@ -4,8 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
|||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||||
@@ -44,32 +44,30 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
|
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
|
||||||
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
|
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||||
github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
|
||||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
@@ -78,14 +76,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
|
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
|
||||||
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
|
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
|
||||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c h1:hIlkLbQ+tYoUqlG42LnxwGcohL5jaGqD8mGeJWavm8A=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/duke-git/lancet/v2 v2.3.8 h1:dlkqn6Nj2LRWFuObNxttkMHxrFeaV6T26JR8jbEVbPg=
|
github.com/duke-git/lancet/v2 v2.3.8 h1:dlkqn6Nj2LRWFuObNxttkMHxrFeaV6T26JR8jbEVbPg=
|
||||||
github.com/duke-git/lancet/v2 v2.3.8/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
github.com/duke-git/lancet/v2 v2.3.8/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -98,8 +96,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
@@ -143,8 +141,8 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
|
|||||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M=
|
github.com/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M=
|
||||||
@@ -153,8 +151,10 @@ github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
|
|||||||
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
|
||||||
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
|
||||||
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
|
||||||
github.com/gotd/td v0.137.0 h1:Mhf9oiRxio40vFcbkft1Cs6jrwV8MMbtGRtW9LAPOhY=
|
github.com/gotd/td v0.140.0 h1:trNBzTnhNtNwHsFp5qwKnNxQRAZJ6/BRE+uH3Lojauk=
|
||||||
github.com/gotd/td v0.137.0/go.mod h1:t0MC7iCm4MkzkGjcZ5NAraStsdBLF3yJlSXhXB8JqdI=
|
github.com/gotd/td v0.140.0/go.mod h1:0ZkRxG7N+5ooG7/zdRXcnGautGPM6IKmyPQvdsAeF20=
|
||||||
|
github.com/gotd/td v0.142.0 h1:hsH8zM7Pv98CkSMvrAEzVJurhntUziqKgf4VEofv5Zg=
|
||||||
|
github.com/gotd/td v0.142.0/go.mod h1:UHO5Gpwce9mH4zplp2qWo6AdzDjFVg7gK+ANMCztsi8=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
||||||
@@ -167,8 +167,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3 h1:2713fQZ560HxoNVgfJH41GKzjMjIG+DW4hH6nYXfXW8=
|
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3 h1:2713fQZ560HxoNVgfJH41GKzjMjIG+DW4hH6nYXfXW8=
|
||||||
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3/go.mod h1:S4S9jGBVlLri0OeqrSSbCGG5vsI6he06UJyuz1WT1EE=
|
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3/go.mod h1:S4S9jGBVlLri0OeqrSSbCGG5vsI6he06UJyuz1WT1EE=
|
||||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
@@ -180,8 +180,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/krau/ffmpeg-go v0.6.0 h1:F4HWvOrKXQsfLsFTOnUfP0HY6WISJqOrsAFGSIzkKto=
|
github.com/krau/ffmpeg-go v0.6.0 h1:F4HWvOrKXQsfLsFTOnUfP0HY6WISJqOrsAFGSIzkKto=
|
||||||
github.com/krau/ffmpeg-go v0.6.0/go.mod h1:sa7/bWHB6fO9j4lhmxnWQ1U07o+dE1leFjhctotxU7A=
|
github.com/krau/ffmpeg-go v0.6.0/go.mod h1:sa7/bWHB6fO9j4lhmxnWQ1U07o+dE1leFjhctotxU7A=
|
||||||
github.com/lrstanley/go-ytdlp v1.2.7 h1:YNDvKkd0OCJSZLZePZvJwcirBCfL8Yw3eCwrTCE5w7Q=
|
github.com/lrstanley/go-ytdlp v1.3.2 h1:ktOav5X8+ZByuaQPFUF3uiPxofw0L5MoQtck6iIkWhI=
|
||||||
github.com/lrstanley/go-ytdlp v1.2.7/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI=
|
github.com/lrstanley/go-ytdlp v1.3.2/go.mod h1:VgjnTrvkTf+23JuySjyPq1iQ8ijSovBtTPpXH5XrLtI=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
@@ -190,16 +190,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
|
||||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
@@ -208,8 +206,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA=
|
github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
|
||||||
github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68=
|
github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
|
||||||
github.com/ncruces/go-sqlite3/gormlite v0.30.2 h1:FZ8mic14xTatssTkHCrelh9nPeFdXuzgMoNGkfuFbBU=
|
github.com/ncruces/go-sqlite3/gormlite v0.30.2 h1:FZ8mic14xTatssTkHCrelh9nPeFdXuzgMoNGkfuFbBU=
|
||||||
github.com/ncruces/go-sqlite3/gormlite v0.30.2/go.mod h1:W9WLBbqrrOIh2dqFZkeC/xKALG2LDIHY91jowahOdtI=
|
github.com/ncruces/go-sqlite3/gormlite v0.30.2/go.mod h1:W9WLBbqrrOIh2dqFZkeC/xKALG2LDIHY91jowahOdtI=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
@@ -218,16 +216,16 @@ github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt
|
|||||||
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/ogen-go/ogen v1.18.0 h1:6RQ7lFBjOeNaUWu4getfqIh4GJbEY4hqKuzDtec/g60=
|
github.com/ogen-go/ogen v1.20.1 h1:AFpIeI2rS37TNIMRQTHhAkThICQpa1p+Pceu7HP7xsA=
|
||||||
github.com/ogen-go/ogen v1.18.0/go.mod h1:dHFr2Wf6cA7tSxMI+zPC21UR5hAlDw8ZYUkK3PziURY=
|
github.com/ogen-go/ogen v1.20.1/go.mod h1:eXQeqzIfw9qUjXdpqNtkX+XCvhlWNymqU1bm7S7y8iU=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/playwright-community/playwright-go v0.5200.1 h1:Sm2oOuhqt0M5Y4kUi/Qh9w4cyyi3ZIWTBeGKImc2UVo=
|
github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U=
|
||||||
github.com/playwright-community/playwright-go v0.5200.1/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=
|
github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -282,12 +280,12 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
|||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
|
||||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
@@ -303,22 +301,28 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -326,6 +330,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -336,31 +342,39 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -378,18 +392,18 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
|||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
|
||||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -398,8 +412,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.44.1 h1:qybx/rNpfQipX/t47OxbHmkkJuv2JWifCMH8SVUiDas=
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
modernc.org/sqlite v1.44.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ package storage
|
|||||||
|
|
||||||
// StorageType
|
// StorageType
|
||||||
/* ENUM(
|
/* ENUM(
|
||||||
local, webdav, alist, minio, telegram, s3
|
local, webdav, alist, minio, telegram, s3, rclone
|
||||||
) */
|
) */
|
||||||
type StorageType string
|
type StorageType string
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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, ", "))
|
||||||
@@ -35,6 +37,7 @@ 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.
|
||||||
@@ -53,6 +56,7 @@ func StorageTypeValues() []StorageType {
|
|||||||
Minio,
|
Minio,
|
||||||
Telegram,
|
Telegram,
|
||||||
S3,
|
S3,
|
||||||
|
Rclone,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ 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.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tasktype
|
package tasktype
|
||||||
|
|
||||||
|
// ENUM(tgfiles,tphpics,parseditem,directlinks,aria2,ytdlp,transfer)
|
||||||
|
//
|
||||||
//go:generate go-enum --values --names --flag --nocase
|
//go:generate go-enum --values --names --flag --nocase
|
||||||
// ENUM(tgfiles,tphpics,parseditem,directlinks,aria2,ytdlp,batchimport)
|
|
||||||
type TaskType string
|
type TaskType string
|
||||||
|
|||||||
@@ -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"
|
||||||
// TaskTypeBatchimport is a TaskType of type batchimport.
|
// TaskTypeTransfer is a TaskType of type transfer.
|
||||||
TaskTypeBatchimport TaskType = "batchimport"
|
TaskTypeTransfer TaskType = "transfer"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Join(_TaskTypeNames, ", "))
|
var ErrInvalidTaskType = fmt.Errorf("not a valid TaskType, try [%s]", strings.Join(_TaskTypeNames, ", "))
|
||||||
@@ -37,7 +37,7 @@ var _TaskTypeNames = []string{
|
|||||||
string(TaskTypeDirectlinks),
|
string(TaskTypeDirectlinks),
|
||||||
string(TaskTypeAria2),
|
string(TaskTypeAria2),
|
||||||
string(TaskTypeYtdlp),
|
string(TaskTypeYtdlp),
|
||||||
string(TaskTypeBatchimport),
|
string(TaskTypeTransfer),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
TaskTypeBatchimport,
|
TaskTypeTransfer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ var _TaskTypeValue = map[string]TaskType{
|
|||||||
"directlinks": TaskTypeDirectlinks,
|
"directlinks": TaskTypeDirectlinks,
|
||||||
"aria2": TaskTypeAria2,
|
"aria2": TaskTypeAria2,
|
||||||
"ytdlp": TaskTypeYtdlp,
|
"ytdlp": TaskTypeYtdlp,
|
||||||
"batchimport": TaskTypeBatchimport,
|
"transfer": TaskTypeTransfer,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseTaskType attempts to convert a string to a TaskType.
|
// ParseTaskType attempts to convert a string to a TaskType.
|
||||||
|
|||||||
@@ -82,17 +82,13 @@ func TestConcurrencySafety(t *testing.T) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
n := 1000
|
n := 1000
|
||||||
// producers
|
// producers
|
||||||
wg.Add(1)
|
wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := range n {
|
for i := range n {
|
||||||
q.Add(newTask(fmt.Sprintf("p%d", i)))
|
q.Add(newTask(fmt.Sprintf("p%d", i)))
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
// consumers
|
// consumers
|
||||||
wg.Add(1)
|
wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
count := 0
|
count := 0
|
||||||
for count < n {
|
for count < n {
|
||||||
_, err := q.Get()
|
_, err := q.Get()
|
||||||
@@ -101,6 +97,6 @@ func TestConcurrencySafety(t *testing.T) {
|
|||||||
}
|
}
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ 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)
|
||||||
@@ -86,12 +87,12 @@ func (l *Local) Exists(ctx context.Context, storagePath string) bool {
|
|||||||
// ListFiles implements StorageListable interface
|
// ListFiles implements StorageListable interface
|
||||||
func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
|
func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
|
||||||
absPath := l.JoinStoragePath(dirPath)
|
absPath := l.JoinStoragePath(dirPath)
|
||||||
|
|
||||||
entries, err := os.ReadDir(absPath)
|
entries, err := os.ReadDir(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read directory %s: %w", absPath, err)
|
return nil, fmt.Errorf("failed to read directory %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files := make([]storagetypes.FileInfo, 0, len(entries))
|
files := make([]storagetypes.FileInfo, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
info, err := entry.Info()
|
info, err := entry.Info()
|
||||||
@@ -99,7 +100,7 @@ func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.F
|
|||||||
l.logger.Warnf("Failed to get file info for %s: %v", entry.Name(), err)
|
l.logger.Warnf("Failed to get file info for %s: %v", entry.Name(), err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := filepath.Join(dirPath, entry.Name())
|
filePath := filepath.Join(dirPath, entry.Name())
|
||||||
files = append(files, storagetypes.FileInfo{
|
files = append(files, storagetypes.FileInfo{
|
||||||
Name: entry.Name(),
|
Name: entry.Name(),
|
||||||
@@ -109,24 +110,24 @@ func (l *Local) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.F
|
|||||||
ModTime: info.ModTime(),
|
ModTime: info.ModTime(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenFile implements StorageReadable interface
|
// OpenFile implements StorageReadable interface
|
||||||
func (l *Local) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
|
func (l *Local) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
|
||||||
absPath := l.JoinStoragePath(filePath)
|
absPath := l.JoinStoragePath(filePath)
|
||||||
|
|
||||||
file, err := os.Open(absPath)
|
file, err := os.Open(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("failed to open file %s: %w", absPath, err)
|
return nil, 0, fmt.Errorf("failed to open file %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
return nil, 0, fmt.Errorf("failed to stat file %s: %w", absPath, err)
|
return nil, 0, fmt.Errorf("failed to stat file %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return file, stat.Size(), nil
|
return file, stat.Size(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 > 100 {
|
if i > 10 {
|
||||||
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
|
||||||
|
|||||||
14
storage/rclone/errs.go
Normal file
14
storage/rclone/errs.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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")
|
||||||
|
)
|
||||||
289
storage/rclone/rclone.go
Normal file
289
storage/rclone/rclone.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 > 100 {
|
if i > 10 {
|
||||||
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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"
|
||||||
@@ -21,7 +22,6 @@ 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,6 +54,7 @@ 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
|
||||||
|
|||||||
@@ -66,15 +66,12 @@ 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")
|
||||||
|
|||||||
@@ -41,15 +41,15 @@ type Response struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Propstat struct {
|
type Propstat struct {
|
||||||
Prop Prop `xml:"prop"`
|
Prop Prop `xml:"prop"`
|
||||||
Status string `xml:"status"`
|
Status string `xml:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Prop struct {
|
type Prop struct {
|
||||||
ResourceType ResourceType `xml:"resourcetype"`
|
ResourceType ResourceType `xml:"resourcetype"`
|
||||||
GetContentLength int64 `xml:"getcontentlength"`
|
GetContentLength int64 `xml:"getcontentlength"`
|
||||||
GetLastModified string `xml:"getlastmodified"`
|
GetLastModified string `xml:"getlastmodified"`
|
||||||
DisplayName string `xml:"displayname"`
|
DisplayName string `xml:"displayname"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResourceType struct {
|
type ResourceType struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user