mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-10-10 04:53:09 +08:00
feat(drivers): add GuangYaPan driver with offline download support (#2882)
* feat(drivers): add GuangYaPan driver with offline download support * fix(drivers): GuangYaPan driver multipartUploadToOSS
This commit is contained in:
@@ -42,6 +42,7 @@ import (
|
|||||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/github_releases"
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/github_releases"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_drive"
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_drive"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_photo"
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_photo"
|
||||||
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud"
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud_open"
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud_open"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/drivers/ilanzou"
|
_ "github.com/OpenListTeam/OpenList/v4/drivers/ilanzou"
|
||||||
|
|||||||
@@ -0,0 +1,786 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/drivers/base"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||||
|
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
accountBaseURL = "https://account.guangyapan.com"
|
||||||
|
apiBaseURL = "https://api.guangyapan.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GuangYaPan struct {
|
||||||
|
model.Storage
|
||||||
|
Addition
|
||||||
|
|
||||||
|
accountClient *resty.Client
|
||||||
|
apiClient *resty.Client
|
||||||
|
|
||||||
|
resolvedRootFolderID string
|
||||||
|
rootFolderResolved bool
|
||||||
|
|
||||||
|
// refreshMu protects concurrent access to AccessToken/RefreshToken during refresh.
|
||||||
|
refreshMu sync.Mutex
|
||||||
|
// statusTimer tracks delayed status updates for cancellation on Drop.
|
||||||
|
statusTimer *time.Timer
|
||||||
|
|
||||||
|
// apiRateLimit throttles requests per API endpoint so that batch operations
|
||||||
|
// (e.g. copying many files cross-storage) don't flood the upstream API.
|
||||||
|
apiRateLimit sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiRateInterval is the minimum gap between two requests to the same endpoint.
|
||||||
|
const apiRateInterval = 500 * time.Millisecond
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Config() driver.Config {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) GetAddition() driver.Additional {
|
||||||
|
return &d.Addition
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Init(ctx context.Context) error {
|
||||||
|
d.ClientID = strings.TrimSpace(d.ClientID)
|
||||||
|
if d.ClientID == "" {
|
||||||
|
return errors.New("client_id is required, please provide a valid client_id")
|
||||||
|
}
|
||||||
|
d.DeviceID = normalizeDeviceID(d.DeviceID)
|
||||||
|
if d.DeviceID == "" {
|
||||||
|
d.DeviceID = randomDeviceID()
|
||||||
|
}
|
||||||
|
deviceSign := strings.TrimSpace(d.DeviceSign)
|
||||||
|
if deviceSign == "" {
|
||||||
|
deviceSign = "wdi10." + d.DeviceID
|
||||||
|
}
|
||||||
|
if d.PageSize <= 0 {
|
||||||
|
d.PageSize = 100
|
||||||
|
}
|
||||||
|
if d.OrderBy < 0 {
|
||||||
|
d.OrderBy = 3
|
||||||
|
}
|
||||||
|
if d.SortType != 0 && d.SortType != 1 {
|
||||||
|
d.SortType = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
d.RootPath = strings.TrimSpace(d.RootPath)
|
||||||
|
d.AccessToken = strings.TrimSpace(d.AccessToken)
|
||||||
|
d.RefreshToken = strings.TrimSpace(d.RefreshToken)
|
||||||
|
d.PhoneNumber = strings.TrimSpace(d.PhoneNumber)
|
||||||
|
d.VerifyCode = strings.TrimSpace(d.VerifyCode)
|
||||||
|
d.CaptchaToken = strings.TrimSpace(d.CaptchaToken)
|
||||||
|
d.VerificationID = strings.TrimSpace(d.VerificationID)
|
||||||
|
d.resolvedRootFolderID = ""
|
||||||
|
d.rootFolderResolved = false
|
||||||
|
|
||||||
|
d.accountClient = base.NewRestyClient().
|
||||||
|
SetBaseURL(accountBaseURL).
|
||||||
|
SetHeader("Accept", "application/json, text/plain, */*").
|
||||||
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetHeader("X-Device-Model", "chrome%2F147.0.0.0").
|
||||||
|
SetHeader("X-Device-Name", "PC-Chrome").
|
||||||
|
SetHeader("X-Device-Sign", deviceSign).
|
||||||
|
SetHeader("X-Net-Work-Type", "NONE").
|
||||||
|
SetHeader("X-OS-Version", "MacIntel").
|
||||||
|
SetHeader("X-Platform-Version", "1").
|
||||||
|
SetHeader("X-Protocol-Version", "301").
|
||||||
|
SetHeader("X-Provider-Name", "NONE").
|
||||||
|
SetHeader("X-SDK-Version", "9.0.2").
|
||||||
|
SetHeader("X-Client-Id", d.ClientID).
|
||||||
|
SetHeader("X-Client-Version", "0.0.1").
|
||||||
|
SetHeader("X-Device-Id", d.DeviceID)
|
||||||
|
|
||||||
|
d.apiClient = base.NewRestyClient().
|
||||||
|
SetBaseURL(apiBaseURL).
|
||||||
|
SetHeader("Accept", "application/json, text/plain, */*").
|
||||||
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetHeader("Did", d.DeviceID).
|
||||||
|
SetHeader("Dt", "4")
|
||||||
|
|
||||||
|
// Priority: access_token -> refresh_token -> sms login.
|
||||||
|
if d.AccessToken != "" {
|
||||||
|
if err := d.validateToken(ctx); err == nil {
|
||||||
|
return d.prepareRootFolder(ctx)
|
||||||
|
}
|
||||||
|
d.AccessToken = ""
|
||||||
|
}
|
||||||
|
if d.RefreshToken != "" {
|
||||||
|
if err := d.refreshToken(ctx); err == nil {
|
||||||
|
if err2 := d.validateToken(ctx); err2 == nil {
|
||||||
|
return d.prepareRootFolder(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two-stage SMS flow:
|
||||||
|
// 1) phone only + send_code=true: send code and cache verification_id (do not fail init).
|
||||||
|
// 2) phone + verify_code: complete login and save tokens.
|
||||||
|
if d.PhoneNumber != "" {
|
||||||
|
if d.canSMSLogin() {
|
||||||
|
if err := d.loginBySMSCode(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.validateToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return d.prepareRootFolder(ctx)
|
||||||
|
}
|
||||||
|
if d.SendCode {
|
||||||
|
d.setTempStatus("SMS sending in progress...")
|
||||||
|
if err := d.prepareSMSCode(ctx); err != nil {
|
||||||
|
d.setTempStatus(fmt.Sprintf("SMS send failed: %v. Please check captcha/meta and set send_code=true to retry.", err))
|
||||||
|
log.Warnf("guangyapan: prepare sms code failed: %v", err)
|
||||||
|
} else {
|
||||||
|
d.setTempStatus("SMS sent successfully. Please fill verify_code and save to complete login.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("login failed: provide a valid access_token, or refresh_token, or phone_number + verify_code + captcha_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Drop(ctx context.Context) error {
|
||||||
|
if d.statusTimer != nil {
|
||||||
|
d.statusTimer.Stop()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) GetRoot(ctx context.Context) (model.Obj, error) {
|
||||||
|
rootID, err := d.getRootFolderID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.Object{
|
||||||
|
ID: rootID,
|
||||||
|
Path: "/",
|
||||||
|
Name: "root",
|
||||||
|
Size: 0,
|
||||||
|
Modified: d.Modified,
|
||||||
|
IsFolder: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := dir.GetID()
|
||||||
|
|
||||||
|
const maxPage = 10000
|
||||||
|
res := make([]model.Obj, 0, d.PageSize)
|
||||||
|
for page := 0; page < maxPage; page++ {
|
||||||
|
var resp listResp
|
||||||
|
body := map[string]any{
|
||||||
|
"parentId": parentID,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": d.PageSize,
|
||||||
|
"orderBy": d.OrderBy,
|
||||||
|
"sortType": d.SortType,
|
||||||
|
}
|
||||||
|
if err := d.postAPI(ctx, "/userres/v1/file/get_file_list", body, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range resp.Data.List {
|
||||||
|
res = append(res, &model.Object{
|
||||||
|
ID: item.FileID,
|
||||||
|
Path: parentID,
|
||||||
|
Name: item.FileName,
|
||||||
|
Size: item.FileSize,
|
||||||
|
Modified: unixOrZero(item.UTime),
|
||||||
|
Ctime: unixOrZero(item.CTime),
|
||||||
|
IsFolder: item.ResType == 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(resp.Data.List) < d.PageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if resp.Data.Total > 0 && len(res) >= resp.Data.Total {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||||
|
if file.IsDir() {
|
||||||
|
return nil, errs.NotFile
|
||||||
|
}
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp downloadResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_download_url", map[string]any{
|
||||||
|
"fileId": file.GetID(),
|
||||||
|
}, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimSpace(resp.Data.SignedURL)
|
||||||
|
if url == "" {
|
||||||
|
url = strings.TrimSpace(resp.Data.DownloadURL)
|
||||||
|
}
|
||||||
|
if url == "" {
|
||||||
|
return nil, errors.New("empty download url")
|
||||||
|
}
|
||||||
|
return &model.Link{URL: url}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(dirName)
|
||||||
|
if name == "" {
|
||||||
|
return errors.New("dir name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := parentDir.GetID()
|
||||||
|
|
||||||
|
var out createDirResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/create_dir", map[string]any{
|
||||||
|
"parentId": parentID,
|
||||||
|
"dirName": name,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(out.Msg) {
|
||||||
|
return fmt.Errorf("make dir failed: %s", strings.TrimSpace(out.Msg))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := strings.TrimSpace(srcObj.GetID())
|
||||||
|
if fileID == "" {
|
||||||
|
return errors.New("file id is empty")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(newName)
|
||||||
|
if name == "" {
|
||||||
|
return errors.New("new name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out commonResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/rename", map[string]any{
|
||||||
|
"fileId": fileID,
|
||||||
|
"newName": name,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(out.Msg) {
|
||||||
|
return fmt.Errorf("rename failed: %s", strings.TrimSpace(out.Msg))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Remove(ctx context.Context, obj model.Obj) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := strings.TrimSpace(obj.GetID())
|
||||||
|
if fileID == "" {
|
||||||
|
return errors.New("file id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var del taskResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/delete_file", map[string]any{
|
||||||
|
"fileIds": []string{fileID},
|
||||||
|
}, &del); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(del.Msg) {
|
||||||
|
return fmt.Errorf("delete failed: %s", strings.TrimSpace(del.Msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := strings.TrimSpace(del.Data.TaskID)
|
||||||
|
if taskID == "" {
|
||||||
|
// Some backends may apply deletion synchronously.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.waitTaskDone(ctx, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := strings.TrimSpace(srcObj.GetID())
|
||||||
|
if fileID == "" {
|
||||||
|
return errors.New("file id is empty")
|
||||||
|
}
|
||||||
|
parentID := dstDir.GetID()
|
||||||
|
|
||||||
|
var out taskResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/move_file", map[string]any{
|
||||||
|
"fileIds": []string{fileID},
|
||||||
|
"parentId": parentID,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(out.Msg) {
|
||||||
|
return fmt.Errorf("move failed: %s", strings.TrimSpace(out.Msg))
|
||||||
|
}
|
||||||
|
taskID := strings.TrimSpace(out.Data.TaskID)
|
||||||
|
if taskID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.waitTaskDone(ctx, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := strings.TrimSpace(srcObj.GetID())
|
||||||
|
if fileID == "" {
|
||||||
|
return errors.New("file id is empty")
|
||||||
|
}
|
||||||
|
parentID := dstDir.GetID()
|
||||||
|
|
||||||
|
var out taskResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/copy_file", map[string]any{
|
||||||
|
"fileIds": []string{fileID},
|
||||||
|
"parentId": parentID,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(out.Msg) {
|
||||||
|
return fmt.Errorf("copy failed: %s", strings.TrimSpace(out.Msg))
|
||||||
|
}
|
||||||
|
taskID := strings.TrimSpace(out.Data.TaskID)
|
||||||
|
if taskID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.waitTaskDone(ctx, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
return nil, errors.New("file is nil")
|
||||||
|
}
|
||||||
|
if file.GetSize() < 0 {
|
||||||
|
return nil, errors.New("invalid file size")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(file.GetName())
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("file name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := dstDir.GetID()
|
||||||
|
|
||||||
|
token, code, err := d.getUploadToken(ctx, parentID, name, file.GetSize())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taskID := strings.TrimSpace(token.TaskID)
|
||||||
|
// code == 156 (instant upload) or AlreadyDone mean the backend has already
|
||||||
|
// finished/imported the file; there is no OSS upload to perform.
|
||||||
|
if code == 156 || token.AlreadyDone {
|
||||||
|
if taskID == "" {
|
||||||
|
return nil, errors.New("instant upload returns empty task id")
|
||||||
|
}
|
||||||
|
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.ObjectPath == "" || token.BucketName == "" || token.EndPoint == "" || token.AccessKeyID == "" || token.SecretAccessKey == "" {
|
||||||
|
return nil, errors.New("upload token is incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
ossEndpoint := normalizeOSSEndpoint(token.EndPoint, token.BucketName)
|
||||||
|
client, err := oss.New(ossEndpoint, token.AccessKeyID, token.SecretAccessKey, oss.SecurityToken(token.SessionToken))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create oss client failed: %w", err)
|
||||||
|
}
|
||||||
|
bucket, err := client.Bucket(token.BucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create oss bucket failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.GetSize() == 0 {
|
||||||
|
if err := bucket.PutObject(token.ObjectPath, strings.NewReader("")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := d.multipartUploadToOSS(ctx, bucket, token.ObjectPath, file, up); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if taskID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp assetsInfoResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizassets.s/v1/get_assets", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.IsSuccess() && resp.Data.TotalSpaceSize > 0 {
|
||||||
|
return &model.StorageDetails{
|
||||||
|
DiskUsage: model.DiskUsage{
|
||||||
|
TotalSpace: resp.Data.TotalSpaceSize,
|
||||||
|
UsedSpace: resp.Data.UsedSpaceSize,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("failed to get storage details")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) getRootFolderID(ctx context.Context) (string, error) {
|
||||||
|
if d.rootFolderResolved {
|
||||||
|
return d.resolvedRootFolderID, nil
|
||||||
|
}
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := d.prepareRootFolder(ctx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return d.resolvedRootFolderID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) prepareRootFolder(ctx context.Context) error {
|
||||||
|
rootID, err := d.resolveConfiguredRootFolderID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.resolvedRootFolderID = rootID
|
||||||
|
d.rootFolderResolved = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) resolveConfiguredRootFolderID(ctx context.Context) (string, error) {
|
||||||
|
root := strings.TrimSpace(d.RootPath)
|
||||||
|
if root == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return d.resolveFolderPath(ctx, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) resolveFolderPath(ctx context.Context, rootPath string) (string, error) {
|
||||||
|
cleanPath := strings.Trim(strings.ReplaceAll(strings.TrimSpace(rootPath), "\\", "/"), "/")
|
||||||
|
if cleanPath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := ""
|
||||||
|
for _, name := range strings.Split(cleanPath, "/") {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childID, err := d.findChildFolderID(ctx, parentID, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parentID = childID
|
||||||
|
}
|
||||||
|
return parentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) findChildFolderID(ctx context.Context, parentID, name string) (string, error) {
|
||||||
|
pageSize := d.PageSize
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPage = 10000
|
||||||
|
seen := 0
|
||||||
|
for page := 0; page < maxPage; page++ {
|
||||||
|
var resp listResp
|
||||||
|
body := map[string]any{
|
||||||
|
"parentId": parentID,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": pageSize,
|
||||||
|
"orderBy": d.OrderBy,
|
||||||
|
"sortType": d.SortType,
|
||||||
|
}
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_file_list", body, &resp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, item := range resp.Data.List {
|
||||||
|
seen++
|
||||||
|
if item.ResType == 2 && item.FileName == name {
|
||||||
|
return item.FileID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(resp.Data.List) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if resp.Data.Total > 0 && seen >= resp.Data.Total {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parentID == "" {
|
||||||
|
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under /", name)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under parent %s", name, parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) ensureAccessToken(ctx context.Context) error {
|
||||||
|
if strings.TrimSpace(d.AccessToken) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.RefreshToken) == "" {
|
||||||
|
return errors.New("not logged in, please re-init storage")
|
||||||
|
}
|
||||||
|
return d.refreshToken(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) validateToken(ctx context.Context) error {
|
||||||
|
var me userMeResp
|
||||||
|
resp, err := d.accountClient.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetHeader("Authorization", "Bearer "+d.AccessToken).
|
||||||
|
SetResult(&me).
|
||||||
|
Get("/v1/user/me")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.IsError() {
|
||||||
|
return fmt.Errorf("validate token failed: status=%d body=%s", resp.StatusCode(), resp.String())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(me.Sub) == "" {
|
||||||
|
return errors.New("validate token failed: empty user sub")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) refreshToken(ctx context.Context) error {
|
||||||
|
if strings.TrimSpace(d.RefreshToken) == "" {
|
||||||
|
return errors.New("refresh_token is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.refreshMu.Lock()
|
||||||
|
defer d.refreshMu.Unlock()
|
||||||
|
|
||||||
|
// Double-check after acquiring lock (may have been refreshed by another goroutine)
|
||||||
|
if strings.TrimSpace(d.AccessToken) != "" {
|
||||||
|
if err := d.validateToken(ctx); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out tokenResp
|
||||||
|
resp, err := d.accountClient.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetBody(map[string]any{
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": d.RefreshToken,
|
||||||
|
}).
|
||||||
|
SetResult(&out).
|
||||||
|
Post("/v1/auth/token")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
|
||||||
|
errMsg := strings.TrimSpace(out.ErrorDesc)
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = strings.TrimSpace(out.Error)
|
||||||
|
}
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = strings.TrimSpace(resp.String())
|
||||||
|
}
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = fmt.Sprintf("status=%d", resp.StatusCode())
|
||||||
|
}
|
||||||
|
return fmt.Errorf("refresh token failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.AccessToken = strings.TrimSpace(out.AccessToken)
|
||||||
|
if strings.TrimSpace(out.RefreshToken) != "" {
|
||||||
|
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
|
||||||
|
}
|
||||||
|
op.MustSaveDriverStorage(d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) canSMSLogin() bool {
|
||||||
|
return d.PhoneNumber != "" && d.VerifyCode != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) loginBySMSCode(ctx context.Context) error {
|
||||||
|
verificationID := strings.TrimSpace(d.VerificationID)
|
||||||
|
if verificationID == "" {
|
||||||
|
var err error
|
||||||
|
verificationID, err = d.requestVerificationID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var step2 verifyResp
|
||||||
|
resp, err := d.accountClient.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetBody(map[string]any{
|
||||||
|
"verification_id": verificationID,
|
||||||
|
"verification_code": d.VerifyCode,
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
}).
|
||||||
|
SetResult(&step2).
|
||||||
|
Post("/v1/auth/verification/verify")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.IsError() || step2.Error != "" || strings.TrimSpace(step2.VerificationToken) == "" {
|
||||||
|
return fmt.Errorf("verify code failed: %s", d.accountErr(step2.ErrorDesc, step2.Error, resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
var out tokenResp
|
||||||
|
resp, err = d.accountClient.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetBody(map[string]any{
|
||||||
|
"verification_code": d.VerifyCode,
|
||||||
|
"verification_token": step2.VerificationToken,
|
||||||
|
"username": normalizePhoneE164(d.PhoneNumber),
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
}).
|
||||||
|
SetResult(&out).
|
||||||
|
Post("/v1/auth/signin")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
|
||||||
|
return fmt.Errorf("signin failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
d.AccessToken = strings.TrimSpace(out.AccessToken)
|
||||||
|
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
|
||||||
|
d.VerificationID = ""
|
||||||
|
// One-time SMS code should not be reused after successful login.
|
||||||
|
d.VerifyCode = ""
|
||||||
|
op.MustSaveDriverStorage(d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) prepareSMSCode(ctx context.Context) error {
|
||||||
|
// Explicit send action should always refresh verification_id.
|
||||||
|
d.VerificationID = ""
|
||||||
|
if err := d.ensureCaptchaToken(ctx, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
verificationID, err := d.requestVerificationID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.VerificationID = verificationID
|
||||||
|
d.SendCode = false
|
||||||
|
op.MustSaveDriverStorage(d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) requestVerificationID(ctx context.Context) (string, error) {
|
||||||
|
req := d.accountClient.R().SetContext(ctx)
|
||||||
|
if d.CaptchaToken != "" {
|
||||||
|
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
var step1 verificationResp
|
||||||
|
resp, err := req.
|
||||||
|
SetBody(map[string]any{
|
||||||
|
"phone_number": normalizePhoneE164(d.PhoneNumber),
|
||||||
|
"target": "ANY",
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
}).
|
||||||
|
SetResult(&step1).
|
||||||
|
Post("/v1/auth/verification")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.IsError() || step1.Error != "" || strings.TrimSpace(step1.VerificationID) == "" {
|
||||||
|
// If captcha token is expired/invalid, refresh it once and retry.
|
||||||
|
if strings.Contains(step1.Error, "captcha_invalid") || strings.Contains(step1.ErrorDesc, "captcha_token expired") {
|
||||||
|
if err := d.ensureCaptchaToken(ctx, true); err == nil {
|
||||||
|
return d.requestVerificationID(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("request verification failed: %s", d.accountErr(step1.ErrorDesc, step1.Error, resp))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(step1.VerificationID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) ensureCaptchaToken(ctx context.Context, force bool) error {
|
||||||
|
if !force && d.CaptchaToken != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out captchaInitResp
|
||||||
|
req := d.accountClient.R().SetContext(ctx)
|
||||||
|
if d.CaptchaToken != "" {
|
||||||
|
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
|
||||||
|
}
|
||||||
|
resp, err := req.
|
||||||
|
SetBody(map[string]any{
|
||||||
|
"client_id": d.ClientID,
|
||||||
|
"action": "POST:/v1/auth/verification",
|
||||||
|
"device_id": d.DeviceID,
|
||||||
|
"meta": map[string]any{
|
||||||
|
"username": normalizePhoneE164(d.PhoneNumber),
|
||||||
|
"phone_number": normalizePhoneE164(d.PhoneNumber),
|
||||||
|
"VERIFICATION_PHONE": normalizePhoneE164(d.PhoneNumber),
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
SetResult(&out).
|
||||||
|
Post("/v1/shield/captcha/init")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.CaptchaToken) == "" {
|
||||||
|
return fmt.Errorf("init captcha token failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
|
||||||
|
}
|
||||||
|
d.CaptchaToken = strings.TrimSpace(out.CaptchaToken)
|
||||||
|
op.MustSaveDriverStorage(d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface compliance checks
|
||||||
|
var (
|
||||||
|
_ driver.Driver = (*GuangYaPan)(nil)
|
||||||
|
_ driver.GetRooter = (*GuangYaPan)(nil)
|
||||||
|
_ driver.Mkdir = (*GuangYaPan)(nil)
|
||||||
|
_ driver.Move = (*GuangYaPan)(nil)
|
||||||
|
_ driver.Copy = (*GuangYaPan)(nil)
|
||||||
|
_ driver.Rename = (*GuangYaPan)(nil)
|
||||||
|
_ driver.Remove = (*GuangYaPan)(nil)
|
||||||
|
_ driver.PutResult = (*GuangYaPan)(nil)
|
||||||
|
_ driver.WithDetails = (*GuangYaPan)(nil)
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Addition struct {
|
||||||
|
RootPath string `json:"root_path" help:"Full path in GuangYaPan cloud drive"`
|
||||||
|
PhoneNumber string `json:"phone_number" type:"text" help:"Phone number for SMS login, e.g. +86 13800000000"`
|
||||||
|
CaptchaToken string `json:"captcha_token" help:"Captcha token required by /v1/auth/verification"`
|
||||||
|
SendCode bool `json:"send_code" type:"bool" help:"Set true and save to send SMS code, it auto-resets to false after sending"`
|
||||||
|
VerifyCode string `json:"verify_code" type:"text" help:"SMS verification code used with phone_number; fill then save to finish login"`
|
||||||
|
VerificationID string `json:"verification_id" type:"text" help:"Auto-generated after sending SMS code; do not edit manually"`
|
||||||
|
AccessToken string `json:"access_token" help:"Bearer access token (optional if refresh_token is provided)"`
|
||||||
|
RefreshToken string `json:"refresh_token" help:"Refresh token for auto-login/auto-refresh"`
|
||||||
|
ClientID string `json:"client_id" required:"true" help:"Client ID for GuangYaPan API, must be provided" default:"aMe-8VSlkrbQXpUR"`
|
||||||
|
DeviceID string `json:"device_id" help:"Optional custom device id (32 hex chars), auto-generated when empty"`
|
||||||
|
DeviceSign string `json:"device_sign" help:"Optional custom X-Device-Sign header (generated from device_id when empty)"`
|
||||||
|
PageSize int `json:"page_size" type:"number" default:"100"`
|
||||||
|
OrderBy int `json:"order_by" type:"number" options:"0,1,2,3,4" default:"3" help:"Sort field used by the file list"`
|
||||||
|
SortType int `json:"sort_type" type:"number" options:"0,1" default:"1" help:"Sort direction used by the file list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = driver.Config{
|
||||||
|
Name: "GuangYaPan",
|
||||||
|
LocalSort: false,
|
||||||
|
OnlyProxy: false,
|
||||||
|
NoCache: false,
|
||||||
|
NoUpload: false,
|
||||||
|
NeedMs: false,
|
||||||
|
DefaultRoot: "",
|
||||||
|
CheckStatus: true,
|
||||||
|
Alert: "info|Two-stage SMS login: (1) fill phone_number (+ captcha_token if needed), set send_code=true and save; (2) fill verify_code and save to finish login and auto-save access_token/refresh_token.",
|
||||||
|
NoOverwriteUpload: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
op.RegisterDriver(func() driver.Driver {
|
||||||
|
return &GuangYaPan{}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
stdpath "path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *GuangYaPan) ResolveOfflineResource(ctx context.Context, fileURL string) (*OfflineResolveData, error) {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fileURL = strings.TrimSpace(fileURL)
|
||||||
|
if fileURL == "" {
|
||||||
|
return nil, errors.New("offline url is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp offlineResolveResp
|
||||||
|
if err := d.postAPI(ctx, "/cloudcollection/v1/resolve_res", map[string]any{
|
||||||
|
"url": fileURL,
|
||||||
|
}, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(resp.Msg) {
|
||||||
|
return nil, fmt.Errorf("resolve offline resource failed: %s", strings.TrimSpace(resp.Msg))
|
||||||
|
}
|
||||||
|
return &resp.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) OfflineDownload(ctx context.Context, fileURL string, parentDir model.Obj, fileName string) (*OfflineTask, error) {
|
||||||
|
resolved, err := d.ResolveOfflineResource(ctx, fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := parentDir.GetID()
|
||||||
|
rootID, err := d.getRootFolderID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if parentID == rootID {
|
||||||
|
parentID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
taskURL := strings.TrimSpace(resolved.URL)
|
||||||
|
if taskURL == "" {
|
||||||
|
taskURL = strings.TrimSpace(fileURL)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(fileName)
|
||||||
|
if name == "" {
|
||||||
|
name = resolved.defaultName(taskURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"url": taskURL,
|
||||||
|
"parentId": parentID,
|
||||||
|
"newName": name,
|
||||||
|
}
|
||||||
|
if indexes := resolved.fileIndexes(); len(indexes) > 0 {
|
||||||
|
body["fileIndexes"] = indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp offlineCreateResp
|
||||||
|
if err := d.postAPI(ctx, "/cloudcollection/v1/create_task", body, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(resp.Msg) {
|
||||||
|
return nil, fmt.Errorf("create offline task failed: %s", strings.TrimSpace(resp.Msg))
|
||||||
|
}
|
||||||
|
taskID := strings.TrimSpace(resp.Data.TaskID)
|
||||||
|
if taskID == "" {
|
||||||
|
return nil, errors.New("create offline task failed: empty task id")
|
||||||
|
}
|
||||||
|
return &OfflineTask{
|
||||||
|
TaskID: taskID,
|
||||||
|
FileName: name,
|
||||||
|
Res: taskURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) OfflineList(ctx context.Context, taskIDs []string, statuses []int, cursor string, pageSize int) ([]OfflineTask, error) {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := map[string]any{}
|
||||||
|
if len(taskIDs) > 0 {
|
||||||
|
body["taskIds"] = taskIDs
|
||||||
|
}
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
body["status"] = statuses
|
||||||
|
}
|
||||||
|
if cursor = strings.TrimSpace(cursor); cursor != "" {
|
||||||
|
body["cursor"] = cursor
|
||||||
|
}
|
||||||
|
if pageSize > 0 {
|
||||||
|
body["pageSize"] = pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp offlineListResp
|
||||||
|
if err := d.postAPI(ctx, "/cloudcollection/v1/list_task", body, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(resp.Msg) {
|
||||||
|
return nil, fmt.Errorf("list offline tasks failed: %s", strings.TrimSpace(resp.Msg))
|
||||||
|
}
|
||||||
|
return resp.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) DeleteOfflineTasks(ctx context.Context, taskIDs []string) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(taskIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp offlineDeleteResp
|
||||||
|
if err := d.postAPI(ctx, "/cloudcollection/v2/delete_task", map[string]any{
|
||||||
|
"taskIds": taskIDs,
|
||||||
|
}, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(resp.Msg) {
|
||||||
|
return fmt.Errorf("delete offline tasks failed: %s", strings.TrimSpace(resp.Msg))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d OfflineResolveData) defaultName(fileURL string) string {
|
||||||
|
if d.BTResInfo != nil && strings.TrimSpace(d.BTResInfo.FileName) != "" {
|
||||||
|
return strings.TrimSpace(d.BTResInfo.FileName)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(fileURL)
|
||||||
|
if err == nil {
|
||||||
|
name := strings.TrimSpace(stdpath.Base(u.Path))
|
||||||
|
if name != "" && name != "." && name != "/" {
|
||||||
|
if decoded, err := url.PathUnescape(name); err == nil {
|
||||||
|
name = decoded
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "offline_download"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d OfflineResolveData) fileIndexes() []int {
|
||||||
|
if d.BTResInfo == nil || len(d.BTResInfo.Subfiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
indexes := make([]int, 0, len(d.BTResInfo.Subfiles))
|
||||||
|
for i, file := range d.BTResInfo.Subfiles {
|
||||||
|
if file.FileIndex != nil {
|
||||||
|
indexes = append(indexes, *file.FileIndex)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indexes = append(indexes, i)
|
||||||
|
}
|
||||||
|
return indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSuccessMsg(msg string) bool {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
return msg == "" || strings.EqualFold(msg, "success")
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorCode int `json:"error_code"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationResp struct {
|
||||||
|
VerificationID string `json:"verification_id"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorCode int `json:"error_code"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type captchaInitResp struct {
|
||||||
|
CaptchaToken string `json:"captcha_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorCode int `json:"error_code"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifyResp struct {
|
||||||
|
VerificationToken string `json:"verification_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorCode int `json:"error_code"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userMeResp struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
List []fileItem `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileItem struct {
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
ParentID string `json:"parentId"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
FileSize int64 `json:"fileSize"`
|
||||||
|
ResType int `json:"resType"`
|
||||||
|
CTime int64 `json:"ctime"`
|
||||||
|
UTime int64 `json:"utime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
SignedURL string `json:"signedURL"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createDirResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
ResType int `json:"resType"`
|
||||||
|
CTime int64 `json:"ctime"`
|
||||||
|
UTime int64 `json:"utime"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commonResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskResp is used for async operations (delete, move, copy) that return a task ID.
|
||||||
|
type taskResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskStatusResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadTokenResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data uploadTokenData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadTokenData struct {
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
AlreadyDone bool `json:"-"`
|
||||||
|
ObjectPath string `json:"objectPath"`
|
||||||
|
Provider any `json:"provider"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
BucketName string `json:"bucketName"`
|
||||||
|
EndPoint string `json:"endPoint"`
|
||||||
|
FullEndPoint string `json:"fullEndPoint"`
|
||||||
|
CallbackVar string `json:"callbackVar"`
|
||||||
|
AccessKeyID string `json:"accessKeyID"`
|
||||||
|
SecretAccessKey string `json:"secretAccessKey"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
|
Creds struct {
|
||||||
|
AccessKeyID string `json:"accessKeyID"`
|
||||||
|
SecretAccessKey string `json:"secretAccessKey"`
|
||||||
|
SessionToken string `json:"sessionToken"`
|
||||||
|
} `json:"creds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskInfoResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetsInfoResp is the response for storage details query.
|
||||||
|
type assetsInfoResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
TotalSpaceSize int64 `json:"totalSpaceSize"`
|
||||||
|
UsedSpaceSize int64 `json:"usedSpaceSize"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r assetsInfoResp) IsSuccess() bool {
|
||||||
|
return isSuccessMsg(r.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline download types
|
||||||
|
|
||||||
|
type offlineResolveResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data OfflineResolveData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineResolveData struct {
|
||||||
|
ResType int `json:"resType"`
|
||||||
|
BTResInfo *OfflineBTResInfo `json:"btResInfo"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineBTResInfo struct {
|
||||||
|
InfoHash string `json:"infoHash"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
FileSize int64 `json:"fileSize"`
|
||||||
|
SubfilesNum int `json:"subfilesNum"`
|
||||||
|
Subfiles []OfflineSubfile `json:"subfiles"`
|
||||||
|
CreateTime int64 `json:"createTime"`
|
||||||
|
ExcludeIndices []int `json:"excludeIndices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineSubfile struct {
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
FileIndex *int `json:"fileIndex"`
|
||||||
|
FileSize int64 `json:"fileSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type offlineCreateResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type offlineDeleteResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
TaskIDs []string `json:"taskIds"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type offlineListResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data struct {
|
||||||
|
StatusCounts []struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"statusCounts"`
|
||||||
|
Cursor string `json:"cursor"`
|
||||||
|
List []OfflineTask `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineTask struct {
|
||||||
|
TaskID string `json:"taskId"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
CreateTime int64 `json:"createTime"`
|
||||||
|
Res string `json:"res"`
|
||||||
|
ResType int `json:"resType"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
Exist bool `json:"exist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func unixOrZero(v int64) time.Time {
|
||||||
|
if v <= 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(v, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/driver"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||||
|
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||||||
|
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||||
|
"github.com/avast/retry-go"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- HTTP request helpers ---
|
||||||
|
|
||||||
|
func (d *GuangYaPan) accountErr(desc, short string, resp *resty.Response) string {
|
||||||
|
msg := strings.TrimSpace(desc)
|
||||||
|
if msg == "" {
|
||||||
|
msg = strings.TrimSpace(short)
|
||||||
|
}
|
||||||
|
if msg == "" && resp != nil {
|
||||||
|
msg = strings.TrimSpace(resp.String())
|
||||||
|
}
|
||||||
|
if msg == "" && resp != nil {
|
||||||
|
msg = fmt.Sprintf("status=%d", resp.StatusCode())
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) apiRateLimitWait(ctx context.Context, path string) error {
|
||||||
|
value, _ := d.apiRateLimit.LoadOrStore(path, rate.NewLimiter(rate.Every(apiRateInterval), 1))
|
||||||
|
return value.(*rate.Limiter).Wait(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) postAPI(ctx context.Context, path string, body any, out any) error {
|
||||||
|
if err := d.ensureAccessToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.apiRateLimitWait(ctx, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := d.apiClient.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetHeader("Authorization", "Bearer "+d.AccessToken).
|
||||||
|
SetBody(body).
|
||||||
|
SetResult(out).
|
||||||
|
Post(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() == 401 || resp.StatusCode() == 403 {
|
||||||
|
if strings.TrimSpace(d.RefreshToken) == "" {
|
||||||
|
return fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode(), resp.String())
|
||||||
|
}
|
||||||
|
if err := d.refreshToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err = d.apiClient.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
SetHeader("Authorization", "Bearer "+d.AccessToken).
|
||||||
|
SetBody(body).
|
||||||
|
SetResult(out).
|
||||||
|
Post(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.IsError() {
|
||||||
|
return fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode(), resp.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) setTempStatus(status string) {
|
||||||
|
if d.statusTimer != nil {
|
||||||
|
d.statusTimer.Stop()
|
||||||
|
}
|
||||||
|
// initStorage sets status to WORK after Init returns, so we update it shortly after.
|
||||||
|
d.statusTimer = time.AfterFunc(200*time.Millisecond, func() {
|
||||||
|
d.GetStorage().SetStatus(status)
|
||||||
|
op.MustSaveDriverStorage(d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Task polling helpers ---
|
||||||
|
|
||||||
|
func (d *GuangYaPan) waitTaskDone(ctx context.Context, taskID string) error {
|
||||||
|
const (
|
||||||
|
maxTry = 30
|
||||||
|
interval = 300 * time.Millisecond
|
||||||
|
)
|
||||||
|
for i := 0; i < maxTry; i++ {
|
||||||
|
var out taskStatusResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_task_status", map[string]any{
|
||||||
|
"taskId": taskID,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isSuccessMsg(out.Msg) {
|
||||||
|
return fmt.Errorf("get task status failed: %s", strings.TrimSpace(out.Msg))
|
||||||
|
}
|
||||||
|
switch out.Data.Status {
|
||||||
|
case 2:
|
||||||
|
return nil
|
||||||
|
case -1, 3:
|
||||||
|
return fmt.Errorf("task %s failed with status=%d", taskID, out.Data.Status)
|
||||||
|
}
|
||||||
|
if i == maxTry-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("task %s timeout", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Upload helpers ---
|
||||||
|
|
||||||
|
func (d *GuangYaPan) getUploadToken(ctx context.Context, parentID, name string, size int64) (*uploadTokenData, int, error) {
|
||||||
|
var out uploadTokenResp
|
||||||
|
err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_center_token", map[string]any{
|
||||||
|
"capacity": 2,
|
||||||
|
"name": name,
|
||||||
|
"parentId": parentID,
|
||||||
|
"res": map[string]any{
|
||||||
|
"fileSize": size,
|
||||||
|
},
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(out.Msg)
|
||||||
|
if !isSuccessMsg(msg) && !isUploadAlreadyDone(msg) {
|
||||||
|
return nil, out.Code, fmt.Errorf("get upload token failed: %s", msg)
|
||||||
|
}
|
||||||
|
if out.Data.TaskID == "" {
|
||||||
|
return nil, out.Code, errors.New("get upload token failed: empty task id")
|
||||||
|
}
|
||||||
|
// When the backend reports the file is already uploaded/instant-uploaded,
|
||||||
|
// it returns a valid TaskID without OSS credentials.
|
||||||
|
// Mark it so the caller can skip the real upload and just wait for the task.
|
||||||
|
if out.Code == 156 || isUploadAlreadyDone(msg) {
|
||||||
|
out.Data.AlreadyDone = true
|
||||||
|
}
|
||||||
|
if out.Data.AccessKeyID == "" {
|
||||||
|
out.Data.AccessKeyID = out.Data.Creds.AccessKeyID
|
||||||
|
}
|
||||||
|
if out.Data.SecretAccessKey == "" {
|
||||||
|
out.Data.SecretAccessKey = out.Data.Creds.SecretAccessKey
|
||||||
|
}
|
||||||
|
if out.Data.SessionToken == "" {
|
||||||
|
out.Data.SessionToken = out.Data.Creds.SessionToken
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.Data.EndPoint) == "" {
|
||||||
|
out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.Data.EndPoint) != "" && !strings.HasPrefix(out.Data.EndPoint, "http://") && !strings.HasPrefix(out.Data.EndPoint, "https://") {
|
||||||
|
if strings.TrimSpace(out.Data.FullEndPoint) != "" {
|
||||||
|
out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint)
|
||||||
|
} else if strings.TrimSpace(out.Data.BucketName) != "" {
|
||||||
|
host := strings.TrimSpace(out.Data.EndPoint)
|
||||||
|
prefix := strings.TrimSpace(out.Data.BucketName) + "."
|
||||||
|
if strings.HasPrefix(host, prefix) {
|
||||||
|
out.Data.EndPoint = "https://" + host
|
||||||
|
} else {
|
||||||
|
out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.BucketName) + "." + host
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.EndPoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &out.Data, out.Code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) waitUploadTaskInfo(ctx context.Context, taskID string) error {
|
||||||
|
const (
|
||||||
|
maxTry = 300
|
||||||
|
interval = 1 * time.Second
|
||||||
|
)
|
||||||
|
for i := 0; i < maxTry; i++ {
|
||||||
|
var out taskInfoResp
|
||||||
|
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_info_by_task_id", map[string]any{
|
||||||
|
"taskId": taskID,
|
||||||
|
}, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out.Data.FileID != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch out.Code {
|
||||||
|
case 145, 146, 147, 155, 163, 0:
|
||||||
|
// uploading/verifying/processing
|
||||||
|
default:
|
||||||
|
if strings.TrimSpace(out.Msg) != "" {
|
||||||
|
return fmt.Errorf("upload task failed: code=%d msg=%s", out.Code, strings.TrimSpace(out.Msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i == maxTry-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("upload task %s timeout", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *GuangYaPan) multipartUploadToOSS(ctx context.Context, bucket *oss.Bucket, objectPath string, file model.FileStreamer, up driver.UpdateProgress) error {
|
||||||
|
partSize := calcUploadPartSize(file.GetSize())
|
||||||
|
imur, err := bucket.InitiateMultipartUpload(objectPath, oss.Sequential())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
total := file.GetSize()
|
||||||
|
partCount := int((total + partSize - 1) / partSize)
|
||||||
|
|
||||||
|
// Use StreamSectionReader for seekable, retryable chunk reads (hybrid cache).
|
||||||
|
ss, err := streamPkg.NewStreamSectionReader(file, int(partSize), &up)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]oss.UploadPart, 0, partCount)
|
||||||
|
for i := 0; i < partCount; i++ {
|
||||||
|
if utils.IsCanceled(ctx) {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := int64(i) * partSize
|
||||||
|
length := partSize
|
||||||
|
if remain := total - offset; length > remain {
|
||||||
|
length = remain
|
||||||
|
}
|
||||||
|
|
||||||
|
rd, err := ss.GetSectionReader(offset, length)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var part oss.UploadPart
|
||||||
|
err = retry.Do(func() error {
|
||||||
|
rd.Seek(0, io.SeekStart)
|
||||||
|
var uploadErr error
|
||||||
|
part, uploadErr = bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), length, i+1)
|
||||||
|
return uploadErr
|
||||||
|
},
|
||||||
|
retry.Context(ctx),
|
||||||
|
retry.Attempts(3),
|
||||||
|
retry.DelayType(retry.BackOffDelay),
|
||||||
|
retry.Delay(time.Second))
|
||||||
|
ss.FreeSectionReader(rd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to upload part %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = bucket.CompleteMultipartUpload(imur, parts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Normalization helpers ---
|
||||||
|
|
||||||
|
func normalizeOSSEndpoint(endpoint, bucket string) string {
|
||||||
|
ep := strings.TrimSpace(endpoint)
|
||||||
|
if ep == "" {
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ep, "http://") && !strings.HasPrefix(ep, "https://") {
|
||||||
|
ep = "https://" + ep
|
||||||
|
}
|
||||||
|
u, err := url.Parse(ep)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
host := u.Host
|
||||||
|
prefix := strings.TrimSpace(bucket)
|
||||||
|
if prefix != "" && strings.HasPrefix(host, prefix+".") {
|
||||||
|
host = strings.TrimPrefix(host, prefix+".")
|
||||||
|
}
|
||||||
|
u.Host = host
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceID(v string) string {
|
||||||
|
v = strings.ToLower(strings.TrimSpace(v))
|
||||||
|
v = strings.ReplaceAll(v, "-", "")
|
||||||
|
if len(v) != 32 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, ch := range v {
|
||||||
|
if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDeviceID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "0123456789abcdef0123456789abcdef"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCaptchaUsername(phone string) string {
|
||||||
|
p := strings.TrimSpace(phone)
|
||||||
|
p = strings.ReplaceAll(p, " ", "")
|
||||||
|
p = strings.TrimPrefix(p, "+")
|
||||||
|
// Keep only digits.
|
||||||
|
b := make([]rune, 0, len(p))
|
||||||
|
for _, ch := range p {
|
||||||
|
if ch >= '0' && ch <= '9' {
|
||||||
|
b = append(b, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digits := string(b)
|
||||||
|
// Mainland number normalization: +86xxxxxxxxxxx -> xxxxxxxxxxx
|
||||||
|
if strings.HasPrefix(digits, "86") && len(digits) > 11 {
|
||||||
|
digits = digits[2:]
|
||||||
|
}
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePhoneE164(phone string) string {
|
||||||
|
p := strings.TrimSpace(phone)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
p = strings.ReplaceAll(p, " ", "")
|
||||||
|
if strings.HasPrefix(p, "+") {
|
||||||
|
// Format as "+86 1xxxxxxxxxx" to match browser payload expectations.
|
||||||
|
if strings.HasPrefix(p, "+86") && len(p) > 3 {
|
||||||
|
rest := strings.TrimPrefix(p, "+86")
|
||||||
|
return "+86 " + rest
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
// If raw mainland number is provided, normalize with +86 prefix.
|
||||||
|
digits := normalizeCaptchaUsername(p)
|
||||||
|
if len(digits) == 11 {
|
||||||
|
return "+86 " + digits
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcUploadPartSize(size int64) int64 {
|
||||||
|
const (
|
||||||
|
mb = int64(1024 * 1024)
|
||||||
|
gb = int64(1024 * 1024 * 1024)
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case size <= 100*mb:
|
||||||
|
return 1 * mb
|
||||||
|
case size <= 16*gb:
|
||||||
|
return 2 * mb
|
||||||
|
case size <= 160*gb:
|
||||||
|
return 4 * mb
|
||||||
|
default:
|
||||||
|
return 8 * mb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUploadAlreadyDone reports whether the upload-token response indicates the
|
||||||
|
// file was already uploaded (instant upload). In that case the backend returns
|
||||||
|
// a valid TaskID but no OSS credentials, and we should just wait for the task
|
||||||
|
// instead of starting a real upload.
|
||||||
|
func isUploadAlreadyDone(msg string) bool {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if msg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(msg, "上传已完成") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(msg, "upload completed") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(msg, "already uploaded") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(msg, "秒传成功") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -95,6 +95,9 @@ const (
|
|||||||
// thunder_browser
|
// thunder_browser
|
||||||
ThunderBrowserTempDir = "thunder_browser_temp_dir"
|
ThunderBrowserTempDir = "thunder_browser_temp_dir"
|
||||||
|
|
||||||
|
// guangyapan
|
||||||
|
GuangYaPanTempDir = "guangyapan_temp_dir"
|
||||||
|
|
||||||
// single
|
// single
|
||||||
Token = "token"
|
Token = "token"
|
||||||
IndexProgress = "index_progress"
|
IndexProgress = "index_progress"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/123"
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/123"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/123_open"
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/123_open"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/aria2"
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/aria2"
|
||||||
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/guangyapan"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/http"
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/http"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/pikpak"
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/pikpak"
|
||||||
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/qbit"
|
_ "github.com/OpenListTeam/OpenList/v4/internal/offline_download/qbit"
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
guangyapandriver "github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/errs"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/model"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GuangYaPan struct {
|
||||||
|
refreshTaskCache atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) Name() string {
|
||||||
|
return "GuangYaPan"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) Items() []model.SettingItem {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) Run(task *tool.DownloadTask) error {
|
||||||
|
return errs.NotSupport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) Init() (string, error) {
|
||||||
|
g.refreshTaskCache.Store(false)
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) IsReady() bool {
|
||||||
|
tempDir := setting.GetStr(conf.GuangYaPanTempDir)
|
||||||
|
if tempDir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
storage, _, err := op.GetStorageAndActualPath(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := storage.(*guangyapandriver.GuangYaPan); !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) AddURL(args *tool.AddUrlArgs) (string, error) {
|
||||||
|
g.refreshTaskCache.Store(true)
|
||||||
|
storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
driver, ok := storage.(*guangyapandriver.GuangYaPan)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("GuangYaPan offline download only supports GuangYaPan destination storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := op.MakeDir(ctx, storage, actualPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parentDir, err := op.GetUnwrap(ctx, storage, actualPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
task, err := driver.OfflineDownload(ctx, args.Url, parentDir, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to add offline download task: %w", err)
|
||||||
|
}
|
||||||
|
return task.TaskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) Remove(task *tool.DownloadTask) error {
|
||||||
|
storage, _, err := op.GetStorageAndActualPath(task.TempDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
driver, ok := storage.(*guangyapandriver.GuangYaPan)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("GuangYaPan offline download only supports GuangYaPan destination storage")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := driver.DeleteOfflineTasks(ctx, []string{task.GID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.DelTaskCache(driver, task.GID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) Status(task *tool.DownloadTask) (*tool.Status, error) {
|
||||||
|
storage, _, err := op.GetStorageAndActualPath(task.TempDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
driver, ok := storage.(*guangyapandriver.GuangYaPan)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("GuangYaPan offline download only supports GuangYaPan destination storage")
|
||||||
|
}
|
||||||
|
tasks, err := g.GetTasks(driver, task.GID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status := &tool.Status{
|
||||||
|
Status: "the task has been deleted",
|
||||||
|
}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.TaskID != task.GID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status.Progress = float64(t.Progress)
|
||||||
|
status.TotalBytes = t.TotalSize
|
||||||
|
status.Completed = t.Status == offlineStatusCompleted || t.Status == offlineStatusPartiallyCompleted
|
||||||
|
status.Status = taskStatusText(t)
|
||||||
|
if t.Status == offlineStatusFailed || t.Status == offlineStatusCanceled {
|
||||||
|
status.Err = errors.New(status.Status)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
status.Err = errors.New("the task has been deleted")
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
tool.Tools.Add(&GuangYaPan{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package guangyapan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
guangyapandriver "github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/internal/op"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/pkg/singleflight"
|
||||||
|
"github.com/OpenListTeam/go-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
offlineStatusQueued = 0
|
||||||
|
offlineStatusRunning = 1
|
||||||
|
offlineStatusCompleted = 2
|
||||||
|
offlineStatusFailed = 3
|
||||||
|
offlineStatusCanceled = 4
|
||||||
|
offlineStatusPartiallyCompleted = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
var taskCache = cache.NewMemCache(cache.WithShards[[]guangyapandriver.OfflineTask](16))
|
||||||
|
var taskG singleflight.Group[[]guangyapandriver.OfflineTask]
|
||||||
|
|
||||||
|
func (g *GuangYaPan) GetTasks(driver *guangyapandriver.GuangYaPan, taskID string) ([]guangyapandriver.OfflineTask, error) {
|
||||||
|
key := op.Key(driver, "/cloudcollection/v1/list_task/"+taskID)
|
||||||
|
if !g.refreshTaskCache.Load() {
|
||||||
|
if tasks, ok := taskCache.Get(key); ok {
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.refreshTaskCache.Store(false)
|
||||||
|
tasks, err, _ := taskG.Do(key, func() ([]guangyapandriver.OfflineTask, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tasks, err := driver.OfflineList(ctx, []string{taskID}, nil, "", 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tasks) > 0 {
|
||||||
|
taskCache.Set(key, tasks, cache.WithEx[[]guangyapandriver.OfflineTask](time.Second*10))
|
||||||
|
} else {
|
||||||
|
taskCache.Del(key)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GuangYaPan) DelTaskCache(driver *guangyapandriver.GuangYaPan, taskID string) {
|
||||||
|
taskCache.Del(op.Key(driver, "/cloudcollection/v1/list_task/"+taskID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskStatusText(task guangyapandriver.OfflineTask) string {
|
||||||
|
switch task.Status {
|
||||||
|
case offlineStatusQueued:
|
||||||
|
return "queued"
|
||||||
|
case offlineStatusRunning:
|
||||||
|
if task.Progress > 0 {
|
||||||
|
return fmt.Sprintf("running (%d%%)", task.Progress)
|
||||||
|
}
|
||||||
|
return "running"
|
||||||
|
case offlineStatusCompleted:
|
||||||
|
return "completed"
|
||||||
|
case offlineStatusFailed:
|
||||||
|
return "failed"
|
||||||
|
case offlineStatusCanceled:
|
||||||
|
return "canceled"
|
||||||
|
case offlineStatusPartiallyCompleted:
|
||||||
|
return "partially completed"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown status %d", task.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
_115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open"
|
_115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open"
|
||||||
_123 "github.com/OpenListTeam/OpenList/v4/drivers/123"
|
_123 "github.com/OpenListTeam/OpenList/v4/drivers/123"
|
||||||
_123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open"
|
_123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
|
||||||
"github.com/OpenListTeam/OpenList/v4/drivers/pikpak"
|
"github.com/OpenListTeam/OpenList/v4/drivers/pikpak"
|
||||||
"github.com/OpenListTeam/OpenList/v4/drivers/thunder"
|
"github.com/OpenListTeam/OpenList/v4/drivers/thunder"
|
||||||
"github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser"
|
"github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser"
|
||||||
@@ -162,6 +163,16 @@ func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskExtensionInfo, erro
|
|||||||
} else {
|
} else {
|
||||||
tempDir = filepath.Join(setting.GetStr(conf.ThunderXTempDir), uid)
|
tempDir = filepath.Join(setting.GetStr(conf.ThunderXTempDir), uid)
|
||||||
}
|
}
|
||||||
|
case "GuangYaPan":
|
||||||
|
if _, ok := storage.(*guangyapan.GuangYaPan); ok {
|
||||||
|
tempDir = args.DstDirPath
|
||||||
|
} else {
|
||||||
|
tempBase := setting.GetStr(conf.GuangYaPanTempDir)
|
||||||
|
if tempBase == "" {
|
||||||
|
return nil, errors.New("GuangYaPan temp dir is not set")
|
||||||
|
}
|
||||||
|
tempDir = filepath.Join(tempBase, uid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
taskCreator, _ := ctx.Value(conf.UserKey).(*model.User) // taskCreator is nil when convert failed
|
taskCreator, _ := ctx.Value(conf.UserKey).(*model.User) // taskCreator is nil when convert failed
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ outer:
|
|||||||
if t.tool.Name() == "ThunderX" {
|
if t.tool.Name() == "ThunderX" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if t.tool.Name() == "GuangYaPan" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if t.tool.Name() == "115 Cloud" {
|
if t.tool.Name() == "115 Cloud" {
|
||||||
// hack for 115
|
// hack for 115
|
||||||
<-time.After(time.Second * 1)
|
<-time.After(time.Second * 1)
|
||||||
@@ -176,7 +179,7 @@ func (t *DownloadTask) Update() (bool, error) {
|
|||||||
|
|
||||||
func (t *DownloadTask) Transfer() error {
|
func (t *DownloadTask) Transfer() error {
|
||||||
toolName := t.tool.Name()
|
toolName := t.tool.Name()
|
||||||
if toolName == "115 Cloud" || toolName == "115 Open" || toolName == "123 Open" || toolName == "123Pan" || toolName == "PikPak" || toolName == "Thunder" || toolName == "ThunderX" || toolName == "ThunderBrowser" {
|
if toolName == "115 Cloud" || toolName == "115 Open" || toolName == "123 Open" || toolName == "123Pan" || toolName == "PikPak" || toolName == "Thunder" || toolName == "ThunderX" || toolName == "ThunderBrowser" || toolName == "GuangYaPan" {
|
||||||
// 如果不是直接下载到目标路径,则进行转存
|
// 如果不是直接下载到目标路径,则进行转存
|
||||||
if t.TempDir != t.DstDirPath {
|
if t.TempDir != t.DstDirPath {
|
||||||
return transferObj(t.Ctx(), t.TempDir, t.DstDirPath, t.DeletePolicy)
|
return transferObj(t.Ctx(), t.TempDir, t.DstDirPath, t.DeletePolicy)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
_115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open"
|
_115_open "github.com/OpenListTeam/OpenList/v4/drivers/115_open"
|
||||||
_123 "github.com/OpenListTeam/OpenList/v4/drivers/123"
|
_123 "github.com/OpenListTeam/OpenList/v4/drivers/123"
|
||||||
_123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open"
|
_123_open "github.com/OpenListTeam/OpenList/v4/drivers/123_open"
|
||||||
|
"github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
|
||||||
"github.com/OpenListTeam/OpenList/v4/drivers/pikpak"
|
"github.com/OpenListTeam/OpenList/v4/drivers/pikpak"
|
||||||
"github.com/OpenListTeam/OpenList/v4/drivers/thunder"
|
"github.com/OpenListTeam/OpenList/v4/drivers/thunder"
|
||||||
"github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser"
|
"github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser"
|
||||||
@@ -472,6 +473,50 @@ func SetThunderBrowser(c *gin.Context) {
|
|||||||
common.SuccessResp(c, "ok")
|
common.SuccessResp(c, "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetGuangYaPanReq struct {
|
||||||
|
TempDir string `json:"temp_dir" form:"temp_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetGuangYaPan(c *gin.Context) {
|
||||||
|
var req SetGuangYaPanReq
|
||||||
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
common.ErrorResp(c, err, 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TempDir != "" {
|
||||||
|
storage, _, err := op.GetStorageAndActualPath(req.TempDir)
|
||||||
|
if err != nil {
|
||||||
|
common.ErrorStrResp(c, "storage does not exists", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if storage.Config().CheckStatus && storage.GetStorage().Status != op.WORK {
|
||||||
|
common.ErrorStrResp(c, "storage not init: "+storage.GetStorage().Status, 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := storage.(*guangyapan.GuangYaPan); !ok {
|
||||||
|
common.ErrorStrResp(c, "unsupported storage driver for offline download, only GuangYaPan is supported", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := []model.SettingItem{
|
||||||
|
{Key: conf.GuangYaPanTempDir, Value: req.TempDir, Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
|
||||||
|
}
|
||||||
|
if err := op.SaveSettingItems(items); err != nil {
|
||||||
|
common.ErrorResp(c, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_tool, err := tool.Tools.Get("GuangYaPan")
|
||||||
|
if err != nil {
|
||||||
|
common.ErrorResp(c, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := _tool.Init(); err != nil {
|
||||||
|
common.ErrorResp(c, err, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.SuccessResp(c, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
func OfflineDownloadTools(c *gin.Context) {
|
func OfflineDownloadTools(c *gin.Context) {
|
||||||
tools := tool.Tools.Names()
|
tools := tool.Tools.Names()
|
||||||
common.SuccessResp(c, tools)
|
common.SuccessResp(c, tools)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ func admin(g *gin.RouterGroup) {
|
|||||||
setting.POST("/set_thunder", handles.SetThunder)
|
setting.POST("/set_thunder", handles.SetThunder)
|
||||||
setting.POST("/set_thunderx", handles.SetThunderX)
|
setting.POST("/set_thunderx", handles.SetThunderX)
|
||||||
setting.POST("/set_thunder_browser", handles.SetThunderBrowser)
|
setting.POST("/set_thunder_browser", handles.SetThunderBrowser)
|
||||||
|
setting.POST("/set_guangyapan", handles.SetGuangYaPan)
|
||||||
|
|
||||||
// retain /admin/task API to ensure compatibility with legacy automation scripts
|
// retain /admin/task API to ensure compatibility with legacy automation scripts
|
||||||
_task(g.Group("/task"))
|
_task(g.Group("/task"))
|
||||||
|
|||||||
Reference in New Issue
Block a user