Repository: AlistGo/alist (language: go)

Issue:
增加PikPak网盘的离线下载功能
### Please make sure of the following things

- [X] I have read the [documentation](https://alist.nn.ci).
- [X] I'm sure there are no duplicate issues or discussions.
- [X] I'm sure this feature is not implemented.
- [x] I'm sure it's a reasonable and popular requirement.

### Description of the feature / 需求描述

因为PikPak网盘离线下载的速度比较快，能不能在`/api/fs/add_offline_download` API下增加对PikPak离线下载的支持

### Suggested solution / 实现思路

这段代码可以通过PikPak离线下载任务的接口实现（可能有些小问题）：
```go
// 离线下载文件
func (d *PikPak) OfflineDownload(ctx context.Context, fileUrl string, parentId string, name string) error {
	requestBody := base.Json{
		"kind":        "drive#file",
		"name":        name,
		"upload_type": "UPLOAD_TYPE_URL",
		"url": base.Json{
			"url": fileUrl,
		},
		"folder_type": "DOWNLOAD",
	}
	if parentId != "" {
		requestBody["folder_type"] = ""
		requestBody["parent_id"] = parentId
	}
	_, err := d.request("https://api-drive.mypikpak.com/drive/v1/files", http.MethodPost, func(req *resty.Request) {
		req.SetBody(requestBody)
	}, nil)
	return err
}

// OfflineList 获取离线下载列表
func (d *PikPak) OfflineList(ctx context.Context, size int, nextPageToken string, phase []string) (*OfflineListResp, error) {
	if size <= 0 {
		size = 10000
	}
	url := "https://api-drive.mypikpak.com/drive/v1/tasks"
	data := map[string]interface{}{
		"type":            "offline",
		"thumbnail_size": "SIZE_SMALL",
		"limit":           size,
		"filters":         fmt.Sprintf(`{"phase": {"in": "%s"}}`, strings.Join(phase, ",")),
		"with":            "reference_resource",
	}
	if nextPageToken != "" {
		data["page_token"] = nextPageToken
	}
	var resp OfflineListResp
	_, err := d.request(url, http.MethodGet, func(req *resty.Request) {
		req.SetQueryParams(data)
	}, &resp)
	return &resp, err
}

// GetTaskStatus 获取离线下载任务状态
func (d *PikPak) GetTaskStatus(ctx context.Context, taskId string, fileId string) (DownloadStatus, error) {
	// 首先尝试从任务列表中查找
	listResp, err := d.OfflineList(ctx, 10000, "", []string{"PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR"})
	if err != nil {
		return DownloadStatusError, err
	}
	for _, task := range listResp.Tasks {
		if task.ID == taskId {
			return DownloadStatusDownloading, nil
		}
	}

	// 如果在任务列表中找不到，则尝试获取文件信息
	fileInfo, err := d.offlineFileInfo(fileId)
	if err != nil {
		if err.(*resty.Response).StatusCode() == http.StatusNotFound {
			return DownloadStatusNotFound, nil
		}
		return DownloadStatusError, err
	}
	if fileInfo != nil {
		return DownloadStatusDone, nil
	}

	return DownloadStatusNotFound, nil
}

// offlineFileInfo 获取离线下载文件信息
func (d *PikPak) offlineFileInfo(fileId string) (*File, error) {
	url := fmt.Sprintf("https://api-drive.mypikpak.com/drive/v1/files/%s", fileId)
	var resp File
	_, err := d.request(url, http.MethodGet, func(req *resty.Request) {
		req.SetQueryParam("thumbnail_size", "SIZE_LARGE")
	}, &resp)
	if err != nil {
		return nil, err
	}
	return &resp, nil
}

// DownloadStatus 表示下载状态
type DownloadStatus string

const (
	DownloadStatusDownloading DownloadStatus = "Downloading"
	DownloadStatusDone        DownloadStatus = "Done"
	DownloadStatusNotFound    DownloadStatus = "NotFound"
	DownloadStatusError       DownloadStatus = "Error"
)

// OfflineListResp 离线下载列表响应
type OfflineListResp struct {
	Tasks []struct {
		ID string `json:"id"`
	} `json:"tasks"`
}

```

### Additional context / 附件

_No response_


Discussion / hints:
Thanks for opening your first issue here! Be sure to follow the issue template!


Fix patch:
diff --git a/drivers/pikpak/driver.go b/drivers/pikpak/driver.go
index 2dab2a9b066..3ecc31d6bff 100644
--- a/drivers/pikpak/driver.go
+++ b/drivers/pikpak/driver.go
@@ -2,8 +2,10 @@ package pikpak
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
+	"strconv"
 	"strings"
 
 	"github.com/alist-org/alist/v3/drivers/base"
@@ -207,4 +209,92 @@ func (d *PikPak) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
 	return err
 }
 
+// 离线下载文件
+func (d *PikPak) OfflineDownload(ctx context.Context, fileUrl string, parentDir model.Obj, fileName string) (*OfflineTask, error) {
+	requestBody := base.Json{
+		"kind":        "drive#file",
+		"name":        fileName,
+		"upload_type": "UPLOAD_TYPE_URL",
+		"url": base.Json{
+			"url": fileUrl,
+		},
+		"parent_id":   parentDir.GetID(),
+		"folder_type": "",
+	}
+
+	var resp OfflineDownloadResp
+	_, err := d.request("https://api-drive.mypikpak.com/drive/v1/files", http.MethodPost, func(req *resty.Request) {
+		req.SetBody(requestBody)
+	}, &resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp.Task, err
+}
+
+/*
+获取离线下载任务列表
+phase 可能的取值：
+PHASE_TYPE_RUNNING, PHASE_TYPE_ERROR, PHASE_TYPE_COMPLETE, PHASE_TYPE_PENDING
+*/
+func (d *PikPak) OfflineList(ctx context.Context, nextPageToken string, phase []string) ([]OfflineTask, error) {
+	res := make([]OfflineTask, 0)
+	url := "https://api-drive.mypikpak.com/drive/v1/tasks"
+
+	if len(phase) == 0 {
+		phase = []string{"PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR", "PHASE_TYPE_COMPLETE", "PHASE_TYPE_PENDING"}
+	}
+	params := map[string]string{
+		"type":           "offline",
+		"thumbnail_size": "SIZE_SMALL",
+		"limit":          "10000",
+		"page_token":     nextPageToken,
+		"with":           "reference_resource",
+	}
+
+	// 处理 phase 参数
+	if len(phase) > 0 {
+		filters := base.Json{
+			"phase": map[string]string{
+				"in": strings.Join(phase, ","),
+			},
+		}
+		filtersJSON, err := json.Marshal(filters)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal filters: %w", err)
+		}
+		params["filters"] = string(filtersJSON)
+	}
+
+	var resp OfflineListResp
+	_, err := d.request(url, http.MethodGet, func(req *resty.Request) {
+		req.SetContext(ctx).
+			SetQueryParams(params)
+	}, &resp)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to get offline list: %w", err)
+	}
+	res = append(res, resp.Tasks...)
+	return res, nil
+}
+
+func (d *PikPak) DeleteOfflineTasks(ctx context.Context, taskIDs []string, deleteFiles bool) error {
+	url := "https://api-drive.mypikpak.com/drive/v1/tasks"
+	params := map[string]string{
+		"task_ids":     strings.Join(taskIDs, ","),
+		"delete_files": strconv.FormatBool(deleteFiles),
+	}
+	_, err := d.request(url, http.MethodDelete, func(req *resty.Request) {
+		req.SetContext(ctx).
+			SetQueryParams(params)
+	}, nil)
+	if err != nil {
+		return fmt.Errorf("failed to delete tasks %v: %w", taskIDs, err)
+	}
+	return nil
+}
+
 var _ driver.Driver = (*PikPak)(nil)
diff --git a/drivers/pikpak/types.go b/drivers/pikpak/types.go
index 489a1efe713..a9928d00ec2 100644
--- a/drivers/pikpak/types.go
+++ b/drivers/pikpak/types.go
@@ -99,3 +99,72 @@ type UploadTaskData struct {
 
 	File File `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"`
+	ReferenceResource ReferenceResource `json:"reference_resource"`
+	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 {
+	Age         string  `json:"age"`
+	MIMEType    *string `json:"mime_type,omitempty"`
+	PredictType string  `json:"predict_type"`
+	URL         string  `json:"url"`
+}
+
+type ReferenceResource struct {
+	Type          string                 `json:"@type"`
+	Audit         interface{}            `json:"audit"`
+	Hash          string                 `json:"hash"`
+	IconLink      string                 `json:"icon_link"`
+	ID            string                 `json:"id"`
+	Kind          string                 `json:"kind"`
+	Medias        []Media                `json:"medias"`
+	MIMEType      string                 `json:"mime_type"`
+	Name          string                 `json:"name"`
+	Params        map[string]interface{} `json:"params"`
+	ParentID      string                 `json:"parent_id"`
+	Phase         string                 `json:"phase"`
+	Size          string                 `json:"size"`
+	Space         string                 `json:"space"`
+	Starred       bool                   `json:"starred"`
+	Tags          []string               `json:"tags"`
+	ThumbnailLink string                 `json:"thumbnail_link"`
+}
diff --git a/internal/offline_download/all.go b/internal/offline_download/all.go
index 2229a855468..67869dee8d2 100644
--- a/internal/offline_download/all.go
+++ b/internal/offline_download/all.go
@@ -3,5 +3,6 @@ package offline_download
 import (
 	_ "github.com/alist-org/alist/v3/internal/offline_download/aria2"
 	_ "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"
 )
diff --git a/internal/offline_download/pikpak/pikpak.go b/internal/offline_download/pikpak/pikpak.go
new file mode 100644
index 00000000000..618b1442b8a
--- /dev/null
+++ b/internal/offline_download/pikpak/pikpak.go
@@ -0,0 +1,120 @@
+package pikpak
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/alist-org/alist/v3/drivers/pikpak"
+	"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 PikPak struct {
+	refreshTaskCache bool
+}
+
+func (p *PikPak) Name() string {
+	return "pikpak"
+}
+
+func (p *PikPak) Items() []model.SettingItem {
+	return nil
+}
+
+func (p *PikPak) Run(task *tool.DownloadTask) error {
+	return errs.NotSupport
+}
+
+func (p *PikPak) Init() (string, error) {
+	p.refreshTaskCache = false
+	return "ok", nil
+}
+
+func (p *PikPak) IsReady() bool {
+	return true
+}
+
+func (p *PikPak) AddURL(args *tool.AddUrlArgs) (string, error) {
+	// 添加新任务刷新缓存
+	p.refreshTaskCache = true
+	// args.TempDir 已经被修改为了 DstDirPath
+	storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir)
+	if err != nil {
+		return "", err
+	}
+	pikpakDriver, ok := storage.(*pikpak.PikPak)
+	if !ok {
+		return "", fmt.Errorf("unsupported storage driver for offline download, only Pikpak is supported")
+	}
+
+	ctx := context.Background()
+	parentDir, err := op.GetUnwrap(ctx, storage, actualPath)
+	if err != nil {
+		return "", err
+	}
+
+	t, err := pikpakDriver.OfflineDownload(ctx, args.Url, parentDir, "")
+	if err != nil {
+		return "", fmt.Errorf("failed to add offline download task: %w", err)
+	}
+
+	return t.ID, nil
+}
+
+func (p *PikPak) Remove(task *tool.DownloadTask) error {
+	storage, _, err := op.GetStorageAndActualPath(task.DstDirPath)
+	if err != nil {
+		return err
+	}
+	pikpakDriver, ok := storage.(*pikpak.PikPak)
+	if !ok {
+		return fmt.Errorf("unsupported storage driver for offline download, only Pikpak is supported")
+	}
+	ctx := context.Background()
+	err = pikpakDriver.DeleteOfflineTasks(ctx, []string{task.GID}, false)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (p *PikPak) Status(task *tool.DownloadTask) (*tool.Status, error) {
+	storage, _, err := op.GetStorageAndActualPath(task.DstDirPath)
+	if err != nil {
+		return nil, err
+	}
+	pikpakDriver, ok := storage.(*pikpak.PikPak)
+	if !ok {
+		return nil, fmt.Errorf("unsupported storage driver for offline download, only Pikpak is supported")
+	}
+	tasks, err := p.GetTasks(pikpakDriver)
+	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")
+			if t.Phase == "PHASE_TYPE_ERROR" {
+				s.Err = fmt.Errorf(t.Message)
+			}
+			return s, nil
+		}
+	}
+	s.Err = fmt.Errorf("the task has been deleted")
+	return s, nil
+}
+
+func init() {
+	tool.Tools.Add(&PikPak{})
+}
diff --git a/internal/offline_download/pikpak/util.go b/internal/offline_download/pikpak/util.go
new file mode 100644
index 00000000000..f7bf9282621
--- /dev/null
+++ b/internal/offline_download/pikpak/util.go
@@ -0,0 +1,43 @@
+package pikpak
+
+import (
+	"context"
+	"time"
+
+	"github.com/Xhofe/go-cache"
+	"github.com/alist-org/alist/v3/drivers/pikpak"
+	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/pkg/singleflight"
+)
+
+var taskCache = cache.NewMemCache(cache.WithShards[[]pikpak.OfflineTask](16))
+var taskG singleflight.Group[[]pikpak.OfflineTask]
+
+func (p *PikPak) GetTasks(pikpakDriver *pikpak.PikPak) ([]pikpak.OfflineTask, error) {
+	key := op.Key(pikpakDriver, "/drive/v1/task")
+	if !p.refreshTaskCache {
+		if tasks, ok := taskCache.Get(key); ok {
+			return tasks, nil
+		}
+	}
+	p.refreshTaskCache = false
+	tasks, err, _ := taskG.Do(key, func() ([]pikpak.OfflineTask, error) {
+		ctx := context.Background()
+		phase := []string{"PHASE_TYPE_RUNNING", "PHASE_TYPE_ERROR", "PHASE_TYPE_PENDING", "PHASE_TYPE_COMPLETE"}
+		tasks, err := pikpakDriver.OfflineList(ctx, "", phase)
+		if err != nil {
+			return nil, err
+		}
+		// 添加缓存 10s
+		if len(tasks) > 0 {
+			taskCache.Set(key, tasks, cache.WithEx[[]pikpak.OfflineTask](time.Second*10))
+		} else {
+			taskCache.Del(key)
+		}
+		return tasks, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return tasks, nil
+}
diff --git a/internal/offline_download/tool/add.go b/internal/offline_download/tool/add.go
index 3da05c8df68..e9bcdc50e52 100644
--- a/internal/offline_download/tool/add.go
+++ b/internal/offline_download/tool/add.go
@@ -2,13 +2,14 @@ package tool
 
 import (
 	"context"
+	"path/filepath"
+
 	"github.com/alist-org/alist/v3/internal/conf"
 	"github.com/alist-org/alist/v3/internal/errs"
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/google/uuid"
 	"github.com/pkg/errors"
 	"github.com/xhofe/tache"
-	"path/filepath"
 )
 
 type DeletePolicy string
@@ -64,11 +65,17 @@ func AddURL(ctx context.Context, args *AddURLArgs) (tache.TaskWithInfo, error) {
 
 	uid := uuid.NewString()
 	tempDir := filepath.Join(conf.Conf.TempDir, args.Tool, uid)
+	deletePolicy := args.DeletePolicy
+	if args.Tool == "pikpak" {
+		tempDir = args.DstDirPath
+		// 防止将下载好的文件删除
+		deletePolicy = DeleteNever
+	}
 	t := &DownloadTask{
 		Url:          args.URL,
 		DstDirPath:   args.DstDirPath,
 		TempDir:      tempDir,
-		DeletePolicy: args.DeletePolicy,
+		DeletePolicy: deletePolicy,
 		tool:         tool,
 	}
 	DownloadTaskManager.Add(t)
diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go
index f0a5d5d4376..79a29ef0c9a 100644
--- a/internal/offline_download/tool/download.go
+++ b/internal/offline_download/tool/download.go
@@ -71,6 +71,9 @@ outer:
 	if err != nil {
 		return err
 	}
+	if t.tool.Name() == "pikpak" {
+		return nil
+	}
 	t.Status = "offline download completed, maybe transferring"
 	// hack for qBittorrent
 	if t.tool.Name() == "qBittorrent" {
@@ -123,6 +126,9 @@ func (t *DownloadTask) Complete() error {
 		files []File
 		err   error
 	)
+	if t.tool.Name() == "pikpak" {
+		return nil
+	}
 	if getFileser, ok := t.tool.(GetFileser); ok {
 		files = getFileser.GetFiles(t)
 	} else {
@@ -132,7 +138,7 @@ func (t *DownloadTask) Complete() error {
 		}
 	}
 	// upload files
-	for i, _ := range files {
+	for i := range files {
 		file := files[i]
 		TransferTaskManager.Add(&TransferTask{
 			file:         file,


Test patch: