mirror of https://github.com/Xhofe/alist
				
				
				
			feat(thunder): add offline download tool (#7673)
* feat(thunder): add offline download tool * fix(thunder): improve error handling and parse file size in status response --------- Co-authored-by: Andy Hsu <i@nn.ci>pull/7727/head
							parent
							
								
									48916cdedf
								
							
						
					
					
						commit
						42243b1517
					
				| 
						 | 
				
			
			@ -4,6 +4,7 @@ import (
 | 
			
		|||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/alist-org/alist/v3/drivers/base"
 | 
			
		||||
| 
						 | 
				
			
			@ -522,3 +523,63 @@ func (xc *XunLeiCommon) IsLogin() bool {
 | 
			
		|||
	_, err := xc.Request(XLUSER_API_URL+"/user/me", http.MethodGet, nil, nil)
 | 
			
		||||
	return err == nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 离线下载文件
 | 
			
		||||
func (xc *XunLeiCommon) OfflineDownload(ctx context.Context, fileUrl string, parentDir model.Obj, fileName string) (*OfflineTask, error) {
 | 
			
		||||
	var resp OfflineDownloadResp
 | 
			
		||||
	_, err := xc.Request(FILE_API_URL, http.MethodPost, func(r *resty.Request) {
 | 
			
		||||
		r.SetContext(ctx)
 | 
			
		||||
		r.SetBody(&base.Json{
 | 
			
		||||
			"kind":        FILE,
 | 
			
		||||
			"name":        fileName,
 | 
			
		||||
			"parent_id":   parentDir.GetID(),
 | 
			
		||||
			"upload_type": UPLOAD_TYPE_URL,
 | 
			
		||||
			"url": base.Json{
 | 
			
		||||
				"url": fileUrl,
 | 
			
		||||
			},
 | 
			
		||||
		})
 | 
			
		||||
	}, &resp)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &resp.Task, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
获取离线下载任务列表
 | 
			
		||||
*/
 | 
			
		||||
func (xc *XunLeiCommon) OfflineList(ctx context.Context, nextPageToken string) ([]OfflineTask, error) {
 | 
			
		||||
	res := make([]OfflineTask, 0)
 | 
			
		||||
 | 
			
		||||
	var resp OfflineListResp
 | 
			
		||||
	_, err := xc.Request(TASK_API_URL, http.MethodGet, func(req *resty.Request) {
 | 
			
		||||
		req.SetContext(ctx).
 | 
			
		||||
			SetQueryParams(map[string]string{
 | 
			
		||||
				"type":       "offline",
 | 
			
		||||
				"limit":      "10000",
 | 
			
		||||
				"page_token": nextPageToken,
 | 
			
		||||
			})
 | 
			
		||||
	}, &resp)
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to get offline list: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	res = append(res, resp.Tasks...)
 | 
			
		||||
	return res, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (xc *XunLeiCommon) DeleteOfflineTasks(ctx context.Context, taskIDs []string, deleteFiles bool) error {
 | 
			
		||||
	_, err := xc.Request(TASK_API_URL, http.MethodDelete, func(req *resty.Request) {
 | 
			
		||||
		req.SetContext(ctx).
 | 
			
		||||
			SetQueryParams(map[string]string{
 | 
			
		||||
				"task_ids":     strings.Join(taskIDs, ","),
 | 
			
		||||
				"delete_files": strconv.FormatBool(deleteFiles),
 | 
			
		||||
			})
 | 
			
		||||
	}, nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to delete tasks %v: %w", taskIDs, err)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -204,3 +204,50 @@ type UploadTaskResponse struct {
 | 
			
		|||
 | 
			
		||||
	File Files `json:"file"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 添加离线下载响应
 | 
			
		||||
type OfflineDownloadResp struct {
 | 
			
		||||
	File       *string     `json:"file"`
 | 
			
		||||
	Task       OfflineTask `json:"task"`
 | 
			
		||||
	UploadType string      `json:"upload_type"`
 | 
			
		||||
	URL        struct {
 | 
			
		||||
		Kind string `json:"kind"`
 | 
			
		||||
	} `json:"url"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 离线下载列表
 | 
			
		||||
type OfflineListResp struct {
 | 
			
		||||
	ExpiresIn     int64         `json:"expires_in"`
 | 
			
		||||
	NextPageToken string        `json:"next_page_token"`
 | 
			
		||||
	Tasks         []OfflineTask `json:"tasks"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// offlineTask
 | 
			
		||||
type OfflineTask struct {
 | 
			
		||||
	Callback    string   `json:"callback"`
 | 
			
		||||
	CreatedTime string   `json:"created_time"`
 | 
			
		||||
	FileID      string   `json:"file_id"`
 | 
			
		||||
	FileName    string   `json:"file_name"`
 | 
			
		||||
	FileSize    string   `json:"file_size"`
 | 
			
		||||
	IconLink    string   `json:"icon_link"`
 | 
			
		||||
	ID          string   `json:"id"`
 | 
			
		||||
	Kind        string   `json:"kind"`
 | 
			
		||||
	Message     string   `json:"message"`
 | 
			
		||||
	Name        string   `json:"name"`
 | 
			
		||||
	Params      Params   `json:"params"`
 | 
			
		||||
	Phase       string   `json:"phase"` // PHASE_TYPE_RUNNING, PHASE_TYPE_ERROR, PHASE_TYPE_COMPLETE, PHASE_TYPE_PENDING
 | 
			
		||||
	Progress    int64    `json:"progress"`
 | 
			
		||||
	Space       string   `json:"space"`
 | 
			
		||||
	StatusSize  int64    `json:"status_size"`
 | 
			
		||||
	Statuses    []string `json:"statuses"`
 | 
			
		||||
	ThirdTaskID string   `json:"third_task_id"`
 | 
			
		||||
	Type        string   `json:"type"`
 | 
			
		||||
	UpdatedTime string   `json:"updated_time"`
 | 
			
		||||
	UserID      string   `json:"user_id"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Params struct {
 | 
			
		||||
	FolderType   string `json:"folder_type"`
 | 
			
		||||
	PredictSpeed string `json:"predict_speed"`
 | 
			
		||||
	PredictType  string `json:"predict_type"`
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ import (
 | 
			
		|||
const (
 | 
			
		||||
	API_URL        = "https://api-pan.xunlei.com/drive/v1"
 | 
			
		||||
	FILE_API_URL   = API_URL + "/files"
 | 
			
		||||
	TASK_API_URL   = API_URL + "/tasks"
 | 
			
		||||
	XLUSER_API_URL = "https://xluser-ssl.xunlei.com/v1"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,5 +6,6 @@ import (
 | 
			
		|||
	_ "github.com/alist-org/alist/v3/internal/offline_download/http"
 | 
			
		||||
	_ "github.com/alist-org/alist/v3/internal/offline_download/pikpak"
 | 
			
		||||
	_ "github.com/alist-org/alist/v3/internal/offline_download/qbit"
 | 
			
		||||
	_ "github.com/alist-org/alist/v3/internal/offline_download/thunder"
 | 
			
		||||
	_ "github.com/alist-org/alist/v3/internal/offline_download/transmission"
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,126 @@
 | 
			
		|||
package thunder
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strconv"
 | 
			
		||||
 | 
			
		||||
	"github.com/alist-org/alist/v3/drivers/thunder"
 | 
			
		||||
	"github.com/alist-org/alist/v3/internal/errs"
 | 
			
		||||
	"github.com/alist-org/alist/v3/internal/model"
 | 
			
		||||
	"github.com/alist-org/alist/v3/internal/offline_download/tool"
 | 
			
		||||
	"github.com/alist-org/alist/v3/internal/op"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Thunder struct {
 | 
			
		||||
	refreshTaskCache bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) Name() string {
 | 
			
		||||
	return "thunder"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) Items() []model.SettingItem {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) Run(task *tool.DownloadTask) error {
 | 
			
		||||
	return errs.NotSupport
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) Init() (string, error) {
 | 
			
		||||
	t.refreshTaskCache = false
 | 
			
		||||
	return "ok", nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) IsReady() bool {
 | 
			
		||||
	return true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) AddURL(args *tool.AddUrlArgs) (string, error) {
 | 
			
		||||
	// 添加新任务刷新缓存
 | 
			
		||||
	t.refreshTaskCache = true
 | 
			
		||||
	// args.TempDir 已经被修改为了 DstDirPath
 | 
			
		||||
	storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	thunderDriver, ok := storage.(*thunder.Thunder)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return "", fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
	parentDir, err := op.GetUnwrap(ctx, storage, actualPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	task, err := thunderDriver.OfflineDownload(ctx, args.Url, parentDir, "")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to add offline download task: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return task.ID, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) Remove(task *tool.DownloadTask) error {
 | 
			
		||||
	storage, _, err := op.GetStorageAndActualPath(task.DstDirPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	thunderDriver, ok := storage.(*thunder.Thunder)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported")
 | 
			
		||||
	}
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
	err = thunderDriver.DeleteOfflineTasks(ctx, []string{task.GID}, false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) Status(task *tool.DownloadTask) (*tool.Status, error) {
 | 
			
		||||
	storage, _, err := op.GetStorageAndActualPath(task.DstDirPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	thunderDriver, ok := storage.(*thunder.Thunder)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, fmt.Errorf("unsupported storage driver for offline download, only Thunder is supported")
 | 
			
		||||
	}
 | 
			
		||||
	tasks, err := t.GetTasks(thunderDriver)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	s := &tool.Status{
 | 
			
		||||
		Progress:  0,
 | 
			
		||||
		NewGID:    "",
 | 
			
		||||
		Completed: false,
 | 
			
		||||
		Status:    "the task has been deleted",
 | 
			
		||||
		Err:       nil,
 | 
			
		||||
	}
 | 
			
		||||
	for _, t := range tasks {
 | 
			
		||||
		if t.ID == task.GID {
 | 
			
		||||
			s.Progress = float64(t.Progress)
 | 
			
		||||
			s.Status = t.Message
 | 
			
		||||
			s.Completed = (t.Phase == "PHASE_TYPE_COMPLETE")
 | 
			
		||||
			s.TotalBytes, err = strconv.ParseInt(t.FileSize, 10, 64)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				s.TotalBytes = 0
 | 
			
		||||
			}
 | 
			
		||||
			if t.Phase == "PHASE_TYPE_ERROR" {
 | 
			
		||||
				s.Err = errors.New(t.Message)
 | 
			
		||||
			}
 | 
			
		||||
			return s, nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	s.Err = fmt.Errorf("the task has been deleted")
 | 
			
		||||
	return s, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	tool.Tools.Add(&Thunder{})
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,42 @@
 | 
			
		|||
package thunder
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/Xhofe/go-cache"
 | 
			
		||||
	"github.com/alist-org/alist/v3/drivers/thunder"
 | 
			
		||||
	"github.com/alist-org/alist/v3/internal/op"
 | 
			
		||||
	"github.com/alist-org/alist/v3/pkg/singleflight"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var taskCache = cache.NewMemCache(cache.WithShards[[]thunder.OfflineTask](16))
 | 
			
		||||
var taskG singleflight.Group[[]thunder.OfflineTask]
 | 
			
		||||
 | 
			
		||||
func (t *Thunder) GetTasks(thunderDriver *thunder.Thunder) ([]thunder.OfflineTask, error) {
 | 
			
		||||
	key := op.Key(thunderDriver, "/drive/v1/task")
 | 
			
		||||
	if !t.refreshTaskCache {
 | 
			
		||||
		if tasks, ok := taskCache.Get(key); ok {
 | 
			
		||||
			return tasks, nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	t.refreshTaskCache = false
 | 
			
		||||
	tasks, err, _ := taskG.Do(key, func() ([]thunder.OfflineTask, error) {
 | 
			
		||||
		ctx := context.Background()
 | 
			
		||||
		tasks, err := thunderDriver.OfflineList(ctx, "")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		// 添加缓存 10s
 | 
			
		||||
		if len(tasks) > 0 {
 | 
			
		||||
			taskCache.Set(key, tasks, cache.WithEx[[]thunder.OfflineTask](time.Second*10))
 | 
			
		||||
		} else {
 | 
			
		||||
			taskCache.Del(key)
 | 
			
		||||
		}
 | 
			
		||||
		return tasks, nil
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return tasks, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -77,6 +77,10 @@ func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskExtensionInfo, erro
 | 
			
		|||
		tempDir = args.DstDirPath
 | 
			
		||||
		// 防止将下载好的文件删除
 | 
			
		||||
		deletePolicy = DeleteNever
 | 
			
		||||
	case "thunder":
 | 
			
		||||
		tempDir = args.DstDirPath
 | 
			
		||||
		// 防止将下载好的文件删除
 | 
			
		||||
		deletePolicy = DeleteNever
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -83,6 +83,9 @@ outer:
 | 
			
		|||
	if t.tool.Name() == "pikpak" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	if t.tool.Name() == "thunder" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	if t.tool.Name() == "115 Cloud" {
 | 
			
		||||
		// hack for 115
 | 
			
		||||
		<-time.After(time.Second * 1)
 | 
			
		||||
| 
						 | 
				
			
			@ -161,6 +164,9 @@ func (t *DownloadTask) Complete() error {
 | 
			
		|||
	if t.tool.Name() == "pikpak" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	if t.tool.Name() == "thunder" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	if t.tool.Name() == "115 Cloud" {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue