Compare commits

...

12 Commits

Author SHA1 Message Date
krau
602fc251d8 fix: upgrade deps and medernize codes 2026-03-05 19:40:25 +08:00
krau
af28738235 test(api): add api tests 2026-03-05 19:30:23 +08:00
krau
3eb3b6e3c8 feat(api): implement task management API with handlers for creating, listing, retrieving, and canceling tasks
- Added Handlers struct and methods for task operations
- Implemented task progress tracking and storage
- Created server setup with middleware for logging and recovery
- Added support for Telegram file extraction and Telegraph image extraction
- Introduced webhook functionality for task status updates
- Defined request and response types for API interactions
2026-03-05 19:11:30 +08:00
krau
f377ee3ca4 chore: format code for consistency and readability 2026-03-05 17:20:45 +08:00
krau
70f7172162 docs: add rclone support to configuration guide 2026-01-31 21:17:30 +08:00
krau
091f581881 Merge branch 'main' of https://github.com/krau/SaveAny-Bot 2026-01-30 13:34:56 +08:00
Krau
8b86330f5c feat: add rclone storage backend (#191)
* fix: update StoragePath method to return specific path for single file

* feat: add Rclone storage support with configuration and file operations

* docs: add Rclone support to documentation for configuration and usage
2026-01-30 13:34:29 +08:00
krau
b431fa08e2 fix: update StoragePath method to return specific path for single file 2026-01-30 13:09:21 +08:00
krau
a02e8a8d90 fix: update storage path handling in Save method 2026-01-30 12:43:47 +08:00
krau
4d2c345003 fix: enhance filename extraction logic for downloads and add unit tests 2026-01-29 17:11:25 +08:00
krau
33a886fac9 docs: update with new features 2026-01-19 21:55:26 +08:00
krau
57539ec3da docs: add file transfer capabilities between different storage backends in README 2026-01-19 21:48:57 +08:00
43 changed files with 3333 additions and 135 deletions

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@ temp/
playwright/ playwright/
testplugins/ testplugins/
*.exe *.exe
tmp-* tmp-*
saveany-bot

View File

@@ -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

View File

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

48
api/auth.go Normal file
View 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
View 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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
View 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
View 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
View 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
View 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
View 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")
// 获取消息和使用的 contextBot 优先,失败回退 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
View 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
View 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
View 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
}

View File

@@ -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
} }

View File

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

View File

@@ -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]]
# 标识名, 需要唯一 # 标识名, 需要唯一

View File

@@ -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
View 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
}

View File

@@ -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,6 +123,12 @@ 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 {

View File

@@ -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

View File

@@ -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
} }

View File

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

View 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)
}
})
}
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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"]
``` ```

View File

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

View File

@@ -20,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)

View File

@@ -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 存储的配置:

View File

@@ -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"]
``` ```

View File

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

67
go.mod
View File

@@ -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.140.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.51.0
golang.org/x/term v0.39.0 golang.org/x/term v0.40.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.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect golang.org/x/tools v0.42.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.19.0
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 golang.org/x/text v0.34.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )

144
go.sum
View File

@@ -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,8 @@ 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/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 +165,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 +178,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 +188,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 +204,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 +214,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 +278,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 +299,22 @@ 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/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 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/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/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=
@@ -336,31 +332,31 @@ 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/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/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/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/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 +374,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 +394,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=

View File

@@ -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

View File

@@ -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.

View File

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

View File

@@ -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()
} }

View File

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

View File

@@ -87,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()
@@ -100,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(),
@@ -110,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
} }

14
storage/rclone/errs.go Normal file
View 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
View 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
}

View File

@@ -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"
@@ -53,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

View File

@@ -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 {