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:
Pikachu Ren
2026-08-04 11:34:56 +08:00
committed by GitHub
parent 940b69fe38
commit 6f80df2827
14 changed files with 1911 additions and 1 deletions
+1
View File
@@ -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"
+786
View File
@@ -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)
)
+42
View File
@@ -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{}
})
}
+169
View File
@@ -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")
}
+232
View File
@@ -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)
}
+407
View File
@@ -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
}
+3
View File
@@ -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"
+1
View File
@@ -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)
}
}
+11
View File
@@ -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
+4 -1
View File
@@ -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)
+45
View File
@@ -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)
+1
View File
@@ -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"))