Compare commits

..

14 Commits

Author SHA1 Message Date
0fd602bc1b refactor(fs):Refactor the delete function and fix known issues (#353)
* fix(move):Fix file move logic

* fix(move):fixed move logic

* fix(move):Fixed move logic

* Fixed errs

* fix(move):fixed movetask

* fix(move):fix movetask

* fixed

* fix(move):Refactoring the move structure

* fix(move):Fixed move system

* Fixed

* Fixed

* Fixed

* Fixed

* Fixed

* Rollback

* Fixed and Refactor

* fix(move):fixed

* fix(move):Solve related performance issues

* refacter(move):fixed build bugs
2025-06-24 23:36:37 +08:00
Dgs
f6470af971 fix(123&123open): repair etag format (#349) 2025-06-24 22:14:11 +08:00
Dgs
d695d28e13 feat(thunder&thunder_browser): fix deviceId generation & support offline download and update login interface (#290)
* fix(thunder): fix deviceID generation

* feat(thunder_browser): support offline download and update login interface

* feat(thunder_browser): add fluent_play method for offline download
2025-06-24 21:54:30 +08:00
ffc14ea14c feature:add crypt cmd (#342) 2025-06-24 19:05:46 +08:00
25df3daba5 chore(google_photo): update titles in getFakeRoot (#343)
chore(google_photo): update titles in getFakeRoot to use constants instead of hardcoded strings
2025-06-24 18:18:53 +08:00
Dgs
ce3cb2e31e feat(quark_open): add quark open driver support (#324) 2025-06-24 18:02:15 +08:00
afe23986d2 chore(issue): Update issue templates with improved descriptions and links (#337)
Enhanced bug and feature request templates with bilingual descriptions, default titles, and updated documentation links. Added new contact options in config.yml, including a Telegram chat link. Improved clarity and localization for user instructions.
2025-06-24 11:24:40 +08:00
0026f0c860 fix(ci):Fixed webversion (#333)
* Revert "fix(ci):fixed webversion (#332)"

This reverts commit 9e69b2aaa3.

* Fixed webversion

Signed-off-by: Suyunjing <69945917+Suyunmeng@users.noreply.github.com>

---------

Signed-off-by: Suyunjing <69945917+Suyunmeng@users.noreply.github.com>
2025-06-24 07:12:18 +08:00
9e69b2aaa3 fix(ci):fixed webversion (#332)
fix(ci):fixed webversion bugs

Signed-off-by: Suyunjing <69945917+Suyunmeng@users.noreply.github.com>
2025-06-24 00:26:52 +08:00
af71deb407 fix(cloudreve_v4): reference error in the refreshToken method (#328) 2025-06-24 00:01:19 +08:00
fe079cf0a3 fix(cloudreve_v4): update rename api path to /file/rename (#331) 2025-06-23 23:59:01 +08:00
cf85d49b6c fix(dropbox):Disable Dropbox's default use of the online API 2025-06-23 20:04:40 +08:00
96cf2f7cf9 fix(fs):Repair file loss caused by special reasons when moving files (#321)
* fix(move):Fix file move logic

* fix(move):fixed move logic
2025-06-23 19:48:17 +08:00
b0736d2d02 fix(cloudreve_v4): change upS3 callback method from POST to GET (#323) 2025-06-23 19:35:48 +08:00
32 changed files with 2210 additions and 107 deletions

View File

@ -1,5 +1,6 @@
name: "Bug report" name: "Bug report"
description: Bug report description: Bug / 错误报告 / 问题
title: "[BUG] "
labels: [bug] labels: [bug]
body: body:
- type: markdown - type: markdown
@ -16,14 +17,14 @@ body:
您必须勾选以下所有内容否则您的issue可能会被直接关闭。或者您可以去[讨论区](https://github.com/OpenListTeam/OpenList/discussions) 您必须勾选以下所有内容否则您的issue可能会被直接关闭。或者您可以去[讨论区](https://github.com/OpenListTeam/OpenList/discussions)
options: options:
- label: | - label: |
I have read the [documentation](https://openlistteam.github.io/docs). I have read the [documentation](https://docs.oplist.org).
我已经阅读了[文档](https://openlistteam.github.io/docs)。 我已经阅读了[文档](https://docs.oplist.org)。
- label: | - label: |
I'm sure there are no duplicate issues or discussions. I'm sure there are no duplicate issues or discussions.
我确定没有重复的issue或讨论。 我确定没有重复的issue或讨论。
- label: | - label: |
I'm sure it's due to `OpenList` and not something else(such as [Network](https://openlistteam.github.io/docs/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host) ,`Dependencies` or `Operational`). I'm sure it's due to `OpenList` and not something else(such as [Network](https://docs.oplist.org/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host) ,`Dependencies` or `Operational`).
我确定是`OpenList`的问题,而不是其他原因(例如[网络](https://openlistteam.github.io/docs/zh/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host)`依赖`或`操作`)。 我确定是`OpenList`的问题,而不是其他原因(例如[网络](https://docs.oplist.org/zh/faq/howto.html#tls-handshake-timeout-read-connection-reset-by-peer-dns-lookup-failed-connect-connection-refused-client-timeout-exceeded-while-awaiting-headers-no-such-host)`依赖`或`操作`)。
- label: | - label: |
I'm sure this issue is not fixed in the latest version. I'm sure this issue is not fixed in the latest version.
我确定这个问题在最新版本中没有被修复。 我确定这个问题在最新版本中没有被修复。
@ -35,7 +36,7 @@ body:
description: | description: |
What version of our software are you running? Do not use `latest` or `master` as an answer. What version of our software are you running? Do not use `latest` or `master` as an answer.
您使用的是哪个版本的软件?请不要使用`latest`或`master`作为答案。 您使用的是哪个版本的软件?请不要使用`latest`或`master`作为答案。
placeholder: v3.xx.xx placeholder: v4.xx.xx
validations: validations:
required: true required: true
- type: input - type: input
@ -60,7 +61,7 @@ body:
label: Reproduction / 复现链接 label: Reproduction / 复现链接
description: | description: |
Please provide a link to a repo that can reproduce the problem you ran into. Please be aware that your issue may be closed directly if you don't provide it. Please provide a link to a repo that can reproduce the problem you ran into. Please be aware that your issue may be closed directly if you don't provide it.
请提供能复现此问题的链接请知悉如果不提供它你的issue可能会被直接关闭 请提供能复现此问题的链接请知悉如果不提供它你的issue可能会被直接关闭
validations: validations:
required: true required: true
- type: textarea - type: textarea
@ -68,8 +69,8 @@ body:
attributes: attributes:
label: Config / 配置 label: Config / 配置
description: | description: |
Please provide the configuration file of your `OpenList` application and take a screenshot of the relevant storage configuration. (hide privacy field) Please provide the configuration file of your `OpenList` application and take a screenshot of the relevant storage configuration. (you can mask sensitive fields)
请提供您的`OpenList`应用的配置文件,并截图相关存储配置。(隐藏隐私字段) 请提供您的`OpenList`应用的配置文件,并截图相关存储配置。(隐藏隐私字段)
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -2,4 +2,7 @@ blank_issues_enabled: true
contact_links: contact_links:
- name: Questions & Discussions - name: Questions & Discussions
url: https://github.com/OpenListTeam/OpenList/discussions url: https://github.com/OpenListTeam/OpenList/discussions
about: Use GitHub discussions for message-board style questions and discussions. about: Discuss / 讨论、问题、想法等
- name: Chat
url: https://t.me/OpenListTeam
about: Chat with us / 与我们聊天

View File

@ -1,5 +1,6 @@
name: "Feature request" name: "Feature request"
description: Feature request description: Feature request / 功能请求 / 增强
title: "[Feature] "
labels: [enhancement] labels: [enhancement]
body: body:
- type: checkboxes - type: checkboxes
@ -7,7 +8,7 @@ body:
label: Please make sure of the following things label: Please make sure of the following things
description: You may select more than one, even select all. description: You may select more than one, even select all.
options: options:
- label: I have read the [documentation](https://openlistteam.github.io/docs). - label: I have read the [documentation](https://docs.openlist.org).
- label: I'm sure there are no duplicate issues or discussions. - label: I'm sure there are no duplicate issues or discussions.
- label: I'm sure this feature is not implemented. - label: I'm sure this feature is not implemented.
- label: I'm sure it's a reasonable and popular requirement. - label: I'm sure it's a reasonable and popular requirement.
@ -30,4 +31,4 @@ body:
label: Additional context / 附件 label: Additional context / 附件
description: | description: |
Any other context or screenshots about the feature request here, or information you find helpful. Any other context or screenshots about the feature request here, or information you find helpful.
相关的任何其他上下文或截图,或者你觉得有帮助的信息 相关的任何其他上下文或截图,或者你觉得有帮助的信息

241
cmd/crypt.go Normal file
View File

@ -0,0 +1,241 @@
package cmd
import (
log "github.com/sirupsen/logrus"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/spf13/cobra"
rcCrypt "github.com/rclone/rclone/backend/crypt"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/config/obscure"
)
// encryption and decryption command format for Crypt driver
type options struct {
Op string //decrypt or encrypt
src string //source dir or file
dst string //out destination
pwd string //de/encrypt password
salt string
filenameEncryption string //reference drivers\crypt\meta.go Addtion
dirnameEncryption string
filenameEncode string
suffix string
}
var opt options
// CryptCmd represents the crypt command
var CryptCmd = &cobra.Command{
Use: "crypt",
Short: "Encrypt or decrypt local file or dir",
Example: `openlist crypt -s ./src/encrypt/ --op=de --pwd=123456 --salt=345678`,
Run: func(cmd *cobra.Command, args []string) {
opt.validate()
opt.cryptFileDir()
},
}
func init() {
RootCmd.AddCommand(CryptCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// versionCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
CryptCmd.Flags().StringVarP(&opt.src, "src", "s", "", "src file or dir to encrypt/decrypt")
CryptCmd.Flags().StringVarP(&opt.dst, "dst", "d", "", "dst dir to output,if not set,output to src dir")
CryptCmd.Flags().StringVar(&opt.Op, "op", "", "de or en which stands for decrypt or encrypt")
CryptCmd.Flags().StringVar(&opt.pwd, "pwd", "", "password used to encrypt/decrypt,if not contain ___Obfuscated___ prefix,will be obfuscated before used")
CryptCmd.Flags().StringVar(&opt.salt, "salt", "", "salt used to encrypt/decrypt,if not contain ___Obfuscated___ prefix,will be obfuscated before used")
CryptCmd.Flags().StringVar(&opt.filenameEncryption, "filename-encrypt", "off", "filename encryption mode: off,standard,obfuscate")
CryptCmd.Flags().StringVar(&opt.dirnameEncryption, "dirname-encrypt", "false", "is dirname encryption enabled:true,false")
CryptCmd.Flags().StringVar(&opt.filenameEncode, "filename-encode", "base64", "filename encoding mode: base64,base32,base32768")
CryptCmd.Flags().StringVar(&opt.suffix, "suffix", ".bin", "suffix for encrypted file,default is .bin")
}
func (o *options) validate() {
if o.src == "" {
log.Fatal("src can not be empty")
}
if o.Op != "encrypt" && o.Op != "decrypt" && o.Op != "en" && o.Op != "de" {
log.Fatal("op must be encrypt or decrypt")
}
if o.filenameEncryption != "off" && o.filenameEncryption != "standard" && o.filenameEncryption != "obfuscate" {
log.Fatal("filename_encryption must be off,standard,obfuscate")
}
if o.filenameEncode != "base64" && o.filenameEncode != "base32" && o.filenameEncode != "base32768" {
log.Fatal("filename_encode must be base64,base32,base32768")
}
}
func (o *options) cryptFileDir() {
src, _ := filepath.Abs(o.src)
log.Infof("src abs is %v", src)
fileInfo, err := os.Stat(src)
if err != nil {
log.Fatalf("reading file/dir %v failed,err:%v", src, err)
}
pwd := updateObfusParm(o.pwd)
salt := updateObfusParm(o.salt)
//create cipher
config := configmap.Simple{
"password": pwd,
"password2": salt,
"filename_encryption": o.filenameEncryption,
"directory_name_encryption": o.dirnameEncryption,
"filename_encoding": o.filenameEncode,
"suffix": o.suffix,
"pass_bad_blocks": "",
}
log.Infof("config:%v", config)
cipher, err := rcCrypt.NewCipher(config)
if err != nil {
log.Fatalf("create cipher failed,err:%v", err)
}
dst := ""
//check and create dst dir
if o.dst != "" {
dst, _ = filepath.Abs(o.dst)
checkCreateDir(dst)
}
// src is file
if !fileInfo.IsDir() { //file
if dst == "" {
dst = filepath.Dir(src)
}
o.cryptFile(cipher, src, dst)
return
}
// src is dir
if dst == "" {
//if src is dir and not set dst dir ,create ${src}_crypt dir as dst dir
dst = path.Join("./", fileInfo.Name()+"_crypt")
}
log.Infof("dst : %v", dst)
filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
log.Errorf("get file %v info failed, err:%v", p, err)
return err
}
if info.IsDir() {
//create output dir
d := strings.Replace(p, src, dst, 1)
log.Infof("create output dir %v", d)
checkCreateDir(d)
return nil
}
d := strings.Replace(filepath.Dir(p), src, dst, 1)
o.cryptFile(cipher, p, d)
return nil
})
}
func (o *options) cryptFile(cipher *rcCrypt.Cipher, src string, dst string) {
fileInfo, err := os.Stat(src)
if err != nil {
log.Fatalf("get file %v info failed,err:%v", src, err)
}
fd, err := os.OpenFile(src, os.O_RDWR, 0666)
if err != nil {
log.Fatalf("open file %v failed,err:%v", src, err)
}
defer fd.Close()
var cryptSrcReader io.Reader
var outFile string
if o.Op == "encrypt" || o.Op == "en" {
filename := fileInfo.Name()
if o.filenameEncryption != "off" {
filename = cipher.EncryptFileName(fileInfo.Name())
log.Infof("encrypt file name %v to %v", fileInfo.Name(), filename)
}
cryptSrcReader, err = cipher.EncryptData(fd)
if err != nil {
log.Fatalf("encrypt file %v failed,err:%v", src, err)
}
outFile = path.Join(dst, filename)
} else {
filename := fileInfo.Name()
if o.filenameEncryption != "off" {
filename, err = cipher.DecryptFileName(filename)
if err != nil {
log.Fatalf("decrypt file name %v failed,err:%v", src, err)
}
log.Infof("decrypt file name %v to %v, ", fileInfo.Name(), filename)
}
cryptSrcReader, err = cipher.DecryptData(fd)
if err != nil {
log.Fatalf("decrypt file %v failed,err:%v", src, err)
}
outFile = path.Join(dst, filename)
}
//write new file
wr, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY, 0755)
if err != nil {
log.Fatalf("create file %v failed,err:%v", outFile, err)
}
defer wr.Close()
_, err = io.Copy(wr, cryptSrcReader)
if err != nil {
log.Fatalf("write file %v failed,err:%v", outFile, err)
}
}
// check dir exist ,if not ,create
func checkCreateDir(dir string) {
_, err := os.Stat(dir)
if os.IsNotExist(err) {
err := os.MkdirAll(dir, 0755)
if err != nil {
log.Fatalf("create dir %v failed,err:%v", dir, err)
}
return
}
log.Fatalf("read dir %v err: %v", dir, err)
}
func updateObfusParm(str string) string {
obfuscatedPrefix := "___Obfuscated___"
if !strings.HasPrefix(str, obfuscatedPrefix) {
str, err := obscure.Obscure(str)
if err != nil {
log.Fatalf("update obfuscated parameter failed,err:%v", str)
}
} else {
str, _ = strings.CutPrefix(str, obfuscatedPrefix)
}
return str
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"sync" "sync"
"time" "time"
@ -195,7 +196,7 @@ func (d *Pan123) Put(ctx context.Context, dstDir model.Obj, file model.FileStrea
data := base.Json{ data := base.Json{
"driveId": 0, "driveId": 0,
"duplicate": 2, // 2->覆盖 1->重命名 0->默认 "duplicate": 2, // 2->覆盖 1->重命名 0->默认
"etag": etag, "etag": strings.ToLower(etag),
"fileName": file.GetName(), "fileName": file.GetName(),
"parentFileId": dstDir.GetID(), "parentFileId": dstDir.GetID(),
"size": file.GetSize(), "size": file.GetSize(),

View File

@ -3,6 +3,7 @@ package _123_open
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/OpenListTeam/OpenList/drivers/base" "github.com/OpenListTeam/OpenList/drivers/base"
@ -21,7 +22,7 @@ func (d *Open123) create(parentFileID int64, filename string, etag string, size
req.SetBody(base.Json{ req.SetBody(base.Json{
"parentFileId": parentFileID, "parentFileId": parentFileID,
"filename": filename, "filename": filename,
"etag": etag, "etag": strings.ToLower(etag),
"size": size, "size": size,
"duplicate": duplicate, "duplicate": duplicate,
"containDir": containDir, "containDir": containDir,

View File

@ -50,6 +50,7 @@ import (
_ "github.com/OpenListTeam/OpenList/drivers/openlist" _ "github.com/OpenListTeam/OpenList/drivers/openlist"
_ "github.com/OpenListTeam/OpenList/drivers/pikpak" _ "github.com/OpenListTeam/OpenList/drivers/pikpak"
_ "github.com/OpenListTeam/OpenList/drivers/pikpak_share" _ "github.com/OpenListTeam/OpenList/drivers/pikpak_share"
_ "github.com/OpenListTeam/OpenList/drivers/quark_open"
_ "github.com/OpenListTeam/OpenList/drivers/quark_uc" _ "github.com/OpenListTeam/OpenList/drivers/quark_uc"
_ "github.com/OpenListTeam/OpenList/drivers/quark_uc_tv" _ "github.com/OpenListTeam/OpenList/drivers/quark_uc_tv"
_ "github.com/OpenListTeam/OpenList/drivers/s3" _ "github.com/OpenListTeam/OpenList/drivers/s3"

View File

@ -173,13 +173,12 @@ func (d *CloudreveV4) Move(ctx context.Context, srcObj, dstDir model.Obj) error
} }
func (d *CloudreveV4) Rename(ctx context.Context, srcObj model.Obj, newName string) error { func (d *CloudreveV4) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
return d.request(http.MethodPost, "/file/create", func(req *resty.Request) { return d.request(http.MethodPost, "/file/rename", func(req *resty.Request) {
req.SetBody(base.Json{ req.SetBody(base.Json{
"new_name": newName, "new_name": newName,
"uri": srcObj.GetPath(), "uri": srcObj.GetPath(),
}) })
}, nil) }, nil)
} }
func (d *CloudreveV4) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { func (d *CloudreveV4) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {

View File

@ -175,8 +175,7 @@ func (d *CloudreveV4) doLogin(needCaptcha bool) error {
} }
func (d *CloudreveV4) refreshToken() error { func (d *CloudreveV4) refreshToken() error {
var token Token if d.RefreshToken == "" {
if token.RefreshToken == "" {
if d.Username != "" { if d.Username != "" {
err := d.login() err := d.login()
if err != nil { if err != nil {
@ -185,6 +184,7 @@ func (d *CloudreveV4) refreshToken() error {
} }
return nil return nil
} }
var token Token
err := d.request(http.MethodPost, "/session/token/refresh", func(req *resty.Request) { err := d.request(http.MethodPost, "/session/token/refresh", func(req *resty.Request) {
req.SetBody(base.Json{ req.SetBody(base.Json{
"refresh_token": d.RefreshToken, "refresh_token": d.RefreshToken,
@ -469,7 +469,7 @@ func (d *CloudreveV4) upS3(ctx context.Context, file model.FileStreamer, u FileU
} }
// 上传成功发送回调请求 // 上传成功发送回调请求
return d.request(http.MethodPost, "/callback/s3/"+u.SessionID+"/"+u.CallbackSecret, func(req *resty.Request) { return d.request(http.MethodGet, "/callback/s3/"+u.SessionID+"/"+u.CallbackSecret, func(req *resty.Request) {
req.SetBody("{}") req.SetBody("{}")
}, nil) }, nil)
} }

View File

@ -7,7 +7,7 @@ import (
type Addition struct { type Addition struct {
driver.RootPath driver.RootPath
UseOnlineAPI bool `json:"use_online_api" default:"true"` UseOnlineAPI bool `json:"use_online_api" default:"false"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/dropboxs/renewapi"` APIAddress string `json:"api_url_address" default:"https://api.oplist.org/dropboxs/renewapi"`
ClientID string `json:"client_id" required:"false" help:"Keep it empty if you don't have one"` ClientID string `json:"client_id" required:"false" help:"Keep it empty if you don't have one"`
ClientSecret string `json:"client_secret" required:"false" help:"Keep it empty if you don't have one"` ClientSecret string `json:"client_secret" required:"false" help:"Keep it empty if you don't have one"`

View File

@ -90,15 +90,15 @@ func (d *GooglePhoto) getFakeRoot() ([]MediaItem, error) {
return []MediaItem{ return []MediaItem{
{ {
Id: FETCH_ALL, Id: FETCH_ALL,
Title: "全部媒体", Title: FETCH_ALL,
}, },
{ {
Id: FETCH_ALBUMS, Id: FETCH_ALBUMS,
Title: "全部影集", Title: FETCH_ALBUMS,
}, },
{ {
Id: FETCH_SHARE_ALBUMS, Id: FETCH_SHARE_ALBUMS,
Title: "共享影集", Title: FETCH_SHARE_ALBUMS,
}, },
}, nil }, nil
} }

View File

@ -0,0 +1,216 @@
package quark_open
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"github.com/OpenListTeam/OpenList/drivers/base"
"github.com/OpenListTeam/OpenList/internal/driver"
"github.com/OpenListTeam/OpenList/internal/errs"
"github.com/OpenListTeam/OpenList/internal/model"
streamPkg "github.com/OpenListTeam/OpenList/internal/stream"
"github.com/OpenListTeam/OpenList/pkg/utils"
"github.com/go-resty/resty/v2"
"hash"
"io"
"net/http"
)
type QuarkOpen struct {
model.Storage
Addition
config driver.Config
conf Conf
}
func (d *QuarkOpen) Config() driver.Config {
return d.config
}
func (d *QuarkOpen) GetAddition() driver.Additional {
return &d.Addition
}
func (d *QuarkOpen) Init(ctx context.Context) error {
_, err := d.request(ctx, "/open/v1/user/info", http.MethodGet, nil, nil)
return err
}
func (d *QuarkOpen) Drop(ctx context.Context) error {
return nil
}
func (d *QuarkOpen) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
files, err := d.GetFiles(ctx, dir.GetID())
if err != nil {
return nil, err
}
return utils.SliceConvert(files, func(src File) (model.Obj, error) {
return fileToObj(src), nil
})
}
func (d *QuarkOpen) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
data := base.Json{
"fid": file.GetID(),
}
var resp FileLikeResp
_, err := d.request(ctx, "/open/v1/file/get_download_url", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, &resp)
if err != nil {
return nil, err
}
return &model.Link{
URL: resp.Data.DownloadURL,
Header: http.Header{
"Cookie": []string{d.generateAuthCookie()},
},
Concurrency: 3,
PartSize: 10 * utils.MB,
}, nil
}
func (d *QuarkOpen) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
data := base.Json{
"dir_path": dirName,
"pdir_fid": parentDir.GetID(),
}
_, err := d.request(ctx, "/open/v1/dir", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, nil)
return err
}
func (d *QuarkOpen) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
data := base.Json{
"action_type": 1,
"fid_list": []string{srcObj.GetID()},
"to_pdir_fid": dstDir.GetID(),
}
_, err := d.request(ctx, "/open/v1/file/move", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, nil)
return err
}
func (d *QuarkOpen) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
data := base.Json{
"fid": srcObj.GetID(),
"file_name": newName,
"conflict_mode": "REUSE",
}
_, err := d.request(ctx, "/open/v1/file/rename", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, nil)
return err
}
func (d *QuarkOpen) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
return errs.NotSupport
}
func (d *QuarkOpen) Remove(ctx context.Context, obj model.Obj) error {
data := base.Json{
"action_type": 1,
"fid_list": []string{obj.GetID()},
}
_, err := d.request(ctx, "/open/v1/file/delete", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, nil)
return err
}
func (d *QuarkOpen) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
md5Str, sha1Str := stream.GetHash().GetHash(utils.MD5), stream.GetHash().GetHash(utils.SHA1)
var (
md5 hash.Hash
sha1 hash.Hash
)
writers := []io.Writer{}
if len(md5Str) != utils.MD5.Width {
md5 = utils.MD5.NewFunc()
writers = append(writers, md5)
}
if len(sha1Str) != utils.SHA1.Width {
sha1 = utils.SHA1.NewFunc()
writers = append(writers, sha1)
}
if len(writers) > 0 {
_, err := streamPkg.CacheFullInTempFileAndWriter(stream, io.MultiWriter(writers...))
if err != nil {
return err
}
if md5 != nil {
md5Str = hex.EncodeToString(md5.Sum(nil))
}
if sha1 != nil {
sha1Str = hex.EncodeToString(sha1.Sum(nil))
}
}
// pre
pre, err := d.upPre(ctx, stream, dstDir.GetID(), md5Str, sha1Str)
if err != nil {
return err
}
// get part info
partInfo := d._getPartInfo(stream, pre.Data.PartSize)
// get upload url info
upUrlInfo, err := d.upUrl(ctx, pre, partInfo)
if err != nil {
return err
}
// part up
total := stream.GetSize()
left := total
part := make([]byte, pre.Data.PartSize)
// 用于存储每个分片的ETag后续commit时需要
etags := make([]string, len(partInfo))
// 遍历上传每个分片
for i, urlInfo := range upUrlInfo.UploadUrls {
if utils.IsCanceled(ctx) {
return ctx.Err()
}
currentSize := int64(urlInfo.PartSize)
if left < currentSize {
part = part[:left]
} else {
part = part[:currentSize]
}
// 读取分片数据
n, err := io.ReadFull(stream, part)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return err
}
// 准备上传分片
reader := driver.NewLimitedUploadStream(ctx, bytes.NewReader(part))
etag, err := d.upPart(ctx, upUrlInfo, i, reader)
if err != nil {
return fmt.Errorf("failed to upload part %d: %w", i, err)
}
// 保存ETag用于后续commit
etags[i] = etag
// 更新剩余大小和进度
left -= int64(n)
up(float64(total-left) / float64(total) * 100)
}
return d.upFinish(ctx, pre, partInfo, etags)
}
var _ driver.Driver = (*QuarkOpen)(nil)

View File

@ -0,0 +1,40 @@
package quark_open
import (
"github.com/OpenListTeam/OpenList/internal/driver"
"github.com/OpenListTeam/OpenList/internal/op"
)
type Addition struct {
driver.RootID
OrderBy string `json:"order_by" type:"select" options:"none,file_type,file_name,updated_at,created_at" default:"none"`
OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"`
UseOnlineAPI bool `json:"use_online_api" default:"true"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/quarkyun/renewapi"`
AccessToken string `json:"access_token" required:"false" default:""`
RefreshToken string `json:"refresh_token" required:"true"`
AppID string `json:"app_id" required:"true" help:"Keep it empty if you don't have one"`
SignKey string `json:"sign_key" required:"true" help:"Keep it empty if you don't have one"`
}
type Conf struct {
ua string
api string
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &QuarkOpen{
config: driver.Config{
Name: "QuarkOpen",
OnlyLocal: true,
DefaultRoot: "0",
NoOverwriteUpload: true,
},
conf: Conf{
ua: "go-resty/3.0.0-beta.1 (https://resty.dev)",
api: "https://open-api-drive.quark.cn",
},
}
})
}

131
drivers/quark_open/types.go Normal file
View File

@ -0,0 +1,131 @@
package quark_open
import (
"time"
"github.com/OpenListTeam/OpenList/internal/model"
)
type Resp struct {
CommonRsp
Errno int `json:"errno"`
ErrorInfo string `json:"error_info"`
}
type CommonRsp struct {
Status int `json:"status"`
ReqID string `json:"req_id"`
}
type RefreshTokenOnlineAPIResp struct {
RefreshToken string `json:"refresh_token"`
AccessToken string `json:"access_token"`
AppID string `json:"app_id"`
SignKey string `json:"sign_key"`
ErrorMessage string `json:"text"`
}
type File struct {
Fid string `json:"fid"`
ParentFid string `json:"parent_fid"`
Category int64 `json:"category"`
FileName string `json:"filename"`
Size int64 `json:"size"`
FileType string `json:"file_type"`
ThumbnailURL string `json:"thumbnail_url"`
ContentHash string `json:"content_hash"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
func fileToObj(f File) *model.Object {
return &model.Object{
ID: f.Fid,
Name: f.FileName,
Size: f.Size,
Modified: time.UnixMilli(f.UpdatedAt),
IsFolder: f.FileType == "0",
}
}
type QueryCursor struct {
Version string `json:"version"`
Token string `json:"token"`
}
type FileListResp struct {
CommonRsp
Data struct {
FileList []File `json:"file_list"`
LastPage bool `json:"last_page"`
NextQueryCursor QueryCursor `json:"next_query_cursor"`
} `json:"data"`
}
type FileLikeResp struct {
CommonRsp
Data struct {
Fid string `json:"fid"`
Size int `json:"size"`
FileName string `json:"file_name"`
DownloadURL string `json:"download_url"`
} `json:"data"`
}
type UpPreResp struct {
CommonRsp
Data struct {
Finish bool `json:"finish"`
TaskID string `json:"task_id"`
Fid string `json:"fid"`
CommonHeaders struct {
XOssContentSha256 string `json:"X-Oss-Content-Sha256"`
XOssDate string `json:"X-Oss-Date"`
} `json:"common_headers"`
UploadUrls []struct {
PartNumber int `json:"part_number"`
SignatureInfo struct {
AuthType string `json:"auth_type"`
Signature string `json:"signature"`
} `json:"signature_info"`
UploadURL string `json:"upload_url"`
Expired int64 `json:"expired"`
} `json:"upload_urls"`
PartSize int64 `json:"part_size"`
} `json:"data"`
}
type UpUrlInfo struct {
UploadUrls []struct {
PartNumber int `json:"part_number"`
PartSize int `json:"part_size"`
SignatureInfo struct {
AuthType string `json:"auth_type"`
Signature string `json:"signature"`
} `json:"signature_info"`
UploadURL string `json:"upload_url"`
} `json:"upload_urls"`
CommonHeaders struct {
XOssContentSha256 string `json:"X-Oss-Content-Sha256"`
XOssDate string `json:"X-Oss-Date"`
} `json:"common_headers"`
UploadID string `json:"upload_id"`
}
type UpUrlResp struct {
CommonRsp
Data UpUrlInfo `json:"data"`
}
type UpFinishResp struct {
CommonRsp
Data struct {
TaskID string `json:"task_id"`
Fid string `json:"fid"`
Finish bool `json:"finish"`
PdirFid string `json:"pdir_fid"`
Thumbnail string `json:"thumbnail"`
FormatType string `json:"format_type"`
Size int `json:"size"`
} `json:"data"`
}

309
drivers/quark_open/util.go Normal file
View File

@ -0,0 +1,309 @@
package quark_open
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/google/uuid"
"io"
"net/http"
"strconv"
"time"
"github.com/OpenListTeam/OpenList/drivers/base"
"github.com/OpenListTeam/OpenList/internal/model"
"github.com/OpenListTeam/OpenList/internal/op"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
)
func (d *QuarkOpen) request(ctx context.Context, pathname string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
u := d.conf.api + pathname
tm, token, reqID := d.generateReqSign(method, pathname, d.Addition.SignKey)
req := base.RestyClient.R()
req.SetContext(ctx)
req.SetHeaders(map[string]string{
"Accept": "application/json, text/plain, */*",
"User-Agent": d.conf.ua,
"x-pan-tm": tm,
"x-pan-token": token,
"x-pan-client-id": d.Addition.AppID,
})
req.SetQueryParams(map[string]string{
"req_id": reqID,
"access_token": d.Addition.AccessToken,
})
if callback != nil {
callback(req)
}
if resp != nil {
req.SetResult(resp)
}
var e Resp
req.SetError(&e)
res, err := req.Execute(method, u)
if err != nil {
return nil, err
}
// 判断 是否需要 刷新 access_token
if e.Status == -1 && (e.Errno == 11001 || e.Errno == 14001) {
// token 过期
err = d.refreshToken()
if err != nil {
return nil, err
}
ctx1, cancelFunc := context.WithTimeout(ctx, 10*time.Second)
defer cancelFunc()
return d.request(ctx1, pathname, method, callback, resp)
}
if e.Status >= 400 || e.Errno != 0 {
return nil, errors.New(e.ErrorInfo)
}
return res.Body(), nil
}
func (d *QuarkOpen) GetFiles(ctx context.Context, parent string) ([]File, error) {
files := make([]File, 0)
var queryCursor QueryCursor
for {
reqBody := map[string]interface{}{
"parent_fid": parent,
"size": 100, // 默认每页100个文件
"sort": "file_name:asc", // 基本排序方式
}
// 如果有排序设置
if d.OrderBy != "none" {
reqBody["sort"] = d.OrderBy + ":" + d.OrderDirection
}
// 设置查询游标(用于分页)
if queryCursor.Token != "" {
reqBody["query_cursor"] = queryCursor
}
var resp FileListResp
_, err := d.request(ctx, "/open/v1/file/list", http.MethodPost, func(req *resty.Request) {
req.SetBody(reqBody)
}, &resp)
if err != nil {
return nil, err
}
files = append(files, resp.Data.FileList...)
if resp.Data.LastPage {
break
}
queryCursor = resp.Data.NextQueryCursor
}
return files, nil
}
func (d *QuarkOpen) upPre(ctx context.Context, file model.FileStreamer, parentId, md5, sha1 string) (UpPreResp, error) {
now := time.Now()
data := base.Json{
"file_name": file.GetName(),
"size": file.GetSize(),
"format_type": file.GetMimetype(),
"md5": md5,
"sha1": sha1,
"l_created_at": now.UnixMilli(),
"l_updated_at": now.UnixMilli(),
"pdir_fid": parentId,
"same_path_reuse": true,
}
var resp UpPreResp
_, err := d.request(ctx, "/open/v1/file/upload_pre", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, &resp)
return resp, err
}
func (d *QuarkOpen) _getPartInfo(stream model.FileStreamer, partSize int64) []base.Json {
// 计算分片信息
partInfo := make([]base.Json, 0)
total := stream.GetSize()
left := total
partNumber := 1
// 计算每个分片的大小和编号
for left > 0 {
size := partSize
if left < partSize {
size = left
}
partInfo = append(partInfo, base.Json{
"part_number": partNumber,
"part_size": size,
})
left -= size
partNumber++
}
return partInfo
}
func (d *QuarkOpen) upUrl(ctx context.Context, pre UpPreResp, partInfo []base.Json) (upUrlInfo UpUrlInfo, err error) {
// 构建请求体
data := base.Json{
"task_id": pre.Data.TaskID,
"part_info_list": partInfo,
}
var resp UpUrlResp
_, err = d.request(ctx, "/open/v1/file/get_upload_urls", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, &resp)
if err != nil {
return upUrlInfo, err
}
return resp.Data, nil
}
func (d *QuarkOpen) upPart(ctx context.Context, upUrlInfo UpUrlInfo, partNumber int, bytes io.Reader) (string, error) {
// 创建请求
req, err := http.NewRequestWithContext(ctx, http.MethodPut, upUrlInfo.UploadUrls[partNumber].UploadURL, bytes)
if err != nil {
return "", err
}
req.Header.Set("Authorization", upUrlInfo.UploadUrls[partNumber].SignatureInfo.Signature)
req.Header.Set("X-Oss-Date", upUrlInfo.CommonHeaders.XOssDate)
req.Header.Set("X-Oss-Content-Sha256", upUrlInfo.CommonHeaders.XOssContentSha256)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("User-Agent", "Go-http-client/1.1")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("up status: %d, error: %s", resp.StatusCode, string(body))
}
// 返回 Etag 作为分片上传的标识
return resp.Header.Get("Etag"), nil
}
func (d *QuarkOpen) upFinish(ctx context.Context, pre UpPreResp, partInfo []base.Json, etags []string) error {
// 创建 part_info_list
partInfoList := make([]base.Json, len(partInfo))
// 确保 partInfo 和 etags 长度一致
if len(partInfo) != len(etags) {
return fmt.Errorf("part info count (%d) does not match etags count (%d)", len(partInfo), len(etags))
}
// 组合 part_info_list
for i, part := range partInfo {
partInfoList[i] = base.Json{
"part_number": part["part_number"],
"part_size": part["part_size"],
"etag": etags[i],
}
}
// 构建请求体
data := base.Json{
"task_id": pre.Data.TaskID,
"part_info_list": partInfoList,
}
// 发送请求
var resp UpFinishResp
_, err := d.request(ctx, "/open/v1/file/upload_finish", http.MethodPost, func(req *resty.Request) {
req.SetBody(data)
}, &resp)
if err != nil {
return err
}
if resp.Data.Finish != true {
return fmt.Errorf("upload finish failed, task_id: %s", resp.Data.TaskID)
}
return nil
}
func (d *QuarkOpen) generateReqSign(method string, pathname string, signKey string) (string, string, string) {
// 生成时间戳 (13位毫秒级)
timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
// 生成 x-pan-token token的组成是: method + "&" + pathname + "&" + timestamp + "&" + signKey
tokenData := method + "&" + pathname + "&" + timestamp + "&" + signKey
tokenHash := sha256.Sum256([]byte(tokenData))
xPanToken := hex.EncodeToString(tokenHash[:])
// 生成 req_id
reqUuid, _ := uuid.NewRandom()
reqID := reqUuid.String()
return timestamp, xPanToken, reqID
}
func (d *QuarkOpen) refreshToken() error {
refresh, access, err := d._refreshToken()
for i := 0; i < 3; i++ {
if err == nil {
break
} else {
log.Errorf("[quark_open] failed to refresh token: %s", err)
}
refresh, access, err = d._refreshToken()
}
if err != nil {
return err
}
log.Infof("[quark_open] token exchange: %s -> %s", d.RefreshToken, refresh)
d.RefreshToken, d.AccessToken = refresh, access
op.MustSaveDriverStorage(d)
return nil
}
func (d *QuarkOpen) _refreshToken() (string, string, error) {
if d.UseOnlineAPI && d.APIAddress != "" {
u := d.APIAddress
var resp RefreshTokenOnlineAPIResp
_, err := base.RestyClient.R().
SetResult(&resp).
SetQueryParams(map[string]string{
"refresh_ui": d.RefreshToken,
"server_use": "true",
"driver_txt": "quarkyun_oa",
}).
Get(u)
if err != nil {
return "", "", err
}
if resp.RefreshToken == "" || resp.AccessToken == "" {
if resp.ErrorMessage != "" {
return "", "", fmt.Errorf("failed to refresh token: %s", resp.ErrorMessage)
}
return "", "", fmt.Errorf("empty token returned from official API")
}
return resp.RefreshToken, resp.AccessToken, nil
}
// TODO 本地刷新逻辑
return "", "", fmt.Errorf("local refresh token logic is not implemented yet, please use online API or contact the developer")
}
// 生成认证 Cookie
func (d *QuarkOpen) generateAuthCookie() string {
return fmt.Sprintf("x_pan_client_id=%s; x_pan_access_token=%s",
d.Addition.AppID, d.Addition.AccessToken)
}

View File

@ -58,7 +58,7 @@ func (x *Thunder) Init(ctx context.Context) (err error) {
}, },
DeviceID: func() string { DeviceID: func() string {
if len(x.DeviceID) != 32 { if len(x.DeviceID) != 32 {
return utils.GetMD5EncodeStr(x.DeviceID) return utils.GetMD5EncodeStr(x.Username + x.Password)
} }
return x.DeviceID return x.DeviceID
}(), }(),

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/OpenListTeam/OpenList/drivers/base" "github.com/OpenListTeam/OpenList/drivers/base"
"github.com/OpenListTeam/OpenList/internal/driver" "github.com/OpenListTeam/OpenList/internal/driver"
@ -65,6 +66,7 @@ func (x *ThunderBrowser) Init(ctx context.Context) (err error) {
UserAgent: BuildCustomUserAgent(utils.GetMD5EncodeStr(x.Username+x.Password), PackageName, SdkVersion, ClientVersion, PackageName), UserAgent: BuildCustomUserAgent(utils.GetMD5EncodeStr(x.Username+x.Password), PackageName, SdkVersion, ClientVersion, PackageName),
DownloadUserAgent: DownloadUserAgent, DownloadUserAgent: DownloadUserAgent,
UseVideoUrl: x.UseVideoUrl, UseVideoUrl: x.UseVideoUrl,
UseFluentPlay: x.UseFluentPlay,
RemoveWay: x.Addition.RemoveWay, RemoveWay: x.Addition.RemoveWay,
refreshCTokenCk: func(token string) { refreshCTokenCk: func(token string) {
x.CaptchaToken = token x.CaptchaToken = token
@ -81,6 +83,8 @@ func (x *ThunderBrowser) Init(ctx context.Context) (err error) {
x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error())) x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error()))
op.MustSaveDriverStorage(x) op.MustSaveDriverStorage(x)
} }
// 清空 信任密钥
x.Addition.CreditKey = ""
} }
x.SetTokenResp(token) x.SetTokenResp(token)
return err return err
@ -93,10 +97,20 @@ func (x *ThunderBrowser) Init(ctx context.Context) (err error) {
if ctoekn != "" { if ctoekn != "" {
x.SetCaptchaToken(ctoekn) x.SetCaptchaToken(ctoekn)
} }
if x.DeviceID == "" {
x.SetDeviceID(utils.GetMD5EncodeStr(x.Username + x.Password)) if x.Addition.CreditKey != "" {
x.SetCreditKey(x.Addition.CreditKey)
} }
if x.Addition.DeviceID != "" {
x.Common.DeviceID = x.Addition.DeviceID
} else {
x.Addition.DeviceID = x.Common.DeviceID
op.MustSaveDriverStorage(x)
}
x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl
x.XunLeiBrowserCommon.UseFluentPlay = x.UseFluentPlay
x.Addition.RootFolderID = x.RootFolderID x.Addition.RootFolderID = x.RootFolderID
// 防止重复登录 // 防止重复登录
identity := x.GetIdentity() identity := x.GetIdentity()
@ -107,6 +121,8 @@ func (x *ThunderBrowser) Init(ctx context.Context) (err error) {
if err != nil { if err != nil {
return err return err
} }
// 清空 信任密钥
x.Addition.CreditKey = ""
x.SetTokenResp(token) x.SetTokenResp(token)
} }
@ -187,8 +203,9 @@ func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
} }
return DownloadUserAgent return DownloadUserAgent
}(), }(),
UseVideoUrl: x.UseVideoUrl, UseVideoUrl: x.UseVideoUrl,
RemoveWay: x.ExpertAddition.RemoveWay, UseFluentPlay: x.UseFluentPlay,
RemoveWay: x.ExpertAddition.RemoveWay,
refreshCTokenCk: func(token string) { refreshCTokenCk: func(token string) {
x.CaptchaToken = token x.CaptchaToken = token
op.MustSaveDriverStorage(x) op.MustSaveDriverStorage(x)
@ -200,7 +217,13 @@ func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
x.SetCaptchaToken(x.ExpertAddition.CaptchaToken) x.SetCaptchaToken(x.ExpertAddition.CaptchaToken)
op.MustSaveDriverStorage(x) op.MustSaveDriverStorage(x)
} }
if x.Common.DeviceID != "" { if x.ExpertAddition.CreditKey != "" {
x.SetCreditKey(x.ExpertAddition.CreditKey)
}
if x.ExpertAddition.DeviceID != "" {
x.Common.DeviceID = x.ExpertAddition.DeviceID
} else {
x.ExpertAddition.DeviceID = x.Common.DeviceID x.ExpertAddition.DeviceID = x.Common.DeviceID
op.MustSaveDriverStorage(x) op.MustSaveDriverStorage(x)
} }
@ -213,6 +236,7 @@ func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
op.MustSaveDriverStorage(x) op.MustSaveDriverStorage(x)
} }
x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl
x.XunLeiBrowserCommon.UseFluentPlay = x.UseFluentPlay
x.ExpertAddition.RootFolderID = x.RootFolderID x.ExpertAddition.RootFolderID = x.RootFolderID
// 签名方法 // 签名方法
if x.SignType == "captcha_sign" { if x.SignType == "captcha_sign" {
@ -253,6 +277,8 @@ func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
if err != nil { if err != nil {
return err return err
} }
// 清空 信任密钥
x.ExpertAddition.CreditKey = ""
x.SetTokenResp(token) x.SetTokenResp(token)
x.SetRefreshTokenFunc(func() error { x.SetRefreshTokenFunc(func() error {
token, err := x.XunLeiBrowserCommon.RefreshToken(x.TokenResp.RefreshToken) token, err := x.XunLeiBrowserCommon.RefreshToken(x.TokenResp.RefreshToken)
@ -261,6 +287,8 @@ func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
if err != nil { if err != nil {
x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error())) x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error()))
} }
// 清空 信任密钥
x.ExpertAddition.CreditKey = ""
} }
x.SetTokenResp(token) x.SetTokenResp(token)
op.MustSaveDriverStorage(x) op.MustSaveDriverStorage(x)
@ -286,6 +314,7 @@ func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
x.XunLeiBrowserCommon.UserAgent = x.UserAgent x.XunLeiBrowserCommon.UserAgent = x.UserAgent
x.XunLeiBrowserCommon.DownloadUserAgent = x.DownloadUserAgent x.XunLeiBrowserCommon.DownloadUserAgent = x.DownloadUserAgent
x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl
x.XunLeiBrowserCommon.UseFluentPlay = x.UseFluentPlay
x.ExpertAddition.RootFolderID = x.RootFolderID x.ExpertAddition.RootFolderID = x.RootFolderID
} }
@ -305,7 +334,8 @@ func (x *ThunderBrowserExpert) SetTokenResp(token *TokenResp) {
type XunLeiBrowserCommon struct { type XunLeiBrowserCommon struct {
*Common *Common
*TokenResp // 登录信息 *TokenResp // 登录信息
*CoreLoginResp // core登录信息
refreshTokenFunc func() error refreshTokenFunc func() error
} }
@ -523,7 +553,8 @@ func (xc *XunLeiBrowserCommon) getFiles(ctx context.Context, dir model.Obj, path
folderSpace = dirF.GetSpace() folderSpace = dirF.GetSpace()
default: default:
// 处理 根目录的情况 // 处理 根目录的情况
folderSpace = ThunderBrowserDriveSpace //folderSpace = ThunderBrowserDriveSpace
folderSpace = ThunderDriveSpace // 迅雷浏览器已经合并到迅雷云盘,因此变更根目录
} }
params := map[string]string{ params := map[string]string{
"parent_id": dir.GetID(), "parent_id": dir.GetID(),
@ -569,6 +600,11 @@ func (xc *XunLeiBrowserCommon) SetTokenResp(tr *TokenResp) {
xc.TokenResp = tr xc.TokenResp = tr
} }
// SetCoreTokenResp 设置CoreToken
func (xc *XunLeiBrowserCommon) SetCoreTokenResp(tr *CoreLoginResp) {
xc.CoreLoginResp = tr
}
// SetSpaceTokenResp 设置Token // SetSpaceTokenResp 设置Token
func (xc *XunLeiBrowserCommon) SetSpaceTokenResp(spaceToken string) { func (xc *XunLeiBrowserCommon) SetSpaceTokenResp(spaceToken string) {
xc.TokenResp.Token = spaceToken xc.TokenResp.Token = spaceToken
@ -614,14 +650,24 @@ func (xc *XunLeiBrowserCommon) Request(url string, method string, callback base.
} }
if errResp.ErrorMsg == "captcha_invalid" { if errResp.ErrorMsg == "captcha_invalid" {
// 验证码token过期 // 验证码token过期
if err = xc.RefreshCaptchaTokenAtLogin(GetAction(method, url), xc.UserID); err != nil { if err = xc.RefreshCaptchaTokenAtLogin(GetAction(method, url), xc.TokenResp.UserID); err != nil {
return nil, err return nil, err
} }
} }
return nil, err
return nil, errors.New(errResp.ErrorMsg)
default: default:
// 处理未捕获到的验证码错误
if errResp.ErrorMsg == "captcha_invalid" {
// 验证码token过期
if err = xc.RefreshCaptchaTokenAtLogin(GetAction(method, url), xc.TokenResp.UserID); err != nil {
return nil, err
}
}
return nil, err return nil, err
} }
return xc.Request(url, method, callback, resp) return xc.Request(url, method, callback, resp)
} }
@ -667,20 +713,25 @@ func (xc *XunLeiBrowserCommon) GetSafeAccessToken(safePassword string) (string,
// Login 登录 // Login 登录
func (xc *XunLeiBrowserCommon) Login(username, password string) (*TokenResp, error) { func (xc *XunLeiBrowserCommon) Login(username, password string) (*TokenResp, error) {
url := XLUSER_API_URL + "/auth/signin" //v3 login拿到 sessionID
err := xc.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), username) sessionID, err := xc.CoreLogin(username, password)
if err != nil { if err != nil {
return nil, err return nil, err
} }
//v1 login拿到令牌
url := XLUSER_API_URL + "/auth/signin/token"
if err = xc.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), username); err != nil {
return nil, err
}
var resp TokenResp var resp TokenResp
_, err = xc.Common.Request(url, http.MethodPost, func(req *resty.Request) { _, err = xc.Common.Request(url, http.MethodPost, func(req *resty.Request) {
req.SetPathParam("client_id", xc.ClientID)
req.SetBody(&SignInRequest{ req.SetBody(&SignInRequest{
CaptchaToken: xc.GetCaptchaToken(),
ClientID: xc.ClientID, ClientID: xc.ClientID,
ClientSecret: xc.ClientSecret, ClientSecret: xc.ClientSecret,
Username: username, Provider: SignProvider,
Password: password, SigninToken: sessionID,
}) })
}, &resp) }, &resp)
if err != nil { if err != nil {
@ -696,3 +747,157 @@ func (xc *XunLeiBrowserCommon) IsLogin() bool {
_, err := xc.Request(XLUSER_API_URL+"/user/me", http.MethodGet, nil, nil) _, err := xc.Request(XLUSER_API_URL+"/user/me", http.MethodGet, nil, nil)
return err == nil return err == nil
} }
// OfflineDownload 离线下载文件
func (xc *XunLeiBrowserCommon) OfflineDownload(ctx context.Context, fileUrl string, parentDir model.Obj, fileName string) (*OfflineTask, error) {
var resp OfflineDownloadResp
body := base.Json{}
from := "cloudadd/"
if xc.UseFluentPlay {
body = base.Json{
"kind": FILE,
"name": fileName,
// 流畅播接口 强制将文件放在 "SPACE_FAVORITE" 文件夹
//"parent_id": parentDir.GetID(),
"upload_type": UPLOAD_TYPE_URL,
"url": base.Json{
"url": fileUrl,
//"files": []string{"0"}, // 0 表示只下载第一个文件
},
"params": base.Json{
"cookie": "null",
"web_title": "",
"lastSession": "",
"flags": "9",
"scene": "smart_spot_panel",
"referer": "https://x.xunlei.com",
"dedup_index": "0",
},
"need_dedup": true,
"folder_type": "FAVORITE",
"space": ThunderBrowserDriveFluentPlayFolderType,
}
from = "FLUENT_PLAY/sniff_ball/fluent_play/SPACE_FAVORITE"
} else {
body = base.Json{
"kind": FILE,
"name": fileName,
"parent_id": parentDir.GetID(),
"upload_type": UPLOAD_TYPE_URL,
"url": base.Json{
"url": fileUrl,
},
}
if files, ok := parentDir.(*Files); ok {
body["space"] = files.GetSpace()
} else {
// 如果不是 Files 类型,则默认使用 ThunderDriveSpace
body["space"] = ThunderDriveSpace
}
}
_, err := xc.Request(FILE_API_URL, http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetQueryParam("_from", from)
r.SetBody(&body)
}, &resp)
if err != nil {
return nil, err
}
return &resp.Task, err
}
// OfflineList 获取离线下载任务列表
func (xc *XunLeiBrowserCommon) OfflineList(ctx context.Context, nextPageToken string) ([]OfflineTask, error) {
res := make([]OfflineTask, 0)
var resp OfflineListResp
_, err := xc.Request(TASK_API_URL, http.MethodGet, func(req *resty.Request) {
req.SetContext(ctx).
SetQueryParams(map[string]string{
"type": "offline",
"limit": "10000",
"page_token": nextPageToken,
"space": "default/*",
})
}, &resp)
if err != nil {
return nil, fmt.Errorf("failed to get offline list: %w", err)
}
res = append(res, resp.Tasks...)
return res, nil
}
func (xc *XunLeiBrowserCommon) DeleteOfflineTasks(ctx context.Context, taskIDs []string) error {
queryParams := map[string]string{
"task_ids": strings.Join(taskIDs, ","),
"_t": fmt.Sprintf("%d", time.Now().UnixMilli()),
}
if xc.UseFluentPlay {
queryParams["space"] = ThunderBrowserDriveFluentPlayFolderType
}
_, err := xc.Request(TASK_API_URL, http.MethodDelete, func(req *resty.Request) {
req.SetContext(ctx).
SetQueryParams(queryParams)
}, nil)
if err != nil {
return fmt.Errorf("failed to delete tasks %v: %w", taskIDs, err)
}
return nil
}
func (xc *XunLeiBrowserCommon) CoreLogin(username string, password string) (sessionID string, err error) {
url := XLUSER_API_BASE_URL + "/xluser.core.login/v3/login"
var resp CoreLoginResp
res, err := xc.Common.Request(url, http.MethodPost, func(req *resty.Request) {
req.SetHeader("User-Agent", "android-ok-http-client/xl-acc-sdk/version-5.0.9.509300")
req.SetBody(&CoreLoginRequest{
ProtocolVersion: "301",
SequenceNo: "1000010",
PlatformVersion: "10",
IsCompressed: "0",
Appid: APPID,
ClientVersion: xc.Common.ClientVersion,
PeerID: "00000000000000000000000000000000",
AppName: "ANDROID-com.xunlei.browser",
SdkVersion: "509300",
Devicesign: generateDeviceSign(xc.DeviceID, xc.PackageName),
NetWorkType: "WIFI",
ProviderName: "NONE",
DeviceModel: "M2004J7AC",
DeviceName: "Xiaomi_M2004j7ac",
OSVersion: "12",
Creditkey: xc.GetCreditKey(),
Hl: "zh-CN",
UserName: username,
PassWord: password,
VerifyKey: "",
VerifyCode: "",
IsMd5Pwd: "0",
})
}, nil)
if err != nil {
return "", err
}
if err = utils.Json.Unmarshal(res, &resp); err != nil {
return "", err
}
xc.SetCoreTokenResp(&resp)
sessionID = resp.SessionID
return sessionID, nil
}

View File

@ -25,19 +25,21 @@ type ExpertAddition struct {
SafePassword string `json:"safe_password" required:"true" help:"super safe password"` // 超级保险箱密码 SafePassword string `json:"safe_password" required:"true" help:"super safe password"` // 超级保险箱密码
// 签名方法1 // 签名方法1
Algorithms string `json:"algorithms" required:"true" help:"sign type is algorithms,this is required" default:"uWRwO7gPfdPB/0NfPtfQO+71,F93x+qPluYy6jdgNpq+lwdH1ap6WOM+nfz8/V,0HbpxvpXFsBK5CoTKam,dQhzbhzFRcawnsZqRETT9AuPAJ+wTQso82mRv,SAH98AmLZLRa6DB2u68sGhyiDh15guJpXhBzI,unqfo7Z64Rie9RNHMOB,7yxUdFADp3DOBvXdz0DPuKNVT35wqa5z0DEyEvf,RBG,ThTWPG5eC0UBqlbQ+04nZAptqGCdpv9o55A"` Algorithms string `json:"algorithms" required:"true" help:"sign type is algorithms,this is required" default:"Cw4kArmKJ/aOiFTxnQ0ES+D4mbbrIUsFn,HIGg0Qfbpm5ThZ/RJfjoao4YwgT9/M,u/PUD,OlAm8tPkOF1qO5bXxRN2iFttuDldrg,FFIiM6sFhWhU7tIMVUKOF7CUv/KzgwwV8FE,yN,4m5mglrIHksI6wYdq,LXEfS7,T+p+C+F2yjgsUtiXWU/cMNYEtJI4pq7GofW,14BrGIEMXkbvFvZ49nDUfVCRcHYFOJ1BP1Y,kWIH3Row,RAmRTKNCjucPWC"`
// 签名方法2 // 签名方法2
CaptchaSign string `json:"captcha_sign" required:"true" help:"sign type is captcha_sign,this is required"` CaptchaSign string `json:"captcha_sign" required:"true" help:"sign type is captcha_sign,this is required"`
Timestamp string `json:"timestamp" required:"true" help:"sign type is captcha_sign,this is required"` Timestamp string `json:"timestamp" required:"true" help:"sign type is captcha_sign,this is required"`
// 验证码 // 验证码
CaptchaToken string `json:"captcha_token"` CaptchaToken string `json:"captcha_token"`
// 信任密钥
CreditKey string `json:"credit_key" help:"credit key,used for login"`
// 必要且影响登录,由签名决定 // 必要且影响登录,由签名决定
DeviceID string `json:"device_id" required:"false" default:""` DeviceID string `json:"device_id" required:"false" default:""`
ClientID string `json:"client_id" required:"true" default:"ZUBzD9J_XPXfn7f7"` ClientID string `json:"client_id" required:"true" default:"ZUBzD9J_XPXfn7f7"`
ClientSecret string `json:"client_secret" required:"true" default:"yESVmHecEe6F0aou69vl-g"` ClientSecret string `json:"client_secret" required:"true" default:"yESVmHecEe6F0aou69vl-g"`
ClientVersion string `json:"client_version" required:"true" default:"1.10.0.2633"` ClientVersion string `json:"client_version" required:"true" default:"1.40.0.7208"`
PackageName string `json:"package_name" required:"true" default:"com.xunlei.browser"` PackageName string `json:"package_name" required:"true" default:"com.xunlei.browser"`
// 不影响登录,影响下载速度 // 不影响登录,影响下载速度
@ -46,6 +48,8 @@ type ExpertAddition struct {
// 优先使用视频链接代替下载链接 // 优先使用视频链接代替下载链接
UseVideoUrl bool `json:"use_video_url"` UseVideoUrl bool `json:"use_video_url"`
// 离线下载是否使用 流畅播(Fluent Play)接口
UseFluentPlay bool `json:"use_fluent_play" default:"false" help:"use fluent play for offline download,only magnet links supported"`
// 移除方式 // 移除方式
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"` RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
} }
@ -79,8 +83,12 @@ type Addition struct {
Password string `json:"password" required:"true"` Password string `json:"password" required:"true"`
SafePassword string `json:"safe_password" required:"true"` // 超级保险箱密码 SafePassword string `json:"safe_password" required:"true"` // 超级保险箱密码
CaptchaToken string `json:"captcha_token"` CaptchaToken string `json:"captcha_token"`
CreditKey string `json:"credit_key" help:"credit key,used for login"` // 信任密钥
DeviceID string `json:"device_id" default:""` // 登录设备ID
UseVideoUrl bool `json:"use_video_url" default:"false"` UseVideoUrl bool `json:"use_video_url" default:"false"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"` // 离线下载是否使用 流畅播(Fluent Play)接口
UseFluentPlay bool `json:"use_fluent_play" default:"false" help:"use fluent play for offline download,only magnet links supported"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
} }
// GetIdentity 登录特征,用于判断是否重新登录 // GetIdentity 登录特征,用于判断是否重新登录

View File

@ -18,6 +18,10 @@ type ErrResp struct {
} }
func (e *ErrResp) IsError() bool { func (e *ErrResp) IsError() bool {
if e.ErrorMsg == "success" {
return false
}
return e.ErrorCode != 0 || e.ErrorMsg != "" || e.ErrorDescription != "" return e.ErrorCode != 0 || e.ErrorMsg != "" || e.ErrorDescription != ""
} }
@ -68,13 +72,78 @@ func (t *TokenResp) GetSpaceToken() string {
} }
type SignInRequest struct { type SignInRequest struct {
CaptchaToken string `json:"captcha_token"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"` ClientSecret string `json:"client_secret"`
Username string `json:"username"` Provider string `json:"provider"`
Password string `json:"password"` SigninToken string `json:"signin_token"`
}
type CoreLoginRequest struct {
ProtocolVersion string `json:"protocolVersion"`
SequenceNo string `json:"sequenceNo"`
PlatformVersion string `json:"platformVersion"`
IsCompressed string `json:"isCompressed"`
Appid string `json:"appid"`
ClientVersion string `json:"clientVersion"`
PeerID string `json:"peerID"`
AppName string `json:"appName"`
SdkVersion string `json:"sdkVersion"`
Devicesign string `json:"devicesign"`
NetWorkType string `json:"netWorkType"`
ProviderName string `json:"providerName"`
DeviceModel string `json:"deviceModel"`
DeviceName string `json:"deviceName"`
OSVersion string `json:"OSVersion"`
Creditkey string `json:"creditkey"`
Hl string `json:"hl"`
UserName string `json:"userName"`
PassWord string `json:"passWord"`
VerifyKey string `json:"verifyKey"`
VerifyCode string `json:"verifyCode"`
IsMd5Pwd string `json:"isMd5Pwd"`
}
type CoreLoginResp struct {
Account string `json:"account"`
Creditkey string `json:"creditkey"`
/* Error string `json:"error"`
ErrorCode string `json:"errorCode"`
ErrorDescription string `json:"error_description"`*/
ExpiresIn int `json:"expires_in"`
IsCompressed string `json:"isCompressed"`
IsSetPassWord string `json:"isSetPassWord"`
KeepAliveMinPeriod string `json:"keepAliveMinPeriod"`
KeepAlivePeriod string `json:"keepAlivePeriod"`
LoginKey string `json:"loginKey"`
NickName string `json:"nickName"`
PlatformVersion string `json:"platformVersion"`
ProtocolVersion string `json:"protocolVersion"`
SecureKey string `json:"secureKey"`
SequenceNo string `json:"sequenceNo"`
SessionID string `json:"sessionID"`
Timestamp string `json:"timestamp"`
UserID string `json:"userID"`
UserName string `json:"userName"`
UserNewNo string `json:"userNewNo"`
Version string `json:"version"`
/* VipList []struct {
ExpireDate string `json:"expireDate"`
IsAutoDeduct string `json:"isAutoDeduct"`
IsVip string `json:"isVip"`
IsYear string `json:"isYear"`
PayID string `json:"payId"`
PayName string `json:"payName"`
Register string `json:"register"`
Vasid string `json:"vasid"`
VasType string `json:"vasType"`
VipDayGrow string `json:"vipDayGrow"`
VipGrow string `json:"vipGrow"`
VipLevel string `json:"vipLevel"`
Icon struct {
General string `json:"general"`
Small string `json:"small"`
} `json:"icon"`
} `json:"vipList"`*/
} }
/* /*
@ -234,3 +303,76 @@ type UploadTaskResponse struct {
File Files `json:"file"` File Files `json:"file"`
} }
// OfflineDownloadResp 离线下载响应
type OfflineDownloadResp struct {
File *string `json:"file"`
Task OfflineTask `json:"task"`
UploadType string `json:"upload_type"`
URL struct {
Kind string `json:"kind"`
} `json:"url"`
}
// OfflineListResp 离线下载列表响应
type OfflineListResp struct {
ExpiresIn int64 `json:"expires_in"`
NextPageToken string `json:"next_page_token"`
Tasks []OfflineTask `json:"tasks"`
}
// OfflineTask 离线下载任务响应
type OfflineTask struct {
Callback string `json:"callback"`
CreatedTime string `json:"created_time"`
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileSize string `json:"file_size"`
IconLink string `json:"icon_link"`
ID string `json:"id"`
Kind string `json:"kind"`
Message string `json:"message"`
Name string `json:"name"`
Params Params `json:"params"`
Phase string `json:"phase"` // PHASE_TYPE_RUNNING, PHASE_TYPE_ERROR, PHASE_TYPE_COMPLETE, PHASE_TYPE_PENDING
Progress int64 `json:"progress"`
Space string `json:"space"`
StatusSize int64 `json:"status_size"`
Statuses []string `json:"statuses"`
ThirdTaskID string `json:"third_task_id"`
Type string `json:"type"`
UpdatedTime string `json:"updated_time"`
UserID string `json:"user_id"`
}
type Params struct {
FolderType string `json:"folder_type"`
PredictSpeed string `json:"predict_speed"`
PredictType string `json:"predict_type"`
}
// LoginReviewResp 登录验证响应
type LoginReviewResp struct {
Creditkey string `json:"creditkey"`
Error string `json:"error"`
ErrorCode string `json:"errorCode"`
ErrorDesc string `json:"errorDesc"`
ErrorDescURL string `json:"errorDescUrl"`
ErrorIsRetry int `json:"errorIsRetry"`
ErrorDescription string `json:"error_description"`
IsCompressed string `json:"isCompressed"`
PlatformVersion string `json:"platformVersion"`
ProtocolVersion string `json:"protocolVersion"`
Reviewurl string `json:"reviewurl"`
SequenceNo string `json:"sequenceNo"`
UserID string `json:"userID"`
VerifyType string `json:"verifyType"`
}
// ReviewData 验证数据
type ReviewData struct {
Creditkey string `json:"creditkey"`
Reviewurl string `json:"reviewurl"`
Deviceid string `json:"deviceid"`
Devicesign string `json:"devicesign"`
}

View File

@ -4,6 +4,7 @@ import (
"crypto/md5" "crypto/md5"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -17,30 +18,35 @@ import (
) )
const ( const (
API_URL = "https://x-api-pan.xunlei.com/drive/v1" API_URL = "https://x-api-pan.xunlei.com/drive/v1"
FILE_API_URL = API_URL + "/files" FILE_API_URL = API_URL + "/files"
XLUSER_API_URL = "https://xluser-ssl.xunlei.com/v1" TASK_API_URL = API_URL + "/tasks"
XLUSER_API_BASE_URL = "https://xluser-ssl.xunlei.com"
XLUSER_API_URL = XLUSER_API_BASE_URL + "/v1"
) )
var Algorithms = []string{ var Algorithms = []string{
"uWRwO7gPfdPB/0NfPtfQO+71", "Cw4kArmKJ/aOiFTxnQ0ES+D4mbbrIUsFn",
"F93x+qPluYy6jdgNpq+lwdH1ap6WOM+nfz8/V", "HIGg0Qfbpm5ThZ/RJfjoao4YwgT9/M",
"0HbpxvpXFsBK5CoTKam", "u/PUD",
"dQhzbhzFRcawnsZqRETT9AuPAJ+wTQso82mRv", "OlAm8tPkOF1qO5bXxRN2iFttuDldrg",
"SAH98AmLZLRa6DB2u68sGhyiDh15guJpXhBzI", "FFIiM6sFhWhU7tIMVUKOF7CUv/KzgwwV8FE",
"unqfo7Z64Rie9RNHMOB", "yN",
"7yxUdFADp3DOBvXdz0DPuKNVT35wqa5z0DEyEvf", "4m5mglrIHksI6wYdq",
"RBG", "LXEfS7",
"ThTWPG5eC0UBqlbQ+04nZAptqGCdpv9o55A", "T+p+C+F2yjgsUtiXWU/cMNYEtJI4pq7GofW",
"14BrGIEMXkbvFvZ49nDUfVCRcHYFOJ1BP1Y",
"kWIH3Row",
"RAmRTKNCjucPWC",
} }
const ( const (
ClientID = "ZUBzD9J_XPXfn7f7" ClientID = "ZUBzD9J_XPXfn7f7"
ClientSecret = "yESVmHecEe6F0aou69vl-g" ClientSecret = "yESVmHecEe6F0aou69vl-g"
ClientVersion = "1.10.0.2633" ClientVersion = "1.40.0.7208"
PackageName = "com.xunlei.browser" PackageName = "com.xunlei.browser"
DownloadUserAgent = "AndroidDownloadManager/13 (Linux; U; Android 13; M2004J7AC Build/SP1A.210812.016)" DownloadUserAgent = "AndroidDownloadManager/13 (Linux; U; Android 13; M2004J7AC Build/SP1A.210812.016)"
SdkVersion = "233100" SdkVersion = "509300"
) )
const ( const (
@ -57,12 +63,19 @@ const (
) )
const ( const (
ThunderDriveSpace = "" ThunderDriveSpace = ""
ThunderDriveSafeSpace = "SPACE_SAFE" ThunderDriveSafeSpace = "SPACE_SAFE"
ThunderBrowserDriveSpace = "SPACE_BROWSER" ThunderBrowserDriveSpace = "SPACE_BROWSER"
ThunderBrowserDriveSafeSpace = "SPACE_BROWSER_SAFE" ThunderBrowserDriveSafeSpace = "SPACE_BROWSER_SAFE"
ThunderDriveFolderType = "DEFAULT_ROOT" ThunderDriveFolderType = "DEFAULT_ROOT"
ThunderBrowserDriveSafeFolderType = "BROWSER_SAFE" ThunderBrowserDriveSafeFolderType = "BROWSER_SAFE"
ThunderBrowserDriveFluentPlayFolderType = "SPACE_FAVORITE" // 流畅播文件夹标识
)
const (
SignProvider = "access_end_point_token"
APPID = "22062"
APPKey = "a5d7416858147a4ab99573872ffccef8"
) )
func GetAction(method string, url string) string { func GetAction(method string, url string) string {
@ -75,6 +88,8 @@ type Common struct {
captchaToken string captchaToken string
creditKey string
// 签名相关,二选一 // 签名相关,二选一
Algorithms []string Algorithms []string
Timestamp, CaptchaSign string Timestamp, CaptchaSign string
@ -88,6 +103,7 @@ type Common struct {
UserAgent string UserAgent string
DownloadUserAgent string DownloadUserAgent string
UseVideoUrl bool UseVideoUrl bool
UseFluentPlay bool
RemoveWay string RemoveWay string
// 验证码token刷新成功回调 // 验证码token刷新成功回调
@ -105,6 +121,13 @@ func (c *Common) GetCaptchaToken() string {
return c.captchaToken return c.captchaToken
} }
func (c *Common) SetCreditKey(creditKey string) {
c.creditKey = creditKey
}
func (c *Common) GetCreditKey() string {
return c.creditKey
}
// RefreshCaptchaTokenAtLogin 刷新验证码token(登录后) // RefreshCaptchaTokenAtLogin 刷新验证码token(登录后)
func (c *Common) RefreshCaptchaTokenAtLogin(action, userID string) error { func (c *Common) RefreshCaptchaTokenAtLogin(action, userID string) error {
metas := map[string]string{ metas := map[string]string{
@ -206,12 +229,53 @@ func (c *Common) Request(url, method string, callback base.ReqCallback, resp int
var erron ErrResp var erron ErrResp
utils.Json.Unmarshal(res.Body(), &erron) utils.Json.Unmarshal(res.Body(), &erron)
if erron.IsError() { if erron.IsError() {
// review_panel 表示需要短信验证码进行验证
if erron.ErrorMsg == "review_panel" {
return nil, c.getReviewData(res)
}
return nil, &erron return nil, &erron
} }
return res.Body(), nil return res.Body(), nil
} }
// 获取验证所需内容
func (c *Common) getReviewData(res *resty.Response) error {
var reviewResp LoginReviewResp
var reviewData ReviewData
if err := utils.Json.Unmarshal(res.Body(), &reviewResp); err != nil {
return err
}
deviceSign := generateDeviceSign(c.DeviceID, c.PackageName)
reviewData = ReviewData{
Creditkey: reviewResp.Creditkey,
Reviewurl: reviewResp.Reviewurl + "&deviceid=" + deviceSign,
Deviceid: deviceSign,
Devicesign: deviceSign,
}
// 将reviewData转为JSON字符串
reviewDataJSON, _ := json.MarshalIndent(reviewData, "", " ")
//reviewDataJSON, _ := json.Marshal(reviewData)
return fmt.Errorf(`
<div style="font-family: Arial, sans-serif; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0;>
<h3 style="color: #d9534f; margin-top: 0;">
<span style="font-size: 16px;">🔒 本次登录需要验证</span><br>
<span style="font-size: 14px; font-weight: normal; color: #666;">This login requires verification</span>
</h3>
<p style="font-size: 14px; margin-bottom: 15px;">下面是验证所需要的数据,具体使用方法请参照对应的驱动文档<br>
<span style="color: #666; font-size: 13px;">Below are the relevant verification data. For specific usage methods, please refer to the corresponding driver documentation.</span></p>
<div style="border: 1px solid #ddd; border-radius: 4px; padding: 10px; overflow-x: auto; font-family: 'Courier New', monospace; font-size: 13px;">
<pre style="margin: 0; white-space: pre-wrap;"><code>%s</code></pre>
</div>
</div>`, string(reviewDataJSON))
}
// 计算文件Gcid // 计算文件Gcid
func getGcid(r io.Reader, size int64) (string, error) { func getGcid(r io.Reader, size int64) (string, error) {
calcBlockSize := func(j int64) int64 { calcBlockSize := func(j int64) int64 {
@ -274,7 +338,7 @@ func EncryptPassword(password string) string {
func generateDeviceSign(deviceID, packageName string) string { func generateDeviceSign(deviceID, packageName string) string {
signatureBase := fmt.Sprintf("%s%s%s%s", deviceID, packageName, "22062", "a5d7416858147a4ab99573872ffccef8") signatureBase := fmt.Sprintf("%s%s%s%s", deviceID, packageName, APPID, APPKey)
sha1Hash := sha1.New() sha1Hash := sha1.New()
sha1Hash.Write([]byte(signatureBase)) sha1Hash.Write([]byte(signatureBase))
@ -299,7 +363,7 @@ func BuildCustomUserAgent(deviceID, appName, sdkVersion, clientVersion, packageN
sb.WriteString(fmt.Sprintf("ANDROID-%s/%s ", appName, clientVersion)) sb.WriteString(fmt.Sprintf("ANDROID-%s/%s ", appName, clientVersion))
sb.WriteString("networkType/WIFI ") sb.WriteString("networkType/WIFI ")
sb.WriteString(fmt.Sprintf("appid/%s ", "22062")) sb.WriteString(fmt.Sprintf("appid/%s ", APPID))
sb.WriteString(fmt.Sprintf("deviceName/Xiaomi_M2004j7ac ")) sb.WriteString(fmt.Sprintf("deviceName/Xiaomi_M2004j7ac "))
sb.WriteString(fmt.Sprintf("deviceModel/M2004J7AC ")) sb.WriteString(fmt.Sprintf("deviceModel/M2004J7AC "))
sb.WriteString(fmt.Sprintf("OSVersion/13 ")) sb.WriteString(fmt.Sprintf("OSVersion/13 "))

View File

@ -69,6 +69,9 @@ const (
// thunder // thunder
ThunderTempDir = "thunder_temp_dir" ThunderTempDir = "thunder_temp_dir"
// thunder_browser
ThunderBrowserTempDir = "thunder_browser_temp_dir"
// single // single
Token = "token" Token = "token"
IndexProgress = "index_progress" IndexProgress = "index_progress"

View File

@ -82,6 +82,14 @@ func MoveWithTask(ctx context.Context, srcPath, dstDirPath string, lazyCache ...
return res, err return res, err
} }
func MoveWithTaskAndValidation(ctx context.Context, srcPath, dstDirPath string, validateExistence bool, lazyCache ...bool) (task.TaskExtensionInfo, error) {
res, err := _moveWithValidation(ctx, srcPath, dstDirPath, validateExistence, lazyCache...)
if err != nil {
log.Errorf("failed move %s to %s: %+v", srcPath, dstDirPath, err)
}
return res, err
}
func Copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (task.TaskExtensionInfo, error) { func Copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (task.TaskExtensionInfo, error) {
res, err := _copy(ctx, srcObjPath, dstDirPath, lazyCache...) res, err := _copy(ctx, srcObjPath, dstDirPath, lazyCache...)
if err != nil { if err != nil {

View File

@ -3,13 +3,16 @@ package fs
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
stdpath "path" stdpath "path"
"sync"
"time" "time"
"github.com/OpenListTeam/OpenList/internal/driver" "github.com/OpenListTeam/OpenList/internal/driver"
"github.com/OpenListTeam/OpenList/internal/errs" "github.com/OpenListTeam/OpenList/internal/errs"
"github.com/OpenListTeam/OpenList/internal/model" "github.com/OpenListTeam/OpenList/internal/model"
"github.com/OpenListTeam/OpenList/internal/op" "github.com/OpenListTeam/OpenList/internal/op"
"github.com/OpenListTeam/OpenList/internal/stream"
"github.com/OpenListTeam/OpenList/internal/task" "github.com/OpenListTeam/OpenList/internal/task"
"github.com/OpenListTeam/OpenList/pkg/utils" "github.com/OpenListTeam/OpenList/pkg/utils"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -18,28 +21,101 @@ import (
type MoveTask struct { type MoveTask struct {
task.TaskExtension task.TaskExtension
Status string `json:"-"` Status string `json:"-"`
SrcObjPath string `json:"src_path"` SrcObjPath string `json:"src_path"`
DstDirPath string `json:"dst_path"` DstDirPath string `json:"dst_path"`
srcStorage driver.Driver `json:"-"` srcStorage driver.Driver `json:"-"`
dstStorage driver.Driver `json:"-"` dstStorage driver.Driver `json:"-"`
SrcStorageMp string `json:"src_storage_mp"` SrcStorageMp string `json:"src_storage_mp"`
DstStorageMp string `json:"dst_storage_mp"` DstStorageMp string `json:"dst_storage_mp"`
IsRootTask bool `json:"is_root_task"`
RootTaskID string `json:"root_task_id"`
TotalFiles int `json:"total_files"`
CompletedFiles int `json:"completed_files"`
Phase string `json:"phase"` // "copying", "verifying", "deleting", "completed"
ValidateExistence bool `json:"validate_existence"`
mu sync.RWMutex `json:"-"`
} }
type MoveProgress struct {
TaskID string `json:"task_id"`
Phase string `json:"phase"`
TotalFiles int `json:"total_files"`
CompletedFiles int `json:"completed_files"`
CurrentFile string `json:"current_file"`
Status string `json:"status"`
Progress int `json:"progress"`
}
var moveProgressMap = sync.Map{}
func (t *MoveTask) GetName() string { func (t *MoveTask) GetName() string {
return fmt.Sprintf("move [%s](%s) to [%s](%s)", t.SrcStorageMp, t.SrcObjPath, t.DstStorageMp, t.DstDirPath) return fmt.Sprintf("move [%s](%s) to [%s](%s)", t.SrcStorageMp, t.SrcObjPath, t.DstStorageMp, t.DstDirPath)
} }
func (t *MoveTask) GetStatus() string { func (t *MoveTask) GetStatus() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Status return t.Status
} }
func (t *MoveTask) GetProgress() float64 {
t.mu.RLock()
defer t.mu.RUnlock()
if t.TotalFiles == 0 {
return 0
}
switch t.Phase {
case "copying":
return float64(t.CompletedFiles*60) / float64(t.TotalFiles)
case "verifying":
return 60 + float64(t.CompletedFiles*20)/float64(t.TotalFiles)
case "deleting":
return 80 + float64(t.CompletedFiles*20)/float64(t.TotalFiles)
case "completed":
return 100
default:
return 0
}
}
func (t *MoveTask) GetMoveProgress() *MoveProgress {
t.mu.RLock()
defer t.mu.RUnlock()
progress := int(t.GetProgress())
return &MoveProgress{
TaskID: t.GetID(),
Phase: t.Phase,
TotalFiles: t.TotalFiles,
CompletedFiles: t.CompletedFiles,
CurrentFile: t.SrcObjPath,
Status: t.Status,
Progress: progress,
}
}
func (t *MoveTask) updateProgress() {
if t.IsRootTask {
progress := t.GetMoveProgress()
moveProgressMap.Store(t.GetID(), progress)
}
}
func (t *MoveTask) Run() error { func (t *MoveTask) Run() error {
t.ReinitCtx() t.ReinitCtx()
t.ClearEndTime() t.ClearEndTime()
t.SetStartTime(time.Now()) t.SetStartTime(time.Now())
defer func() { t.SetEndTime(time.Now()) }() defer func() {
t.SetEndTime(time.Now())
if t.IsRootTask {
moveProgressMap.Delete(t.GetID())
}
}()
var err error var err error
if t.srcStorage == nil { if t.srcStorage == nil {
t.srcStorage, err = op.GetStorageByMountPath(t.SrcStorageMp) t.srcStorage, err = op.GetStorageByMountPath(t.SrcStorageMp)
@ -51,11 +127,337 @@ func (t *MoveTask) Run() error {
return errors.WithMessage(err, "failed get storage") return errors.WithMessage(err, "failed get storage")
} }
return moveBetween2Storages(t, t.srcStorage, t.dstStorage, t.SrcObjPath, t.DstDirPath) // Phase 1: Async validation (all validation happens in background)
t.mu.Lock()
t.Status = "validating source and destination"
t.mu.Unlock()
// Check if source exists
srcObj, err := op.Get(t.Ctx(), t.srcStorage, t.SrcObjPath)
if err != nil {
return errors.WithMessagef(err, "source file [%s] not found", stdpath.Base(t.SrcObjPath))
}
// Check if destination already exists (if validation is required)
if t.ValidateExistence {
dstFilePath := stdpath.Join(t.DstDirPath, srcObj.GetName())
if res, _ := op.Get(t.Ctx(), t.dstStorage, dstFilePath); res != nil {
return errors.Errorf("destination file [%s] already exists", srcObj.GetName())
}
}
// Phase 2: Execute move operation with proper sequencing
// Determine if we should use batch optimization for directories
if srcObj.IsDir() {
t.mu.Lock()
t.IsRootTask = true
t.RootTaskID = t.GetID()
t.mu.Unlock()
return t.runRootMoveTask()
}
// Use safe move logic for files
return t.safeMoveOperation(srcObj)
}
func (t *MoveTask) runRootMoveTask() error {
// First check if source is actually a directory
// If not, fall back to regular move logic
srcObj, err := op.Get(t.Ctx(), t.srcStorage, t.SrcObjPath)
if err != nil {
return errors.WithMessagef(err, "failed get src [%s] object", t.SrcObjPath)
}
if !srcObj.IsDir() {
// Source is not a directory, use regular move logic
t.mu.Lock()
t.IsRootTask = false
t.mu.Unlock()
return t.safeMoveOperation(srcObj)
}
// Phase 1: Count total files and create directory structure
t.mu.Lock()
t.Phase = "preparing"
t.Status = "counting files and preparing directory structure"
t.mu.Unlock()
t.updateProgress()
totalFiles, err := t.countFilesAndCreateDirs(t.srcStorage, t.dstStorage, t.SrcObjPath, t.DstDirPath)
if err != nil {
return errors.WithMessage(err, "failed to prepare directory structure")
}
t.mu.Lock()
t.TotalFiles = totalFiles
t.Phase = "copying"
t.Status = "copying files"
t.mu.Unlock()
t.updateProgress()
// Phase 2: Copy all files
err = t.copyAllFiles(t.srcStorage, t.dstStorage, t.SrcObjPath, t.DstDirPath)
if err != nil {
return errors.WithMessage(err, "failed to copy files")
}
// Phase 3: Verify directory structure
t.mu.Lock()
t.Phase = "verifying"
t.Status = "verifying copied files"
t.CompletedFiles = 0
t.mu.Unlock()
t.updateProgress()
err = t.verifyDirectoryStructure(t.srcStorage, t.dstStorage, t.SrcObjPath, t.DstDirPath)
if err != nil {
return errors.WithMessage(err, "verification failed")
}
// Phase 4: Delete source files and directories
t.mu.Lock()
t.Phase = "deleting"
t.Status = "deleting source files"
t.CompletedFiles = 0
t.mu.Unlock()
t.updateProgress()
err = t.deleteSourceRecursively(t.srcStorage, t.SrcObjPath)
if err != nil {
return errors.WithMessage(err, "failed to delete source files")
}
t.mu.Lock()
t.Phase = "completed"
t.Status = "completed"
t.mu.Unlock()
t.updateProgress()
return nil
} }
var MoveTaskManager *tache.Manager[*MoveTask] var MoveTaskManager *tache.Manager[*MoveTask]
// GetMoveProgress returns the progress of a move task by task ID
func GetMoveProgress(taskID string) (*MoveProgress, bool) {
if progress, ok := moveProgressMap.Load(taskID); ok {
return progress.(*MoveProgress), true
}
return nil, false
}
// GetMoveTaskProgress returns the progress of a specific move task
func GetMoveTaskProgress(task *MoveTask) *MoveProgress {
return task.GetMoveProgress()
}
// countFilesAndCreateDirs recursively counts files and creates directory structure
func (t *MoveTask) countFilesAndCreateDirs(srcStorage, dstStorage driver.Driver, srcPath, dstPath string) (int, error) {
srcObj, err := op.Get(t.Ctx(), srcStorage, srcPath)
if err != nil {
return 0, errors.WithMessagef(err, "failed get src [%s] object", srcPath)
}
if !srcObj.IsDir() {
return 1, nil
}
// Create destination directory
dstObjPath := stdpath.Join(dstPath, srcObj.GetName())
err = op.MakeDir(t.Ctx(), dstStorage, dstObjPath)
if err != nil {
if errors.Is(err, errs.UploadNotSupported) {
return 0, errors.WithMessagef(err, "destination storage [%s] does not support creating directories", dstStorage.GetStorage().MountPath)
}
return 0, errors.WithMessagef(err, "failed to create destination directory [%s] in storage [%s]", dstObjPath, dstStorage.GetStorage().MountPath)
}
// List and count files recursively
objs, err := op.List(t.Ctx(), srcStorage, srcPath, model.ListArgs{})
if err != nil {
return 0, errors.WithMessagef(err, "failed list src [%s] objs", srcPath)
}
totalFiles := 0
for _, obj := range objs {
if utils.IsCanceled(t.Ctx()) {
return 0, nil
}
srcSubPath := stdpath.Join(srcPath, obj.GetName())
subCount, err := t.countFilesAndCreateDirs(srcStorage, dstStorage, srcSubPath, dstObjPath)
if err != nil {
return 0, err
}
totalFiles += subCount
}
return totalFiles, nil
}
// copyAllFiles recursively copies all files
func (t *MoveTask) copyAllFiles(srcStorage, dstStorage driver.Driver, srcPath, dstPath string) error {
srcObj, err := op.Get(t.Ctx(), srcStorage, srcPath)
if err != nil {
return errors.WithMessagef(err, "failed get src [%s] object", srcPath)
}
if !srcObj.IsDir() {
// Copy single file
err := t.copyFile(srcStorage, dstStorage, srcPath, dstPath)
if err != nil {
return err
}
t.mu.Lock()
t.CompletedFiles++
t.mu.Unlock()
t.updateProgress()
return nil
}
// Copy directory contents
objs, err := op.List(t.Ctx(), srcStorage, srcPath, model.ListArgs{})
if err != nil {
return errors.WithMessagef(err, "failed list src [%s] objs", srcPath)
}
dstObjPath := stdpath.Join(dstPath, srcObj.GetName())
for _, obj := range objs {
if utils.IsCanceled(t.Ctx()) {
return nil
}
srcSubPath := stdpath.Join(srcPath, obj.GetName())
err := t.copyAllFiles(srcStorage, dstStorage, srcSubPath, dstObjPath)
if err != nil {
return err
}
}
return nil
}
// copyFile copies a single file between storages
func (t *MoveTask) copyFile(srcStorage, dstStorage driver.Driver, srcFilePath, dstDirPath string) error {
srcFile, err := op.Get(t.Ctx(), srcStorage, srcFilePath)
if err != nil {
return errors.WithMessagef(err, "failed get src [%s] file", srcFilePath)
}
link, _, err := op.Link(t.Ctx(), srcStorage, srcFilePath, model.LinkArgs{
Header: http.Header{},
})
if err != nil {
return errors.WithMessagef(err, "failed get [%s] link", srcFilePath)
}
fs := stream.FileStream{
Obj: srcFile,
Ctx: t.Ctx(),
}
ss, err := stream.NewSeekableStream(fs, link)
if err != nil {
return errors.WithMessagef(err, "failed get [%s] stream", srcFilePath)
}
return op.Put(t.Ctx(), dstStorage, dstDirPath, ss, nil, true)
}
// verifyDirectoryStructure compares source and destination directory structures
func (t *MoveTask) verifyDirectoryStructure(srcStorage, dstStorage driver.Driver, srcPath, dstPath string) error {
srcObj, err := op.Get(t.Ctx(), srcStorage, srcPath)
if err != nil {
return errors.WithMessagef(err, "failed get src [%s] object", srcPath)
}
if !srcObj.IsDir() {
// Verify single file
dstFilePath := stdpath.Join(dstPath, srcObj.GetName())
_, err := op.Get(t.Ctx(), dstStorage, dstFilePath)
if err != nil {
return errors.WithMessagef(err, "verification failed: destination file [%s] not found", dstFilePath)
}
t.mu.Lock()
t.CompletedFiles++
t.mu.Unlock()
t.updateProgress()
return nil
}
// Verify directory
dstObjPath := stdpath.Join(dstPath, srcObj.GetName())
_, err = op.Get(t.Ctx(), dstStorage, dstObjPath)
if err != nil {
return errors.WithMessagef(err, "verification failed: destination directory [%s] not found", dstObjPath)
}
// Verify directory contents
srcObjs, err := op.List(t.Ctx(), srcStorage, srcPath, model.ListArgs{})
if err != nil {
return errors.WithMessagef(err, "failed list src [%s] objs for verification", srcPath)
}
for _, obj := range srcObjs {
if utils.IsCanceled(t.Ctx()) {
return nil
}
srcSubPath := stdpath.Join(srcPath, obj.GetName())
err := t.verifyDirectoryStructure(srcStorage, dstStorage, srcSubPath, dstObjPath)
if err != nil {
return err
}
}
return nil
}
// deleteSourceRecursively deletes source files and directories recursively
func (t *MoveTask) deleteSourceRecursively(srcStorage driver.Driver, srcPath string) error {
srcObj, err := op.Get(t.Ctx(), srcStorage, srcPath)
if err != nil {
return errors.WithMessagef(err, "failed get src [%s] object for deletion", srcPath)
}
if !srcObj.IsDir() {
// Delete single file
err := op.Remove(t.Ctx(), srcStorage, srcPath)
if err != nil {
return errors.WithMessagef(err, "failed to delete src [%s] file", srcPath)
}
t.mu.Lock()
t.CompletedFiles++
t.mu.Unlock()
t.updateProgress()
return nil
}
// Delete directory contents first
objs, err := op.List(t.Ctx(), srcStorage, srcPath, model.ListArgs{})
if err != nil {
return errors.WithMessagef(err, "failed list src [%s] objs for deletion", srcPath)
}
for _, obj := range objs {
if utils.IsCanceled(t.Ctx()) {
return nil
}
srcSubPath := stdpath.Join(srcPath, obj.GetName())
err := t.deleteSourceRecursively(srcStorage, srcSubPath)
if err != nil {
return err
}
}
// Delete the directory itself
err = op.Remove(t.Ctx(), srcStorage, srcPath)
if err != nil {
return errors.WithMessagef(err, "failed to delete src [%s] directory", srcPath)
}
return nil
}
func moveBetween2Storages(t *MoveTask, srcStorage, dstStorage driver.Driver, srcObjPath, dstDirPath string) error { func moveBetween2Storages(t *MoveTask, srcStorage, dstStorage driver.Driver, srcObjPath, dstDirPath string) error {
t.Status = "getting src object" t.Status = "getting src object"
@ -156,7 +558,22 @@ func moveFileBetween2Storages(tsk *MoveTask, srcStorage, dstStorage driver.Drive
} }
// safeMoveOperation ensures copy-then-delete sequence for safe move operations
func (t *MoveTask) safeMoveOperation(srcObj model.Obj) error {
if srcObj.IsDir() {
// For directories, use the original logic but ensure proper sequencing
return moveBetween2Storages(t, t.srcStorage, t.dstStorage, t.SrcObjPath, t.DstDirPath)
} else {
// For files, use the safe file move logic
return moveFileBetween2Storages(t, t.srcStorage, t.dstStorage, t.SrcObjPath, t.DstDirPath)
}
}
func _move(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (task.TaskExtensionInfo, error) { func _move(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (task.TaskExtensionInfo, error) {
return _moveWithValidation(ctx, srcObjPath, dstDirPath, false, lazyCache...)
}
func _moveWithValidation(ctx context.Context, srcObjPath, dstDirPath string, validateExistence bool, lazyCache ...bool) (task.TaskExtensionInfo, error) {
srcStorage, srcObjActualPath, err := op.GetStorageAndActualPath(srcObjPath) srcStorage, srcObjActualPath, err := op.GetStorageAndActualPath(srcObjPath)
if err != nil { if err != nil {
return nil, errors.WithMessage(err, "failed get src storage") return nil, errors.WithMessage(err, "failed get src storage")
@ -166,6 +583,7 @@ func _move(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool
return nil, errors.WithMessage(err, "failed get dst storage") return nil, errors.WithMessage(err, "failed get dst storage")
} }
// Try native move first if in the same storage
if srcStorage.GetStorage() == dstStorage.GetStorage() { if srcStorage.GetStorage() == dstStorage.GetStorage() {
err = op.Move(ctx, srcStorage, srcObjActualPath, dstDirActualPath, lazyCache...) err = op.Move(ctx, srcStorage, srcObjActualPath, dstDirActualPath, lazyCache...)
if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) { if !errors.Is(err, errs.NotImplement) && !errors.Is(err, errs.NotSupport) {
@ -174,17 +592,23 @@ func _move(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool
} }
taskCreator, _ := ctx.Value("user").(*model.User) taskCreator, _ := ctx.Value("user").(*model.User)
// Create task immediately without any synchronous checks to avoid blocking frontend
// All validation and type checking will be done asynchronously in the Run method
t := &MoveTask{ t := &MoveTask{
TaskExtension: task.TaskExtension{ TaskExtension: task.TaskExtension{
Creator: taskCreator, Creator: taskCreator,
}, },
srcStorage: srcStorage, srcStorage: srcStorage,
dstStorage: dstStorage, dstStorage: dstStorage,
SrcObjPath: srcObjActualPath, SrcObjPath: srcObjActualPath,
DstDirPath: dstDirActualPath, DstDirPath: dstDirActualPath,
SrcStorageMp: srcStorage.GetStorage().MountPath, SrcStorageMp: srcStorage.GetStorage().MountPath,
DstStorageMp: dstStorage.GetStorage().MountPath, DstStorageMp: dstStorage.GetStorage().MountPath,
ValidateExistence: validateExistence,
Phase: "initializing",
} }
MoveTaskManager.Add(t) MoveTaskManager.Add(t)
return t, nil return t, nil
} }

View File

@ -7,5 +7,6 @@ import (
_ "github.com/OpenListTeam/OpenList/internal/offline_download/pikpak" _ "github.com/OpenListTeam/OpenList/internal/offline_download/pikpak"
_ "github.com/OpenListTeam/OpenList/internal/offline_download/qbit" _ "github.com/OpenListTeam/OpenList/internal/offline_download/qbit"
_ "github.com/OpenListTeam/OpenList/internal/offline_download/thunder" _ "github.com/OpenListTeam/OpenList/internal/offline_download/thunder"
_ "github.com/OpenListTeam/OpenList/internal/offline_download/thunder_browser"
_ "github.com/OpenListTeam/OpenList/internal/offline_download/transmission" _ "github.com/OpenListTeam/OpenList/internal/offline_download/transmission"
) )

View File

@ -0,0 +1,171 @@
package thunder_browser
import (
"context"
"errors"
"fmt"
"github.com/OpenListTeam/OpenList/drivers/thunder_browser"
"github.com/OpenListTeam/OpenList/internal/conf"
"github.com/OpenListTeam/OpenList/internal/setting"
"strconv"
"github.com/OpenListTeam/OpenList/internal/errs"
"github.com/OpenListTeam/OpenList/internal/model"
"github.com/OpenListTeam/OpenList/internal/offline_download/tool"
"github.com/OpenListTeam/OpenList/internal/op"
)
type ThunderBrowser struct {
refreshTaskCache bool
}
func (t *ThunderBrowser) Name() string {
return "ThunderBrowser"
}
func (t *ThunderBrowser) Items() []model.SettingItem {
return nil
}
func (t *ThunderBrowser) Run(task *tool.DownloadTask) error {
return errs.NotSupport
}
func (t *ThunderBrowser) Init() (string, error) {
t.refreshTaskCache = false
return "ok", nil
}
func (t *ThunderBrowser) IsReady() bool {
tempDir := setting.GetStr(conf.ThunderBrowserTempDir)
if tempDir == "" {
return false
}
storage, _, err := op.GetStorageAndActualPath(tempDir)
if err != nil {
return false
}
switch storage.(type) {
case *thunder_browser.ThunderBrowser, *thunder_browser.ThunderBrowserExpert:
return true
default:
return false
}
}
func (t *ThunderBrowser) AddURL(args *tool.AddUrlArgs) (string, error) {
// 添加新任务刷新缓存
t.refreshTaskCache = true
storage, actualPath, err := op.GetStorageAndActualPath(args.TempDir)
if err != nil {
return "", err
}
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
}
var task *thunder_browser.OfflineTask
switch v := storage.(type) {
case *thunder_browser.ThunderBrowser:
task, err = v.OfflineDownload(ctx, args.Url, parentDir, "")
case *thunder_browser.ThunderBrowserExpert:
task, err = v.OfflineDownload(ctx, args.Url, parentDir, "")
default:
return "", fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported")
}
if err != nil {
return "", fmt.Errorf("failed to add offline download task: %w", err)
}
if task == nil {
return "", fmt.Errorf("failed to add offline download task: task is nil")
}
return task.ID, nil
}
func (t *ThunderBrowser) Remove(task *tool.DownloadTask) error {
storage, _, err := op.GetStorageAndActualPath(task.TempDir)
if err != nil {
return err
}
ctx := context.Background()
switch v := storage.(type) {
case *thunder_browser.ThunderBrowser:
err = v.DeleteOfflineTasks(ctx, []string{task.GID})
case *thunder_browser.ThunderBrowserExpert:
err = v.DeleteOfflineTasks(ctx, []string{task.GID})
default:
return fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported")
}
if err != nil {
return err
}
return nil
}
func (t *ThunderBrowser) Status(task *tool.DownloadTask) (*tool.Status, error) {
storage, _, err := op.GetStorageAndActualPath(task.TempDir)
if err != nil {
return nil, err
}
var tasks []thunder_browser.OfflineTask
switch v := storage.(type) {
case *thunder_browser.ThunderBrowser:
tasks, err = t.GetTasks(v)
case *thunder_browser.ThunderBrowserExpert:
tasks, err = t.GetTasksExpert(v)
default:
return nil, fmt.Errorf("unsupported storage driver for offline download, only ThunderBrowser is supported")
}
if err != nil {
return nil, err
}
s := &tool.Status{
Progress: 0,
NewGID: "",
Completed: false,
Status: "the task has been deleted",
Err: nil,
}
for _, t := range tasks {
if t.ID == task.GID {
s.Progress = float64(t.Progress)
s.Status = t.Message
s.Completed = t.Phase == "PHASE_TYPE_COMPLETE"
s.TotalBytes, err = strconv.ParseInt(t.FileSize, 10, 64)
if err != nil {
s.TotalBytes = 0
}
if t.Phase == "PHASE_TYPE_ERROR" {
s.Err = errors.New(t.Message)
}
return s, nil
}
}
s.Err = fmt.Errorf("the task has been deleted")
return s, nil
}
func init() {
tool.Tools.Add(&ThunderBrowser{})
}

View File

@ -0,0 +1,70 @@
package thunder_browser
import (
"context"
"time"
"github.com/OpenListTeam/OpenList/drivers/thunder_browser"
"github.com/OpenListTeam/OpenList/internal/op"
"github.com/OpenListTeam/OpenList/pkg/singleflight"
"github.com/Xhofe/go-cache"
)
var taskCache = cache.NewMemCache(cache.WithShards[[]thunder_browser.OfflineTask](16))
var taskG singleflight.Group[[]thunder_browser.OfflineTask]
func (t *ThunderBrowser) GetTasks(thunderDriver *thunder_browser.ThunderBrowser) ([]thunder_browser.OfflineTask, error) {
key := op.Key(thunderDriver, "/drive/v1/task")
if !t.refreshTaskCache {
if tasks, ok := taskCache.Get(key); ok {
return tasks, nil
}
}
t.refreshTaskCache = false
tasks, err, _ := taskG.Do(key, func() ([]thunder_browser.OfflineTask, error) {
ctx := context.Background()
tasks, err := thunderDriver.OfflineList(ctx, "")
if err != nil {
return nil, err
}
// 添加缓存 10s
if len(tasks) > 0 {
taskCache.Set(key, tasks, cache.WithEx[[]thunder_browser.OfflineTask](time.Second*10))
} else {
taskCache.Del(key)
}
return tasks, nil
})
if err != nil {
return nil, err
}
return tasks, nil
}
func (t *ThunderBrowser) GetTasksExpert(thunderDriver *thunder_browser.ThunderBrowserExpert) ([]thunder_browser.OfflineTask, error) {
key := op.Key(thunderDriver, "/drive/v1/task")
if !t.refreshTaskCache {
if tasks, ok := taskCache.Get(key); ok {
return tasks, nil
}
}
t.refreshTaskCache = false
tasks, err, _ := taskG.Do(key, func() ([]thunder_browser.OfflineTask, error) {
ctx := context.Background()
tasks, err := thunderDriver.OfflineList(ctx, "")
if err != nil {
return nil, err
}
// 添加缓存 10s
if len(tasks) > 0 {
taskCache.Set(key, tasks, cache.WithEx[[]thunder_browser.OfflineTask](time.Second*10))
} else {
taskCache.Del(key)
}
return tasks, nil
})
if err != nil {
return nil, err
}
return tasks, nil
}

View File

@ -2,6 +2,7 @@ package tool
import ( import (
"context" "context"
"net/url" "net/url"
stdpath "path" stdpath "path"
"path/filepath" "path/filepath"
@ -9,6 +10,7 @@ import (
_115 "github.com/OpenListTeam/OpenList/drivers/115" _115 "github.com/OpenListTeam/OpenList/drivers/115"
"github.com/OpenListTeam/OpenList/drivers/pikpak" "github.com/OpenListTeam/OpenList/drivers/pikpak"
"github.com/OpenListTeam/OpenList/drivers/thunder" "github.com/OpenListTeam/OpenList/drivers/thunder"
"github.com/OpenListTeam/OpenList/drivers/thunder_browser"
"github.com/OpenListTeam/OpenList/internal/conf" "github.com/OpenListTeam/OpenList/internal/conf"
"github.com/OpenListTeam/OpenList/internal/errs" "github.com/OpenListTeam/OpenList/internal/errs"
"github.com/OpenListTeam/OpenList/internal/fs" "github.com/OpenListTeam/OpenList/internal/fs"
@ -103,6 +105,13 @@ func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskExtensionInfo, erro
} else { } else {
tempDir = filepath.Join(setting.GetStr(conf.ThunderTempDir), uid) tempDir = filepath.Join(setting.GetStr(conf.ThunderTempDir), uid)
} }
case "ThunderBrowser":
switch storage.(type) {
case *thunder_browser.ThunderBrowser, *thunder_browser.ThunderBrowserExpert:
tempDir = args.DstDirPath
default:
tempDir = filepath.Join(setting.GetStr(conf.ThunderBrowserTempDir), uid)
}
} }
taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed

View File

@ -87,6 +87,9 @@ outer:
if t.tool.Name() == "Thunder" { if t.tool.Name() == "Thunder" {
return nil return nil
} }
if t.tool.Name() == "ThunderBrowser" {
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)
@ -159,7 +162,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 == "PikPak" || toolName == "Thunder" { if toolName == "115 Cloud" || toolName == "PikPak" || toolName == "Thunder" || toolName == "ThunderBrowser" {
// 如果不是直接下载到目标路径,则进行转存 // 如果不是直接下载到目标路径,则进行转存
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)

View File

@ -88,17 +88,12 @@ func FsMove(c *gin.Context) {
common.ErrorResp(c, err, 403) common.ErrorResp(c, err, 403)
return return
} }
if !req.Overwrite {
for _, name := range req.Names { // Create all tasks immediately without any synchronous validation
if res, _ := fs.Get(c, stdpath.Join(dstDir, name), &fs.GetArgs{NoLog: true}); res != nil { // All validation will be done asynchronously in the background
common.ErrorStrResp(c, fmt.Sprintf("file [%s] exists", name), 403)
return
}
}
}
var addedTasks []task.TaskExtensionInfo var addedTasks []task.TaskExtensionInfo
for i, name := range req.Names { for i, name := range req.Names {
t, err := fs.MoveWithTask(c, stdpath.Join(srcDir, name), dstDir, len(req.Names) > i+1) t, err := fs.MoveWithTaskAndValidation(c, stdpath.Join(srcDir, name), dstDir, !req.Overwrite, len(req.Names) > i+1)
if t != nil { if t != nil {
addedTasks = append(addedTasks, t) addedTasks = append(addedTasks, t)
} }
@ -107,12 +102,17 @@ func FsMove(c *gin.Context) {
return return
} }
} }
// Return immediately with task information
if len(addedTasks) > 0 { if len(addedTasks) > 0 {
common.SuccessResp(c, gin.H{ common.SuccessResp(c, gin.H{
"message": fmt.Sprintf("Successfully created %d move task(s)", len(addedTasks)),
"tasks": getTaskInfos(addedTasks), "tasks": getTaskInfos(addedTasks),
}) })
} else { } else {
common.SuccessResp(c) common.SuccessResp(c, gin.H{
"message": "Move operations completed immediately",
})
} }
} }
@ -141,14 +141,9 @@ func FsCopy(c *gin.Context) {
common.ErrorResp(c, err, 403) common.ErrorResp(c, err, 403)
return return
} }
if !req.Overwrite {
for _, name := range req.Names { // Create all tasks immediately without any synchronous validation
if res, _ := fs.Get(c, stdpath.Join(dstDir, name), &fs.GetArgs{NoLog: true}); res != nil { // All validation will be done asynchronously in the background
common.ErrorStrResp(c, fmt.Sprintf("file [%s] exists", name), 403)
return
}
}
}
var addedTasks []task.TaskExtensionInfo var addedTasks []task.TaskExtensionInfo
for i, name := range req.Names { for i, name := range req.Names {
t, err := fs.Copy(c, stdpath.Join(srcDir, name), dstDir, len(req.Names) > i+1) t, err := fs.Copy(c, stdpath.Join(srcDir, name), dstDir, len(req.Names) > i+1)
@ -160,9 +155,18 @@ func FsCopy(c *gin.Context) {
return return
} }
} }
common.SuccessResp(c, gin.H{
"tasks": getTaskInfos(addedTasks), // Return immediately with task information
}) if len(addedTasks) > 0 {
common.SuccessResp(c, gin.H{
"message": fmt.Sprintf("Successfully created %d copy task(s)", len(addedTasks)),
"tasks": getTaskInfos(addedTasks),
})
} else {
common.SuccessResp(c, gin.H{
"message": "Copy operations completed immediately",
})
}
} }
type RenameReq struct { type RenameReq struct {

View File

@ -4,6 +4,7 @@ import (
_115 "github.com/OpenListTeam/OpenList/drivers/115" _115 "github.com/OpenListTeam/OpenList/drivers/115"
"github.com/OpenListTeam/OpenList/drivers/pikpak" "github.com/OpenListTeam/OpenList/drivers/pikpak"
"github.com/OpenListTeam/OpenList/drivers/thunder" "github.com/OpenListTeam/OpenList/drivers/thunder"
"github.com/OpenListTeam/OpenList/drivers/thunder_browser"
"github.com/OpenListTeam/OpenList/internal/conf" "github.com/OpenListTeam/OpenList/internal/conf"
"github.com/OpenListTeam/OpenList/internal/model" "github.com/OpenListTeam/OpenList/internal/model"
"github.com/OpenListTeam/OpenList/internal/offline_download/tool" "github.com/OpenListTeam/OpenList/internal/offline_download/tool"
@ -239,6 +240,51 @@ func SetThunder(c *gin.Context) {
common.SuccessResp(c, "ok") common.SuccessResp(c, "ok")
} }
type SetThunderBrowserReq struct {
TempDir string `json:"temp_dir" form:"temp_dir"`
}
func SetThunderBrowser(c *gin.Context) {
var req SetThunderBrowserReq
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
}
switch storage.(type) {
case *thunder_browser.ThunderBrowser, *thunder_browser.ThunderBrowserExpert:
default:
common.ErrorStrResp(c, "unsupported storage driver for offline download, only ThunderBrowser is supported", 400)
}
}
items := []model.SettingItem{
{Key: conf.ThunderBrowserTempDir, 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("ThunderBrowser")
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)

View File

@ -147,6 +147,7 @@ func admin(g *gin.RouterGroup) {
setting.POST("/set_115", handles.Set115) setting.POST("/set_115", handles.Set115)
setting.POST("/set_pikpak", handles.SetPikPak) setting.POST("/set_pikpak", handles.SetPikPak)
setting.POST("/set_thunder", handles.SetThunder) setting.POST("/set_thunder", handles.SetThunder)
setting.POST("/set_thunder_browser", handles.SetThunderBrowser)
// 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"))

View File

@ -15,7 +15,7 @@ type SiteConfig struct {
func getSiteConfig() SiteConfig { func getSiteConfig() SiteConfig {
siteConfig := SiteConfig{ siteConfig := SiteConfig{
BasePath: conf.URL.Path, BasePath: conf.URL.Path,
Cdn: strings.ReplaceAll(strings.TrimSuffix(conf.Conf.Cdn, "/"), "$version", conf.WebVersion), Cdn: strings.ReplaceAll(strings.TrimSuffix(conf.Conf.Cdn, "/"), "$version", strings.TrimPrefix(conf.WebVersion, "v"),),
} }
if siteConfig.BasePath != "" { if siteConfig.BasePath != "" {
siteConfig.BasePath = utils.FixAndCleanPath(siteConfig.BasePath) siteConfig.BasePath = utils.FixAndCleanPath(siteConfig.BasePath)