mirror of
https://github.com/krau/SaveAny-Bot.git
synced 2026-08-31 13:16:41 +08:00
feat(core): persist tasks and recover them after restart
This commit is contained in:
+17
-1
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/queue"
|
||||
"github.com/krau/SaveAny-Bot/pkg/taskevent"
|
||||
@@ -45,6 +46,9 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Executable], semaphore chan
|
||||
exe := qtask.Data
|
||||
taskCtx := qtask.Context()
|
||||
logger.Infof("Processing task: %s", exe.TaskID())
|
||||
if err := database.UpdateTaskStatus(taskCtx, exe.TaskID(), database.TaskStatusRunning, ""); err != nil {
|
||||
logger.Errorf("Failed to mark task %s as running: %v", exe.TaskID(), err)
|
||||
}
|
||||
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)
|
||||
@@ -70,6 +74,9 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Executable], semaphore chan
|
||||
}
|
||||
taskevent.Emit(taskCtx, taskevent.Event{TaskID: exe.TaskID(), Phase: taskevent.PhaseDone, Err: err})
|
||||
qe.Done(qtask.ID)
|
||||
if err := database.DeleteTask(ctx, exe.TaskID()); err != nil {
|
||||
logger.Errorf("Failed to delete persisted task %s: %v", exe.TaskID(), err)
|
||||
}
|
||||
<-semaphore
|
||||
}
|
||||
}
|
||||
@@ -92,12 +99,21 @@ func Close() {
|
||||
}
|
||||
|
||||
func AddTask(ctx context.Context, task Executable) error {
|
||||
if err := persistTask(ctx, task); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to persist task %s: %v", task.TaskID(), err)
|
||||
}
|
||||
return initQueue().Add(queue.NewTask(ctx, task.TaskID(), task.Title(), task))
|
||||
}
|
||||
|
||||
func CancelTask(ctx context.Context, id string) error {
|
||||
err := queueInstance.CancelTask(id)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := database.DeleteTask(ctx, id); err != nil {
|
||||
log.FromContext(ctx).Errorf("Failed to delete persisted task %s: %v", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLength(ctx context.Context) int {
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/telegram/downloader"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
)
|
||||
|
||||
// TaskCodec serializes and rebuilds a task from its persisted payload.
|
||||
// Task types without a registered codec are dropped with a warning on
|
||||
// recovery instead of being silently re-enqueued.
|
||||
type TaskCodec interface {
|
||||
Marshal(task Executable) ([]byte, error)
|
||||
Unmarshal(payload []byte) (Executable, error)
|
||||
}
|
||||
|
||||
var (
|
||||
taskCodecsMu sync.RWMutex
|
||||
taskCodecs = make(map[tasktype.TaskType]TaskCodec)
|
||||
|
||||
dlerMu sync.RWMutex
|
||||
dlerProvider func() downloader.Client
|
||||
)
|
||||
|
||||
func RegisterTaskCodec(t tasktype.TaskType, codec TaskCodec) {
|
||||
taskCodecsMu.Lock()
|
||||
defer taskCodecsMu.Unlock()
|
||||
taskCodecs[t] = codec
|
||||
}
|
||||
|
||||
func TaskCodecFor(t tasktype.TaskType) (TaskCodec, bool) {
|
||||
taskCodecsMu.RLock()
|
||||
defer taskCodecsMu.RUnlock()
|
||||
codec, ok := taskCodecs[t]
|
||||
return codec, ok
|
||||
}
|
||||
|
||||
// SetDownloaderProvider registers the download client factory used to
|
||||
// rebuild tfile.TGFile values when recovering tasks.
|
||||
func SetDownloaderProvider(f func() downloader.Client) {
|
||||
dlerMu.Lock()
|
||||
defer dlerMu.Unlock()
|
||||
dlerProvider = f
|
||||
}
|
||||
|
||||
// DownloaderClient returns the registered download client, or nil.
|
||||
func DownloaderClient() downloader.Client {
|
||||
dlerMu.RLock()
|
||||
defer dlerMu.RUnlock()
|
||||
if dlerProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return dlerProvider()
|
||||
}
|
||||
|
||||
func persistTask(ctx context.Context, task Executable) error {
|
||||
codec, ok := TaskCodecFor(task.Type())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
payload, err := codec.Marshal(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.UpsertTask(ctx, &database.Task{
|
||||
ID: task.TaskID(),
|
||||
Type: string(task.Type()),
|
||||
Payload: payload,
|
||||
Status: string(database.TaskStatusQueued),
|
||||
})
|
||||
}
|
||||
|
||||
// RecoverTasks re-enqueues tasks that were unfinished when the process last
|
||||
// exited. Must be called after storages are loaded and before Run. Tasks of
|
||||
// types without a registered codec are dropped with a warning.
|
||||
func RecoverTasks(ctx context.Context) {
|
||||
logger := log.FromContext(ctx)
|
||||
tasks, err := database.GetUnfinishedTasks(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to load unfinished tasks: %v", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
codec, ok := TaskCodecFor(tasktype.TaskType(t.Type))
|
||||
if !ok {
|
||||
logger.Warnf("Dropping unrecoverable task %s (type %s): no codec registered", t.ID, t.Type)
|
||||
if err := database.DeleteTask(ctx, t.ID); err != nil {
|
||||
logger.Errorf("Failed to delete task %s: %v", t.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
task, err := codec.Unmarshal(t.Payload)
|
||||
if err != nil {
|
||||
logger.Errorf("Dropping task %s: failed to rebuild: %v", t.ID, err)
|
||||
if err := database.DeleteTask(ctx, t.ID); err != nil {
|
||||
logger.Errorf("Failed to delete task %s: %v", t.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := AddTask(ctx, task); err != nil {
|
||||
logger.Errorf("Dropping task %s: failed to re-enqueue: %v", t.ID, err)
|
||||
if err := database.DeleteTask(ctx, t.ID); err != nil {
|
||||
logger.Errorf("Failed to delete task %s: %v", t.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
logger.Infof("Recovered task %s (%s)", t.ID, t.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
)
|
||||
|
||||
const testRecoverType = tasktype.TaskType("test-recover")
|
||||
|
||||
type stubTask struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func (s *stubTask) Type() tasktype.TaskType { return testRecoverType }
|
||||
func (s *stubTask) Title() string { return s.id }
|
||||
func (s *stubTask) TaskID() string { return s.id }
|
||||
func (s *stubTask) Execute(context.Context) error { return nil }
|
||||
|
||||
type stubCodec struct{}
|
||||
|
||||
func (stubCodec) Marshal(task Executable) ([]byte, error) {
|
||||
return []byte(task.TaskID()), nil
|
||||
}
|
||||
|
||||
func (stubCodec) Unmarshal(payload []byte) (Executable, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("empty payload")
|
||||
}
|
||||
return &stubTask{id: string(payload)}, nil
|
||||
}
|
||||
|
||||
func initRecoveryEnv(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.toml")
|
||||
content := fmt.Sprintf("[db]\npath = %q\n", filepath.Join(dir, "test.db"))
|
||||
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := config.Init(context.Background(), cfgPath); err != nil {
|
||||
t.Fatalf("config init: %v", err)
|
||||
}
|
||||
database.Init(context.Background())
|
||||
RegisterTaskCodec(testRecoverType, stubCodec{})
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func TestRecoverTasksReenqueuesAndDropsUnknown(t *testing.T) {
|
||||
ctx := initRecoveryEnv(t)
|
||||
|
||||
if err := database.CreateTask(ctx, &database.Task{
|
||||
ID: "rec-1", Type: string(testRecoverType), Payload: []byte("rec-1"), Status: string(database.TaskStatusQueued),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateTask(ctx, &database.Task{
|
||||
ID: "rec-2", Type: string(testRecoverType), Payload: []byte("rec-2"), Status: string(database.TaskStatusRunning),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CreateTask(ctx, &database.Task{
|
||||
ID: "drop-1", Type: "unregistered", Payload: nil, Status: string(database.TaskStatusQueued),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
RecoverTasks(ctx)
|
||||
|
||||
ids := map[string]bool{}
|
||||
for _, info := range GetQueuedTasks(ctx) {
|
||||
ids[info.ID] = true
|
||||
}
|
||||
if !ids["rec-1"] || !ids["rec-2"] {
|
||||
t.Fatalf("recovered task ids = %v, want rec-1 and rec-2", ids)
|
||||
}
|
||||
|
||||
unfinished, err := database.GetUnfinishedTasks(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unfinished) != 2 {
|
||||
t.Fatalf("unfinished rows = %d, want 2", len(unfinished))
|
||||
}
|
||||
for _, task := range unfinished {
|
||||
if task.ID == "drop-1" {
|
||||
t.Fatalf("unregistered task record was not dropped")
|
||||
}
|
||||
if task.Status != string(database.TaskStatusQueued) {
|
||||
t.Fatalf("recovered task status = %s, want queued", task.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverTasksDropsInvalidPayload(t *testing.T) {
|
||||
ctx := initRecoveryEnv(t)
|
||||
|
||||
if err := database.CreateTask(ctx, &database.Task{
|
||||
ID: "bad-1", Type: string(testRecoverType), Payload: nil, Status: string(database.TaskStatusQueued),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
RecoverTasks(ctx)
|
||||
|
||||
// bad-1 must not be enqueued nor remain in the database.
|
||||
for _, info := range GetQueuedTasks(ctx) {
|
||||
if info.ID == "bad-1" {
|
||||
t.Fatalf("task with invalid payload was enqueued")
|
||||
}
|
||||
}
|
||||
count, err := database.CountUnfinishedTasks(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unfinished rows = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package batchtfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
tfilepkg "github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
type elementPayload struct {
|
||||
ID string `json:"id"`
|
||||
Storage string `json:"storage"`
|
||||
Path string `json:"path"`
|
||||
File tfilepkg.FilePayload `json:"file"`
|
||||
SourceGroupKey string `json:"source_group_key"`
|
||||
SourceCaption string `json:"source_caption"`
|
||||
PreserveCaption bool `json:"preserve_caption"`
|
||||
}
|
||||
|
||||
type taskPayload struct {
|
||||
ID string `json:"id"`
|
||||
Elements []elementPayload `json:"elements"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
IgnoreErrors bool `json:"ignore_errors"`
|
||||
}
|
||||
|
||||
type taskCodec struct{}
|
||||
|
||||
func init() {
|
||||
core.RegisterTaskCodec(tasktype.TaskTypeTgfiles, taskCodec{})
|
||||
}
|
||||
|
||||
func (taskCodec) Marshal(task core.Executable) ([]byte, error) {
|
||||
t, ok := task.(*Task)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected task type %T", task)
|
||||
}
|
||||
p := taskPayload{
|
||||
ID: t.ID,
|
||||
IgnoreErrors: t.IgnoreErrors,
|
||||
}
|
||||
for _, elem := range t.elems {
|
||||
filePayload, ok := tfilepkg.FilePayloadOf(elem.File)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %T is not serializable", elem.File)
|
||||
}
|
||||
p.Elements = append(p.Elements, elementPayload{
|
||||
ID: elem.ID,
|
||||
Storage: elem.Storage.Name(),
|
||||
Path: elem.Path,
|
||||
File: filePayload,
|
||||
SourceGroupKey: elem.sourceGroupKey,
|
||||
SourceCaption: elem.sourceCaption,
|
||||
PreserveCaption: elem.preserveCaption,
|
||||
})
|
||||
}
|
||||
if progress, ok := t.Progress.(*Progress); ok {
|
||||
p.ChatID = progress.ChatID
|
||||
p.MessageID = progress.MessageID
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
func (taskCodec) Unmarshal(data []byte) (core.Executable, error) {
|
||||
var p taskPayload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid task payload: %w", err)
|
||||
}
|
||||
dler := core.DownloaderClient()
|
||||
if dler == nil {
|
||||
return nil, fmt.Errorf("no downloader client available")
|
||||
}
|
||||
elems := make([]TaskElement, 0, len(p.Elements))
|
||||
for _, ep := range p.Elements {
|
||||
stor, err := storage.GetStorageByName(context.Background(), ep.Storage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage %q: %w", ep.Storage, err)
|
||||
}
|
||||
localPath, err := filepath.Abs(filepath.Join(config.C().Temp.BasePath, fmt.Sprintf("%s_%s", ep.ID, ep.File.Name)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build cache path: %w", err)
|
||||
}
|
||||
elems = append(elems, TaskElement{
|
||||
ID: ep.ID,
|
||||
Storage: stor,
|
||||
Path: ep.Path,
|
||||
File: tfilepkg.FileFromPayload(ep.File, dler),
|
||||
localPath: localPath,
|
||||
sourceGroupKey: ep.SourceGroupKey,
|
||||
sourceCaption: ep.SourceCaption,
|
||||
preserveCaption: ep.PreserveCaption,
|
||||
})
|
||||
}
|
||||
var progress ProgressTracker
|
||||
if p.ChatID != 0 {
|
||||
progress = NewProgressTracker(p.MessageID, p.ChatID)
|
||||
}
|
||||
return NewBatchTGFileTask(p.ID, context.Background(), elems, progress, p.IgnoreErrors), nil
|
||||
}
|
||||
@@ -134,7 +134,12 @@ func (t *Task) processElements(ctx context.Context, elems []*TaskElement) error
|
||||
}
|
||||
|
||||
func (t *Task) processBatch(ctx context.Context, group executionGroup) error {
|
||||
// Cache files are kept on failure so a later restart can resume upload.
|
||||
uploaded := false
|
||||
defer func() {
|
||||
if !uploaded {
|
||||
return
|
||||
}
|
||||
for _, elem := range group.elems {
|
||||
if err := os.Remove(elem.localPath); err != nil && !os.IsNotExist(err) {
|
||||
log.FromContext(ctx).Warnf("Failed to cleanup batch cache file %s: %v", elem.localPath, err)
|
||||
@@ -219,7 +224,11 @@ func (t *Task) processBatch(ctx context.Context, group executionGroup) error {
|
||||
for index, item := range items {
|
||||
t.recordDownloadComplete(successElems[index].ID, item.Size)
|
||||
}
|
||||
return t.saveBatchItems(ctx, successElems, items)
|
||||
err := t.saveBatchItems(ctx, successElems, items)
|
||||
if err == nil {
|
||||
uploaded = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Task) saveBatchItems(ctx context.Context, successElems []*TaskElement, items []storagetypes.BatchItem) error {
|
||||
@@ -289,34 +298,10 @@ func (t *Task) unmarkProcessing(id string) {
|
||||
func (t *Task) downloadElement(ctx context.Context, elem *TaskElement) error {
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.File.Name()))
|
||||
logger.Info("Starting file download")
|
||||
localFile, err := fsutil.CreateFile(elem.localPath)
|
||||
if err != nil {
|
||||
t.markItemFailed(elem.ID, FailureStageCache, err)
|
||||
if err := t.downloadToCache(ctx, elem); err != nil {
|
||||
t.markItemFailed(elem.ID, FailureStageDownload, err)
|
||||
t.notifyStateChange(ctx)
|
||||
return fmt.Errorf("failed to create local file: %w", err)
|
||||
}
|
||||
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
|
||||
t.recordItemDownload(elem.ID, int64(n), time.Now())
|
||||
downloaded := t.downloaded.Add(int64(n))
|
||||
t.notifyProgress(ctx)
|
||||
taskevent.Emit(ctx, taskevent.Event{
|
||||
TaskID: t.ID,
|
||||
Phase: taskevent.PhaseProgress,
|
||||
TotalBytes: t.totalSize,
|
||||
DownloadedBytes: downloaded,
|
||||
})
|
||||
})
|
||||
_, downloadErr := tdler.NewDownloader(elem.File).Parallel(ctx, wrAt)
|
||||
closeErr := localFile.Close()
|
||||
if downloadErr != nil {
|
||||
t.markItemFailed(elem.ID, FailureStageDownload, downloadErr)
|
||||
t.notifyStateChange(ctx)
|
||||
return fmt.Errorf("failed to download file: %w", downloadErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
t.markItemFailed(elem.ID, FailureStageCache, closeErr)
|
||||
t.notifyStateChange(ctx)
|
||||
return fmt.Errorf("failed to close cache file: %w", closeErr)
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
logger.Info("File downloaded successfully")
|
||||
if path.Ext(elem.FileName()) == "" {
|
||||
@@ -387,24 +372,15 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
t.notifyStateChange(ctx)
|
||||
return fmt.Errorf("failed to create local file: %w", err)
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if err := localFile.CloseAndRemove(); err != nil {
|
||||
logger.Errorf("Failed to close local file: %v", err)
|
||||
if success {
|
||||
if err := localFile.CloseAndRemove(); err != nil {
|
||||
logger.Errorf("Failed to close local file: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
|
||||
t.recordItemDownload(elem.ID, int64(n), time.Now())
|
||||
downloaded := t.downloaded.Add(int64(n))
|
||||
t.notifyProgress(ctx)
|
||||
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 {
|
||||
if err := t.downloadToCache(ctx, &elem); err != nil {
|
||||
t.markItemFailed(elem.ID, FailureStageDownload, err)
|
||||
t.notifyStateChange(ctx)
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
@@ -460,6 +436,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
|
||||
onProgress(fileStat.Size(), fileStat.Size())
|
||||
t.markItemCompleted(elem.ID)
|
||||
t.notifyStateChange(vctx)
|
||||
success = true
|
||||
} else {
|
||||
t.markItemFailed(elem.ID, lastFailureStage, err)
|
||||
t.notifyStateChange(vctx)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package tfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
tfilepkg "github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
type taskPayload struct {
|
||||
ID string `json:"id"`
|
||||
Storage string `json:"storage"`
|
||||
Path string `json:"path"`
|
||||
File tfilepkg.FilePayload `json:"file"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
}
|
||||
|
||||
type taskCodec struct{}
|
||||
|
||||
func init() {
|
||||
core.RegisterTaskCodec(tasktype.TaskTypeTgfiles, taskCodec{})
|
||||
}
|
||||
|
||||
func (taskCodec) Marshal(task core.Executable) ([]byte, error) {
|
||||
t, ok := task.(*Task)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected task type %T", task)
|
||||
}
|
||||
filePayload, ok := tfilepkg.FilePayloadOf(t.File)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %T is not serializable", t.File)
|
||||
}
|
||||
p := taskPayload{
|
||||
ID: t.ID,
|
||||
Storage: t.Storage.Name(),
|
||||
Path: t.Path,
|
||||
File: filePayload,
|
||||
}
|
||||
if progress, ok := t.Progress.(*Progress); ok {
|
||||
p.ChatID = progress.ChatID
|
||||
p.MessageID = progress.MessageID
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
func (taskCodec) Unmarshal(data []byte) (core.Executable, error) {
|
||||
var p taskPayload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid task payload: %w", err)
|
||||
}
|
||||
dler := core.DownloaderClient()
|
||||
if dler == nil {
|
||||
return nil, fmt.Errorf("no downloader client available")
|
||||
}
|
||||
file := tfilepkg.FileFromPayload(p.File, dler)
|
||||
stor, err := storage.GetStorageByName(context.Background(), p.Storage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage %q: %w", p.Storage, err)
|
||||
}
|
||||
var progress ProgressTracker
|
||||
if p.ChatID != 0 {
|
||||
progress = NewProgressTrack(p.MessageID, p.ChatID)
|
||||
}
|
||||
localPath, err := filepath.Abs(filepath.Join(config.C().Temp.BasePath, fmt.Sprintf("%s_%s", p.ID, file.Name())))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build cache path: %w", err)
|
||||
}
|
||||
return &Task{
|
||||
ID: p.ID,
|
||||
Ctx: context.Background(),
|
||||
File: file,
|
||||
Storage: stor,
|
||||
Path: p.Path,
|
||||
Progress: progress,
|
||||
stream: false, // recovered tasks always download to cache first
|
||||
localPath: localPath,
|
||||
}, nil
|
||||
}
|
||||
+12
-23
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/duke-git/lancet/v2/retry"
|
||||
"github.com/krau/SaveAny-Bot/common/tdler"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
@@ -19,8 +18,13 @@ import (
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func (t *Task) Execute(ctx context.Context) error {
|
||||
func (t *Task) Execute(ctx context.Context) (err error) {
|
||||
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", t.File.Name()))
|
||||
defer func() {
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
}()
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnStart(ctx, t)
|
||||
}
|
||||
@@ -29,35 +33,16 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
}
|
||||
|
||||
logger.Info("Starting file download")
|
||||
localFile, err := fsutil.CreateFile(t.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create local file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := localFile.CloseAndRemove(); err != nil {
|
||||
logger.Errorf("Failed to close local file: %v", err)
|
||||
}
|
||||
}()
|
||||
wrAt := newWriterAt(ctx, localFile, t.Progress, t)
|
||||
|
||||
defer func() {
|
||||
if t.Progress != nil {
|
||||
t.Progress.OnDone(ctx, t, err)
|
||||
}
|
||||
}()
|
||||
_, err = tdler.NewDownloader(t.File).Parallel(ctx, wrAt)
|
||||
if err != nil {
|
||||
if err := t.download(ctx); err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
logger.Infof("File downloaded successfully")
|
||||
if path.Ext(t.File.Name()) == "" {
|
||||
ext := fsutil.DetectFileExt(t.localPath)
|
||||
if ext != "" {
|
||||
t.Path = t.Path + ext
|
||||
}
|
||||
}
|
||||
var fileStat os.FileInfo
|
||||
fileStat, err = os.Stat(t.localPath)
|
||||
fileStat, err := os.Stat(t.localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file stat: %w", err)
|
||||
}
|
||||
@@ -97,6 +82,10 @@ func (t *Task) Execute(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save file after retries: %w", err)
|
||||
}
|
||||
// Cache file is kept on failure so a later restart can resume upload.
|
||||
if err := os.Remove(t.localPath); err != nil {
|
||||
logger.Errorf("Failed to remove cache file: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/core"
|
||||
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
|
||||
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
type elementPayload struct {
|
||||
ID string `json:"id"`
|
||||
SourceStorage string `json:"source_storage"`
|
||||
SourcePath string `json:"source_path"`
|
||||
FileInfo storagetypes.FileInfo `json:"file_info"`
|
||||
TargetStorage string `json:"target_storage"`
|
||||
TargetPath string `json:"target_path"`
|
||||
}
|
||||
|
||||
type taskPayload struct {
|
||||
ID string `json:"id"`
|
||||
Elements []elementPayload `json:"elements"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
IgnoreErrors bool `json:"ignore_errors"`
|
||||
}
|
||||
|
||||
type taskCodec struct{}
|
||||
|
||||
func init() {
|
||||
core.RegisterTaskCodec(tasktype.TaskTypeTransfer, taskCodec{})
|
||||
}
|
||||
|
||||
func (taskCodec) Marshal(task core.Executable) ([]byte, error) {
|
||||
t, ok := task.(*Task)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected task type %T", task)
|
||||
}
|
||||
p := taskPayload{
|
||||
ID: t.ID,
|
||||
IgnoreErrors: t.IgnoreErrors,
|
||||
}
|
||||
for _, elem := range t.elems {
|
||||
p.Elements = append(p.Elements, elementPayload{
|
||||
ID: elem.ID,
|
||||
SourceStorage: elem.SourceStorage.Name(),
|
||||
SourcePath: elem.SourcePath,
|
||||
FileInfo: elem.FileInfo,
|
||||
TargetStorage: elem.TargetStorage.Name(),
|
||||
TargetPath: elem.TargetPath,
|
||||
})
|
||||
}
|
||||
if progress, ok := t.Progress.(*Progress); ok {
|
||||
p.ChatID = progress.ChatID
|
||||
p.MessageID = progress.MessageID
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
func (taskCodec) Unmarshal(data []byte) (core.Executable, error) {
|
||||
var p taskPayload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid task payload: %w", err)
|
||||
}
|
||||
elems := make([]TaskElement, 0, len(p.Elements))
|
||||
for _, ep := range p.Elements {
|
||||
source, err := storage.GetStorageByName(context.Background(), ep.SourceStorage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source storage %q: %w", ep.SourceStorage, err)
|
||||
}
|
||||
target, err := storage.GetStorageByName(context.Background(), ep.TargetStorage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("target storage %q: %w", ep.TargetStorage, err)
|
||||
}
|
||||
elems = append(elems, TaskElement{
|
||||
ID: ep.ID,
|
||||
SourceStorage: source,
|
||||
SourcePath: ep.SourcePath,
|
||||
FileInfo: ep.FileInfo,
|
||||
TargetStorage: target,
|
||||
TargetPath: ep.TargetPath,
|
||||
})
|
||||
}
|
||||
var progress ProgressTracker
|
||||
if p.ChatID != 0 {
|
||||
progress = NewProgressTracker(p.MessageID, p.ChatID)
|
||||
}
|
||||
return NewTransferTask(p.ID, context.Background(), elems, progress, p.IgnoreErrors), nil
|
||||
}
|
||||
Reference in New Issue
Block a user