Repository: AlistGo/alist (language: go)

Issue:
希望增加天翼魔盘的支持
### Please make sure of the following things

- [ ] I have read the [documentation](https://alist.nn.ci).
- [ ] I'm sure there are no duplicate issues or discussions.
- [ ] I'm sure this feature is not implemented.
- [ ] I'm sure it's a reasonable and popular requirement.

### Description of the feature / 需求描述

    电天翼最近新推出了一个魔盘服务，每个电信用户都能有免费50g云空间，可接入诸多服务。
望请开发者大佬提供支持服务。

### Suggested solution / 实现思路

_No response_

### Additional context / 附件

_No response_


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

### See
1. 67% #894
2. 56% #1630
3. 50% #1434

<!-- Created by actions-cool/issues-similarity-analysis. Do not remove. -->
50G....太小了吧
> 50G....太小了吧

宽带免费送会员有4t😂
> > 50G....太小了吧
> 
> 宽带免费送会员有4t😂

我也是电信宽带用户,请问怎么操作啊? 没看到有送会员的地方
我是四川电信的，魔盘也是四川电信推出的，外地不知道送不送

Fix patch:
diff --git a/drivers/all.go b/drivers/all.go
index 314cf9cb4f2..c468412afe0 100644
--- a/drivers/all.go
+++ b/drivers/all.go
@@ -25,6 +25,7 @@ import (
 	_ "github.com/alist-org/alist/v3/drivers/local"
 	_ "github.com/alist-org/alist/v3/drivers/mediatrack"
 	_ "github.com/alist-org/alist/v3/drivers/mega"
+	_ "github.com/alist-org/alist/v3/drivers/mopan"
 	_ "github.com/alist-org/alist/v3/drivers/onedrive"
 	_ "github.com/alist-org/alist/v3/drivers/onedrive_app"
 	_ "github.com/alist-org/alist/v3/drivers/pikpak"
diff --git a/drivers/mopan/driver.go b/drivers/mopan/driver.go
new file mode 100644
index 00000000000..dfe8399cc43
--- /dev/null
+++ b/drivers/mopan/driver.go
@@ -0,0 +1,295 @@
+package mopan
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/alist-org/alist/v3/drivers/base"
+	"github.com/alist-org/alist/v3/internal/driver"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/pkg/utils"
+	"github.com/avast/retry-go"
+	"github.com/foxxorcat/mopan-sdk-go"
+)
+
+type MoPan struct {
+	model.Storage
+	Addition
+	client *mopan.MoClient
+
+	userID string
+}
+
+func (d *MoPan) Config() driver.Config {
+	return config
+}
+
+func (d *MoPan) GetAddition() driver.Additional {
+	return &d.Addition
+}
+
+func (d *MoPan) Init(ctx context.Context) error {
+	login := func() error {
+		data, err := d.client.Login(d.Phone, d.Password)
+		if err != nil {
+			return err
+		}
+		d.client.SetAuthorization(data.Token)
+
+		info, err := d.client.GetUserInfo()
+		if err != nil {
+			return err
+		}
+		d.userID = info.UserID
+		return nil
+	}
+	d.client = mopan.NewMoClient().
+		SetRestyClient(base.RestyClient).
+		SetOnAuthorizationExpired(func(_ error) error {
+			err := login()
+			if err != nil {
+				d.Status = err.Error()
+				op.MustSaveDriverStorage(d)
+			}
+			return err
+		}).SetDeviceInfo(d.DeviceInfo)
+	d.DeviceInfo = d.client.GetDeviceInfo()
+	return login()
+}
+
+func (d *MoPan) Drop(ctx context.Context) error {
+	d.client = nil
+	d.userID = ""
+	return nil
+}
+
+func (d *MoPan) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
+	var files []model.Obj
+	for page := 1; ; page++ {
+		data, err := d.client.QueryFiles(dir.GetID(), page, mopan.WarpParamOption(
+			func(j mopan.Json) {
+				j["orderBy"] = d.OrderBy
+				j["descending"] = d.OrderDirection == "desc"
+			},
+			mopan.ParamOptionShareFile(d.CloudID),
+		))
+		if err != nil {
+			return nil, err
+		}
+
+		if len(data.FileListAO.FileList)+len(data.FileListAO.FolderList) == 0 {
+			break
+		}
+
+		files = append(files, utils.MustSliceConvert(data.FileListAO.FolderList, folderToObj)...)
+		files = append(files, utils.MustSliceConvert(data.FileListAO.FileList, fileToObj)...)
+	}
+	return files, nil
+}
+
+func (d *MoPan) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
+	data, err := d.client.GetFileDownloadUrl(file.GetID(), mopan.WarpParamOption(mopan.ParamOptionShareFile(d.CloudID)))
+	if err != nil {
+		return nil, err
+	}
+
+	return &model.Link{
+		URL: data.DownloadUrl,
+	}, nil
+}
+
+func (d *MoPan) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+	f, err := d.client.CreateFolder(dirName, parentDir.GetID(), mopan.WarpParamOption(
+		mopan.ParamOptionShareFile(d.CloudID),
+	))
+	if err != nil {
+		return nil, err
+	}
+	return folderToObj(*f), nil
+}
+
+func (d *MoPan) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
+	return d.newTask(srcObj, dstDir, mopan.TASK_MOVE)
+}
+
+func (d *MoPan) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
+	if srcObj.IsDir() {
+		_, err := d.client.RenameFolder(srcObj.GetID(), newName, mopan.WarpParamOption(
+			mopan.ParamOptionShareFile(d.CloudID),
+		))
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		_, err := d.client.RenameFile(srcObj.GetID(), newName, mopan.WarpParamOption(
+			mopan.ParamOptionShareFile(d.CloudID),
+		))
+		if err != nil {
+			return nil, err
+		}
+	}
+	return CloneObj(srcObj, srcObj.GetID(), newName), nil
+}
+
+func (d *MoPan) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
+	return d.newTask(srcObj, dstDir, mopan.TASK_COPY)
+}
+
+func (d *MoPan) newTask(srcObj, dstDir model.Obj, taskType mopan.TaskType) (model.Obj, error) {
+	param := mopan.TaskParam{
+		UserOrCloudID:       d.userID,
+		Source:              1,
+		TaskType:            taskType,
+		TargetSource:        1,
+		TargetUserOrCloudID: d.userID,
+		TargetType:          1,
+		TargetFolderID:      dstDir.GetID(),
+		TaskStatusDetailDTOList: []mopan.TaskFileParam{
+			{
+				FileID:   srcObj.GetID(),
+				IsFolder: srcObj.IsDir(),
+				FileName: srcObj.GetName(),
+			},
+		},
+	}
+	if d.CloudID != "" {
+		param.UserOrCloudID = d.CloudID
+		param.Source = 2
+		param.TargetSource = 2
+		param.TargetUserOrCloudID = d.CloudID
+	}
+
+	task, err := d.client.AddBatchTask(param)
+	if err != nil {
+		return nil, err
+	}
+
+	for count := 0; count < 5; count++ {
+		stat, err := d.client.CheckBatchTask(mopan.TaskCheckParam{
+			TaskId:              task.TaskIDList[0],
+			TaskType:            task.TaskType,
+			TargetType:          1,
+			TargetFolderID:      task.TargetFolderID,
+			TargetSource:        param.TargetSource,
+			TargetUserOrCloudID: param.TargetUserOrCloudID,
+		})
+		if err != nil {
+			return nil, err
+		}
+
+		switch stat.TaskStatus {
+		case 2:
+			if err := d.client.CancelBatchTask(stat.TaskID, task.TaskType); err != nil {
+				return nil, err
+			}
+			return nil, errors.New("file name conflict")
+		case 4:
+			if task.TaskType == mopan.TASK_MOVE {
+				return CloneObj(srcObj, srcObj.GetID(), srcObj.GetName()), nil
+			}
+			return CloneObj(srcObj, stat.SuccessedFileIDList[0], srcObj.GetName()), nil
+		}
+		time.Sleep(time.Second)
+	}
+	return nil, nil
+}
+
+func (d *MoPan) Remove(ctx context.Context, obj model.Obj) error {
+	_, err := d.client.DeleteToRecycle([]mopan.TaskFileParam{
+		{
+			FileID:   obj.GetID(),
+			IsFolder: obj.IsDir(),
+			FileName: obj.GetName(),
+		},
+	}, mopan.WarpParamOption(mopan.ParamOptionShareFile(d.CloudID)))
+	return err
+}
+
+func (d *MoPan) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
+	file, err := utils.CreateTempFile(stream)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		_ = file.Close()
+		_ = os.Remove(file.Name())
+	}()
+
+	initUpdload, err := d.client.InitMultiUpload(ctx, mopan.UpdloadFileParam{
+		ParentFolderId: dstDir.GetID(),
+		FileName:       stream.GetName(),
+		FileSize:       stream.GetSize(),
+		File:           file,
+	}, mopan.WarpParamOption(
+		mopan.ParamOptionShareFile(d.CloudID),
+	))
+	if err != nil {
+		return nil, err
+	}
+
+	if !initUpdload.FileDataExists {
+		parts, err := d.client.GetAllMultiUploadUrls(initUpdload.UploadFileID, initUpdload.PartInfo)
+		if err != nil {
+			return nil, err
+		}
+		d.client.CloudDiskStartBusiness()
+		for i, part := range parts {
+			if utils.IsCanceled(ctx) {
+				return nil, ctx.Err()
+			}
+
+			err := retry.Do(func() error {
+				if _, err := file.Seek(int64(part.PartNumber-1)*int64(initUpdload.PartSize), io.SeekStart); err != nil {
+					return retry.Unrecoverable(err)
+				}
+
+				req, err := part.NewRequest(ctx, io.LimitReader(file, int64(initUpdload.PartSize)))
+				if err != nil {
+					return err
+				}
+
+				resp, err := base.HttpClient.Do(req)
+				if err != nil {
+					return err
+				}
+
+				if resp.StatusCode != http.StatusOK {
+					return fmt.Errorf("upload err,code=%d", resp.StatusCode)
+				}
+				return nil
+			},
+				retry.Context(ctx),
+				retry.Attempts(3),
+				retry.Delay(time.Second),
+				retry.MaxDelay(5*time.Second))
+			if err != nil {
+				return nil, err
+			}
+			up(100 * (i + 1) / len(parts))
+		}
+	}
+	uFile, err := d.client.CommitMultiUploadFile(initUpdload.UploadFileID, nil)
+	if err != nil {
+		return nil, err
+	}
+	return &model.Object{
+		ID:       uFile.UserFileID,
+		Name:     uFile.FileName,
+		Size:     int64(uFile.FileSize),
+		Modified: time.Time(uFile.CreateDate),
+	}, nil
+}
+
+var _ driver.Driver = (*MoPan)(nil)
+var _ driver.MkdirResult = (*MoPan)(nil)
+var _ driver.MoveResult = (*MoPan)(nil)
+var _ driver.RenameResult = (*MoPan)(nil)
+var _ driver.Remove = (*MoPan)(nil)
+var _ driver.CopyResult = (*MoPan)(nil)
+var _ driver.PutResult = (*MoPan)(nil)
diff --git a/drivers/mopan/meta.go b/drivers/mopan/meta.go
new file mode 100644
index 00000000000..2b64955b187
--- /dev/null
+++ b/drivers/mopan/meta.go
@@ -0,0 +1,37 @@
+package mopan
+
+import (
+	"github.com/alist-org/alist/v3/internal/driver"
+	"github.com/alist-org/alist/v3/internal/op"
+)
+
+type Addition struct {
+	Phone    string `json:"phone" required:"true"`
+	Password string `json:"password" required:"true"`
+
+	RootFolderID string `json:"root_folder_id" default:"-11" required:"true" help:"be careful when using the -11 value, some operations may cause system errors"`
+
+	CloudID string `json:"cloud_id"`
+
+	OrderBy        string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"`
+	OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
+
+	DeviceInfo string `json:"device_info"`
+}
+
+func (a *Addition) GetRootId() string {
+	return a.RootFolderID
+}
+
+var config = driver.Config{
+	Name:        "MoPan",
+	// DefaultRoot: "root, / or other",
+	CheckStatus: true,
+	Alert:       "warning|This network disk may store your password in clear text. Please set your password carefully",
+}
+
+func init() {
+	op.RegisterDriver(func() driver.Driver {
+		return &MoPan{}
+	})
+}
diff --git a/drivers/mopan/types.go b/drivers/mopan/types.go
new file mode 100644
index 00000000000..54b02f9a5b2
--- /dev/null
+++ b/drivers/mopan/types.go
@@ -0,0 +1,1 @@
+package mopan
diff --git a/drivers/mopan/util.go b/drivers/mopan/util.go
new file mode 100644
index 00000000000..f35a226a056
--- /dev/null
+++ b/drivers/mopan/util.go
@@ -0,0 +1,58 @@
+package mopan
+
+import (
+	"time"
+
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/foxxorcat/mopan-sdk-go"
+)
+
+func fileToObj(f mopan.File) model.Obj {
+	return &model.ObjThumb{
+		Object: model.Object{
+			ID:       string(f.ID),
+			Name:     f.Name,
+			Size:     f.Size,
+			Modified: time.Time(f.LastOpTime),
+		},
+		Thumbnail: model.Thumbnail{
+			Thumbnail: f.Icon.SmallURL,
+		},
+	}
+}
+
+func folderToObj(f mopan.Folder) model.Obj {
+	return &model.Object{
+		ID:       string(f.ID),
+		Name:     f.Name,
+		Modified: time.Time(f.LastOpTime),
+		IsFolder: true,
+	}
+}
+
+func CloneObj(o model.Obj, newID, newName string) model.Obj {
+	if o.IsDir() {
+		return &model.Object{
+			ID:       newID,
+			Name:     newName,
+			IsFolder: true,
+			Modified: o.ModTime(),
+		}
+	}
+
+	thumb := ""
+	if o, ok := o.(model.Thumb); ok {
+		thumb = o.Thumb()
+	}
+	return &model.ObjThumb{
+		Object: model.Object{
+			ID:       newID,
+			Name:     newName,
+			Size:     o.GetSize(),
+			Modified: o.ModTime(),
+		},
+		Thumbnail: model.Thumbnail{
+			Thumbnail: thumb,
+		},
+	}
+}
diff --git a/go.mod b/go.mod
index 3b4d47c44d6..779abf18a13 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
 	github.com/deckarep/golang-set/v2 v2.3.0
 	github.com/disintegration/imaging v1.6.2
 	github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564
+	github.com/foxxorcat/mopan-sdk-go v0.1.0
 	github.com/gin-contrib/cors v1.4.0
 	github.com/gin-gonic/gin v1.9.1
 	github.com/go-resty/resty/v2 v2.7.0
diff --git a/go.sum b/go.sum
index c6a74caa115..5dd44fe685a 100644
--- a/go.sum
+++ b/go.sum
@@ -86,6 +86,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564 h1:I6KUy4CI6hHjqnyJLNCEi7YHVMkwwtfSr2k9splgdSM=
 github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564/go.mod h1:yekO+3ZShy19S+bsmnERmznGy9Rfg6dWWWpiGJjNAz8=
+github.com/foxxorcat/mopan-sdk-go v0.1.0 h1:U/E/uNK4N7xNbcHXdw+DG56LWw2W6Xpjj+yoH8EmTj0=
+github.com/foxxorcat/mopan-sdk-go v0.1.0/go.mod h1:LpBPmwezjQNyhaNo3HGzgFtQbhvxmF5ZybSVuKi7OVA=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
 github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
@@ -208,6 +210,7 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP
 github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
 github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
 github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
 github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=


Test patch: