Compare commits

..

16 Commits

Author SHA1 Message Date
krau
2bc460c609 feat: add preset rule import functionality and update related messages 2026-06-26 15:39:14 +08:00
Krau
f02860ff3f feat: implement task event system for progress tracking and reporting (#220) 2026-06-25 21:37:36 +08:00
krau
9c2e70ed43 feat: upgrade deps 2026-06-25 16:30:21 +08:00
krau
3d6cd45909 feat: add configuration options for video download settings 2026-06-25 16:17:03 +08:00
Krau
77ef3154cf feat: add directory watching and auto-upload functionality (#219) 2026-06-25 14:34:59 +08:00
krau
88b170acaa fix: update backoff handling to use a factory function for concurrency safety 2026-06-25 10:22:31 +08:00
Ilham Syahid S
8059e27978 fix(api): sync task lifecycle state and restore GET /api/v1/tasks (#216)
* fix(api): update task route to handle GET and POST methods

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>

* fix(api): implement ExecutableWrapper to manage task execution and status updates

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>

* fix(api): refactor task registration and enqueueing into a separate method

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>

---------

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>
2026-05-24 23:42:16 +08:00
HLD
bfab4c85c8 fix: correct S3 signature and path handling (#213)
* fix: correct S3 signature and path handling

* fix: preserve existing overwrite behavior
2026-05-22 09:22:09 +08:00
Ryan
62e4a08e28 Merge pull request #204 from Rain-kl/feat/save-strategy
新增功能: 重名文件时选择保存策略 - 重命名,覆盖,跳过
2026-05-20 16:11:00 +08:00
Krau
0982abe7bc fix: apply user filename strategy in media group processing (#207)
Co-authored-by: xiangyang <xiangyang2001@outlook.com>
2026-05-05 14:29:01 +08:00
xiangyang
e74839b8e9 fix: apply user filename strategy in media group processing 2026-05-05 13:58:08 +08:00
krau
58ce8275b2 fix: upgrade deps 2026-04-14 09:55:15 +08:00
krau
8f67f778a3 feat(config): add log level configuration and update logging initialization, close #202 2026-04-14 09:41:02 +08:00
krau
159dba6224 fix(config): comment out default config saving logic 2026-03-30 18:33:03 +08:00
krau
22d773da10 fix(entrypoint): remove config.toml check 2026-03-30 18:17:44 +08:00
krau
69ccfa664f chore: bump gotd version 2026-03-30 16:14:13 +08:00
61 changed files with 2088 additions and 423 deletions

View File

@@ -20,6 +20,7 @@ import (
"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/taskevent"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
@@ -66,6 +67,24 @@ func (f *TaskFactory) CreateTask(req *CreateTaskRequest) (*CreateTaskResponse, e
}
}
func (f *TaskFactory) registerAndEnqueueTask(task core.Executable, taskType tasktype.TaskType, storageName, path, webhook string) error {
taskID := task.TaskID()
info := RegisterTask(taskID, string(taskType), storageName, path, task.Title(), webhook)
// Inject the progress sink into the context so the task's Emit calls update
// the API store (and fire the webhook on terminal states) without the task
// knowing about the API.
taskCtx := taskevent.WithSink(f.ctx, info)
err := core.AddTask(taskCtx, task)
if err != nil {
DeleteTask(taskID)
return fmt.Errorf("failed to add task: %w", err)
}
return nil
}
// createDirectLinksTask 创建直链下载任务
func (f *TaskFactory) createDirectLinksTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params DirectLinksParams
@@ -79,8 +98,9 @@ func (f *TaskFactory) createDirectLinksTask(taskID string, createdAt time.Time,
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)
err := f.registerAndEnqueueTask(task, tasktype.TaskTypeDirectlinks, req.Storage, req.Path, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{
@@ -104,8 +124,9 @@ func (f *TaskFactory) createYTDLPTask(taskID string, createdAt time.Time, req *C
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)
err := f.registerAndEnqueueTask(task, tasktype.TaskTypeYtdlp, req.Storage, req.Path, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{
@@ -146,8 +167,9 @@ func (f *TaskFactory) createAria2Task(taskID string, createdAt time.Time, req *C
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)
err = f.registerAndEnqueueTask(task, tasktype.TaskTypeAria2, req.Storage, req.Path, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{
@@ -190,8 +212,9 @@ func (f *TaskFactory) createParsedTask(taskID string, createdAt time.Time, req *
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)
err = f.registerAndEnqueueTask(task, tasktype.TaskTypeParseditem, req.Storage, req.Path, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{
@@ -223,15 +246,15 @@ func (f *TaskFactory) createTGFilesTask(taskID string, createdAt time.Time, req
return nil, fmt.Errorf("no files found in provided links")
}
var task core.Executable
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)
}
task = tfileTask
} else {
// 批量文件任务
elems := make([]batchtfile.TaskElement, 0, len(files))
@@ -243,10 +266,12 @@ func (f *TaskFactory) createTGFilesTask(taskID string, createdAt time.Time, req
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)
}
task = batchtfile.NewBatchTGFileTask(taskID, f.ctx, elems, nil, true)
}
err = f.registerAndEnqueueTask(task, tasktype.TaskTypeTgfiles, req.Storage, req.Path, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{
@@ -281,8 +306,9 @@ func (f *TaskFactory) createTPHPicsTask(taskID string, createdAt time.Time, req
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)
err = f.registerAndEnqueueTask(task, tasktype.TaskTypeTphpics, req.Storage, req.Path, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{
@@ -342,8 +368,9 @@ func (f *TaskFactory) createTransferTask(taskID string, createdAt time.Time, req
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)
err = f.registerAndEnqueueTask(task, tasktype.TaskTypeTransfer, params.TargetStorage, params.TargetPath, req.Webhook)
if err != nil {
return nil, err
}
return &CreateTaskResponse{

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
@@ -117,7 +118,7 @@ func (h *Handlers) CancelTaskHandler(w http.ResponseWriter, r *http.Request) {
return
}
// 取消任务
// Cancel the task; the terminal status is set via the task event stream.
if err := core.CancelTask(r.Context(), taskID); err != nil {
WriteError(w, http.StatusInternalServerError, "cancel_failed", "failed to cancel task: "+err.Error())
return
@@ -184,27 +185,45 @@ func extractTaskIDFromPath(path string) string {
return parts[3]
}
// convertTaskProgressToResponse 将任务进度转换为响应格式
// convertTaskProgressToResponse renders a task's current state, computing
// percent and speed from the snapshot taken under the task's mutex.
func convertTaskProgressToResponse(task *TaskProgressInfo) TaskInfoResponse {
status, total, downloaded, totalFiles, downloadedFiles, startedAt, errMsg, updatedAt := task.snapshot()
resp := TaskInfoResponse{
TaskID: task.TaskID,
Type: tasktype.TaskType(task.Type),
Status: task.Status,
Status: status,
Title: task.Title,
Storage: task.Storage,
Path: task.Path,
Error: task.Error,
Error: errMsg,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
UpdatedAt: updatedAt,
}
// 计算进度
if task.TotalBytes > 0 {
percent := float64(task.DownloadedBytes) * 100 / float64(task.TotalBytes)
var percent float64
var speedMBPS float64
if total > 0 {
percent = float64(downloaded) * 100 / float64(total)
} else if totalFiles > 0 {
percent = float64(downloadedFiles) * 100 / float64(totalFiles)
}
if !startedAt.IsZero() {
elapsed := time.Since(startedAt).Seconds()
if elapsed > 0 {
speedMBPS = float64(downloaded) / elapsed / (1024 * 1024)
}
}
if total > 0 || totalFiles > 0 {
resp.Progress = &TaskProgress{
TotalBytes: task.TotalBytes,
DownloadedBytes: task.DownloadedBytes,
TotalBytes: total,
DownloadedBytes: downloaded,
TotalFiles: totalFiles,
DownloadedFiles: downloadedFiles,
Percent: percent,
SpeedMBPS: speedMBPS,
}
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
// setupTestServer creates a test server with handlers
@@ -403,32 +404,38 @@ func TestConcurrentProgressStore(t *testing.T) {
// TestProgressTrackerConcurrentUpdates tests concurrent progress updates
func TestProgressTrackerConcurrentUpdates(t *testing.T) {
tracker := NewProgressTracker("concurrent-progress", "directlinks", "local", "downloads", "Test", "")
tracker.OnStart(10000, 10)
info := RegisterTask("concurrent-progress", "directlinks", "local", "downloads", "Test", "")
info.Emit(taskevent.Event{TaskID: "concurrent-progress", Phase: taskevent.PhaseStart, TotalBytes: 10000})
var wg sync.WaitGroup
numGoroutines := 50
updatesPerGoroutine := 100
// Concurrent progress updates
// Concurrent progress updates via the Sink interface
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)
info.Emit(taskevent.Event{
TaskID: "concurrent-progress",
Phase: taskevent.PhaseProgress,
DownloadedBytes: int64(id*updatesPerGoroutine + j),
TotalBytes: 10000,
})
}
}(i)
}
wg.Wait()
info := tracker.GetInfo()
if info.Status != TaskStatusRunning {
t.Errorf("expected status Running after concurrent updates, got %s", info.Status)
status, _, downloaded, _, _, _, _, _ := info.snapshot()
if status != TaskStatusRunning {
t.Errorf("expected status Running after concurrent updates, got %s", status)
}
if downloaded <= 0 {
t.Errorf("expected downloaded bytes > 0 after concurrent updates, got %d", downloaded)
}
// Note: Due to race conditions in the simple implementation,
// we can't reliably check exact values without proper synchronization
}
// TestTaskFactoryValidation tests TaskFactory parameter validation
@@ -526,8 +533,7 @@ func TestEdgeCases(t *testing.T) {
{
name: "Progress tracker with empty webhook",
fn: func(t *testing.T) {
tracker := NewProgressTracker("test", "type", "storage", "path", "title", "")
info := tracker.GetInfo()
info := RegisterTask("test-empty-webhook", "type", "storage", "path", "title", "")
if info.Webhook != "" {
t.Error("expected empty webhook")
}

View File

@@ -2,39 +2,48 @@ package api
import (
"sync"
"sync/atomic"
"time"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
// TaskProgressInfo 存储任务的进度信息
// TaskProgressInfo stores the progress of an API-submitted task. All fields are
// guarded by mu. It implements taskevent.Sink so the task layer can update it
// without knowing about the API.
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
mu sync.Mutex
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
StartedAt time.Time
Webhook string
webhookNotified bool
}
// progressStore 存储所有 API 任务的进度信息
// progressStore holds all API tasks. Entries are removed a fixed duration after
// they reach a terminal state to bound memory usage.
type progressStore struct {
mu sync.RWMutex
tasks map[string]*TaskProgressInfo
mu sync.RWMutex
tasks map[string]*TaskProgressInfo
retention time.Duration
}
var store = &progressStore{
tasks: make(map[string]*TaskProgressInfo),
tasks: make(map[string]*TaskProgressInfo),
retention: 24 * time.Hour,
}
// RegisterTask 注册一个新的 API 任务
// RegisterTask registers a new API task and returns its progress info.
func RegisterTask(taskID, taskType, storage, path, title, webhook string) *TaskProgressInfo {
info := &TaskProgressInfo{
TaskID: taskID,
@@ -55,7 +64,7 @@ func RegisterTask(taskID, taskType, storage, path, title, webhook string) *TaskP
return info
}
// GetTask 获取任务进度信息
// GetTask returns the progress info for a task.
func GetTask(taskID string) (*TaskProgressInfo, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
@@ -63,7 +72,7 @@ func GetTask(taskID string) (*TaskProgressInfo, bool) {
return info, ok
}
// GetAllTasks 获取所有任务
// GetAllTasks returns all tracked tasks.
func GetAllTasks() []*TaskProgressInfo {
store.mu.RLock()
defer store.mu.RUnlock()
@@ -75,76 +84,133 @@ func GetAllTasks() []*TaskProgressInfo {
return tasks
}
// DeleteTask 删除任务记录
// DeleteTask removes a task record.
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()
// CleanupExpired removes tasks that reached a terminal state more than the
// store's retention duration ago. It is safe to call periodically.
func CleanupExpired() {
now := time.Now()
store.mu.Lock()
defer store.mu.Unlock()
for id, info := range store.tasks {
info.mu.Lock()
terminal := info.Status == TaskStatusCompleted || info.Status == TaskStatusFailed || info.Status == TaskStatusCancelled
stale := terminal && now.Sub(info.UpdatedAt) > store.retention
info.mu.Unlock()
if stale {
delete(store.tasks, id)
}
}
}
// SetError 设置错误信息
// StartCleanupLoop runs CleanupExpired on a fixed interval until ctx is done.
// It should be started once during API server initialization.
func StartCleanupLoop(ctx interface{ Done() <-chan struct{} }) {
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
CleanupExpired()
}
}
}()
}
// UpdateStatus sets the task status.
func (t *TaskProgressInfo) UpdateStatus(status TaskStatus) {
t.mu.Lock()
t.Status = status
t.UpdatedAt = time.Now()
if status == TaskStatusRunning && t.StartedAt.IsZero() {
t.StartedAt = t.UpdatedAt
}
t.mu.Unlock()
}
// SetError marks the task failed with an error message.
func (t *TaskProgressInfo) SetError(err string) {
t.mu.Lock()
t.Error = err
t.Status = TaskStatusFailed
t.UpdatedAt = time.Now()
t.mu.Unlock()
}
// ProgressTracker 用于 API 任务的进度追踪
type ProgressTracker struct {
info *TaskProgressInfo
// snapshot returns a point-in-time copy of the fields needed to render a
// response, so callers never touch the mutex directly.
func (t *TaskProgressInfo) snapshot() (status TaskStatus, total, downloaded int64, totalFiles, downloadedFiles int, startedAt time.Time, err string, updatedAt time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
return t.Status, t.TotalBytes, t.DownloadedBytes, t.TotalFiles, t.DownloadedFiles, t.StartedAt, t.Error, t.UpdatedAt
}
// 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
// Emit implements taskevent.Sink. It translates task lifecycle events into
// status/progress updates and fires the webhook on terminal transitions.
func (t *TaskProgressInfo) Emit(e taskevent.Event) {
t.mu.Lock()
switch e.Phase {
case taskevent.PhaseStart:
t.Status = TaskStatusRunning
if t.StartedAt.IsZero() {
t.StartedAt = time.Now()
}
if e.TotalBytes > 0 {
t.TotalBytes = e.TotalBytes
}
case taskevent.PhaseProgress:
t.Status = TaskStatusRunning
if e.TotalBytes > 0 {
t.TotalBytes = e.TotalBytes
}
t.DownloadedBytes = e.DownloadedBytes
if e.TotalFiles > 0 {
t.TotalFiles = e.TotalFiles
}
if e.DownloadedFiles > 0 {
t.DownloadedFiles = e.DownloadedFiles
}
case taskevent.PhaseDone:
if e.Err != nil {
t.Status = TaskStatusFailed
t.Error = e.Err.Error()
} else {
t.Status = TaskStatusCompleted
}
}
t.UpdatedAt = time.Now()
notify := t.Webhook != "" && !t.webhookNotified && (t.Status == TaskStatusCompleted || t.Status == TaskStatusFailed)
if notify {
t.webhookNotified = true
}
t.mu.Unlock()
if notify {
payload := CreateWebhookPayload(t.TaskID, t.Type, t.Status, t.Storage, t.Path, e.Err)
SendWebhook(nil, payload)
}
p.info.UpdatedAt = time.Now()
}
// GetInfo 获取任务信息
func (p *ProgressTracker) GetInfo() *TaskProgressInfo {
return p.info
// ProgressTracker is retained for compatibility but is no longer the primary
// progress path; taskevent drives updates now. These methods are safe no-ops
// when called on a nil receiver.
type ProgressTracker struct{}
func NewProgressTracker(taskID, taskType, storage, path, title, webhook string) *ProgressTracker {
return &ProgressTracker{}
}
// 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()
}
func (p *ProgressTracker) OnStart(totalBytes int64, totalFiles int) {}
func (p *ProgressTracker) OnProgress(downloadedBytes int64, downloadedFiles int) {}
func (p *ProgressTracker) OnDone(err error) {}
func (p *ProgressTracker) GetInfo() *TaskProgressInfo { return nil }
func (p *ProgressTracker) UpdateProgressBytes(bytes int64) {}
func (p *ProgressTracker) UpdateProgressFiles(files int) {}

View File

@@ -30,16 +30,21 @@ func NewServer(ctx context.Context) *Server {
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:
handlers.ListTasksHandler(w, r)
case http.MethodPost:
handlers.CreateTaskHandler(w, r)
default:
MethodNotAllowedHandler(w, r)
}
})
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)
}
handlers.GetTaskHandler(w, r)
case http.MethodDelete:
handlers.CancelTaskHandler(w, r)
default:
@@ -52,22 +57,19 @@ func NewServer(ctx context.Context) *Server {
// 404 处理
mux.HandleFunc("/", NotFoundHandler)
// 应用中间件
// Apply middleware chain.
var handler http.Handler = mux
// 添加认证中间件
// Apply auth middleware when a token is configured.
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)
}
// 添加日志中间件
// Add logging middleware.
handler = loggingMiddleware(handler)
// 添加恢复中间件
// Add recovery middleware.
handler = recoveryMiddleware(handler)
return &Server{
@@ -146,7 +148,8 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.ResponseWriter.WriteHeader(code)
}
// Start 初始化并启动 API 服务器
// Start initializes and starts the API server. It refuses to start without a
// token, since an open download proxy is a security risk.
func Start(ctx context.Context) error {
cfg := config.C().API
@@ -155,9 +158,13 @@ func Start(ctx context.Context) error {
}
if cfg.Token == "" {
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
return fmt.Errorf("API server is enabled but no token is set; refusing to start insecurely")
}
server := NewServer(ctx)
return server.Start(ctx)
if err := server.Start(ctx); err != nil {
return err
}
StartCleanupLoop(ctx)
return nil
}

View File

@@ -40,6 +40,8 @@ type CreateTaskResponse struct {
type TaskProgress struct {
TotalBytes int64 `json:"total_bytes,omitempty"`
DownloadedBytes int64 `json:"downloaded_bytes,omitempty"`
TotalFiles int `json:"total_files,omitempty"`
DownloadedFiles int `json:"downloaded_files,omitempty"`
Percent float64 `json:"percent,omitempty"`
SpeedMBPS float64 `json:"speed_mbps,omitempty"`
}

View File

@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
@@ -30,9 +29,14 @@ func SendWebhook(ctx context.Context, payload *WebhookPayload) {
webhookURL := info.Webhook
// 异步发送 webhook
// Async send with retries.
go func() {
logger := log.FromContext(ctx).With("task_id", payload.TaskID)
var logger *log.Logger
if ctx != nil {
logger = log.FromContext(ctx).With("task_id", payload.TaskID)
} else {
logger = log.Default().With("task_id", payload.TaskID)
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
@@ -72,7 +76,7 @@ func SendWebhook(ctx context.Context, payload *WebhookPayload) {
}()
}
// CreateWebhookPayload 创建 Webhook 负载
// CreateWebhookPayload creates a Webhook payload.
func CreateWebhookPayload(taskID string, taskType string, status TaskStatus, storage, path string, err error) *WebhookPayload {
payload := &WebhookPayload{
TaskID: taskID,
@@ -93,38 +97,3 @@ func CreateWebhookPayload(taskID string, taskType string, status TaskStatus, sto
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

@@ -73,14 +73,16 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups
}
dirPath = dir.Path
} else if data.SelectedDirPath != "" {
dirPath = data.SelectedDirPath
}
switch data.TaskType {
case tasktype.TaskTypeTgfiles:
if data.AsBatch {
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID, data.ConflictStrategy)
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID, data.ConflictStrategy)
case tasktype.TaskTypeTphpics:
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
case tasktype.TaskTypeParseditem:

View File

@@ -8,6 +8,7 @@ import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/conflictutil"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
@@ -26,6 +27,10 @@ func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
Text: i18n.T(i18nk.BotMsgConfigButtonFilenameStrategy),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
},
&tg.KeyboardButtonCallback{
Text: i18n.T(i18nk.BotMsgConfigButtonConflictStrategy),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "conflictst"),
},
},
},
},
@@ -51,6 +56,8 @@ func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
switch args[1] {
case "fnamest":
return handleConfigFnameSTCallback(ctx, update)
case "conflictst":
return handleConfigConflictSTCallback(ctx, update)
default:
return invaildDataAnswer()
}
@@ -110,6 +117,55 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups
}
func handleConfigConflictSTCallback(ctx *ext.Context, update *ext.Update) error {
userID := update.CallbackQuery.GetUserID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.CallbackQuery.Data))
if len(args) == 3 {
selected := args[2]
if !tcbdata.IsConflictStrategy(selected) {
return fmt.Errorf("invalid conflict strategy: %s", selected)
}
user.ConflictStrategy = selected
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigInfoConflictStrategySet, map[string]any{
"Strategy": conflictutil.Display(selected),
}),
})
return dispatcher.EndGroups
}
opts := tcbdata.ConflictStrategyValues()
rows := make([]tg.KeyboardButtonRow, 0, len(opts))
for _, opt := range opts {
rows = append(rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: conflictutil.Display(opt),
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "conflictst", opt),
},
},
})
}
markup := &tg.ReplyInlineMarkup{Rows: rows}
currentSt := conflictutil.EffectiveStrategy(user)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigPromptSelectConflictStrategy, map[string]any{
"Strategy": conflictutil.Display(currentSt),
}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)

View File

@@ -13,7 +13,7 @@ import (
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
@@ -53,9 +53,13 @@ func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.M
if !supported {
return dispatcher.EndGroups
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
tgutil.GenFileNameFromMessage(*message),
))
userId := update.GetUserChat().GetID()
userDB, err := database.GetUserByChatID(ctx, userId)
if err != nil {
return err
}
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfOpts...)
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
return dispatcher.EndGroups

View File

@@ -13,6 +13,7 @@ import (
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/rule"
)
@@ -84,6 +85,46 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoCreateRuleSuccess, nil)), nil)
case "preset":
// /rule preset <storage> [base_path]
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
storageName := args[2]
if !config.C().HasStorage(user.ChatID, storageName) {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorStorageNotFound, map[string]any{
"Storage": storageName,
})), nil)
return dispatcher.EndGroups
}
basePath := ""
if len(args) >= 4 {
basePath = args[3]
}
presets := rule.PresetCategories(basePath)
imported := 0
for _, p := range presets {
rd := &database.Rule{
Type: rule.FileNameRegex.String(),
Data: p.Regex,
StorageName: storageName,
DirPath: p.Dir,
UserID: user.ID,
}
if err := database.CreateRule(ctx, rd); err != nil {
logger.Errorf("failed to create preset rule %s: %s", p.Name, err)
continue
}
imported++
}
if imported == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorCreateRuleFailed, nil)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoPresetImported, map[string]any{
"Count": imported,
})), nil)
case "del":
// /rule del <id>
if len(args) < 3 {

View File

@@ -0,0 +1,55 @@
package conflictutil
import (
"fmt"
"strings"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
const maxConflictLines = 10
func EffectiveStrategy(user *database.User) string {
if user != nil && tcbdata.IsConflictStrategy(user.ConflictStrategy) {
return user.ConflictStrategy
}
return tcbdata.ConflictStrategyRename
}
func ResolveStrategy(user *database.User, override string) string {
if tcbdata.IsConflictStrategy(override) {
return override
}
return EffectiveStrategy(user)
}
func Display(strategy string) string {
switch strategy {
case tcbdata.ConflictStrategyRename:
return i18n.T(i18nk.BotMsgConfigConflictStrategyRename, nil)
case tcbdata.ConflictStrategyAsk:
return i18n.T(i18nk.BotMsgConfigConflictStrategyAsk, nil)
case tcbdata.ConflictStrategyOverwrite:
return i18n.T(i18nk.BotMsgConfigConflictStrategyOverwrite, nil)
case tcbdata.ConflictStrategySkip:
return i18n.T(i18nk.BotMsgConfigConflictStrategySkip, nil)
default:
return strategy
}
}
func FormatPaths(conflicts []string) string {
if len(conflicts) <= maxConflictLines {
return strings.Join(conflicts, "\n")
}
return strings.Join(conflicts[:maxConflictLines], "\n") + "\n" + i18n.T(i18nk.BotMsgCommonPromptConflictMoreFiles, map[string]any{
"Count": len(conflicts) - maxConflictLines,
})
}
func FormatPath(storageName, storagePath string) string {
return fmt.Sprintf("[%s]:%s", storageName, storagePath)
}

View File

@@ -24,6 +24,8 @@ func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledT
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpSwitchSuffix, nil)),
styling.Code("add"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAddSuffix, nil)),
styling.Code("preset"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpPresetSuffix, nil)),
styling.Code("del"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpDelSuffix, nil)),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpExistingRulesPrefix, nil)),

View File

@@ -38,6 +38,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
data := tcbdata.Add{
TaskType: taskType,
SelectedStorName: storage.Name(),
SelectedDirPath: adddata.SelectedDirPath,
ConflictStrategy: adddata.ConflictStrategy,
Files: adddata.Files,
AsBatch: len(adddata.Files) > 1,
@@ -109,6 +111,38 @@ func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storag
}, nil
}
func BuildConflictStrategyMarkup(adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
type option struct {
text string
strategy string
}
options := []option{
{text: i18n.T(i18nk.BotMsgCommonButtonConflictRename, nil), strategy: tcbdata.ConflictStrategyRename},
{text: i18n.T(i18nk.BotMsgCommonButtonConflictOverwrite, nil), strategy: tcbdata.ConflictStrategyOverwrite},
{text: i18n.T(i18nk.BotMsgCommonButtonConflictSkip, nil), strategy: tcbdata.ConflictStrategySkip},
}
buttons := make([]tg.KeyboardButtonClass, 0, len(options))
for _, opt := range options {
data := adddata
data.ConflictStrategy = opt.strategy
dataid := xid.New().String()
if err := cache.Set(dataid, data); err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: opt.text,
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid),
})
}
rows := make([]tg.KeyboardButtonRow, 0, len(buttons))
for _, button := range buttons {
rows = append(rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{button},
})
}
return &tg.ReplyInlineMarkup{Rows: rows}, nil
}
// Builds the inline keyboard for setting default storage
func BuildSetDefaultStorageMarkup(
ctx context.Context,

View File

@@ -8,6 +8,7 @@ import (
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/conflictutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
"github.com/krau/SaveAny-Bot/common/i18n"
@@ -17,14 +18,17 @@ import (
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
// 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int, conflictStrategy ...string) error {
logger := log.FromContext(ctx)
strategy := selectedConflictStrategy(conflictStrategy)
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
@@ -36,6 +40,7 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
})
return dispatcher.EndGroups
}
strategy = conflictutil.ResolveStrategy(user, strategy)
if user.ApplyRule && user.Rules != nil {
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
@@ -60,7 +65,26 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
}
startCreateTask:
storagePath := path.Join(dirPath, file.Name())
if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip {
exists := stor.Exists(ctx, storagePath)
if exists && strategy == tcbdata.ConflictStrategyAsk {
return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, []tfile.TGFileMessage{file}, false, []string{conflictutil.FormatPath(stor.Name(), storagePath)}, trackMsgID)
}
if exists {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{
"Skipped": file.Name(),
}),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
if strategy == tcbdata.ConflictStrategyOverwrite {
injectCtx = storage.WithOverwrite(injectCtx)
}
taskid := xid.New().String()
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
tftask.NewProgressTrack(
@@ -97,8 +121,9 @@ startCreateTask:
}
// 创建一个 batchtfile.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int, conflictStrategy ...string) error {
logger := log.FromContext(ctx)
strategy := selectedConflictStrategy(conflictStrategy)
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
@@ -110,6 +135,7 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
})
return dispatcher.EndGroups
}
strategy = conflictutil.ResolveStrategy(user, strategy)
useRule := user.ApplyRule && user.Rules != nil
@@ -128,14 +154,17 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
return storname, dirP
}
skipped := make([]string, 0)
conflicts := make([]string, 0)
elems := make([]batchtfile.TaskElement, 0, len(files))
type albumFile struct {
file tfile.TGFileMessage
storage storage.Storage
dirPath string
}
albumFiles := make(map[int64][]albumFile, 0)
for _, file := range files {
storName, dirPath := applyRule(file)
storName, matchedDirPath := applyRule(file)
fileStor := stor
if storName != stor.Name() && storName != "" {
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
@@ -150,8 +179,19 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
return dispatcher.EndGroups
}
}
if !dirPath.NeedNewForAlbum() {
storPath := path.Join(dirPath.String(), file.Name())
if !matchedDirPath.NeedNewForAlbum() {
storPath := path.Join(matchedDirPath.String(), file.Name())
if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip {
exists := fileStor.Exists(ctx, storPath)
if exists && strategy == tcbdata.ConflictStrategyAsk {
conflicts = append(conflicts, conflictutil.FormatPath(fileStor.Name(), storPath))
continue
}
if exists {
skipped = append(skipped, file.Name())
continue
}
}
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
if err != nil {
logger.Errorf("Failed to create task element: %s", err)
@@ -170,12 +210,17 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
continue
}
fileDirPath := matchedDirPath.String()
if matchedDirPath.NeedNewForAlbum() {
fileDirPath = dirPath
}
if _, ok := albumFiles[groupId]; !ok {
albumFiles[groupId] = make([]albumFile, 0)
}
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
file: file,
storage: fileStor,
dirPath: fileDirPath,
})
}
}
@@ -188,7 +233,18 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage
for _, af := range afiles {
afstorPath := path.Join(dirPath, albumDir, af.file.Name())
afstorPath := path.Join(af.dirPath, albumDir, af.file.Name())
if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip {
exists := albumStor.Exists(ctx, afstorPath)
if exists && strategy == tcbdata.ConflictStrategyAsk {
conflicts = append(conflicts, conflictutil.FormatPath(albumStor.Name(), afstorPath))
continue
}
if exists {
skipped = append(skipped, af.file.Name())
continue
}
}
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
if err != nil {
logger.Errorf("Failed to create task element for album file: %s", err)
@@ -204,9 +260,26 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
}
}
if strategy == tcbdata.ConflictStrategyAsk && len(conflicts) > 0 {
return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, files, true, conflicts, trackMsgID)
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
if strategy == tcbdata.ConflictStrategyOverwrite {
injectCtx = storage.WithOverwrite(injectCtx)
}
if len(elems) == 0 {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{
"Skipped": strings.Join(skipped, "\n"),
}),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}
taskid := xid.New().String()
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTracker(trackMsgID, userID), true)
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTrackerWithSkipped(trackMsgID, userID, skipped), true)
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add batch task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
@@ -218,11 +291,48 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
"Count": len(files),
}),
ID: trackMsgID,
Message: buildBatchAddedMessage(len(elems), skipped),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}
func promptTGFileConflictStrategy(ctx *ext.Context, userID int64, storageName, dirPath string, files []tfile.TGFileMessage, asBatch bool, conflicts []string, trackMsgID int) error {
markup, err := msgelem.BuildConflictStrategyMarkup(tcbdata.Add{
TaskType: tasktype.TaskTypeTgfiles,
SelectedStorName: storageName,
SettedDir: true,
SelectedDirPath: dirPath,
Files: files,
AsBatch: asBatch,
})
if err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonPromptSelectConflictStrategy, map[string]any{"Files": conflictutil.FormatPaths(conflicts)}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func selectedConflictStrategy(strategies []string) string {
if len(strategies) == 0 {
return ""
}
return strategies[0]
}
func buildBatchAddedMessage(count int, skipped []string) string {
if len(skipped) == 0 {
return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
"Count": count,
})
}
return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAddedWithSkipped, map[string]any{
"Count": count,
"Skipped": strings.Join(skipped, "\n"),
})
}

View File

@@ -15,7 +15,7 @@ import (
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
return []telegram.Middleware{
recovery.New(ctx, newBackoff(timeout)),
recovery.New(ctx, func() backoff.BackOff { return newBackoff(timeout) }),
retry.New(config.C().Telegram.RpcRetry),
floodwait.NewSimpleWaiter(),
}

View File

@@ -14,19 +14,28 @@ import (
)
type recovery struct {
ctx context.Context
backoff backoff.BackOff
ctx context.Context
newBackoff func() backoff.BackOff
}
func New(ctx context.Context, backoff backoff.BackOff) telegram.Middleware {
// New returns a recovery middleware.
//
// newBackoff is a factory that must return a fresh backoff.BackOff on every call: backoff implementations in
// cenkalti/backoff/v4 (notably ExponentialBackOff) are not safe for concurrent
// use, and the Telegram client invokes RPCs from many goroutines in parallel.
//
// Sharing a single instance corrupts its internal counters, breaks the
// exponential interval, and defeats MaxElapsedTime - see issue #218.
func New(ctx context.Context, newBackoff func() backoff.BackOff) telegram.Middleware {
return &recovery{
ctx: ctx,
backoff: backoff,
ctx: ctx,
newBackoff: newBackoff,
}
}
func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
b := r.newBackoff()
return backoff.RetryNotify(func() error {
if err := next.Invoke(ctx, input, output); err != nil {
@@ -38,7 +47,7 @@ func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
}
return nil
}, r.backoff, func(err error, duration time.Duration) {
}, b, func(err error, duration time.Duration) {
log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
})
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/krau/SaveAny-Bot/cmd/upload"
"github.com/krau/SaveAny-Bot/cmd/watch"
"github.com/krau/SaveAny-Bot/config"
"github.com/spf13/cobra"
)
@@ -18,6 +19,7 @@ var rootCmd = &cobra.Command{
func init() {
config.RegisterFlags(rootCmd)
upload.Register(rootCmd)
watch.Register(rootCmd)
}
func Execute(ctx context.Context) {

View File

@@ -2,9 +2,9 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"slices"
@@ -27,14 +27,27 @@ import (
func Run(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithCancel(cmd.Context())
logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.DebugLevel,
Level: log.InfoLevel,
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
ReportCaller: true,
})
log.SetDefault(logger)
ctx = log.WithContext(ctx, logger)
exitChan, err := initAll(ctx, cmd)
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
logger.Fatal("Init failed", "error", err)
}
level, err := log.ParseLevel(strings.TrimSpace(config.C().Log.Level))
if err != nil {
logger.Warn("Invalid log level, fallback to debug", "level", config.C().Log.Level, "error", err)
level = log.DebugLevel
}
logger.SetLevel(level)
exitChan, err := initAll(ctx)
if err != nil {
logger.Fatal("Init failed", "error", err)
}
@@ -51,11 +64,7 @@ func Run(cmd *cobra.Command, _ []string) {
cleanCache()
}
func initAll(ctx context.Context, cmd *cobra.Command) (<-chan struct{}, error) {
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
func initAll(ctx context.Context) (<-chan struct{}, error) {
cache.Init()
logger := log.FromContext(ctx)
i18n.Init(config.C().Lang)

145
cmd/watch/cmd.go Normal file
View File

@@ -0,0 +1,145 @@
package watch
import (
"fmt"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
stortype "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/storage"
"github.com/spf13/cobra"
)
var watchCmd = &cobra.Command{
Use: "watch",
Short: "watch a local directory and auto-upload changed files to storage",
Long: `Watch a local directory and automatically upload created or modified files
to the specified storage backend, preserving the relative directory structure.
Example:
saveany-bot watch -p /data/inbox -s mystorage -d backup --recursive`,
RunE: runWatch,
}
func Register(root *cobra.Command) {
flags := watchCmd.Flags()
flags.StringP("path", "p", "", "local directory path to watch")
watchCmd.MarkFlagRequired("path")
flags.StringP("storage", "s", "", "storage name to upload to")
watchCmd.MarkFlagRequired("storage")
flags.StringP("dir", "d", "", "storage dir to upload to, default is the base_path of the storage")
flags.BoolP("recursive", "r", false, "watch subdirectories recursively")
flags.Bool("overwrite", false, "overwrite existing files on storage instead of skipping")
flags.Bool("initial-scan", false, "upload existing files in the directory on startup")
flags.Duration("debounce", 2*time.Second, "wait time after the last change before uploading a file")
flags.Int("upload-workers", 0, "number of concurrent uploads, default is config.workers")
flags.Duration("retry-delay", 3*time.Second, "delay between upload retries")
root.AddCommand(watchCmd)
}
func runWatch(cmd *cobra.Command, _ []string) error {
watchPath, err := cmd.Flags().GetString("path")
if err != nil {
return err
}
storName, err := cmd.Flags().GetString("storage")
if err != nil {
return err
}
destDir, err := cmd.Flags().GetString("dir")
if err != nil {
return err
}
recursive, err := cmd.Flags().GetBool("recursive")
if err != nil {
return err
}
overwrite, err := cmd.Flags().GetBool("overwrite")
if err != nil {
return err
}
initialScan, err := cmd.Flags().GetBool("initial-scan")
if err != nil {
return err
}
debounce, err := cmd.Flags().GetDuration("debounce")
if err != nil {
return err
}
uploadWorkers, err := cmd.Flags().GetInt("upload-workers")
if err != nil {
return err
}
retryDelay, err := cmd.Flags().GetDuration("retry-delay")
if err != nil {
return err
}
ctx := cmd.Context()
logger := log.FromContext(ctx)
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
i18n.Init(config.C().Lang)
cache.Init()
database.Init(ctx)
stor, err := storage.GetStorageByName(ctx, storName)
if err != nil {
return fmt.Errorf("failed to get storage %q: %w", storName, err)
}
// Telegram storage needs the bot client and its ext context injected into ctx.
if stor.Type() == stortype.Telegram {
bot.Init(ctx)
ctx = tgutil.ExtWithContext(ctx, bot.ExtContext())
}
if uploadWorkers < 1 {
uploadWorkers = config.C().Workers
}
uploader := NewUploader(ctx, UploaderOptions{
Storage: stor,
DestDir: destDir,
Overwrite: overwrite,
Workers: uploadWorkers,
Retry: config.C().Retry,
RetryDelay: retryDelay,
})
watcher, err := NewWatcher(ctx, WatcherOptions{
Root: watchPath,
Recursive: recursive,
Debounce: debounce,
Uploader: uploader,
})
if err != nil {
uploader.Close()
return fmt.Errorf("failed to create watcher: %w", err)
}
if initialScan {
watcher.ScanExisting(ctx)
}
logger.Infof("watch started: %s -> storage %q dir %q", watchPath, storName, destDir)
// Run blocks until ctx is cancelled (e.g. SIGINT).
runErr := watcher.Run(ctx)
// Wait for in-flight uploads to finish before exiting.
logger.Info("waiting for in-flight uploads to finish...")
uploader.Close()
logger.Info("watch stopped")
return runErr
}

227
cmd/watch/uploader.go Normal file
View File

@@ -0,0 +1,227 @@
package watch
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/storage"
)
type uploadJob struct {
// localPath is the absolute path of the local file.
localPath string
// relPath is relative to the watch root, used to preserve directory structure on storage.
relPath string
}
// Uploader uploads local files to the target storage via a worker pool.
// If a file changes while being uploaded, it is re-uploaded once after the
// current upload finishes, instead of being queued multiple times.
type Uploader struct {
stor storage.Storage
destDir string
overwrite bool
retry int
retryDelay time.Duration
logger *log.Logger
jobs chan uploadJob
wg sync.WaitGroup
mu sync.Mutex
// inflight maps in-progress (or queued) file paths. A true value means the
// file changed during upload and must be re-queued once done.
inflight map[string]bool
}
type UploaderOptions struct {
Storage storage.Storage
DestDir string
Overwrite bool
Workers int
Retry int
RetryDelay time.Duration
QueueSize int
}
// NewUploader creates and starts an Uploader. The caller must call Close when done.
func NewUploader(ctx context.Context, opts UploaderOptions) *Uploader {
if opts.Workers < 1 {
opts.Workers = 1
}
if opts.Retry < 1 {
opts.Retry = 1
}
if opts.RetryDelay <= 0 {
opts.RetryDelay = 3 * time.Second
}
if opts.QueueSize < opts.Workers {
opts.QueueSize = opts.Workers * 64
}
u := &Uploader{
stor: opts.Storage,
destDir: opts.DestDir,
overwrite: opts.Overwrite,
retry: opts.Retry,
retryDelay: opts.RetryDelay,
logger: log.FromContext(ctx).WithPrefix("uploader"),
jobs: make(chan uploadJob, opts.QueueSize),
inflight: make(map[string]bool),
}
for i := 0; i < opts.Workers; i++ {
u.wg.Add(1)
go u.worker(ctx)
}
return u
}
// Submit enqueues an upload job. If the same file is already in flight, it is
// marked for re-upload instead of being queued again. Returns false if ctx is
// cancelled before the job can be enqueued.
func (u *Uploader) Submit(ctx context.Context, job uploadJob) bool {
u.mu.Lock()
if _, ok := u.inflight[job.localPath]; ok {
u.inflight[job.localPath] = true
u.mu.Unlock()
u.logger.Debugf("file %s already in flight, marked for re-upload", job.localPath)
return true
}
u.inflight[job.localPath] = false
u.mu.Unlock()
select {
case u.jobs <- job:
return true
case <-ctx.Done():
u.mu.Lock()
delete(u.inflight, job.localPath)
u.mu.Unlock()
return false
}
}
func (u *Uploader) worker(ctx context.Context) {
defer u.wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-u.jobs:
if !ok {
return
}
u.process(ctx, job)
}
}
}
func (u *Uploader) process(ctx context.Context, job uploadJob) {
if err := u.uploadWithRetry(ctx, job); err != nil {
if ctx.Err() != nil {
u.clearInflight(job.localPath)
return
}
u.logger.Errorf("failed to upload %s after %d attempt(s): %v", job.localPath, u.retry, err)
}
// Re-queue if the file changed again while it was being uploaded.
u.mu.Lock()
needReupload := u.inflight[job.localPath]
if needReupload {
u.inflight[job.localPath] = false
} else {
delete(u.inflight, job.localPath)
}
u.mu.Unlock()
if needReupload {
select {
case u.jobs <- job:
u.logger.Debugf("re-queued %s due to changes during upload", job.localPath)
case <-ctx.Done():
u.clearInflight(job.localPath)
}
}
}
func (u *Uploader) clearInflight(localPath string) {
u.mu.Lock()
delete(u.inflight, localPath)
u.mu.Unlock()
}
func (u *Uploader) uploadWithRetry(ctx context.Context, job uploadJob) error {
var lastErr error
for attempt := 1; attempt <= u.retry; attempt++ {
if ctx.Err() != nil {
return ctx.Err()
}
err := u.upload(ctx, job)
if err == nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
lastErr = err
u.logger.Warnf("upload %s failed (attempt %d/%d): %v", job.localPath, attempt, u.retry, err)
if attempt < u.retry {
select {
case <-time.After(u.retryDelay):
case <-ctx.Done():
return ctx.Err()
}
}
}
return lastErr
}
func (u *Uploader) upload(ctx context.Context, job uploadJob) error {
file, err := os.Open(filepath.Clean(job.localPath))
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory, not a file")
}
// Keep the relative directory structure on the storage side.
storagePath := path.Join(u.destDir, filepath.ToSlash(job.relPath))
uploadCtx := context.WithValue(ctx, ctxkey.ContentLength, info.Size())
if u.overwrite {
uploadCtx = storage.WithOverwrite(uploadCtx)
} else if u.stor.Exists(uploadCtx, storagePath) {
u.logger.Infof("skip existing file: %s", storagePath)
return nil
}
u.logger.Infof("uploading %s -> %s (%d bytes)", job.localPath, storagePath, info.Size())
if err := u.stor.Save(uploadCtx, file, storagePath); err != nil {
return fmt.Errorf("failed to save to storage: %w", err)
}
u.logger.Infof("uploaded %s", storagePath)
return nil
}
// Close stops accepting jobs and waits for in-flight uploads to finish.
func (u *Uploader) Close() {
close(u.jobs)
u.wg.Wait()
}

269
cmd/watch/watcher.go Normal file
View File

@@ -0,0 +1,269 @@
package watch
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/fsnotify/fsnotify"
)
// Watcher watches a local directory and submits stable files to the Uploader.
//
// Write-completion detection: fsnotify emits Write events throughout a write.
// Watcher debounces per file and only uploads once the file size stays
// unchanged across a debounce window, avoiding uploads of partial files.
type Watcher struct {
root string
recursive bool
debounce time.Duration
uploader *Uploader
logger *log.Logger
fsw *fsnotify.Watcher
mu sync.Mutex
pending map[string]*time.Timer
// lastSize is the last observed file size, used to detect a stable write.
lastSize map[string]int64
}
type WatcherOptions struct {
Root string
Recursive bool
Debounce time.Duration
Uploader *Uploader
}
// NewWatcher creates a Watcher.
func NewWatcher(ctx context.Context, opts WatcherOptions) (*Watcher, error) {
if opts.Debounce <= 0 {
opts.Debounce = 2 * time.Second
}
root, err := filepath.Abs(opts.Root)
if err != nil {
return nil, fmt.Errorf("failed to resolve root path: %w", err)
}
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("failed to stat root path: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("watch path must be a directory: %s", root)
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create fsnotify watcher: %w", err)
}
w := &Watcher{
root: root,
recursive: opts.Recursive,
debounce: opts.Debounce,
uploader: opts.Uploader,
logger: log.FromContext(ctx).WithPrefix("watcher"),
fsw: fsw,
pending: make(map[string]*time.Timer),
lastSize: make(map[string]int64),
}
return w, nil
}
// Run starts watching and blocks until ctx is cancelled.
func (w *Watcher) Run(ctx context.Context) error {
if err := w.addDir(w.root); err != nil {
w.fsw.Close()
return fmt.Errorf("failed to watch root: %w", err)
}
w.logger.Infof("watching %s (recursive=%v, debounce=%s)", w.root, w.recursive, w.debounce)
defer w.cleanup()
for {
select {
case <-ctx.Done():
w.logger.Info("stopping watcher")
return nil
case event, ok := <-w.fsw.Events:
if !ok {
return nil
}
w.handleEvent(ctx, event)
case err, ok := <-w.fsw.Errors:
if !ok {
return nil
}
w.logger.Errorf("watch error: %v", err)
}
}
}
func (w *Watcher) handleEvent(ctx context.Context, event fsnotify.Event) {
// Remove/Rename: cancel any pending upload for this path.
if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
w.cancelPending(event.Name)
return
}
if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) {
return
}
info, err := os.Stat(event.Name)
if err != nil {
// File may have been removed or moved; ignore.
return
}
if info.IsDir() {
// New directory: watch it recursively and scan files already inside.
if event.Has(fsnotify.Create) && w.recursive {
if err := w.addDir(event.Name); err != nil {
w.logger.Errorf("failed to watch new dir %s: %v", event.Name, err)
}
w.scanExisting(ctx, event.Name)
}
return
}
w.scheduleUpload(ctx, event.Name)
}
// scheduleUpload schedules a debounced upload for a file.
func (w *Watcher) scheduleUpload(ctx context.Context, file string) {
w.mu.Lock()
defer w.mu.Unlock()
if t, ok := w.pending[file]; ok {
t.Stop()
}
w.pending[file] = time.AfterFunc(w.debounce, func() {
w.maybeUpload(ctx, file)
})
}
// maybeUpload submits the upload once the debounce window passes and the file
// size is stable; otherwise it waits another window.
func (w *Watcher) maybeUpload(ctx context.Context, file string) {
if ctx.Err() != nil {
return
}
info, err := os.Stat(file)
if err != nil {
w.cancelPending(file)
return
}
if info.IsDir() {
w.cancelPending(file)
return
}
w.mu.Lock()
prevSize, seen := w.lastSize[file]
curSize := info.Size()
if !seen || prevSize != curSize {
// Size still changing: likely still being written, wait another window.
w.lastSize[file] = curSize
w.pending[file] = time.AfterFunc(w.debounce, func() {
w.maybeUpload(ctx, file)
})
w.mu.Unlock()
return
}
// Size stable: treat write as complete.
delete(w.pending, file)
delete(w.lastSize, file)
w.mu.Unlock()
relPath, err := filepath.Rel(w.root, file)
if err != nil {
w.logger.Errorf("failed to compute relative path for %s: %v", file, err)
return
}
w.uploader.Submit(ctx, uploadJob{localPath: file, relPath: relPath})
}
func (w *Watcher) cancelPending(file string) {
w.mu.Lock()
defer w.mu.Unlock()
if t, ok := w.pending[file]; ok {
t.Stop()
delete(w.pending, file)
}
delete(w.lastSize, file)
}
// addDir adds a directory to the watch list, recursively when enabled.
func (w *Watcher) addDir(dir string) error {
if !w.recursive {
return w.fsw.Add(dir)
}
return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
w.logger.Warnf("skip path %s: %v", p, err)
return nil
}
if d.IsDir() {
if addErr := w.fsw.Add(p); addErr != nil {
w.logger.Warnf("failed to watch dir %s: %v", p, addErr)
} else {
w.logger.Debugf("watching dir %s", p)
}
}
return nil
})
}
// scanExisting submits files already present under dir (initial sync and new-dir backfill).
func (w *Watcher) scanExisting(ctx context.Context, dir string) {
walkFn := func(p string, d fs.DirEntry, err error) error {
if err != nil {
w.logger.Warnf("skip path %s: %v", p, err)
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if !w.recursive && p != dir {
return fs.SkipDir
}
return nil
}
relPath, relErr := filepath.Rel(w.root, p)
if relErr != nil {
w.logger.Errorf("failed to compute relative path for %s: %v", p, relErr)
return nil
}
w.uploader.Submit(ctx, uploadJob{localPath: p, relPath: relPath})
return nil
}
if err := filepath.WalkDir(dir, walkFn); err != nil && ctx.Err() == nil {
w.logger.Errorf("failed to scan dir %s: %v", dir, err)
}
}
// ScanExisting triggers a one-time scan and upload of existing files under the watch root.
func (w *Watcher) ScanExisting(ctx context.Context) {
w.logger.Info("scanning existing files for initial sync")
w.scanExisting(ctx, w.root)
}
func (w *Watcher) cleanup() {
w.mu.Lock()
for _, t := range w.pending {
t.Stop()
}
w.pending = make(map[string]*time.Timer)
w.lastSize = make(map[string]int64)
w.mu.Unlock()
w.fsw.Close()
}

View File

@@ -36,6 +36,9 @@ const (
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
BotMsgCmdYtdlp Key = "bot.msg.cmd.ytdlp"
BotMsgCommonButtonConflictOverwrite Key = "bot.msg.common.button_conflict_overwrite"
BotMsgCommonButtonConflictRename Key = "bot.msg.common.button_conflict_rename"
BotMsgCommonButtonConflictSkip Key = "bot.msg.common.button_conflict_skip"
BotMsgCommonCancelButtonText Key = "bot.msg.common.cancel_button_text"
BotMsgCommonErrorBuildDirSelectKeyboardFailed Key = "bot.msg.common.error_build_dir_select_keyboard_failed"
BotMsgCommonErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.common.error_build_storage_select_keyboard_failed"
@@ -63,7 +66,10 @@ const (
BotMsgCommonErrorTaskAddFailed Key = "bot.msg.common.error_task_add_failed"
BotMsgCommonErrorTaskCreateFailed Key = "bot.msg.common.error_task_create_failed"
BotMsgCommonErrorUpdateUserInfoFailed Key = "bot.msg.common.error_update_user_info_failed"
BotMsgCommonInfoAllConflictFilesSkipped Key = "bot.msg.common.info_all_conflict_files_skipped"
BotMsgCommonInfoBatchTasksAdded Key = "bot.msg.common.info_batch_tasks_added"
BotMsgCommonInfoBatchTasksAddedWithSkipped Key = "bot.msg.common.info_batch_tasks_added_with_skipped"
BotMsgCommonInfoConflictFilesSkipped Key = "bot.msg.common.info_conflict_files_skipped"
BotMsgCommonInfoDefaultStorageSet Key = "bot.msg.common.info_default_storage_set"
BotMsgCommonInfoDefaultStorageWithDirSet Key = "bot.msg.common.info_default_storage_with_dir_set"
BotMsgCommonInfoFetchingFileInfo Key = "bot.msg.common.info_fetching_file_info"
@@ -73,16 +79,25 @@ const (
BotMsgCommonInfoSilentModeOff Key = "bot.msg.common.info_silent_mode_off"
BotMsgCommonInfoSilentModeOn Key = "bot.msg.common.info_silent_mode_on"
BotMsgCommonInfoTaskAdded Key = "bot.msg.common.info_task_added"
BotMsgCommonPromptConflictMoreFiles Key = "bot.msg.common.prompt_conflict_more_files"
BotMsgCommonPromptSelectConflictStrategy Key = "bot.msg.common.prompt_select_conflict_strategy"
BotMsgCommonPromptSelectDefaultDir Key = "bot.msg.common.prompt_select_default_dir"
BotMsgCommonPromptSelectDefaultStorage Key = "bot.msg.common.prompt_select_default_storage"
BotMsgCommonPromptSelectDir Key = "bot.msg.common.prompt_select_dir"
BotMsgConfigButtonConflictStrategy Key = "bot.msg.config.button_conflict_strategy"
BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy"
BotMsgConfigConflictStrategyAsk Key = "bot.msg.config.conflict_strategy_ask"
BotMsgConfigConflictStrategyOverwrite Key = "bot.msg.config.conflict_strategy_overwrite"
BotMsgConfigConflictStrategyRename Key = "bot.msg.config.conflict_strategy_rename"
BotMsgConfigConflictStrategySkip Key = "bot.msg.config.conflict_strategy_skip"
BotMsgConfigErrorInvalidCallbackData Key = "bot.msg.config.error_invalid_callback_data"
BotMsgConfigErrorInvalidTemplate Key = "bot.msg.config.error_invalid_template"
BotMsgConfigFnametmplHelp Key = "bot.msg.config.fnametmpl_help"
BotMsgConfigInfoConflictStrategySet Key = "bot.msg.config.info_conflict_strategy_set"
BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix"
BotMsgConfigInfoFilenameStrategySet Key = "bot.msg.config.info_filename_strategy_set"
BotMsgConfigInfoTemplateUpdated Key = "bot.msg.config.info_template_updated"
BotMsgConfigPromptSelectConflictStrategy Key = "bot.msg.config.prompt_select_conflict_strategy"
BotMsgConfigPromptSelectFilenameStrategy Key = "bot.msg.config.prompt_select_filename_strategy"
BotMsgConfigPromptSelectOption Key = "bot.msg.config.prompt_select_option"
BotMsgDirButtonDefault Key = "bot.msg.dir.button_default"
@@ -185,6 +200,7 @@ const (
BotMsgRuleErrorGetUserRulesFailed Key = "bot.msg.rule.error_get_user_rules_failed"
BotMsgRuleErrorInvalidRuleId Key = "bot.msg.rule.error_invalid_rule_id"
BotMsgRuleErrorInvalidRuleType Key = "bot.msg.rule.error_invalid_rule_type"
BotMsgRuleErrorStorageNotFound Key = "bot.msg.rule.error_storage_not_found"
BotMsgRuleErrorUpdateUserFailed Key = "bot.msg.rule.error_update_user_failed"
BotMsgRuleHelpAddSuffix Key = "bot.msg.rule.help_add_suffix"
BotMsgRuleHelpAvailableOps Key = "bot.msg.rule.help_available_ops"
@@ -192,13 +208,16 @@ const (
BotMsgRuleHelpCurrentModeEnabled Key = "bot.msg.rule.help_current_mode_enabled"
BotMsgRuleHelpDelSuffix Key = "bot.msg.rule.help_del_suffix"
BotMsgRuleHelpExistingRulesPrefix Key = "bot.msg.rule.help_existing_rules_prefix"
BotMsgRuleHelpPresetSuffix Key = "bot.msg.rule.help_preset_suffix"
BotMsgRuleHelpSwitchSuffix Key = "bot.msg.rule.help_switch_suffix"
BotMsgRuleHelpUsage Key = "bot.msg.rule.help_usage"
BotMsgRuleInfoCreateRuleSuccess Key = "bot.msg.rule.info_create_rule_success"
BotMsgRuleInfoDeleteRuleSuccess Key = "bot.msg.rule.info_delete_rule_success"
BotMsgRuleInfoPresetImported Key = "bot.msg.rule.info_preset_imported"
BotMsgRuleInfoRuleModeDisabled Key = "bot.msg.rule.info_rule_mode_disabled"
BotMsgRuleInfoRuleModeEnabled Key = "bot.msg.rule.info_rule_mode_enabled"
BotMsgRulePromptProvideRuleId Key = "bot.msg.rule.prompt_provide_rule_id"
BotMsgRulePromptProvideStorageName Key = "bot.msg.rule.prompt_provide_storage_name"
BotMsgSaveErrorInvalidIdOrUsername Key = "bot.msg.save.error_invalid_id_or_username"
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
BotMsgStorageInfoFilenamePrefix Key = "bot.msg.storage.info_filename_prefix"

View File

@@ -112,9 +112,17 @@ bot:
error_task_add_failed: "Failed to add task: {{.Error}}"
info_task_added: "Task added"
info_batch_tasks_added: "Batch tasks added, total {{.Count}} files"
info_batch_tasks_added_with_skipped: "Batch tasks added, total {{.Count}} files\nSkipped conflicting files:\n{{.Skipped}}"
info_all_conflict_files_skipped: "All conflicting files were skipped:\n{{.Skipped}}"
info_conflict_files_skipped: "Skipped conflicting files:\n{{.Skipped}}"
error_task_create_failed: "Failed to create task: {{.Error}}"
error_get_dir_failed: "Failed to get directory: {{.Error}}"
prompt_select_dir: "Please select a directory to store to"
prompt_select_conflict_strategy: "Files with the same name already exist. Please select a save strategy:\n{{.Files}}"
prompt_conflict_more_files: "...and {{.Count}} more files"
button_conflict_rename: "Rename"
button_conflict_overwrite: "Overwrite"
button_conflict_skip: "Skip"
prompt_select_default_dir: "Please select a default directory to save to"
info_default_storage_set: "Default storage set to: {{.Name}}"
info_default_storage_with_dir_set: "Default storage set to: {{.Name}}:/{{.Dir}}"
@@ -188,7 +196,11 @@ bot:
help_switch_suffix: " - Toggle rule mode\n"
help_add_suffix: " <type> <data> <storage_name> <path> - Add rule\n"
help_del_suffix: " <rule_id> - Delete rule\n"
help_preset_suffix: " <storage_name> [base_path] - Import built-in filetype rules (video/image/audio/document/archive)\n"
help_existing_rules_prefix: "\nCurrent rules:\n"
prompt_provide_storage_name: "Please provide a storage name"
error_storage_not_found: "Storage not found: {{.Storage}}"
info_preset_imported: "Imported {{.Count}} built-in classification rules into storage {{.Storage}}"
dir:
error_get_user_dirs_failed: "Failed to get user directories"
error_get_user_failed: "Failed to get user"
@@ -266,10 +278,17 @@ bot:
config:
prompt_select_option: "Please select an option to configure"
button_filename_strategy: "Filename strategy"
button_conflict_strategy: "Duplicate file strategy"
error_invalid_callback_data: "Invalid callback data"
error_invalid_template: "Invalid template, please check syntax\n{{.Error}}"
info_filename_strategy_set: "Filename strategy set to: {{.Strategy}}"
info_conflict_strategy_set: "Duplicate file strategy set to: {{.Strategy}}"
prompt_select_filename_strategy: "Please select filename strategy, current strategy: {{.Strategy}}"
prompt_select_conflict_strategy: "Please select duplicate file strategy, current strategy: {{.Strategy}}"
conflict_strategy_rename: "Always rename"
conflict_strategy_ask: "Ask every time"
conflict_strategy_overwrite: "Always overwrite"
conflict_strategy_skip: "Always skip"
fnametmpl_help: |-
Use this command to set filename template, for example:
/fnametmpl Image_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg

View File

@@ -113,9 +113,17 @@ bot:
error_task_add_failed: "任务添加失败: {{.Error}}"
info_task_added: "任务已添加"
info_batch_tasks_added: "已添加批量任务, 共 {{.Count}} 个文件"
info_batch_tasks_added_with_skipped: "已添加批量任务, 共 {{.Count}} 个文件\n已跳过同名文件:\n{{.Skipped}}"
info_all_conflict_files_skipped: "全部同名文件已跳过:\n{{.Skipped}}"
info_conflict_files_skipped: "已跳过同名文件:\n{{.Skipped}}"
error_task_create_failed: "任务创建失败: {{.Error}}"
error_get_dir_failed: "获取目录失败: {{.Error}}"
prompt_select_dir: "请选择要存储到的目录"
prompt_select_conflict_strategy: "检测到同名文件, 请选择保存策略:\n{{.Files}}"
prompt_conflict_more_files: "...还有 {{.Count}} 个文件"
button_conflict_rename: "重命名"
button_conflict_overwrite: "覆盖"
button_conflict_skip: "跳过"
prompt_select_default_dir: "请选择要保存到的默认文件夹"
info_default_storage_set: "已将默认存储位置设为: {{.Name}}"
info_default_storage_with_dir_set: "已将默认存储位置设为: {{.Name}}:/{{.Dir}}"
@@ -189,7 +197,11 @@ bot:
help_switch_suffix: " - 开关规则模式\n"
help_add_suffix: " <类型> <数据> <存储名> <路径> - 添加规则\n"
help_del_suffix: " <规则ID> - 删除规则\n"
help_preset_suffix: " <存储名> [基础路径] - 导入内置文件类型分类规则(视频/图片/音频/文档/压缩包)\n"
help_existing_rules_prefix: "\n当前已添加的规则:\n"
prompt_provide_storage_name: "请提供存储名称"
error_storage_not_found: "未找到存储: {{.Storage}}"
info_preset_imported: "已导入 {{.Count}} 条内置分类规则到存储 {{.Storage}}"
dir:
error_get_user_dirs_failed: "获取用户文件夹失败"
error_get_user_failed: "获取用户失败"
@@ -267,10 +279,17 @@ bot:
config:
prompt_select_option: "请选择要配置的选项"
button_filename_strategy: "文件名策略"
button_conflict_strategy: "重名文件保存策略"
error_invalid_callback_data: "无效的回调数据"
error_invalid_template: "无效的模板, 请检查语法\n{{.Error}}"
info_filename_strategy_set: "已将文件名策略设置为: {{.Strategy}}"
info_conflict_strategy_set: "已将重名文件保存策略设置为: {{.Strategy}}"
prompt_select_filename_strategy: "请选择文件名策略, 当前策略: {{.Strategy}}"
prompt_select_conflict_strategy: "请选择重名文件保存策略, 当前策略: {{.Strategy}}"
conflict_strategy_rename: "始终重命名"
conflict_strategy_ask: "每次询问"
conflict_strategy_overwrite: "始终覆盖"
conflict_strategy_skip: "始终跳过"
fnametmpl_help: |-
使用该命令设置文件名模板, 示例:
/fnametmpl 图片_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg

View File

@@ -5,6 +5,10 @@ retry = 3 # 下载失败重试次数
threads = 4 # 单个任务下载使用的最大线程数
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
[log]
# 日志级别, 可选: debug, info, warn, error, fatal
level = "debug"
[telegram]
# Bot Token
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
@@ -29,6 +33,17 @@ secret = ""
# 转存完成后删除 Aria2 下载的本地文件
remove_after_transfer = true
# yt-dlp 视频下载配置
[ytdlp]
# 默认下载的最高视频清晰度 (按高度限制), 如 1080, 720, 480; 0 表示不限制 (下载最佳画质)
# 仅在使用 /ytdlp 命令且未手动指定任何参数时生效
max_height = 1080
# 直接指定 yt-dlp format 选择表达式, 留空则使用 max_height
# 设置后优先级高于 max_height, 例如: "bv*[height<=720]+ba/b"
format = ""
# 下载后转封装的视频容器格式, 留空则不转封装. 默认 mp4
recode = "mp4"
# HTTP API 配置
[api]
# 启用 HTTP API
@@ -73,4 +88,4 @@ blacklist = true
[[users]]
id = 123456
storages = ["本机1"]
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储

View File

@@ -17,6 +17,7 @@ func RegisterFlags(cmd *cobra.Command) {
flags.Bool("stream", false, "enable stream mode")
flags.Bool("no-clean-cache", false, "do not clean cache on exit")
flags.String("proxy", "", "proxy URL (http, https, socks5, socks5h)")
flags.String("log-level", "", "log level (trace/debug, info, warn, error, fatal)")
// Telegram 配置
flags.String("telegram-token", "", "telegram bot token")
@@ -54,6 +55,7 @@ func bindFlags(cmd *cobra.Command) {
viper.BindPFlag("stream", flags.Lookup("stream"))
viper.BindPFlag("no_clean_cache", flags.Lookup("no-clean-cache"))
viper.BindPFlag("proxy", flags.Lookup("proxy"))
viper.BindPFlag("log.level", flags.Lookup("log-level"))
// Telegram
viper.BindPFlag("telegram.token", flags.Lookup("telegram-token"))

5
config/log.go Normal file
View File

@@ -0,0 +1,5 @@
package config
type logConfig struct {
Level string `toml:"level" mapstructure:"level" json:"level"`
}

View File

@@ -23,6 +23,7 @@ type Config struct {
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
Log logConfig `toml:"log" mapstructure:"log" json:"log"`
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
API apiConfig `toml:"api" mapstructure:"api" json:"api"`
@@ -34,6 +35,7 @@ type Config struct {
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
Parser parserConfig `toml:"parser" mapstructure:"parser" json:"parser"`
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
Ytdlp YtdlpConfig `toml:"ytdlp" mapstructure:"ytdlp" json:"ytdlp"`
}
type aria2Config struct {
@@ -100,10 +102,11 @@ func Init(ctx context.Context, configFile ...string) error {
defaultConfigs := map[string]any{
// 基础配置
"lang": "zh-Hans",
"workers": 3,
"retry": 3,
"threads": 4,
"lang": "zh-Hans",
"workers": 3,
"retry": 3,
"threads": 4,
"log.level": "debug",
// 缓存配置
"cache.ttl": 86400,
@@ -129,18 +132,15 @@ func Init(ctx context.Context, configFile ...string) error {
"api.host": "0.0.0.0",
"api.port": 8080,
"api.token": "",
// yt-dlp
"ytdlp.recode": "mp4",
}
for key, value := range defaultConfigs {
viper.SetDefault(key, value)
}
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
return fmt.Errorf("error saving default config: %w", err)
}
}
if err := viper.ReadInConfig(); err != nil {
fmt.Println("Error reading config file, ", err)
return err

13
config/ytdlp.go Normal file
View File

@@ -0,0 +1,13 @@
package config
type YtdlpConfig struct {
// MaxHeight limits the video resolution by height in pixels (e.g. 1080, 720).
// 0 means no limit (best available). Ignored when Format is set.
MaxHeight int `toml:"max_height" mapstructure:"max_height" json:"max_height"`
// Format is a raw yt-dlp format selector (-f). When set, it takes precedence
// over MaxHeight and gives the user full control.
Format string `toml:"format" mapstructure:"format" json:"format"`
// Recode is the target video container yt-dlp recodes into (e.g. mp4).
// Empty disables recoding.
Recode string `toml:"recode" mapstructure:"recode" json:"recode"`
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/queue"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
var queueInstance *queue.TaskQueue[Executable]
@@ -30,11 +31,14 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Executable], semaphore chan
break // queue closed and empty
}
exe := qtask.Data
taskCtx := qtask.Context()
logger.Infof("Processing task: %s", exe.TaskID())
if err := ExecCommandString(qtask.Context(), execHooks.TaskBeforeStart); err != nil {
taskevent.Emit(taskCtx, taskevent.Event{TaskID: exe.TaskID(), Phase: taskevent.PhaseStart})
if err := ExecCommandString(taskCtx, execHooks.TaskBeforeStart); err != nil {
logger.Errorf("Failed to execute before start hook for task %s: %v", exe.TaskID(), err)
}
if err := exe.Execute(qtask.Context()); err != nil {
err = exe.Execute(taskCtx)
if err != nil {
if errors.Is(err, context.Canceled) {
logger.Infof("Task %s was canceled", exe.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskCancel); err != nil {
@@ -52,6 +56,7 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Executable], semaphore chan
logger.Errorf("Failed to execute success hook for task %s: %v", exe.TaskID(), err)
}
}
taskevent.Emit(taskCtx, taskevent.Event{TaskID: exe.TaskID(), Phase: taskevent.PhaseDone, Err: err})
qe.Done(qtask.ID)
<-semaphore
}

View File

@@ -6,12 +6,14 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
// Execute implements core.Executable.
@@ -77,6 +79,12 @@ func (t *Task) waitForDownload(ctx context.Context) error {
if t.Progress != nil {
t.Progress.OnProgress(ctx, t, status)
}
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: parseInt64(status.TotalLength),
DownloadedBytes: parseInt64(status.CompletedLength),
})
// Check if download is complete
if status.IsDownloadComplete() {
@@ -248,3 +256,16 @@ func (t *Task) cancelAria2Download() {
logger.Debugf("Failed to remove download result for %s: %v", t.GID, err)
}
}
// parseInt64 parses an aria2 status string (decimal bytes) into int64,
// returning 0 on failure so it can be used directly in progress events.
func parseInt64(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
"golang.org/x/sync/errgroup"
)
@@ -62,8 +63,14 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
return elem.Storage.Save(uploadCtx, pr, elem.Path)
})
wr := ioutil.NewProgressWriter(pw, func(n int) {
t.downloaded.Add(int64(n))
downloaded := t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: t.totalSize,
DownloadedBytes: downloaded,
})
})
errg.Go(func() error {
defer pw.Close()
@@ -92,8 +99,14 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
}
}()
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
t.downloaded.Add(int64(n))
downloaded := t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: t.totalSize,
DownloadedBytes: downloaded,
})
})
_, err = tdler.NewDownloader(elem.File).Parallel(ctx, wrAt)
if err != nil {

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -30,6 +31,7 @@ type Progress struct {
ChatID int64
start time.Time
lastUpdatePercent atomic.Int32
skippedFiles []string
}
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
@@ -151,6 +153,14 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
styling.Code(strconv.Itoa(info.Count())),
styling.Plain(i18n.T(i18nk.BotMsgProgressTotalSizePrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))),
func() styling.StyledTextOption {
if len(p.skippedFiles) == 0 {
return styling.Plain("")
}
return styling.Plain("\n\n" + i18n.T(i18nk.BotMsgCommonInfoConflictFilesSkipped, map[string]any{
"Skipped": strings.Join(p.skippedFiles, "\n"),
}))
}(),
)
}
@@ -173,8 +183,13 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
}
func NewProgressTracker(messageID int, chatID int64) ProgressTracker {
return NewProgressTrackerWithSkipped(messageID, chatID, nil)
}
func NewProgressTrackerWithSkipped(messageID int, chatID int64, skippedFiles []string) ProgressTracker {
return &Progress{
MessageID: messageID,
ChatID: chatID,
MessageID: messageID,
ChatID: chatID,
skippedFiles: skippedFiles,
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
"golang.org/x/sync/errgroup"
)
@@ -143,10 +144,16 @@ func (t *Task) processLink(ctx context.Context, file *File) error {
}
}()
wr := ioutil.NewProgressWriter(cacheFile, func(n int) {
t.downloadedBytes.Add(int64(n))
downloaded := t.downloadedBytes.Add(int64(n))
if t.Progress != nil {
t.Progress.OnProgress(ctx, t)
}
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: t.totalBytes,
DownloadedBytes: downloaded,
})
})
copyResultCh := make(chan error, 1)

View File

@@ -16,6 +16,7 @@ import (
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/parser"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
"golang.org/x/sync/errgroup"
)
@@ -107,10 +108,16 @@ func (t *Task) processResource(ctx context.Context, resource parser.Resource) er
}
}()
wr := ioutil.NewProgressWriter(cacheFile, func(n int) {
t.downloadedBytes.Add(int64(n))
downloaded := t.downloadedBytes.Add(int64(n))
if t.progress != nil {
t.progress.OnProgress(ctx, t)
}
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: t.totalBytes,
DownloadedBytes: downloaded,
})
})
copyResultCh := make(chan error, 1)

View File

@@ -11,6 +11,7 @@ import (
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
"golang.org/x/sync/errgroup"
)
@@ -27,8 +28,14 @@ func (t *Task) Execute(ctx context.Context) error {
logger.Errorf("Error processing picture %s: %v", pic, err)
return fmt.Errorf("failed to process picture %s: %w", pic, err)
}
t.downloaded.Add(1)
downloaded := t.downloaded.Add(1)
t.progress.OnProgress(gctx, t)
taskevent.Emit(gctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalFiles: t.totalpics,
DownloadedFiles: int(downloaded),
})
return nil
})
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"io"
"sync/atomic"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
type ProgressWriterAt struct {
@@ -20,9 +22,16 @@ func (w *ProgressWriterAt) WriteAt(p []byte, off int64) (int, error) {
if err != nil {
return 0, err
}
downloaded := w.downloaded.Add(int64(at))
if w.progress != nil {
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
w.progress.OnProgress(w.ctx, w.info, downloaded, w.total)
}
taskevent.Emit(w.ctx, taskevent.Event{
TaskID: w.info.TaskID(),
Phase: taskevent.PhaseProgress,
TotalBytes: w.total,
DownloadedBytes: downloaded,
})
return at, nil
}
@@ -56,9 +65,16 @@ func (w *ProgressWriter) Write(p []byte) (int, error) {
if err != nil {
return 0, err
}
downloaded := w.downloaded.Add(int64(at))
if w.progress != nil {
w.progress.OnProgress(w.ctx, w.info, w.downloaded.Add(int64(at)), w.total)
w.progress.OnProgress(w.ctx, w.info, downloaded, w.total)
}
taskevent.Emit(w.ctx, taskevent.Event{
TaskID: w.info.TaskID(),
Phase: taskevent.PhaseProgress,
TotalBytes: w.total,
DownloadedBytes: downloaded,
})
return at, nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
"github.com/krau/SaveAny-Bot/storage"
"golang.org/x/sync/errgroup"
)
@@ -116,6 +117,12 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
t.uploaded.Add(size)
t.Progress.OnProgress(ctx, t)
taskevent.Emit(ctx, taskevent.Event{
TaskID: t.ID,
Phase: taskevent.PhaseProgress,
TotalBytes: t.totalSize,
DownloadedBytes: t.uploaded.Load(),
})
logger.Info("File uploaded successfully")
return nil

View File

@@ -85,12 +85,10 @@ func (t *Task) downloadFiles(ctx context.Context, tempDir string) ([]string, err
cmd := ytdlp.New().
Output(filepath.Join(tempDir, "%(title)s.%(ext)s"))
// If no custom flags are provided, use default behavior
// Apply config-based format/quality defaults only when the user passes no
// custom flags. Any user flag means they take full control of yt-dlp.
if len(t.Flags) == 0 {
cmd = cmd.
FormatSort("res,ext:mp4:m4a").
RecodeVideo("mp4").
RestrictFilenames()
cmd = applyFormatConfig(cmd, config.C().Ytdlp)
}
// Note: If custom flags are provided, users have full control over format/quality
// The output path is always set above to ensure downloads go to the correct directory

View File

@@ -0,0 +1,40 @@
package ytdlp
import (
"strconv"
ytdlp "github.com/lrstanley/go-ytdlp"
"github.com/krau/SaveAny-Bot/config"
)
// buildFormatSelector translates a max height into a yt-dlp format selector.
// It prefers merging the best video+audio within the height limit, then falls
// back to a single muxed stream. An empty result means "no explicit selector".
func buildFormatSelector(maxHeight int) string {
if maxHeight <= 0 {
return ""
}
h := strconv.Itoa(maxHeight)
return "bv*[height<=" + h + "]+ba/b[height<=" + h + "]/b"
}
// applyFormatConfig configures format/quality on the yt-dlp command according to
// the ytdlp config. It is only meant to be called when the user did not supply
// any custom flags, so config-driven defaults never conflict with user input.
func applyFormatConfig(cmd *ytdlp.Command, cfg config.YtdlpConfig) *ytdlp.Command {
switch {
case cfg.Format != "":
cmd = cmd.Format(cfg.Format)
case cfg.MaxHeight > 0:
cmd = cmd.Format(buildFormatSelector(cfg.MaxHeight))
default:
// Preserve the original default: prefer highest resolution mp4/m4a.
cmd = cmd.FormatSort("res,ext:mp4:m4a")
}
if cfg.Recode != "" {
cmd = cmd.RecodeVideo(cfg.Recode)
}
cmd = cmd.RestrictFilenames()
return cmd
}

View File

@@ -0,0 +1,23 @@
package ytdlp
import "testing"
func TestBuildFormatSelector(t *testing.T) {
tests := []struct {
name string
maxHeight int
want string
}{
{"no limit", 0, ""},
{"negative", -1, ""},
{"1080p", 1080, "bv*[height<=1080]+ba/b[height<=1080]/b"},
{"720p", 720, "bv*[height<=720]+ba/b[height<=720]/b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildFormatSelector(tt.maxHeight); got != tt.want {
t.Errorf("buildFormatSelector(%d) = %q, want %q", tt.maxHeight, got, tt.want)
}
})
}
}

View File

@@ -3,7 +3,6 @@
package database
import (
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/ncruces/go-sqlite3/gormlite"
"gorm.io/gorm"
)

View File

@@ -16,6 +16,7 @@ type User struct {
WatchChats []WatchChat
FilenameStrategy string
FilenameTemplate string
ConflictStrategy string
}
type WatchChat struct {

View File

@@ -9,10 +9,5 @@ if [ -n "$CONFIG_URL" ]; then
exit 1
fi
fi
if [ ! -f /app/config.toml ]; then
echo "[ERROR] Missing config.toml: Please provide the configuration file via mounting or CONFIG_URL"
exit 1
fi
exec /app/saveany-bot

96
go.mod
View File

@@ -9,61 +9,61 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/charmbracelet/log v1.0.0
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.13
github.com/goccy/go-yaml v1.19.2
github.com/gotd/contrib v0.21.1
github.com/gotd/td v0.140.0
github.com/gotd/td v0.143.0
github.com/johannesboyne/gofakes3 v0.0.0-20250916175020-ebf3e50324d3
github.com/krau/ffmpeg-go v0.6.0
github.com/lrstanley/go-ytdlp v1.3.2
github.com/minio/minio-go/v7 v7.0.98
github.com/lrstanley/go-ytdlp v1.3.5
github.com/minio/minio-go/v7 v7.2.0
github.com/playwright-community/playwright-go v0.5700.1
github.com/rs/xid v1.6.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/unvgo/ghselfupdate v1.0.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/net v0.51.0
golang.org/x/term v0.40.0
golang.org/x/time v0.14.0
golang.org/x/net v0.56.0
golang.org/x/term v0.44.0
golang.org/x/time v0.15.0
)
require (
github.com/AnimeKaizoku/cacher v1.0.3 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/deckarep/golang-set/v2 v2.9.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-faster/jx v1.2.0 // indirect
github.com/go-faster/xor v1.0.0 // indirect
github.com/go-faster/yaml v0.4.6 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-stack/stack v1.8.1 // 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-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
@@ -72,19 +72,20 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-sqlite3-wasm/v3 v3.1.35302 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/ogen-go/ogen v1.20.1 // indirect
github.com/ogen-go/ogen v1.22.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -92,40 +93,43 @@ require (
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/tools v0.46.0 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.69.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
modernc.org/sqlite v1.53.0 // indirect
rsc.io/qr v0.2.0 // indirect
)
require (
github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
github.com/duke-git/lancet/v2 v2.3.8
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
github.com/duke-git/lancet/v2 v2.3.9
github.com/fsnotify/fsnotify v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.30.5
github.com/ncruces/go-sqlite3/gormlite v0.30.2
github.com/ncruces/go-sqlite3 v0.35.1 // indirect
github.com/ncruces/go-sqlite3/gormlite v0.34.0
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.4.2 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@@ -133,9 +137,9 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/sync v0.19.0
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0
gorm.io/gorm v1.31.1
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0
gorm.io/gorm v1.31.2
)

225
go.sum
View File

@@ -2,10 +2,10 @@ github.com/AnimeKaizoku/cacher v1.0.3 h1:foNAmLfY/DXfA4yEy4uP6WK2Ni7JC+s3QhZv72D
github.com/AnimeKaizoku/cacher v1.0.3/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
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/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/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/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
@@ -48,16 +48,16 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
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/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
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/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
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=
@@ -66,36 +66,38 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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.9.0 h1:prva4eP9UysWagLyKrtn074ughi0NnkIf0A4M5yOCKI=
github.com/deckarep/golang-set/v2 v2.9.0/go.mod h1:EWknQXbs0mcFpat2QOoXV0Ee57cD+w6ZEN76BR2JVrM=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
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/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c h1:hIlkLbQ+tYoUqlG42LnxwGcohL5jaGqD8mGeJWavm8A=
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/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/duke-git/lancet/v2 v2.3.9 h1:ZxUvfoEY7YbsGIeoXRxHWIkRCAt6VN7UBKWgCCqBB3U=
github.com/duke-git/lancet/v2 v2.3.9/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
@@ -113,10 +115,8 @@ github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -141,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.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
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/gotd/contrib v0.21.1 h1:NSF+0YEnosQ34QEo2o4s6MA5YFDAor1LVvLhN1L3H1M=
@@ -151,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/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.140.0 h1:trNBzTnhNtNwHsFp5qwKnNxQRAZJ6/BRE+uH3Lojauk=
github.com/gotd/td v0.140.0/go.mod h1:0ZkRxG7N+5ooG7/zdRXcnGautGPM6IKmyPQvdsAeF20=
github.com/gotd/td v0.143.0 h1:p0U/Nn92zXmAsahDn5CIVzay2kQ36lBBENT/FlWR2nQ=
github.com/gotd/td v0.143.0/go.mod h1:8GA5ecTI5iswLwBAlqf0u6/+j+BqSWUARSrX2Xk1usQ=
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/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
@@ -165,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/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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -178,24 +178,26 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:sa7/bWHB6fO9j4lhmxnWQ1U07o+dE1leFjhctotxU7A=
github.com/lrstanley/go-ytdlp v1.3.2 h1:ktOav5X8+ZByuaQPFUF3uiPxofw0L5MoQtck6iIkWhI=
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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/lrstanley/go-ytdlp v1.3.5 h1:eT+29mK3Lp+XPMQOH25+jVerrrjifYW1o3IkTYJ9SMs=
github.com/lrstanley/go-ytdlp v1.3.5/go.mod h1:VgjnTrvkTf+23JuySjyPq1iQ8ijSovBtTPpXH5XrLtI=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
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-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/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/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.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -204,20 +206,22 @@ 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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
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/go.mod h1:W9WLBbqrrOIh2dqFZkeC/xKALG2LDIHY91jowahOdtI=
github.com/ncruces/go-sqlite3 v0.35.1 h1:h/LaVyQwIvBBT0+2JmVe2tbYyWjUQ093/pYhpBqdxJo=
github.com/ncruces/go-sqlite3 v0.35.1/go.mod h1:fXOSIkWwN5NXgbJk+7Zls8QIW4xOflmgh11OFvcY+J0=
github.com/ncruces/go-sqlite3-wasm/v3 v3.1.35302 h1:Cew7/eNAMd1zhpXYBjofBua/63pFvbvB2h4PM/p6gKU=
github.com/ncruces/go-sqlite3-wasm/v3 v3.1.35302/go.mod h1:xe0CfafDUxfh+fSVKjHHMiAxoG9KALt5nFtbGNb/jRs=
github.com/ncruces/go-sqlite3/gormlite v0.34.0 h1:QLlOy/i7OabsFUQ+d5KyXmq2hw9sMh/CRW435+eQMRY=
github.com/ncruces/go-sqlite3/gormlite v0.34.0/go.mod h1:CMv+6YhqLmPBXYACiQtrWA0q/JLIMTKB4E65SUfLgF0=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
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/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/ogen-go/ogen v1.20.1 h1:AFpIeI2rS37TNIMRQTHhAkThICQpa1p+Pceu7HP7xsA=
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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/ogen-go/ogen v1.22.0 h1:7wU+jcIKg/JBAhM95909ULLdAkGr43KQOuvNpJ7Mxb4=
github.com/ogen-go/ogen v1.22.0/go.mod h1:7BOh9a51QiPCC92RMrj1LlkLjejhBAyPhR+oMc6lR9g=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -255,16 +259,20 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/unvgo/ghselfupdate v1.0.1 h1:4clbOkfPbfEmRnnYxVXDSBs0JG12DO+0FfqplJckreU=
@@ -274,16 +282,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
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/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -292,36 +308,36 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
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-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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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-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.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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -329,39 +345,40 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
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.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.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
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/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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.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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -370,32 +387,34 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
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/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
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/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
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/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,6 +1,6 @@
package ctxkey
// ENUM(content-length)
// ENUM(content-length, overwrite-existing)
//
//go:generate go-enum --values --names --flag --nocase --noprefix
type ContextKey string

View File

@@ -14,12 +14,15 @@ import (
const (
// ContentLength is a ContextKey of type content-length.
ContentLength ContextKey = "content-length"
// OverwriteExisting is a ContextKey of type overwrite-existing.
OverwriteExisting ContextKey = "overwrite-existing"
)
var ErrInvalidContextKey = fmt.Errorf("not a valid ContextKey, try [%s]", strings.Join(_ContextKeyNames, ", "))
var _ContextKeyNames = []string{
string(ContentLength),
string(OverwriteExisting),
}
// ContextKeyNames returns a list of possible string values of ContextKey.
@@ -33,6 +36,7 @@ func ContextKeyNames() []string {
func ContextKeyValues() []ContextKey {
return []ContextKey{
ContentLength,
OverwriteExisting,
}
}
@@ -49,7 +53,8 @@ func (x ContextKey) IsValid() bool {
}
var _ContextKeyValue = map[string]ContextKey{
"content-length": ContentLength,
"content-length": ContentLength,
"overwrite-existing": OverwriteExisting,
}
// ParseContextKey attempts to convert a string to a ContextKey.

55
pkg/rule/preset.go Normal file
View File

@@ -0,0 +1,55 @@
package rule
import "path"
// PresetCategory describes a built-in filetype classification: files whose name
// matches Regex are routed into the Dir subdirectory (joined with a user base path).
type PresetCategory struct {
// Name is a stable identifier for the category (used in logs/messages).
Name string
// Regex is a FILENAME-REGEX rule data string matching this category's extensions.
Regex string
// Dir is the default subdirectory name for this category.
Dir string
}
// presetCategories holds the default filetype classification rules.
// Regexes are case-insensitive and match common file extensions.
var presetCategories = []PresetCategory{
{
Name: "video",
Regex: `(?i)\.(mp4|mkv|ts|avi|flv|mov|webm|wmv|rmvb|m2ts)$`,
Dir: "视频",
},
{
Name: "image",
Regex: `(?i)\.(jpg|jpeg|png|gif|webp|bmp)$`,
Dir: "图片",
},
{
Name: "audio",
Regex: `(?i)\.(mp3|flac|wav|aac|m4a|ogg)$`,
Dir: "音频",
},
{
Name: "document",
Regex: `(?i)\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md|csv|epub|mobi|azw3|chm)$`,
Dir: "文档",
},
{
Name: "archive",
Regex: `(?i)\.(zip|rar|7z|tar|gz|bz2|xz|r\d{1,3}|z\d{1,3}|\d{3}|part\d+\.rar|7z\.\d{3})$`,
Dir: "压缩包",
},
}
// PresetCategories returns the built-in filetype classification rules with each
// category's directory joined under basePath. basePath may be empty.
func PresetCategories(basePath string) []PresetCategory {
out := make([]PresetCategory, len(presetCategories))
for i, c := range presetCategories {
c.Dir = path.Join(basePath, c.Dir)
out[i] = c
}
return out
}

55
pkg/rule/preset_test.go Normal file
View File

@@ -0,0 +1,55 @@
package rule
import (
"regexp"
"testing"
)
func TestPresetCategoriesCompile(t *testing.T) {
for _, c := range PresetCategories("") {
if _, err := regexp.Compile(c.Regex); err != nil {
t.Errorf("preset %q has invalid regex %q: %v", c.Name, c.Regex, err)
}
}
}
func TestPresetCategoriesMatch(t *testing.T) {
cases := map[string]string{
"video": "movie.MP4",
"image": "photo.jpg",
"audio": "song.flac",
"document": "report.pdf",
"archive": "backup.zip",
}
byName := make(map[string]*regexp.Regexp)
for _, c := range PresetCategories("") {
byName[c.Name] = regexp.MustCompile(c.Regex)
}
for name, filename := range cases {
re, ok := byName[name]
if !ok {
t.Errorf("missing preset category %q", name)
continue
}
if !re.MatchString(filename) {
t.Errorf("preset %q did not match %q", name, filename)
}
}
}
func TestPresetCategoriesBasePath(t *testing.T) {
presets := PresetCategories("/media")
for _, c := range presets {
if c.Dir == "" || c.Dir[0] != '/' {
t.Errorf("preset %q dir %q not joined under base path", c.Name, c.Dir)
}
}
// Empty base path must not prefix a separator.
for _, c := range PresetCategories("") {
if c.Dir == "" || c.Dir[0] == '/' {
t.Errorf("preset %q dir %q should be relative when base path empty", c.Name, c.Dir)
}
}
}

View File

@@ -133,7 +133,7 @@ func (c *Client) Put(ctx context.Context, key string, r io.Reader, size int64) e
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("put object failed: %s", resp.Status)
return responseError("put object", resp)
}
return nil
}
@@ -170,10 +170,21 @@ func signRequest(req *http.Request, region, accessKey, secretKey string, payload
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHash)
// Canonical headers
var headers []string
// Canonical headers. Host is required by SigV4, but Go stores it on
// Request.Host/URL.Host rather than in Request.Header.
headerValues := map[string]string{
"host": req.URL.Host,
}
if req.Host != "" {
headerValues["host"] = req.Host
}
for k := range req.Header {
headers = append(headers, strings.ToLower(k))
headerValues[strings.ToLower(k)] = strings.TrimSpace(req.Header.Get(k))
}
var headers []string
for k := range headerValues {
headers = append(headers, k)
}
sort.Strings(headers)
@@ -181,7 +192,7 @@ func signRequest(req *http.Request, region, accessKey, secretKey string, payload
for _, k := range headers {
canonicalHeaders.WriteString(k)
canonicalHeaders.WriteString(":")
canonicalHeaders.WriteString(strings.TrimSpace(req.Header.Get(k)))
canonicalHeaders.WriteString(headerValues[k])
canonicalHeaders.WriteString("\n")
}
@@ -189,7 +200,7 @@ func signRequest(req *http.Request, region, accessKey, secretKey string, payload
canonicalRequest := strings.Join([]string{
req.Method,
req.URL.EscapedPath(),
canonicalURI(req.URL.Path),
req.URL.RawQuery,
canonicalHeaders.String(),
signedHeaders,
@@ -219,3 +230,54 @@ func signRequest(req *http.Request, region, accessKey, secretKey string, payload
req.Header.Set("Authorization", auth)
return nil
}
func responseError(operation string, resp *http.Response) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return fmt.Errorf("%s failed: %s", operation, resp.Status)
}
message := strings.TrimSpace(string(body))
if message == "" {
return fmt.Errorf("%s failed: %s", operation, resp.Status)
}
return fmt.Errorf("%s failed: %s: %s", operation, resp.Status, message)
}
func canonicalURI(path string) string {
if path == "" {
return "/"
}
var b strings.Builder
for i := 0; i < len(path); i++ {
c := path[i]
if shouldEscapePathByte(c) {
b.WriteByte('%')
b.WriteByte("0123456789ABCDEF"[c>>4])
b.WriteByte("0123456789ABCDEF"[c&15])
continue
}
b.WriteByte(c)
}
return b.String()
}
func shouldEscapePathByte(c byte) bool {
if c >= 'A' && c <= 'Z' {
return false
}
if c >= 'a' && c <= 'z' {
return false
}
if c >= '0' && c <= '9' {
return false
}
switch c {
case '-', '.', '_', '~', '/':
return false
default:
return true
}
}

View File

@@ -0,0 +1,88 @@
// Package taskevent provides a decoupled, context-scoped event bus for task
// lifecycle progress. Producers (task implementations) emit events via Emit;
// consumers (e.g. the API progress store, the Telegram message editor) register
// as Sinks and are injected through context. This keeps the task layer free of
// any concrete progress-display dependency, so new task types gain progress
// reporting for free and new observers can be added without touching tasks.
package taskevent
import "context"
// Phase marks a stage in a task's lifecycle.
type Phase int
const (
PhaseStart Phase = iota
PhaseProgress
PhaseDone
)
func (p Phase) String() string {
switch p {
case PhaseStart:
return "start"
case PhaseProgress:
return "progress"
case PhaseDone:
return "done"
default:
return "unknown"
}
}
// Event describes a single progress observation for a task. Byte fields are
// populated by byte-stream tasks; file-count fields by count-based tasks. A
// task may fill whichever subset it has; observers ignore zero values.
type Event struct {
TaskID string
Phase Phase
TotalBytes int64
DownloadedBytes int64
TotalFiles int
DownloadedFiles int
Err error
}
// Sink receives task events. Implementations must be safe for concurrent use.
type Sink interface {
Emit(Event)
}
// SinkFunc is a function adapter for Sink.
type SinkFunc func(Event)
func (f SinkFunc) Emit(e Event) { f(e) }
type sinkKey struct{}
// WithSink returns a ctx carrying the given sinks. Multiple sinks can be passed
// and all will receive every emitted event. Sinks already present in ctx are
// preserved.
func WithSink(ctx context.Context, sinks ...Sink) context.Context {
if len(sinks) == 0 {
return ctx
}
var existing []Sink
if v, ok := ctx.Value(sinkKey{}).([]Sink); ok {
existing = v
}
merged := make([]Sink, 0, len(existing)+len(sinks))
merged = append(merged, existing...)
merged = append(merged, sinks...)
return context.WithValue(ctx, sinkKey{}, merged)
}
// Emit broadcasts an event to all sinks carried by ctx. It is a no-op when no
// sink is attached, so producers can call it unconditionally.
func Emit(ctx context.Context, e Event) {
if ctx == nil {
return
}
sinks, ok := ctx.Value(sinkKey{}).([]Sink)
if !ok {
return
}
for _, s := range sinks {
s.Emit(e)
}
}

View File

@@ -14,6 +14,31 @@ const (
TypeCancel = "cancel"
)
const (
ConflictStrategyRename = "rename"
ConflictStrategyAsk = "ask"
ConflictStrategyOverwrite = "overwrite"
ConflictStrategySkip = "skip"
)
func ConflictStrategyValues() []string {
return []string{
ConflictStrategyRename,
ConflictStrategyAsk,
ConflictStrategyOverwrite,
ConflictStrategySkip,
}
}
func IsConflictStrategy(strategy string) bool {
for _, value := range ConflictStrategyValues() {
if strategy == value {
return true
}
}
return false
}
// type TaskDataTGFiles struct {
// Files []tfile.TGFileMessage
// AsBatch bool
@@ -34,6 +59,8 @@ type Add struct {
SelectedStorName string
DirID uint
SettedDir bool
SelectedDirPath string
ConflictStrategy string
// tfiles
Files []tfile.TGFileMessage
AsBatch bool

View File

@@ -108,8 +108,10 @@ func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; a.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite {
for i := 1; a.existsPath(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.baseURL+"/api/fs/put", reader)
@@ -158,6 +160,10 @@ func (a *Alist) JoinStoragePath(p string) string {
}
func (a *Alist) Exists(ctx context.Context, storagePath string) bool {
return a.existsPath(ctx, a.JoinStoragePath(storagePath))
}
func (a *Alist) existsPath(ctx context.Context, storagePath string) bool {
// POST /api/fs/get
/*
body:

View File

@@ -1,6 +1,10 @@
package storage
import "context"
import (
"context"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
)
type contextKey struct{}
@@ -20,3 +24,7 @@ func FromContext(ctx context.Context) Storage {
}
return storage
}
func WithOverwrite(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxkey.OverwriteExisting, true)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/fileutil"
config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
)
@@ -56,8 +57,10 @@ func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error
ext := filepath.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; l.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite {
for i := 1; l.existsPath(candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
}
}
absPath, err := filepath.Abs(candidate)
@@ -77,6 +80,10 @@ func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error
}
func (l *Local) Exists(ctx context.Context, storagePath string) bool {
return l.existsPath(l.JoinStoragePath(storagePath))
}
func (l *Local) existsPath(storagePath string) bool {
absPath, err := filepath.Abs(storagePath)
if err != nil {
return false

View File

@@ -81,12 +81,14 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 10 {
m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite {
for i := 1; m.existsObject(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 10 {
m.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
}
}
}
size := int64(-1)
@@ -106,6 +108,10 @@ func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error
func (m *Minio) Exists(ctx context.Context, storagePath string) bool {
m.logger.Debugf("Checking if file exists at %s", storagePath)
return m.existsObject(ctx, m.JoinStoragePath(storagePath))
}
func (m *Minio) existsObject(ctx context.Context, storagePath string) bool {
_, err := m.client.StatObject(ctx, m.config.BucketName, storagePath, minio.StatObjectOptions{})
return err == nil
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/rs/xid"
@@ -107,12 +108,14 @@ func (r *Rclone) Save(ctx context.Context, reader io.Reader, storagePath string)
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
if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite {
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
}
}
}

View File

@@ -70,13 +70,15 @@ func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error {
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
// Unique filename
for i := 1; m.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 10 {
m.logger.Errorf("Too many attempts for unique filename: %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite {
// Unique filename
for i := 1; m.existsKey(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 10 {
m.logger.Errorf("Too many attempts for unique filename: %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
}
}
}
@@ -99,5 +101,9 @@ func (m *S3) Save(ctx context.Context, r io.Reader, storagePath string) error {
func (m *S3) Exists(ctx context.Context, storagePath string) bool {
m.logger.Debugf("Checking if file exists at %s", storagePath)
return m.client.Exists(ctx, storagePath)
return m.existsKey(ctx, m.JoinStoragePath(storagePath))
}
func (m *S3) existsKey(ctx context.Context, key string) bool {
return m.client.Exists(ctx, key)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/rs/xid"
@@ -57,12 +58,14 @@ func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) erro
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; w.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 1000 {
w.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
if overwrite, _ := ctx.Value(ctxkey.OverwriteExisting).(bool); !overwrite {
for i := 1; w.existsPath(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 1000 {
w.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
}
}
}
@@ -79,6 +82,10 @@ func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) erro
func (w *Webdav) Exists(ctx context.Context, storagePath string) bool {
w.logger.Debugf("Checking if file exists at %s", storagePath)
return w.existsPath(ctx, w.JoinStoragePath(storagePath))
}
func (w *Webdav) existsPath(ctx context.Context, storagePath string) bool {
exists, err := w.client.Exists(ctx, storagePath)
if err != nil {
w.logger.Errorf("Failed to check if file exists at %s: %v", storagePath, err)