diff --git a/drivers/all.go b/drivers/all.go
index b976f92f..1f015ef7 100644
--- a/drivers/all.go
+++ b/drivers/all.go
@@ -41,6 +41,7 @@ import (
_ "github.com/alist-org/alist/v3/drivers/pikpak"
_ "github.com/alist-org/alist/v3/drivers/pikpak_share"
_ "github.com/alist-org/alist/v3/drivers/quark_uc"
+ _ "github.com/alist-org/alist/v3/drivers/quark_uc_tv"
_ "github.com/alist-org/alist/v3/drivers/quqi"
_ "github.com/alist-org/alist/v3/drivers/s3"
_ "github.com/alist-org/alist/v3/drivers/seafile"
diff --git a/drivers/quark_uc_tv/driver.go b/drivers/quark_uc_tv/driver.go
new file mode 100644
index 00000000..ff7ccf20
--- /dev/null
+++ b/drivers/quark_uc_tv/driver.go
@@ -0,0 +1,174 @@
+package quark_uc_tv
+
+import (
+ "context"
+ "fmt"
+ "github.com/alist-org/alist/v3/internal/op"
+ "github.com/alist-org/alist/v3/pkg/utils"
+ "github.com/go-resty/resty/v2"
+ "strconv"
+ "time"
+
+ "github.com/alist-org/alist/v3/internal/driver"
+ "github.com/alist-org/alist/v3/internal/errs"
+ "github.com/alist-org/alist/v3/internal/model"
+)
+
+type QuarkUCTV struct {
+ *QuarkUCTVCommon
+ model.Storage
+ Addition
+ config driver.Config
+ conf Conf
+}
+
+func (d *QuarkUCTV) Config() driver.Config {
+ return d.config
+}
+
+func (d *QuarkUCTV) GetAddition() driver.Additional {
+ return &d.Addition
+}
+
+func (d *QuarkUCTV) Init(ctx context.Context) error {
+
+ if d.Addition.DeviceID == "" {
+ d.Addition.DeviceID = utils.GetMD5EncodeStr(time.Now().String())
+ }
+ op.MustSaveDriverStorage(d)
+
+ if d.QuarkUCTVCommon == nil {
+ d.QuarkUCTVCommon = &QuarkUCTVCommon{
+ AccessToken: "",
+ }
+ }
+ ctx1, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
+ defer cancelFunc()
+ if d.Addition.RefreshToken == "" {
+ if d.Addition.QueryToken == "" {
+ qrData, err := d.getLoginCode(ctx1)
+ if err != nil {
+ return err
+ }
+ // 展示二维码
+ qrTemplate := `
+
+ `
+ qrPage := fmt.Sprintf(qrTemplate, qrData)
+ return fmt.Errorf("need verify: \n%s", qrPage)
+ } else {
+ // 通过query token获取code -> refresh token
+ code, err := d.getCode(ctx1)
+ if err != nil {
+ return err
+ }
+ // 通过code获取refresh token
+ err = d.getRefreshTokenByTV(ctx1, code, false)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ // 通过refresh token获取access token
+ if d.QuarkUCTVCommon.AccessToken == "" {
+ err := d.getRefreshTokenByTV(ctx1, d.Addition.RefreshToken, true)
+ if err != nil {
+ return err
+ }
+ }
+
+ // 验证 access token 是否有效
+ _, err := d.isLogin(ctx1)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (d *QuarkUCTV) Drop(ctx context.Context) error {
+ return nil
+}
+
+func (d *QuarkUCTV) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
+ files := make([]model.Obj, 0)
+ pageIndex := int64(0)
+ pageSize := int64(100)
+ for {
+ var filesData FilesData
+ _, err := d.request(ctx, "/file", "GET", func(req *resty.Request) {
+ req.SetQueryParams(map[string]string{
+ "method": "list",
+ "parent_fid": dir.GetID(),
+ "order_by": "3",
+ "desc": "1",
+ "category": "",
+ "source": "",
+ "ex_source": "",
+ "list_all": "0",
+ "page_size": strconv.FormatInt(pageSize, 10),
+ "page_index": strconv.FormatInt(pageIndex, 10),
+ })
+ }, &filesData)
+ if err != nil {
+ return nil, err
+ }
+ for i := range filesData.Data.Files {
+ files = append(files, &filesData.Data.Files[i])
+ }
+ if pageIndex*pageSize >= filesData.Data.TotalCount {
+ break
+ } else {
+ pageIndex++
+ }
+ }
+ return files, nil
+}
+
+func (d *QuarkUCTV) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
+ files := &model.Link{}
+ var fileLink FileLink
+ _, err := d.request(ctx, "/file", "GET", func(req *resty.Request) {
+ req.SetQueryParams(map[string]string{
+ "method": "download",
+ "group_by": "source",
+ "fid": file.GetID(),
+ "resolution": "low,normal,high,super,2k,4k",
+ "support": "dolby_vision",
+ })
+ }, &fileLink)
+ if err != nil {
+ return nil, err
+ }
+ files.URL = fileLink.Data.DownloadURL
+ return files, nil
+}
+
+func (d *QuarkUCTV) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+ return nil, errs.NotImplement
+}
+
+func (d *QuarkUCTV) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
+ return nil, errs.NotImplement
+}
+
+func (d *QuarkUCTV) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
+ return nil, errs.NotImplement
+}
+
+func (d *QuarkUCTV) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
+ return nil, errs.NotImplement
+}
+
+func (d *QuarkUCTV) Remove(ctx context.Context, obj model.Obj) error {
+ return errs.NotImplement
+}
+
+func (d *QuarkUCTV) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
+ return nil, errs.NotImplement
+}
+
+type QuarkUCTVCommon struct {
+ AccessToken string
+}
+
+var _ driver.Driver = (*QuarkUCTV)(nil)
diff --git a/drivers/quark_uc_tv/meta.go b/drivers/quark_uc_tv/meta.go
new file mode 100644
index 00000000..cf7e4785
--- /dev/null
+++ b/drivers/quark_uc_tv/meta.go
@@ -0,0 +1,67 @@
+package quark_uc_tv
+
+import (
+ "github.com/alist-org/alist/v3/internal/driver"
+ "github.com/alist-org/alist/v3/internal/op"
+)
+
+type Addition struct {
+ // Usually one of two
+ driver.RootID
+ // define other
+ RefreshToken string `json:"refresh_token" required:"false" default:""`
+ // 必要且影响登录,由签名决定
+ DeviceID string `json:"device_id" required:"false" default:""`
+ // 登陆所用的数据 无需手动填写
+ QueryToken string `json:"query_token" required:"false" default:"" help:"don't edit'"`
+}
+
+type Conf struct {
+ api string
+ clientID string
+ signKey string
+ appVer string
+ channel string
+ codeApi string
+}
+
+func init() {
+ op.RegisterDriver(func() driver.Driver {
+ return &QuarkUCTV{
+ config: driver.Config{
+ Name: "QuarkTV",
+ OnlyLocal: false,
+ DefaultRoot: "0",
+ NoOverwriteUpload: true,
+ NoUpload: true,
+ },
+ conf: Conf{
+ api: "https://open-api-drive.quark.cn",
+ clientID: "d3194e61504e493eb6222857bccfed94",
+ signKey: "kw2dvtd7p4t3pjl2d9ed9yc8yej8kw2d",
+ appVer: "1.5.6",
+ channel: "CP",
+ codeApi: "http://api.extscreen.com/quarkdrive",
+ },
+ }
+ })
+ op.RegisterDriver(func() driver.Driver {
+ return &QuarkUCTV{
+ config: driver.Config{
+ Name: "UCTV",
+ OnlyLocal: false,
+ DefaultRoot: "0",
+ NoOverwriteUpload: true,
+ NoUpload: true,
+ },
+ conf: Conf{
+ api: "https://open-api-drive.uc.cn",
+ clientID: "5acf882d27b74502b7040b0c65519aa7",
+ signKey: "l3srvtd7p42l0d0x1u8d7yc8ye9kki4d",
+ appVer: "1.6.5",
+ channel: "UCTVOFFICIALWEB",
+ codeApi: "http://api.extscreen.com/ucdrive",
+ },
+ }
+ })
+}
diff --git a/drivers/quark_uc_tv/types.go b/drivers/quark_uc_tv/types.go
new file mode 100644
index 00000000..fb35b8b2
--- /dev/null
+++ b/drivers/quark_uc_tv/types.go
@@ -0,0 +1,102 @@
+package quark_uc_tv
+
+import (
+ "github.com/alist-org/alist/v3/internal/model"
+ "github.com/alist-org/alist/v3/pkg/utils"
+ "time"
+)
+
+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 RefreshTokenAuthResp struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data struct {
+ Status int `json:"status"`
+ Errno int `json:"errno"`
+ ErrorInfo string `json:"error_info"`
+ ReqID string `json:"req_id"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int `json:"expires_in"`
+ Scope string `json:"scope"`
+ } `json:"data"`
+}
+type Files struct {
+ Fid string `json:"fid"`
+ ParentFid string `json:"parent_fid"`
+ Category int `json:"category"`
+ Filename string `json:"filename"`
+ Size int64 `json:"size"`
+ FileType string `json:"file_type"`
+ SubItems int `json:"sub_items,omitempty"`
+ Isdir int `json:"isdir"`
+ Duration int `json:"duration"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ IsBackup int `json:"is_backup"`
+ ThumbnailURL string `json:"thumbnail_url,omitempty"`
+}
+
+func (f *Files) GetSize() int64 {
+ return f.Size
+}
+
+func (f *Files) GetName() string {
+ return f.Filename
+}
+
+func (f *Files) ModTime() time.Time {
+ //return time.Unix(f.UpdatedAt, 0)
+ return time.Unix(0, f.UpdatedAt*int64(time.Millisecond))
+}
+
+func (f *Files) CreateTime() time.Time {
+ //return time.Unix(f.CreatedAt, 0)
+ return time.Unix(0, f.CreatedAt*int64(time.Millisecond))
+}
+
+func (f *Files) IsDir() bool {
+ return f.Isdir == 1
+}
+
+func (f *Files) GetHash() utils.HashInfo {
+ return utils.HashInfo{}
+}
+
+func (f *Files) GetID() string {
+ return f.Fid
+}
+
+func (f *Files) GetPath() string {
+ return ""
+}
+
+var _ model.Obj = (*Files)(nil)
+
+type FilesData struct {
+ CommonRsp
+ Data struct {
+ TotalCount int64 `json:"total_count"`
+ Files []Files `json:"files"`
+ } `json:"data"`
+}
+
+type FileLink struct {
+ CommonRsp
+ Data struct {
+ Fid string `json:"fid"`
+ FileName string `json:"file_name"`
+ Size int64 `json:"size"`
+ DownloadURL string `json:"download_url"`
+ } `json:"data"`
+}
diff --git a/drivers/quark_uc_tv/util.go b/drivers/quark_uc_tv/util.go
new file mode 100644
index 00000000..fefbb036
--- /dev/null
+++ b/drivers/quark_uc_tv/util.go
@@ -0,0 +1,211 @@
+package quark_uc_tv
+
+import (
+ "context"
+ "crypto/md5"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "github.com/alist-org/alist/v3/drivers/base"
+ "github.com/alist-org/alist/v3/internal/op"
+ "github.com/alist-org/alist/v3/pkg/utils"
+ "github.com/go-resty/resty/v2"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+const (
+ UserAgent = "Mozilla/5.0 (Linux; U; Android 13; zh-cn; M2004J7AC Build/UKQ1.231108.001) AppleWebKit/533.1 (KHTML, like Gecko) Mobile Safari/533.1"
+ DeviceBrand = "Xiaomi"
+ Platform = "tv"
+ DeviceName = "M2004J7AC"
+ DeviceModel = "M2004J7AC"
+ BuildDevice = "M2004J7AC"
+ BuildProduct = "M2004J7AC"
+ DeviceGpu = "Adreno (TM) 550"
+ ActivityRect = "{}"
+)
+
+func (d *QuarkUCTV) 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.conf.signKey)
+ req := base.RestyClient.R()
+ req.SetContext(ctx)
+ req.SetHeaders(map[string]string{
+ "Accept": "application/json, text/plain, */*",
+ "User-Agent": UserAgent,
+ "x-pan-tm": tm,
+ "x-pan-token": token,
+ "x-pan-client-id": d.conf.clientID,
+ })
+ req.SetQueryParams(map[string]string{
+ "req_id": reqID,
+ "access_token": d.QuarkUCTVCommon.AccessToken,
+ "app_ver": d.conf.appVer,
+ "device_id": d.Addition.DeviceID,
+ "device_brand": DeviceBrand,
+ "platform": Platform,
+ "device_name": DeviceName,
+ "device_model": DeviceModel,
+ "build_device": BuildDevice,
+ "build_product": BuildProduct,
+ "device_gpu": DeviceGpu,
+ "activity_rect": ActivityRect,
+ "channel": d.conf.channel,
+ })
+ 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 == 10001 {
+ // token 过期
+ err = d.getRefreshTokenByTV(ctx, d.Addition.RefreshToken, true)
+ 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 *QuarkUCTV) getLoginCode(ctx context.Context) (string, error) {
+ // 获取登录二维码
+ pathname := "/oauth/authorize"
+ var resp struct {
+ CommonRsp
+ QrData string `json:"qr_data"`
+ QueryToken string `json:"query_token"`
+ }
+ _, err := d.request(ctx, pathname, "GET", func(req *resty.Request) {
+ req.SetQueryParams(map[string]string{
+ "auth_type": "code",
+ "client_id": d.conf.clientID,
+ "scope": "netdisk",
+ "qrcode": "1",
+ "qr_width": "460",
+ "qr_height": "460",
+ })
+ }, &resp)
+ if err != nil {
+ return "", err
+ }
+ // 保存query_token 用于后续登录
+ if resp.QueryToken != "" {
+ d.Addition.QueryToken = resp.QueryToken
+ op.MustSaveDriverStorage(d)
+ }
+ return resp.QrData, nil
+}
+
+func (d *QuarkUCTV) getCode(ctx context.Context) (string, error) {
+ // 通过query token获取code
+ pathname := "/oauth/code"
+ var resp struct {
+ CommonRsp
+ Code string `json:"code"`
+ }
+ _, err := d.request(ctx, pathname, "GET", func(req *resty.Request) {
+ req.SetQueryParams(map[string]string{
+ "client_id": d.conf.clientID,
+ "scope": "netdisk",
+ "query_token": d.Addition.QueryToken,
+ })
+ }, &resp)
+ if err != nil {
+ return "", err
+ }
+ return resp.Code, nil
+}
+
+func (d *QuarkUCTV) getRefreshTokenByTV(ctx context.Context, code string, isRefresh bool) error {
+ pathname := "/token"
+ _, _, reqID := d.generateReqSign("POST", pathname, d.conf.signKey)
+ u := d.conf.codeApi + pathname
+ var resp RefreshTokenAuthResp
+ body := map[string]string{
+ "req_id": reqID,
+ "app_ver": d.conf.appVer,
+ "device_id": d.Addition.DeviceID,
+ "device_brand": DeviceBrand,
+ "platform": Platform,
+ "device_name": DeviceName,
+ "device_model": DeviceModel,
+ "build_device": BuildDevice,
+ "build_product": BuildProduct,
+ "device_gpu": DeviceGpu,
+ "activity_rect": ActivityRect,
+ "channel": d.conf.channel,
+ }
+ if isRefresh {
+ body["refresh_token"] = code
+ } else {
+ body["code"] = code
+ }
+
+ _, err := base.RestyClient.R().
+ SetHeader("Content-Type", "application/json").
+ SetBody(body).
+ SetResult(&resp).
+ SetContext(ctx).
+ Post(u)
+ if err != nil {
+ return err
+ }
+ if resp.Code != 200 {
+ return errors.New(resp.Message)
+ }
+ if resp.Data.RefreshToken != "" {
+ d.Addition.RefreshToken = resp.Data.RefreshToken
+ op.MustSaveDriverStorage(d)
+ d.QuarkUCTVCommon.AccessToken = resp.Data.AccessToken
+ } else {
+ return errors.New("refresh token is empty")
+ }
+ return nil
+}
+
+func (d *QuarkUCTV) isLogin(ctx context.Context) (bool, error) {
+ _, err := d.request(ctx, "/user", http.MethodGet, func(req *resty.Request) {
+ req.SetQueryParams(map[string]string{
+ "method": "user_info",
+ })
+ }, nil)
+ return err == nil, err
+}
+
+func (d *QuarkUCTV) generateReqSign(method string, pathname string, key string) (string, string, string) {
+ //timestamp 13位时间戳
+ timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
+ deviceID := d.Addition.DeviceID
+ if deviceID == "" {
+ deviceID = utils.GetMD5EncodeStr(timestamp)
+ d.Addition.DeviceID = deviceID
+ op.MustSaveDriverStorage(d)
+ }
+ // 生成req_id
+ reqID := md5.Sum([]byte(deviceID + timestamp))
+ reqIDHex := hex.EncodeToString(reqID[:])
+
+ // 生成x_pan_token
+ tokenData := method + "&" + pathname + "&" + timestamp + "&" + key
+ xPanToken := sha256.Sum256([]byte(tokenData))
+ xPanTokenHex := hex.EncodeToString(xPanToken[:])
+
+ return timestamp, xPanTokenHex, reqIDHex
+}