Repository: AlistGo/alist (language: go)

Issue:
COS无法预览文本文件
### Please make sure of the following things

- [X] I have read the [documentation](https://alist.nn.ci).
我已经阅读了[文档](https://alist.nn.ci)。

- [X] I'm sure there are no duplicate issues or discussions.
我确定没有重复的issue或讨论。

- [X] I'm sure it's due to `AList` and not something else(such as [Network](https://alist.nn.ci/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`).
我确定是`AList`的问题，而不是其他原因（例如[网络](https://alist.nn.ci/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)，`依赖`或`操作`）。

- [X] I'm sure this issue is not fixed in the latest version.
我确定这个问题在最新版本中没有被修复。


### AList Version / AList 版本

v3.28.0

### Driver used / 使用的存储驱动

对象存储

### Describe the bug / 问题描述

腾讯云COS，无法直接预览文本文件，报错SignatureDoesNotMatch，但可以正常下载，且同一个存储桶下的图片预览正常。


### Reproduction / 复现链接

https://drive.wozsun.com

### Config / 配置

{
  "force": false,
  "site_url": "",
  "cdn": "",
  "jwt_secret": "",
  "token_expires_in": 2160,
  "database": {
    "type": "mysql",
    "host": "localhost",
    "port": 3306,
    "user": "alist",
    "password": "",
    "name": "alist",
    "db_file": "",
    "table_prefix": "a_",
    "ssl_mode": ""
  },
  "scheme": {
    "address": "127.0.0.1",
    "http_port": -1,
    "https_port": -1,
    "force_https": false,
    "cert_file": "/etc/nginx/cert/ucert.pem",
    "key_file": "/etc/nginx/cert/ukey.pem",
    "unix_file": "/opt/alist/alist.sock",
    "unix_file_perm": "766"
  },
  "temp_dir": "data/temp",
  "bleve_dir": "data/bleve",
  "log": {
    "enable": true,
    "name": "data/log.log",
    "max_size": 10,
    "max_backups": 5,
    "max_age": 28,
    "compress": false
  },
  "delayed_start": 0,
  "max_connections": 30,
  "tls_insecure_skip_verify": false
}

### Logs / 日志

[2023-10-26 16:49:28] http request [https://backup-1316235332.cos.ap-guangzhou.myqcloud.com/VPS/Nginx/nginx.conf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIDMbnvsdBUUIsDI9oJXYZSBk6INtGPN7FM%2F20231026%2Falist%2Fs3%2Faws4_request&X-Amz-Date=20231026T084928Z&X-Amz-Expires=14400&X-Amz-SignedHeaders=host&response-content-disposition=attachment%3B%20filename%2A%3DUTF-8%27%27nginx.conf&X-Amz-Signature=dd6c4a2fd9d20faeef97313ecf784c5d44dedb2cf0a29805c86c1c95884b8f74&ts=1698310168483] failure,status: 403 response:<?xml version='1.0' encoding='utf-8' ?>
<Error>
	<Code>SignatureDoesNotMatch</Code>
	<Message>The Signature you specified is invalid.</Message>
	<StringToSign>AWS4-HMAC-SHA256
20231026T084928Z
20231026/alist/s3/aws4_request
806791650c2f467ee725bbf5d068d8ee544f557a9a98e808de46b9cb4145a19d</StringToSign>
	<CononicalRequest>GET
/VPS/Nginx/nginx.conf
X-Amz-Algorithm=AWS4-HMAC-SHA256&amp;X-Amz-Credential=AKIDMbnvsdBUUIsDI9oJXYZSBk6INtGPN7FM%2F20231026%2Falist%2Fs3%2Faws4_request&amp;X-Amz-Date=20231026T084928Z&amp;X-Amz-Expires=14400&amp;X-Amz-SignedHeaders=host&amp;response-content-disposition=attachment%3B%20filename%2A%3DUTF-8%27%27nginx.conf&amp;ts=1698310168483
host:backup-1316235332.cos.ap-guangzhou.myqcloud.com

host
UNSIGNED-PAYLOAD</CononicalRequest>
	<Resource>/VPS/Nginx/nginx.conf</Resource>
	<RequestId>NjUzYTI4MThfNDgzNjQwMGJfODY4Ml9hMTIwN2Ew</RequestId>
	<TraceId>OGVmYzZiMmQzYjA2OWNhODk0NTRkMTBiOWVmMDAxODc0OWRkZjk0ZDM1NmI1M2E2MTRlY2MzZDhmNmI5MWI1OTQyYWVlY2QwZTk2MDVmZDQ3MmI2Y2I4ZmI5ZmM4ODFjNzNlZDg0ZTBmYTRkOGJiOWI5N2Y3OWQ2ZTc2ZDYwNGY=</TraceId>
</Error>


Discussion / hints:

Fix patch:
diff --git a/cmd/root.go b/cmd/root.go
index 297eb7f8940..6bd82b7a4a3 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,6 +5,8 @@ import (
 	"os"
 
 	"github.com/alist-org/alist/v3/cmd/flags"
+	_ "github.com/alist-org/alist/v3/drivers"
+	_ "github.com/alist-org/alist/v3/internal/offline_download"
 	"github.com/spf13/cobra"
 )
 
diff --git a/cmd/server.go b/cmd/server.go
index 94a60c7208f..0678e3e1188 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -13,7 +13,6 @@ import (
 	"time"
 
 	"github.com/alist-org/alist/v3/cmd/flags"
-	_ "github.com/alist-org/alist/v3/drivers"
 	"github.com/alist-org/alist/v3/internal/bootstrap"
 	"github.com/alist-org/alist/v3/internal/conf"
 	"github.com/alist-org/alist/v3/pkg/utils"
@@ -35,8 +34,7 @@ the address is defined in config file`,
 			utils.Log.Infof("delayed start for %d seconds", conf.Conf.DelayedStart)
 			time.Sleep(time.Duration(conf.Conf.DelayedStart) * time.Second)
 		}
-		bootstrap.InitAria2()
-		bootstrap.InitQbittorrent()
+		bootstrap.InitOfflineDownloadTools()
 		bootstrap.LoadStorages()
 		if !flags.Debug && !flags.Dev {
 			gin.SetMode(gin.ReleaseMode)
diff --git a/drivers/123/upload.go b/drivers/123/upload.go
index ae28d6aa519..6f6221f1148 100644
--- a/drivers/123/upload.go
+++ b/drivers/123/upload.go
@@ -107,7 +107,7 @@ func (d *Pan123) newUpload(ctx context.Context, upReq *UploadResp, file model.Fi
 			if err != nil {
 				return err
 			}
-			up(j * 100 / chunkCount)
+			up(float64(j) * 100 / float64(chunkCount))
 		}
 	}
 	// complete s3 upload
diff --git a/drivers/189/util.go b/drivers/189/util.go
index 680ce252133..0b4c0633d7b 100644
--- a/drivers/189/util.go
+++ b/drivers/189/util.go
@@ -380,7 +380,7 @@ func (d *Cloud189) newUpload(ctx context.Context, dstDir model.Obj, file model.F
 		if err != nil {
 			return err
 		}
-		up(int(i * 100 / count))
+		up(float64(i) * 100 / float64(count))
 	}
 	fileMd5 := hex.EncodeToString(md5Sum.Sum(nil))
 	sliceMd5 := fileMd5
diff --git a/drivers/189pc/utils.go b/drivers/189pc/utils.go
index 1868aeb25ff..5e403a830e4 100644
--- a/drivers/189pc/utils.go
+++ b/drivers/189pc/utils.go
@@ -513,7 +513,7 @@ func (y *Cloud189PC) StreamUpload(ctx context.Context, dstDir model.Obj, file mo
 			if err != nil {
 				return err
 			}
-			up(int(threadG.Success()) * 100 / count)
+			up(float64(threadG.Success()) * 100 / float64(count))
 			return nil
 		})
 	}
@@ -676,7 +676,7 @@ func (y *Cloud189PC) FastUpload(ctx context.Context, dstDir model.Obj, file mode
 					return err
 				}
 
-				up(int(threadG.Success()) * 100 / len(uploadUrls))
+				up(float64(threadG.Success()) * 100 / float64(len(uploadUrls)))
 				uploadProgress.UploadParts[i] = ""
 				return nil
 			})
@@ -812,7 +812,7 @@ func (y *Cloud189PC) OldUpload(ctx context.Context, dstDir model.Obj, file model
 		if _, err := tempFile.Seek(status.GetSize(), io.SeekStart); err != nil {
 			return nil, err
 		}
-		up(int(status.GetSize()/file.GetSize()) * 100)
+		up(float64(status.GetSize()) / float64(file.GetSize()) * 100)
 	}
 
 	return y.OldUploadCommit(ctx, status.FileCommitUrl, status.UploadFileId)
diff --git a/drivers/aliyundrive/driver.go b/drivers/aliyundrive/driver.go
index f11452629a4..83c3f522452 100644
--- a/drivers/aliyundrive/driver.go
+++ b/drivers/aliyundrive/driver.go
@@ -7,7 +7,6 @@ import (
 	"encoding/base64"
 	"encoding/hex"
 	"fmt"
-	"github.com/alist-org/alist/v3/internal/stream"
 	"io"
 	"math"
 	"math/big"
@@ -15,6 +14,8 @@ import (
 	"os"
 	"time"
 
+	"github.com/alist-org/alist/v3/internal/stream"
+
 	"github.com/alist-org/alist/v3/drivers/base"
 	"github.com/alist-org/alist/v3/internal/conf"
 	"github.com/alist-org/alist/v3/internal/driver"
@@ -304,7 +305,7 @@ func (d *AliDrive) Put(ctx context.Context, dstDir model.Obj, streamer model.Fil
 		}
 		res.Body.Close()
 		if count > 0 {
-			up(i * 100 / count)
+			up(float64(i) * 100 / float64(count))
 		}
 	}
 	var resp2 base.Json
diff --git a/drivers/aliyundrive_open/upload.go b/drivers/aliyundrive_open/upload.go
index 73c37dcfbde..3b224e7d225 100644
--- a/drivers/aliyundrive_open/upload.go
+++ b/drivers/aliyundrive_open/upload.go
@@ -258,7 +258,7 @@ func (d *AliyundriveOpen) upload(ctx context.Context, dstDir model.Obj, stream m
 				return nil, err
 			}
 			offset += partSize
-			up(i * 100 / count)
+			up(float64(i*100) / float64(count))
 		}
 	} else {
 		log.Debugf("[aliyundrive_open] rapid upload success, file id: %s", createResp.FileId)
diff --git a/drivers/baidu_netdisk/driver.go b/drivers/baidu_netdisk/driver.go
index d5f71814d0f..20810a768de 100644
--- a/drivers/baidu_netdisk/driver.go
+++ b/drivers/baidu_netdisk/driver.go
@@ -278,7 +278,7 @@ func (d *BaiduNetdisk) Put(ctx context.Context, dstDir model.Obj, stream model.F
 			if err != nil {
 				return err
 			}
-			up(int(threadG.Success()) * 100 / len(precreateResp.BlockList))
+			up(float64(threadG.Success()) * 100 / float64(len(precreateResp.BlockList)))
 			precreateResp.BlockList[i] = -1
 			return nil
 		})
diff --git a/drivers/baidu_photo/driver.go b/drivers/baidu_photo/driver.go
index 9105260d94d..c29bc110095 100644
--- a/drivers/baidu_photo/driver.go
+++ b/drivers/baidu_photo/driver.go
@@ -329,7 +329,7 @@ func (d *BaiduPhoto) Put(ctx context.Context, dstDir model.Obj, stream model.Fil
 				if err != nil {
 					return err
 				}
-				up(int(threadG.Success()) * 100 / len(precreateResp.BlockList))
+				up(float64(threadG.Success()) * 100 / float64(len(precreateResp.BlockList)))
 				precreateResp.BlockList[i] = -1
 				return nil
 			})
diff --git a/drivers/cloudreve/driver.go b/drivers/cloudreve/driver.go
index 2a22380e314..49c2d5f00f2 100644
--- a/drivers/cloudreve/driver.go
+++ b/drivers/cloudreve/driver.go
@@ -53,6 +53,14 @@ func (d *Cloudreve) List(ctx context.Context, dir model.Obj, args model.ListArgs
 		if err != nil {
 			return nil, err
 		}
+		if src.Type == "dir" && d.EnableThumbAndFolderSize {
+			var dprop DirectoryProp
+			err = d.request(http.MethodGet, "/object/property/"+src.Id+"?is_folder=true", nil, &dprop)
+			if err != nil {
+				return nil, err
+			}
+			src.Size = dprop.Size
+		}
 		return objectToObj(src, thumb), nil
 	})
 }
diff --git a/drivers/cloudreve/meta.go b/drivers/cloudreve/meta.go
index d01d54791d3..92c0b9fb1d7 100644
--- a/drivers/cloudreve/meta.go
+++ b/drivers/cloudreve/meta.go
@@ -9,11 +9,12 @@ type Addition struct {
 	// Usually one of two
 	driver.RootPath
 	// define other
-	Address  string `json:"address" required:"true"`
-	Username string `json:"username"`
-	Password string `json:"password"`
-	Cookie   string `json:"cookie"`
-	CustomUA string `json:"custom_ua"`
+	Address                  string `json:"address" required:"true"`
+	Username                 string `json:"username"`
+	Password                 string `json:"password"`
+	Cookie                   string `json:"cookie"`
+	CustomUA                 string `json:"custom_ua"`
+	EnableThumbAndFolderSize bool   `json:"enable_thumb_and_folder_size"`
 }
 
 var config = driver.Config{
diff --git a/drivers/cloudreve/types.go b/drivers/cloudreve/types.go
index e25673829a8..241d993ebb8 100644
--- a/drivers/cloudreve/types.go
+++ b/drivers/cloudreve/types.go
@@ -44,6 +44,10 @@ type Object struct {
 	SourceEnabled bool      `json:"source_enabled"`
 }
 
+type DirectoryProp struct {
+	Size int `json:"size"`
+}
+
 func objectToObj(f Object, t model.Thumbnail) *model.ObjThumb {
 	return &model.ObjThumb{
 		Object: model.Object{
diff --git a/drivers/cloudreve/util.go b/drivers/cloudreve/util.go
index ed8794667c1..284e3289dee 100644
--- a/drivers/cloudreve/util.go
+++ b/drivers/cloudreve/util.go
@@ -151,6 +151,9 @@ func convertSrc(obj model.Obj) map[string]interface{} {
 }
 
 func (d *Cloudreve) GetThumb(file Object) (model.Thumbnail, error) {
+	if !d.Addition.EnableThumbAndFolderSize {
+		return model.Thumbnail{}, nil
+	}
 	ua := d.CustomUA
 	if ua == "" {
 		ua = base.UserAgent
diff --git a/drivers/dropbox/driver.go b/drivers/dropbox/driver.go
index 7559d645858..95148b94e96 100644
--- a/drivers/dropbox/driver.go
+++ b/drivers/dropbox/driver.go
@@ -203,7 +203,7 @@ func (d *Dropbox) Put(ctx context.Context, dstDir model.Obj, stream model.FileSt
 		_ = res.Body.Close()
 
 		if count > 0 {
-			up((i + 1) * 100 / count)
+			up(float64(i+1) * 100 / float64(count))
 		}
 
 		offset += byteSize
diff --git a/drivers/mega/driver.go b/drivers/mega/driver.go
index c1ae9f7f6c9..9fa1d0eeafa 100644
--- a/drivers/mega/driver.go
+++ b/drivers/mega/driver.go
@@ -4,11 +4,12 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"github.com/alist-org/alist/v3/pkg/http_range"
-	"github.com/rclone/rclone/lib/readers"
 	"io"
 	"time"
 
+	"github.com/alist-org/alist/v3/pkg/http_range"
+	"github.com/rclone/rclone/lib/readers"
+
 	"github.com/alist-org/alist/v3/internal/driver"
 	"github.com/alist-org/alist/v3/internal/errs"
 	"github.com/alist-org/alist/v3/internal/model"
@@ -169,7 +170,7 @@ func (d *Mega) Put(ctx context.Context, dstDir model.Obj, stream model.FileStrea
 			if err != nil {
 				return err
 			}
-			up(id * 100 / u.Chunks())
+			up(float64(id) * 100 / float64(u.Chunks()))
 		}
 
 		_, err = u.Finish()
diff --git a/drivers/mopan/driver.go b/drivers/mopan/driver.go
index bd2de2b30af..78ec0423cc3 100644
--- a/drivers/mopan/driver.go
+++ b/drivers/mopan/driver.go
@@ -308,7 +308,7 @@ func (d *MoPan) Put(ctx context.Context, dstDir model.Obj, stream model.FileStre
 				if resp.StatusCode != http.StatusOK {
 					return fmt.Errorf("upload err,code=%d", resp.StatusCode)
 				}
-				up(100 * int(threadG.Success()) / len(parts))
+				up(100 * float64(threadG.Success()) / float64(len(parts)))
 				initUpdload.PartInfos[i] = ""
 				return nil
 			})
diff --git a/drivers/onedrive/util.go b/drivers/onedrive/util.go
index 0539e098682..a0c6fa8fcbf 100644
--- a/drivers/onedrive/util.go
+++ b/drivers/onedrive/util.go
@@ -203,7 +203,7 @@ func (d *Onedrive) upBig(ctx context.Context, dstDir model.Obj, stream model.Fil
 			return errors.New(string(data))
 		}
 		res.Body.Close()
-		up(int(finish * 100 / stream.GetSize()))
+		up(float64(finish) * 100 / float64(stream.GetSize()))
 	}
 	return nil
 }
diff --git a/drivers/onedrive_app/util.go b/drivers/onedrive_app/util.go
index 525a451dd7e..28b34837806 100644
--- a/drivers/onedrive_app/util.go
+++ b/drivers/onedrive_app/util.go
@@ -194,7 +194,7 @@ func (d *OnedriveAPP) upBig(ctx context.Context, dstDir model.Obj, stream model.
 			return errors.New(string(data))
 		}
 		res.Body.Close()
-		up(int(finish * 100 / stream.GetSize()))
+		up(float64(finish) * 100 / float64(stream.GetSize()))
 	}
 	return nil
 }
diff --git a/drivers/quark_uc/driver.go b/drivers/quark_uc/driver.go
index 7c254022a92..291189ce088 100644
--- a/drivers/quark_uc/driver.go
+++ b/drivers/quark_uc/driver.go
@@ -209,7 +209,7 @@ func (d *QuarkOrUC) Put(ctx context.Context, dstDir model.Obj, stream model.File
 		}
 		md5s = append(md5s, m)
 		partNumber++
-		up(int(100 * (total - left) / total))
+		up(100 * float64(total-left) / float64(total))
 	}
 	err = d.upCommit(pre, md5s)
 	if err != nil {
diff --git a/drivers/s3/driver.go b/drivers/s3/driver.go
index dd643f5d76e..c8099ee43dd 100644
--- a/drivers/s3/driver.go
+++ b/drivers/s3/driver.go
@@ -4,13 +4,14 @@ import (
 	"bytes"
 	"context"
 	"fmt"
-	"github.com/alist-org/alist/v3/internal/stream"
 	"io"
 	"net/url"
 	stdpath "path"
 	"strings"
 	"time"
 
+	"github.com/alist-org/alist/v3/internal/stream"
+
 	"github.com/alist-org/alist/v3/internal/driver"
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/aws/aws-sdk-go/aws/session"
@@ -104,7 +105,7 @@ func (d *S3) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) e
 		},
 		Reader:   io.NopCloser(bytes.NewReader([]byte{})),
 		Mimetype: "application/octet-stream",
-	}, func(int) {})
+	}, func(float64) {})
 }
 
 func (d *S3) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
diff --git a/drivers/teambition/util.go b/drivers/teambition/util.go
index 04f222de95f..c39ffb18286 100644
--- a/drivers/teambition/util.go
+++ b/drivers/teambition/util.go
@@ -189,7 +189,7 @@ func (d *Teambition) chunkUpload(ctx context.Context, file model.FileStreamer, t
 		if err != nil {
 			return nil, err
 		}
-		up(i * 100 / newChunk.Chunks)
+		up(float64(i) * 100 / float64(newChunk.Chunks))
 	}
 	_, err = base.RestyClient.R().SetHeader("Authorization", token).Post(
 		fmt.Sprintf("https://%s.teambition.net/upload/chunk/%s",
diff --git a/drivers/terabox/driver.go b/drivers/terabox/driver.go
index b5287f5a7f8..c9662fce03a 100644
--- a/drivers/terabox/driver.go
+++ b/drivers/terabox/driver.go
@@ -213,7 +213,7 @@ func (d *Terabox) Put(ctx context.Context, dstDir model.Obj, stream model.FileSt
 		}
 		log.Debugln(res.String())
 		if len(precreateResp.BlockList) > 0 {
-			up(i * 100 / len(precreateResp.BlockList))
+			up(float64(i) * 100 / float64(len(precreateResp.BlockList)))
 		}
 	}
 	_, err = d.create(rawPath, stream.GetSize(), 0, precreateResp.Uploadid, block_list_str)
diff --git a/drivers/trainbit/driver.go b/drivers/trainbit/driver.go
index 63bd0627f63..795b2fb8a2e 100644
--- a/drivers/trainbit/driver.go
+++ b/drivers/trainbit/driver.go
@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
-	"math"
 	"net/http"
 	"net/url"
 	"strings"
@@ -128,7 +127,7 @@ func (d *Trainbit) Put(ctx context.Context, dstDir model.Obj, stream model.FileS
 		stream,
 		func(byteNum int) {
 			total += int64(byteNum)
-			up(int(math.Round(float64(total) / float64(stream.GetSize()) * 100)))
+			up(float64(total) / float64(stream.GetSize()) * 100)
 		},
 	}
 	req, err := http.NewRequest(http.MethodPost, endpoint.String(), progressReader)
diff --git a/drivers/wopan/driver.go b/drivers/wopan/driver.go
index a3f222e8ef3..e5e26c94a08 100644
--- a/drivers/wopan/driver.go
+++ b/drivers/wopan/driver.go
@@ -159,7 +159,7 @@ func (d *Wopan) Put(ctx context.Context, dstDir model.Obj, stream model.FileStre
 		ContentType: stream.GetMimetype(),
 	}, dstDir.GetID(), d.FamilyID, wopan.Upload2COption{
 		OnProgress: func(current, total int64) {
-			up(int(100 * current / total))
+			up(100 * float64(current) / float64(total))
 		},
 	})
 	return err
diff --git a/go.mod b/go.mod
index f185aef9b1e..5a6ed9b2390 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
 	github.com/caarlos0/env/v9 v9.0.0
 	github.com/charmbracelet/bubbles v0.16.1
 	github.com/charmbracelet/bubbletea v0.24.2
-	github.com/charmbracelet/lipgloss v0.7.1
+	github.com/charmbracelet/lipgloss v0.9.1
 	github.com/coreos/go-oidc v2.2.1+incompatible
 	github.com/deckarep/golang-set/v2 v2.3.1
 	github.com/disintegration/imaging v1.6.2
@@ -48,10 +48,11 @@ require (
 	github.com/upyun/go-sdk/v3 v3.0.4
 	github.com/winfsp/cgofuse v1.5.1-0.20230130140708-f87f5db493b5
 	golang.org/x/crypto v0.14.0
-	golang.org/x/exp v0.0.0-20230905200255-921286631fa9
+	golang.org/x/exp v0.0.0-20231006140011-7918f672742d
 	golang.org/x/image v0.11.0
-	golang.org/x/net v0.16.0
+	golang.org/x/net v0.17.0
 	golang.org/x/oauth2 v0.12.0
+	golang.org/x/time v0.3.0
 	gorm.io/driver/mysql v1.4.7
 	gorm.io/driver/postgres v1.4.8
 	gorm.io/driver/sqlite v1.4.4
@@ -137,7 +138,7 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.19 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
-	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/mattn/go-sqlite3 v1.14.15 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect
@@ -150,7 +151,7 @@ require (
 	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
-	github.com/muesli/termenv v0.15.1 // indirect
+	github.com/muesli/termenv v0.15.2 // indirect
 	github.com/multiformats/go-base32 v0.1.0 // indirect
 	github.com/multiformats/go-base36 v0.2.0 // indirect
 	github.com/multiformats/go-multiaddr v0.9.0 // indirect
@@ -189,7 +190,6 @@ require (
 	golang.org/x/sys v0.13.0 // indirect
 	golang.org/x/term v0.13.0 // indirect
 	golang.org/x/text v0.13.0 // indirect
-	golang.org/x/time v0.3.0 // indirect
 	google.golang.org/api v0.134.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5 // indirect
diff --git a/go.sum b/go.sum
index 0fb9a131f25..940eed1d6ed 100644
--- a/go.sum
+++ b/go.sum
@@ -117,6 +117,8 @@ github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06
 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
+github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
+github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
 github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764=
 github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
 github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
@@ -320,6 +322,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
 github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
@@ -349,6 +353,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
 github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
 github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
@@ -520,6 +526,8 @@ golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq
 golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
 golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
 golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M=
 golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
@@ -546,6 +554,8 @@ golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
 golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
 golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
 golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
diff --git a/internal/aria2/monitor.go b/internal/aria2/monitor.go
index 77265b372b1..aaef3fd7c70 100644
--- a/internal/aria2/monitor.go
+++ b/internal/aria2/monitor.go
@@ -2,7 +2,6 @@ package aria2
 
 import (
 	"fmt"
-	"github.com/alist-org/alist/v3/internal/stream"
 	"os"
 	"path"
 	"path/filepath"
@@ -11,6 +10,8 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/alist-org/alist/v3/internal/stream"
+
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/alist-org/alist/v3/pkg/task"
@@ -100,7 +101,7 @@ func (m *Monitor) Update() (bool, error) {
 		downloaded = 0
 	}
 	progress := float64(downloaded) / float64(total) * 100
-	m.tsk.SetProgress(int(progress))
+	m.tsk.SetProgress(progress)
 	switch info.Status {
 	case "complete":
 		err := m.Complete()
diff --git a/internal/bootstrap/aria2.go b/internal/bootstrap/aria2.go
deleted file mode 100644
index 60017ddaa6f..00000000000
--- a/internal/bootstrap/aria2.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package bootstrap
-
-import (
-	"github.com/alist-org/alist/v3/internal/aria2"
-	"github.com/alist-org/alist/v3/pkg/utils"
-)
-
-func InitAria2() {
-	go func() {
-		_, err := aria2.InitClient(2)
-		if err != nil {
-			//utils.Log.Errorf("failed to init aria2 client: %+v", err)
-			utils.Log.Infof("Aria2 not ready.")
-		}
-	}()
-}
diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go
index ca17b6b148f..21a432ddebd 100644
--- a/internal/bootstrap/data/setting.go
+++ b/internal/bootstrap/data/setting.go
@@ -4,6 +4,7 @@ import (
 	"github.com/alist-org/alist/v3/cmd/flags"
 	"github.com/alist-org/alist/v3/internal/conf"
 	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/alist-org/alist/v3/pkg/utils"
 	"github.com/alist-org/alist/v3/pkg/utils/random"
@@ -139,12 +140,9 @@ func InitialSettings() []model.SettingItem {
 		{Key: conf.OcrApi, Value: "https://api.nn.ci/ocr/file/json", Type: conf.TypeString, Group: model.GLOBAL},
 		{Key: conf.FilenameCharMapping, Value: `{"/": "|"}`, Type: conf.TypeText, Group: model.GLOBAL},
 		{Key: conf.ForwardDirectLinkParams, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL},
+		{Key: conf.IgnoreDirectLinkParams, Value: "sign,alist_ts", Type: conf.TypeString, Group: model.GLOBAL},
 		{Key: conf.WebauthnLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.GLOBAL, Flag: model.PUBLIC},
 
-		// aria2 settings
-		{Key: conf.Aria2Uri, Value: "http://localhost:6800/jsonrpc", Type: conf.TypeString, Group: model.ARIA2, Flag: model.PRIVATE},
-		{Key: conf.Aria2Secret, Value: "", Type: conf.TypeString, Group: model.ARIA2, Flag: model.PRIVATE},
-
 		// single settings
 		{Key: conf.Token, Value: token, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},
 		{Key: conf.SearchIndex, Value: "none", Type: conf.TypeSelect, Options: "database,database_non_full_text,bleve,none", Group: model.INDEX},
@@ -167,11 +165,8 @@ func InitialSettings() []model.SettingItem {
 		{Key: conf.SSODefaultDir, Value: "/", Type: conf.TypeString, Group: model.SSO, Flag: model.PRIVATE},
 		{Key: conf.SSODefaultPermission, Value: "0", Type: conf.TypeNumber, Group: model.SSO, Flag: model.PRIVATE},
 		{Key: conf.SSOCompatibilityMode, Value: "false", Type: conf.TypeBool, Group: model.SSO, Flag: model.PUBLIC},
-
-		// qbittorrent settings
-		{Key: conf.QbittorrentUrl, Value: "http://admin:adminadmin@localhost:8080/", Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},
-		{Key: conf.QbittorrentSeedtime, Value: "0", Type: conf.TypeNumber, Group: model.SINGLE, Flag: model.PRIVATE},
 	}
+	initialSettingItems = append(initialSettingItems, tool.Tools.Items()...)
 	if flags.Dev {
 		initialSettingItems = append(initialSettingItems, []model.SettingItem{
 			{Key: "test_deprecated", Value: "test_value", Type: conf.TypeString, Flag: model.DEPRECATED},
diff --git a/internal/bootstrap/offline_download.go b/internal/bootstrap/offline_download.go
new file mode 100644
index 00000000000..26e04071b10
--- /dev/null
+++ b/internal/bootstrap/offline_download.go
@@ -0,0 +1,17 @@
+package bootstrap
+
+import (
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
+	"github.com/alist-org/alist/v3/pkg/utils"
+)
+
+func InitOfflineDownloadTools() {
+	for k, v := range tool.Tools {
+		res, err := v.Init()
+		if err != nil {
+			utils.Log.Warnf("init tool %s failed: %s", k, err)
+		} else {
+			utils.Log.Infof("init tool %s success: %s", k, res)
+		}
+	}
+}
diff --git a/internal/bootstrap/qbittorrent.go b/internal/bootstrap/qbittorrent.go
deleted file mode 100644
index 315977ebe3f..00000000000
--- a/internal/bootstrap/qbittorrent.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package bootstrap
-
-import (
-	"github.com/alist-org/alist/v3/internal/qbittorrent"
-	"github.com/alist-org/alist/v3/pkg/utils"
-)
-
-func InitQbittorrent() {
-	go func() {
-		err := qbittorrent.InitClient()
-		if err != nil {
-			utils.Log.Infof("qbittorrent not ready.")
-		}
-	}()
-}
diff --git a/internal/conf/const.go b/internal/conf/const.go
index 02a00060a68..eb70602ad3f 100644
--- a/internal/conf/const.go
+++ b/internal/conf/const.go
@@ -41,6 +41,7 @@ const (
 	OcrApi                  = "ocr_api"
 	FilenameCharMapping     = "filename_char_mapping"
 	ForwardDirectLinkParams = "forward_direct_link_params"
+	IgnoreDirectLinkParams  = "ignore_direct_link_params"
 	WebauthnLoginEnabled    = "webauthn_login_enabled"
 
 	// index
diff --git a/internal/driver/driver.go b/internal/driver/driver.go
index e0a7c93d908..781e85325ee 100644
--- a/internal/driver/driver.go
+++ b/internal/driver/driver.go
@@ -109,7 +109,7 @@ type PutResult interface {
 	Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up UpdateProgress) (model.Obj, error)
 }
 
-type UpdateProgress func(percentage int)
+type UpdateProgress func(percentage float64)
 
 type Progress struct {
 	Total int64
@@ -120,7 +120,7 @@ type Progress struct {
 func (p *Progress) Write(b []byte) (n int, err error) {
 	n = len(b)
 	p.Done += int64(n)
-	p.up(int(float64(p.Done) / float64(p.Total) * 100))
+	p.up(float64(p.Done) / float64(p.Total) * 100)
 	return
 }
 
diff --git a/internal/model/setting.go b/internal/model/setting.go
index f4202ee022c..3b2c30f1361 100644
--- a/internal/model/setting.go
+++ b/internal/model/setting.go
@@ -6,7 +6,7 @@ const (
 	STYLE
 	PREVIEW
 	GLOBAL
-	ARIA2
+	OFFLINE_DOWNLOAD
 	INDEX
 	SSO
 )
diff --git a/internal/model/user.go b/internal/model/user.go
index d7b2863cebe..46fe9bd301d 100644
--- a/internal/model/user.go
+++ b/internal/model/user.go
@@ -32,7 +32,7 @@ type User struct {
 	// Determine permissions by bit
 	//   0: can see hidden files
 	//   1: can access without password
-	//   2: can add aria2 tasks
+	//   2: can add offline download tasks
 	//   3: can mkdir and upload
 	//   4: can rename
 	//   5: can move
@@ -40,7 +40,6 @@ type User struct {
 	//   7: can remove
 	//   8: webdav read
 	//   9: webdav write
-	//  10: can add qbittorrent tasks
 	Permission int32  `json:"permission"`
 	OtpSecret  string `json:"-"`
 	SsoID      string `json:"sso_id"` // unique by sso platform
@@ -83,7 +82,7 @@ func (u *User) CanAccessWithoutPassword() bool {
 	return u.IsAdmin() || (u.Permission>>1)&1 == 1
 }
 
-func (u *User) CanAddAria2Tasks() bool {
+func (u *User) CanAddOfflineDownloadTasks() bool {
 	return u.IsAdmin() || (u.Permission>>2)&1 == 1
 }
 
@@ -115,10 +114,6 @@ func (u *User) CanWebdavManage() bool {
 	return u.IsAdmin() || (u.Permission>>9)&1 == 1
 }
 
-func (u *User) CanAddQbittorrentTasks() bool {
-	return u.IsAdmin() || (u.Permission>>10)&1 == 1
-}
-
 func (u *User) JoinPath(reqPath string) (string, error) {
 	return utils.JoinBasePath(u.BasePath, reqPath)
 }
diff --git a/internal/offline_download/all.go b/internal/offline_download/all.go
new file mode 100644
index 00000000000..0c7853cb13f
--- /dev/null
+++ b/internal/offline_download/all.go
@@ -0,0 +1,6 @@
+package offline_download
+
+import (
+	_ "github.com/alist-org/alist/v3/internal/offline_download/aria2"
+	_ "github.com/alist-org/alist/v3/internal/offline_download/qbit"
+)
diff --git a/internal/offline_download/aria2/aria2.go b/internal/offline_download/aria2/aria2.go
new file mode 100644
index 00000000000..f2b9628c240
--- /dev/null
+++ b/internal/offline_download/aria2/aria2.go
@@ -0,0 +1,133 @@
+package aria2
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"time"
+
+	"github.com/alist-org/alist/v3/internal/conf"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
+	"github.com/alist-org/alist/v3/internal/setting"
+	"github.com/alist-org/alist/v3/pkg/aria2/rpc"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+)
+
+var notify = NewNotify()
+
+type Aria2 struct {
+	client rpc.Client
+}
+
+func (a *Aria2) Items() []model.SettingItem {
+	// aria2 settings
+	return []model.SettingItem{
+		{Key: conf.Aria2Uri, Value: "http://localhost:6800/jsonrpc", Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+		{Key: conf.Aria2Secret, Value: "", Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+	}
+}
+
+func (a *Aria2) Init() (string, error) {
+	a.client = nil
+	uri := setting.GetStr(conf.Aria2Uri)
+	secret := setting.GetStr(conf.Aria2Secret)
+	c, err := rpc.New(context.Background(), uri, secret, 4*time.Second, notify)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to init aria2 client")
+	}
+	version, err := c.GetVersion()
+	if err != nil {
+		return "", errors.Wrapf(err, "failed get aria2 version")
+	}
+	a.client = c
+	log.Infof("using aria2 version: %s", version.Version)
+	return fmt.Sprintf("aria2 version: %s", version.Version), nil
+}
+
+func (a *Aria2) IsReady() bool {
+	return a.client != nil
+}
+
+func (a *Aria2) AddURL(args *tool.AddUrlArgs) (string, error) {
+	options := map[string]interface{}{
+		"dir": args.TempDir,
+	}
+	gid, err := a.client.AddURI([]string{args.Url}, options)
+	if err != nil {
+		return "", err
+	}
+	return gid, nil
+}
+
+func (a *Aria2) Remove(tid string) error {
+	_, err := a.client.Remove(tid)
+	return err
+}
+
+func (a *Aria2) Status(tid string) (*tool.Status, error) {
+	info, err := a.client.TellStatus(tid)
+	if err != nil {
+		return nil, err
+	}
+	total, err := strconv.ParseUint(info.TotalLength, 10, 64)
+	if err != nil {
+		total = 0
+	}
+	downloaded, err := strconv.ParseUint(info.CompletedLength, 10, 64)
+	if err != nil {
+		downloaded = 0
+	}
+	s := &tool.Status{
+		Completed: info.Status == "complete",
+		Err:       err,
+	}
+	s.Progress = float64(downloaded) / float64(total) * 100
+	if len(info.FollowedBy) != 0 {
+		s.NewTID = info.FollowedBy[0]
+		notify.Signals.Delete(tid)
+		//notify.Signals.Store(gid, m.c)
+	}
+	switch info.Status {
+	case "complete":
+		s.Completed = true
+	case "error":
+		s.Err = errors.Errorf("failed to download %s, error: %s", tid, info.ErrorMessage)
+	case "active":
+		s.Status = "aria2: " + info.Status
+		if info.Seeder == "true" {
+			s.Completed = true
+		}
+	case "waiting", "paused":
+		s.Status = "aria2: " + info.Status
+	case "removed":
+		s.Err = errors.Errorf("failed to download %s, removed", tid)
+	default:
+		return nil, errors.Errorf("[aria2] unknown status %s", info.Status)
+	}
+	return s, nil
+}
+
+func (a *Aria2) GetFiles(tid string) []tool.File {
+	//files, err := a.client.GetFiles(tid)
+	//if err != nil {
+	//	return nil
+	//}
+	//return utils.MustSliceConvert(files, func(f rpc.FileInfo) tool.File {
+	//	return tool.File{
+	//		//ReadCloser: nil,
+	//		Name:     path.Base(f.Path),
+	//		Size:     f.Length,
+	//		Path:     "",
+	//		Modified: time.Time{},
+	//	}
+	//})
+	return nil
+}
+
+var _ tool.Tool = (*Aria2)(nil)
+
+func init() {
+	tool.Tools.Add("aria2", &Aria2{})
+}
diff --git a/internal/offline_download/aria2/notify.go b/internal/offline_download/aria2/notify.go
new file mode 100644
index 00000000000..056fe5147b4
--- /dev/null
+++ b/internal/offline_download/aria2/notify.go
@@ -0,0 +1,70 @@
+package aria2
+
+import (
+	"github.com/alist-org/alist/v3/pkg/aria2/rpc"
+	"github.com/alist-org/alist/v3/pkg/generic_sync"
+)
+
+const (
+	Downloading = iota
+	Paused
+	Stopped
+	Completed
+	Errored
+)
+
+type Notify struct {
+	Signals generic_sync.MapOf[string, chan int]
+}
+
+func NewNotify() *Notify {
+	return &Notify{Signals: generic_sync.MapOf[string, chan int]{}}
+}
+
+func (n *Notify) OnDownloadStart(events []rpc.Event) {
+	for _, e := range events {
+		if signal, ok := n.Signals.Load(e.Gid); ok {
+			signal <- Downloading
+		}
+	}
+}
+
+func (n *Notify) OnDownloadPause(events []rpc.Event) {
+	for _, e := range events {
+		if signal, ok := n.Signals.Load(e.Gid); ok {
+			signal <- Paused
+		}
+	}
+}
+
+func (n *Notify) OnDownloadStop(events []rpc.Event) {
+	for _, e := range events {
+		if signal, ok := n.Signals.Load(e.Gid); ok {
+			signal <- Stopped
+		}
+	}
+}
+
+func (n *Notify) OnDownloadComplete(events []rpc.Event) {
+	for _, e := range events {
+		if signal, ok := n.Signals.Load(e.Gid); ok {
+			signal <- Completed
+		}
+	}
+}
+
+func (n *Notify) OnDownloadError(events []rpc.Event) {
+	for _, e := range events {
+		if signal, ok := n.Signals.Load(e.Gid); ok {
+			signal <- Errored
+		}
+	}
+}
+
+func (n *Notify) OnBtDownloadComplete(events []rpc.Event) {
+	for _, e := range events {
+		if signal, ok := n.Signals.Load(e.Gid); ok {
+			signal <- Completed
+		}
+	}
+}
diff --git a/internal/offline_download/qbit/qbit.go b/internal/offline_download/qbit/qbit.go
new file mode 100644
index 00000000000..594088f0eb2
--- /dev/null
+++ b/internal/offline_download/qbit/qbit.go
@@ -0,0 +1,80 @@
+package qbit
+
+import (
+	"github.com/alist-org/alist/v3/internal/conf"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
+	"github.com/alist-org/alist/v3/internal/qbittorrent"
+	"github.com/alist-org/alist/v3/internal/setting"
+	"github.com/pkg/errors"
+)
+
+type QBittorrent struct {
+	client qbittorrent.Client
+}
+
+func (a *QBittorrent) Items() []model.SettingItem {
+	// qBittorrent settings
+	return []model.SettingItem{
+		{Key: conf.QbittorrentUrl, Value: "http://admin:adminadmin@localhost:8080/", Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+		{Key: conf.QbittorrentSeedtime, Value: "0", Type: conf.TypeNumber, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+	}
+}
+
+func (a *QBittorrent) Init() (string, error) {
+	a.client = nil
+	url := setting.GetStr(conf.QbittorrentUrl)
+	qbClient, err := qbittorrent.New(url)
+	if err != nil {
+		return "", err
+	}
+	a.client = qbClient
+	return "ok", nil
+}
+
+func (a *QBittorrent) IsReady() bool {
+	return a.client != nil
+}
+
+func (a *QBittorrent) AddURL(args *tool.AddUrlArgs) (string, error) {
+	err := a.client.AddFromLink(args.Url, args.TempDir, args.UID)
+	if err != nil {
+		return "", err
+	}
+	return args.UID, nil
+}
+
+func (a *QBittorrent) Remove(tid string) error {
+	err := a.client.Delete(tid, true)
+	return err
+}
+
+func (a *QBittorrent) Status(tid string) (*tool.Status, error) {
+	info, err := a.client.GetInfo(tid)
+	if err != nil {
+		return nil, err
+	}
+	s := &tool.Status{}
+	s.Progress = float64(info.Completed) / float64(info.Size) * 100
+	switch info.State {
+	case qbittorrent.UPLOADING, qbittorrent.PAUSEDUP, qbittorrent.QUEUEDUP, qbittorrent.STALLEDUP, qbittorrent.FORCEDUP, qbittorrent.CHECKINGUP:
+		s.Completed = true
+	case qbittorrent.ALLOCATING, qbittorrent.DOWNLOADING, qbittorrent.METADL, qbittorrent.PAUSEDDL, qbittorrent.QUEUEDDL, qbittorrent.STALLEDDL, qbittorrent.CHECKINGDL, qbittorrent.FORCEDDL, qbittorrent.CHECKINGRESUMEDATA, qbittorrent.MOVING:
+		s.Status = "[qBittorrent] downloading"
+	case qbittorrent.ERROR, qbittorrent.MISSINGFILES, qbittorrent.UNKNOWN:
+		s.Err = errors.Errorf("[qBittorrent] failed to download %s, error: %s", tid, info.State)
+	default:
+		s.Err = errors.Errorf("[qBittorrent] unknown error occurred downloading %s", tid)
+	}
+	return s, nil
+}
+
+func (a *QBittorrent) GetFiles(tid string) []tool.File {
+	return nil
+}
+
+var _ tool.Tool = (*QBittorrent)(nil)
+
+func init() {
+	tool.Tools.Add("qBittorrent", &QBittorrent{})
+}
diff --git a/internal/offline_download/tool/add.go b/internal/offline_download/tool/add.go
new file mode 100644
index 00000000000..ceaf92d3bc3
--- /dev/null
+++ b/internal/offline_download/tool/add.go
@@ -0,0 +1,84 @@
+package tool
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+
+	"github.com/alist-org/alist/v3/internal/conf"
+	"github.com/alist-org/alist/v3/internal/errs"
+	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/pkg/task"
+	"github.com/google/uuid"
+	"github.com/pkg/errors"
+)
+
+type AddURLArgs struct {
+	URL        string
+	DstDirPath string
+	Tool       string
+}
+
+func AddURL(ctx context.Context, args *AddURLArgs) error {
+	// get tool
+	tool, err := Tools.Get(args.Tool)
+	if err != nil {
+		return errors.Wrapf(err, "failed get tool")
+	}
+	// check tool is ready
+	if !tool.IsReady() {
+		// try to init tool
+		if _, err := tool.Init(); err != nil {
+			return errors.Wrapf(err, "failed init tool %s", args.Tool)
+		}
+	}
+	// check storage
+	storage, dstDirActualPath, err := op.GetStorageAndActualPath(args.DstDirPath)
+	if err != nil {
+		return errors.WithMessage(err, "failed get storage")
+	}
+	// check is it could upload
+	if storage.Config().NoUpload {
+		return errors.WithStack(errs.UploadNotSupported)
+	}
+	// check path is valid
+	obj, err := op.Get(ctx, storage, dstDirActualPath)
+	if err != nil {
+		if !errs.IsObjectNotFound(err) {
+			return errors.WithMessage(err, "failed get object")
+		}
+	} else {
+		if !obj.IsDir() {
+			// can't add to a file
+			return errors.WithStack(errs.NotFolder)
+		}
+	}
+
+	uid := uuid.NewString()
+	tempDir := filepath.Join(conf.Conf.TempDir, args.Tool, uid)
+	signal := make(chan int)
+	gid, err := tool.AddURL(&AddUrlArgs{
+		Url:     args.URL,
+		UID:     uid,
+		TempDir: tempDir,
+		Signal:  signal,
+	})
+	if err != nil {
+		return errors.Wrapf(err, "[%s] failed to add uri %s", args.Tool, args.URL)
+	}
+	DownTaskManager.Submit(task.WithCancelCtx(&task.Task[string]{
+		ID:   gid,
+		Name: fmt.Sprintf("download %s to [%s](%s)", args.URL, storage.GetStorage().MountPath, dstDirActualPath),
+		Func: func(tsk *task.Task[string]) error {
+			m := &Monitor{
+				tool:       tool,
+				tsk:        tsk,
+				tempDir:    tempDir,
+				dstDirPath: args.DstDirPath,
+				signal:     signal,
+			}
+			return m.Loop()
+		},
+	}))
+	return nil
+}
diff --git a/internal/offline_download/tool/base.go b/internal/offline_download/tool/base.go
new file mode 100644
index 00000000000..4689635b54e
--- /dev/null
+++ b/internal/offline_download/tool/base.go
@@ -0,0 +1,59 @@
+package tool
+
+import (
+	"io"
+	"os"
+	"time"
+
+	"github.com/alist-org/alist/v3/internal/model"
+)
+
+type AddUrlArgs struct {
+	Url     string
+	UID     string
+	TempDir string
+	Signal  chan int
+}
+
+type Status struct {
+	Progress  float64
+	NewTID    string
+	Completed bool
+	Status    string
+	Err       error
+}
+
+type Tool interface {
+	// Items return the setting items the tool need
+	Items() []model.SettingItem
+	Init() (string, error)
+	IsReady() bool
+	// AddURL add an uri to download, return the task id
+	AddURL(args *AddUrlArgs) (string, error)
+	// Remove the download if task been canceled
+	Remove(tid string) error
+	// Status return the status of the download task, if an error occurred, return the error in Status.Err
+	Status(tid string) (*Status, error)
+	// GetFiles return the files of the download task, if nil, means walk the temp dir to get the files
+	GetFiles(tid string) []File
+}
+
+type File struct {
+	// ReadCloser for http client
+	io.ReadCloser
+	Name     string
+	Size     int64
+	Path     string
+	Modified time.Time
+}
+
+func (f *File) GetReadCloser() (io.ReadCloser, error) {
+	if f.ReadCloser != nil {
+		return f.ReadCloser, nil
+	}
+	file, err := os.Open(f.Path)
+	if err != nil {
+		return nil, err
+	}
+	return file, nil
+}
diff --git a/internal/offline_download/tool/monitor.go b/internal/offline_download/tool/monitor.go
new file mode 100644
index 00000000000..984bda17cb7
--- /dev/null
+++ b/internal/offline_download/tool/monitor.go
@@ -0,0 +1,159 @@
+package tool
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/internal/stream"
+	"github.com/alist-org/alist/v3/pkg/task"
+	"github.com/alist-org/alist/v3/pkg/utils"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+)
+
+type Monitor struct {
+	tool       Tool
+	tsk        *task.Task[string]
+	tempDir    string
+	retried    int
+	dstDirPath string
+	finish     chan struct{}
+	signal     chan int
+}
+
+func (m *Monitor) Loop() error {
+	m.finish = make(chan struct{})
+	var (
+		err error
+		ok  bool
+	)
+outer:
+	for {
+		select {
+		case <-m.tsk.Ctx.Done():
+			err := m.tool.Remove(m.tsk.ID)
+			return err
+		case <-m.signal:
+			ok, err = m.Update()
+			if ok {
+				break outer
+			}
+		case <-time.After(time.Second * 2):
+			ok, err = m.Update()
+			if ok {
+				break outer
+			}
+		}
+	}
+	if err != nil {
+		return err
+	}
+	m.tsk.SetStatus("aria2 download completed, transferring")
+	<-m.finish
+	m.tsk.SetStatus("completed")
+	return nil
+}
+
+// Update download status, return true if download completed
+func (m *Monitor) Update() (bool, error) {
+	info, err := m.tool.Status(m.tsk.ID)
+	if err != nil {
+		m.retried++
+		log.Errorf("failed to get status of %s, retried %d times", m.tsk.ID, m.retried)
+		return false, nil
+	}
+	if m.retried > 5 {
+		return true, errors.Errorf("failed to get status of %s, retried %d times", m.tsk.ID, m.retried)
+	}
+	m.retried = 0
+	m.tsk.SetProgress(info.Progress)
+	m.tsk.SetStatus("tool: " + info.Status)
+	if info.NewTID != "" {
+		log.Debugf("followen by: %+v", info.NewTID)
+		DownTaskManager.RawTasks().Delete(m.tsk.ID)
+		m.tsk.ID = info.NewTID
+		DownTaskManager.RawTasks().Store(m.tsk.ID, m.tsk)
+		return false, nil
+	}
+	// if download completed
+	if info.Completed {
+		err := m.Complete()
+		return true, errors.WithMessage(err, "failed to transfer file")
+	}
+	// if download failed
+	if info.Err != nil {
+		return true, errors.Errorf("failed to download %s, error: %s", m.tsk.ID, info.Err.Error())
+	}
+	return false, nil
+}
+
+var TransferTaskManager = task.NewTaskManager(3, func(k *uint64) {
+	atomic.AddUint64(k, 1)
+})
+
+func (m *Monitor) Complete() error {
+	// check dstDir again
+	storage, dstDirActualPath, err := op.GetStorageAndActualPath(m.dstDirPath)
+	if err != nil {
+		return errors.WithMessage(err, "failed get storage")
+	}
+	var files []File
+	if f := m.tool.GetFiles(m.tsk.ID); f != nil {
+		files = f
+	} else {
+		files, err = GetFiles(m.tempDir)
+		if err != nil {
+			return errors.Wrapf(err, "failed to get files")
+		}
+	}
+	// upload files
+	var wg sync.WaitGroup
+	wg.Add(len(files))
+	go func() {
+		wg.Wait()
+		err := os.RemoveAll(m.tempDir)
+		m.finish <- struct{}{}
+		if err != nil {
+			log.Errorf("failed to remove aria2 temp dir: %+v", err.Error())
+		}
+	}()
+	for i, _ := range files {
+		file := files[i]
+		TransferTaskManager.Submit(task.WithCancelCtx(&task.Task[uint64]{
+			Name: fmt.Sprintf("transfer %s to [%s](%s)", file.Path, storage.GetStorage().MountPath, dstDirActualPath),
+			Func: func(tsk *task.Task[uint64]) error {
+				defer wg.Done()
+				mimetype := utils.GetMimeType(file.Path)
+				rc, err := file.GetReadCloser()
+				if err != nil {
+					return errors.Wrapf(err, "failed to open file %s", file.Path)
+				}
+				s := &stream.FileStream{
+					Ctx: nil,
+					Obj: &model.Object{
+						Name:     filepath.Base(file.Path),
+						Size:     file.Size,
+						Modified: file.Modified,
+						IsFolder: false,
+					},
+					Reader:   rc,
+					Mimetype: mimetype,
+					Closers:  utils.NewClosers(rc),
+				}
+				relDir, err := filepath.Rel(m.tempDir, filepath.Dir(file.Path))
+				if err != nil {
+					log.Errorf("find relation directory error: %v", err)
+				}
+				newDistDir := filepath.Join(dstDirActualPath, relDir)
+				return op.Put(tsk.Ctx, storage, newDistDir, s, tsk.SetProgress)
+			},
+		}))
+	}
+	return nil
+}
diff --git a/internal/offline_download/tool/tools.go b/internal/offline_download/tool/tools.go
new file mode 100644
index 00000000000..b7eacbd2b9b
--- /dev/null
+++ b/internal/offline_download/tool/tools.go
@@ -0,0 +1,42 @@
+package tool
+
+import (
+	"fmt"
+
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/pkg/task"
+)
+
+var (
+	Tools           = make(ToolsManager)
+	DownTaskManager = task.NewTaskManager[string](3)
+)
+
+type ToolsManager map[string]Tool
+
+func (t ToolsManager) Get(name string) (Tool, error) {
+	if tool, ok := t[name]; ok {
+		return tool, nil
+	}
+	return nil, fmt.Errorf("tool %s not found", name)
+}
+
+func (t ToolsManager) Add(name string, tool Tool) {
+	t[name] = tool
+}
+
+func (t ToolsManager) Names() []string {
+	names := make([]string, 0, len(t))
+	for name := range t {
+		names = append(names, name)
+	}
+	return names
+}
+
+func (t ToolsManager) Items() []model.SettingItem {
+	var items []model.SettingItem
+	for _, tool := range t {
+		items = append(items, tool.Items()...)
+	}
+	return items
+}
diff --git a/internal/offline_download/tool/util.go b/internal/offline_download/tool/util.go
new file mode 100644
index 00000000000..4258eff61e0
--- /dev/null
+++ b/internal/offline_download/tool/util.go
@@ -0,0 +1,28 @@
+package tool
+
+import (
+	"os"
+	"path/filepath"
+)
+
+func GetFiles(dir string) ([]File, error) {
+	var files []File
+	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if !info.IsDir() {
+			files = append(files, File{
+				Name:     info.Name(),
+				Size:     info.Size(),
+				Path:     path,
+				Modified: info.ModTime(),
+			})
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return files, nil
+}
diff --git a/internal/op/fs.go b/internal/op/fs.go
index 8ee6993e091..9fe7d5e6a3f 100644
--- a/internal/op/fs.go
+++ b/internal/op/fs.go
@@ -534,7 +534,7 @@ func Put(ctx context.Context, storage driver.Driver, dstDirPath string, file mod
 	}
 	// if up is nil, set a default to prevent panic
 	if up == nil {
-		up = func(p int) {}
+		up = func(p float64) {}
 	}
 
 	switch s := storage.(type) {
diff --git a/internal/op/hook.go b/internal/op/hook.go
index e37e52df269..23b8e59af2c 100644
--- a/internal/op/hook.go
+++ b/internal/op/hook.go
@@ -78,6 +78,10 @@ var settingItemHooks = map[string]SettingItemHook{
 		log.Debugf("filename char mapping: %+v", conf.FilenameCharMap)
 		return nil
 	},
+	conf.IgnoreDirectLinkParams: func(item *model.SettingItem) error {
+		conf.SlicesMap[conf.IgnoreDirectLinkParams] = strings.Split(item.Value, ",")
+		return nil
+	},
 }
 
 func RegisterSettingItemHook(key string, hook SettingItemHook) {
diff --git a/internal/qbittorrent/monitor.go b/internal/qbittorrent/monitor.go
index 12bb4ad21c5..bfb1bcf42e1 100644
--- a/internal/qbittorrent/monitor.go
+++ b/internal/qbittorrent/monitor.go
@@ -2,13 +2,14 @@ package qbittorrent
 
 import (
 	"fmt"
-	"github.com/alist-org/alist/v3/internal/stream"
 	"os"
 	"path/filepath"
 	"sync"
 	"sync/atomic"
 	"time"
 
+	"github.com/alist-org/alist/v3/internal/stream"
+
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/alist-org/alist/v3/pkg/task"
@@ -85,7 +86,7 @@ func (m *Monitor) update() (bool, error) {
 	}
 
 	progress := float64(info.Completed) / float64(info.Size) * 100
-	m.tsk.SetProgress(int(progress))
+	m.tsk.SetProgress(progress)
 	switch info.State {
 	case UPLOADING, PAUSEDUP, QUEUEDUP, STALLEDUP, FORCEDUP, CHECKINGUP:
 		err = m.complete()
diff --git a/pkg/qbittorrent/client.go b/pkg/qbittorrent/client.go
new file mode 100644
index 00000000000..ec3f7e7b00c
--- /dev/null
+++ b/pkg/qbittorrent/client.go
@@ -0,0 +1,366 @@
+package qbittorrent
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+
+	"github.com/alist-org/alist/v3/pkg/utils"
+)
+
+type Client interface {
+	AddFromLink(link string, savePath string, id string) error
+	GetInfo(id string) (TorrentInfo, error)
+	GetFiles(id string) ([]FileInfo, error)
+	Delete(id string, deleteFiles bool) error
+}
+
+type client struct {
+	url    *url.URL
+	client http.Client
+	Client
+}
+
+func New(webuiUrl string) (Client, error) {
+	u, err := url.Parse(webuiUrl)
+	if err != nil {
+		return nil, err
+	}
+
+	jar, err := cookiejar.New(nil)
+	if err != nil {
+		return nil, err
+	}
+	var c = &client{
+		url:    u,
+		client: http.Client{Jar: jar},
+	}
+
+	err = c.checkAuthorization()
+	if err != nil {
+		return nil, err
+	}
+	return c, nil
+}
+
+func (c *client) checkAuthorization() error {
+	// check authorization
+	if c.authorized() {
+		return nil
+	}
+
+	// check authorization after logging in
+	err := c.login()
+	if err != nil {
+		return err
+	}
+	if c.authorized() {
+		return nil
+	}
+	return errors.New("unauthorized qbittorrent url")
+}
+
+func (c *client) authorized() bool {
+	resp, err := c.post("/api/v2/app/version", nil)
+	if err != nil {
+		return false
+	}
+	return resp.StatusCode == 200 // the status code will be 403 if not authorized
+}
+
+func (c *client) login() error {
+	// prepare HTTP request
+	v := url.Values{}
+	v.Set("username", c.url.User.Username())
+	passwd, _ := c.url.User.Password()
+	v.Set("password", passwd)
+	resp, err := c.post("/api/v2/auth/login", v)
+	if err != nil {
+		return err
+	}
+
+	// check result
+	body := make([]byte, 2)
+	_, err = resp.Body.Read(body)
+	if err != nil {
+		return err
+	}
+	if string(body) != "Ok" {
+		return errors.New("failed to login into qBittorrent webui with url: " + c.url.String())
+	}
+	return nil
+}
+
+func (c *client) post(path string, data url.Values) (*http.Response, error) {
+	u := c.url.JoinPath(path)
+	u.User = nil // remove userinfo for requests
+
+	req, err := http.NewRequest("POST", u.String(), bytes.NewReader([]byte(data.Encode())))
+	if err != nil {
+		return nil, err
+	}
+	if data != nil {
+		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	}
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	if resp.Cookies() != nil {
+		c.client.Jar.SetCookies(u, resp.Cookies())
+	}
+	return resp, nil
+}
+
+func (c *client) AddFromLink(link string, savePath string, id string) error {
+	err := c.checkAuthorization()
+	if err != nil {
+		return err
+	}
+
+	buf := new(bytes.Buffer)
+	writer := multipart.NewWriter(buf)
+
+	addField := func(name string, value string) {
+		if err != nil {
+			return
+		}
+		err = writer.WriteField(name, value)
+	}
+	addField("urls", link)
+	addField("savepath", savePath)
+	addField("tags", "alist-"+id)
+	addField("autoTMM", "false")
+	if err != nil {
+		return err
+	}
+
+	err = writer.Close()
+	if err != nil {
+		return err
+	}
+
+	u := c.url.JoinPath("/api/v2/torrents/add")
+	u.User = nil // remove userinfo for requests
+	req, err := http.NewRequest("POST", u.String(), buf)
+	if err != nil {
+		return err
+	}
+	req.Header.Add("Content-Type", writer.FormDataContentType())
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return err
+	}
+
+	// check result
+	body := make([]byte, 2)
+	_, err = resp.Body.Read(body)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != 200 || string(body) != "Ok" {
+		return errors.New("failed to add qBittorrent task: " + link)
+	}
+	return nil
+}
+
+type TorrentStatus string
+
+const (
+	ERROR              TorrentStatus = "error"
+	MISSINGFILES       TorrentStatus = "missingFiles"
+	UPLOADING          TorrentStatus = "uploading"
+	PAUSEDUP           TorrentStatus = "pausedUP"
+	QUEUEDUP           TorrentStatus = "queuedUP"
+	STALLEDUP          TorrentStatus = "stalledUP"
+	CHECKINGUP         TorrentStatus = "checkingUP"
+	FORCEDUP           TorrentStatus = "forcedUP"
+	ALLOCATING         TorrentStatus = "allocating"
+	DOWNLOADING        TorrentStatus = "downloading"
+	METADL             TorrentStatus = "metaDL"
+	PAUSEDDL           TorrentStatus = "pausedDL"
+	QUEUEDDL           TorrentStatus = "queuedDL"
+	STALLEDDL          TorrentStatus = "stalledDL"
+	CHECKINGDL         TorrentStatus = "checkingDL"
+	FORCEDDL           TorrentStatus = "forcedDL"
+	CHECKINGRESUMEDATA TorrentStatus = "checkingResumeData"
+	MOVING             TorrentStatus = "moving"
+	UNKNOWN            TorrentStatus = "unknown"
+)
+
+// https://github.com/DGuang21/PTGo/blob/main/app/client/client_distributer.go
+type TorrentInfo struct {
+	AddedOn           int           `json:"added_on"`           // 将 torrent 添加到客户端的时间（Unix Epoch）
+	AmountLeft        int64         `json:"amount_left"`        // 剩余大小（字节）
+	AutoTmm           bool          `json:"auto_tmm"`           // 此 torrent 是否由 Automatic Torrent Management 管理
+	Availability      float64       `json:"availability"`       // 当前百分比
+	Category          string        `json:"category"`           //
+	Completed         int64         `json:"completed"`          // 完成的传输数据量（字节）
+	CompletionOn      int           `json:"completion_on"`      // Torrent 完成的时间（Unix Epoch）
+	ContentPath       string        `json:"content_path"`       // torrent 内容的绝对路径（多文件 torrent 的根路径，单文件 torrent 的绝对文件路径）
+	DlLimit           int           `json:"dl_limit"`           // Torrent 下载速度限制（字节/秒）
+	Dlspeed           int           `json:"dlspeed"`            // Torrent 下载速度（字节/秒）
+	Downloaded        int64         `json:"downloaded"`         // 已经下载大小
+	DownloadedSession int64         `json:"downloaded_session"` // 此会话下载的数据量
+	Eta               int           `json:"eta"`                //
+	FLPiecePrio       bool          `json:"f_l_piece_prio"`     // 如果第一个最后一块被优先考虑，则为true
+	ForceStart        bool          `json:"force_start"`        // 如果为此 torrent 启用了强制启动，则为true
+	Hash              string        `json:"hash"`               //
+	LastActivity      int           `json:"last_activity"`      // 上次活跃的时间（Unix Epoch）
+	MagnetURI         string        `json:"magnet_uri"`         // 与此 torrent 对应的 Magnet URI
+	MaxRatio          float64       `json:"max_ratio"`          // 种子/上传停止种子前的最大共享比率
+	MaxSeedingTime    int           `json:"max_seeding_time"`   // 停止种子种子前的最长种子时间（秒）
+	Name              string        `json:"name"`               //
+	NumComplete       int           `json:"num_complete"`       //
+	NumIncomplete     int           `json:"num_incomplete"`     //
+	NumLeechs         int           `json:"num_leechs"`         // 连接到的 leechers 的数量
+	NumSeeds          int           `json:"num_seeds"`          // 连接到的种子数
+	Priority          int           `json:"priority"`           // 速度优先。如果队列被禁用或 torrent 处于种子模式，则返回 -1
+	Progress          float64       `json:"progress"`           // 进度
+	Ratio             float64       `json:"ratio"`              // Torrent 共享比率
+	RatioLimit        int           `json:"ratio_limit"`        //
+	SavePath          string        `json:"save_path"`
+	SeedingTime       int           `json:"seeding_time"`       // Torrent 完成用时（秒）
+	SeedingTimeLimit  int           `json:"seeding_time_limit"` // max_seeding_time
+	SeenComplete      int           `json:"seen_complete"`      // 上次 torrent 完成的时间
+	SeqDl             bool          `json:"seq_dl"`             // 如果启用顺序下载，则为true
+	Size              int64         `json:"size"`               //
+	State             TorrentStatus `json:"state"`              // 参见https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list
+	SuperSeeding      bool          `json:"super_seeding"`      // 如果启用超级播种，则为true
+	Tags              string        `json:"tags"`               // Torrent 的逗号连接标签列表
+	TimeActive        int           `json:"time_active"`        // 总活动时间（秒）
+	TotalSize         int64         `json:"total_size"`         // 此 torrent 中所有文件的总大小（字节）（包括未选择的文件）
+	Tracker           string        `json:"tracker"`            // 第一个具有工作状态的tracker。如果没有tracker在工作，则返回空字符串。
+	TrackersCount     int           `json:"trackers_count"`     //
+	UpLimit           int           `json:"up_limit"`           // 上传限制
+	Uploaded          int64         `json:"uploaded"`           // 累计上传
+	UploadedSession   int64         `json:"uploaded_session"`   // 当前session累计上传
+	Upspeed           int           `json:"upspeed"`            // 上传速度（字节/秒）
+}
+
+type InfoNotFoundError struct {
+	Id  string
+	Err error
+}
+
+func (i InfoNotFoundError) Error() string {
+	return "there should be exactly one task with tag \"alist-" + i.Id + "\""
+}
+
+func NewInfoNotFoundError(id string) InfoNotFoundError {
+	return InfoNotFoundError{Id: id}
+}
+
+func (c *client) GetInfo(id string) (TorrentInfo, error) {
+	var infos []TorrentInfo
+
+	err := c.checkAuthorization()
+	if err != nil {
+		return TorrentInfo{}, err
+	}
+
+	v := url.Values{}
+	v.Set("tag", "alist-"+id)
+	response, err := c.post("/api/v2/torrents/info", v)
+	if err != nil {
+		return TorrentInfo{}, err
+	}
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return TorrentInfo{}, err
+	}
+	err = utils.Json.Unmarshal(body, &infos)
+	if err != nil {
+		return TorrentInfo{}, err
+	}
+	if len(infos) != 1 {
+		return TorrentInfo{}, NewInfoNotFoundError(id)
+	}
+	return infos[0], nil
+}
+
+type FileInfo struct {
+	Index        int     `json:"index"`
+	Name         string  `json:"name"`
+	Size         int64   `json:"size"`
+	Progress     float32 `json:"progress"`
+	Priority     int     `json:"priority"`
+	IsSeed       bool    `json:"is_seed"`
+	PieceRange   []int   `json:"piece_range"`
+	Availability float32 `json:"availability"`
+}
+
+func (c *client) GetFiles(id string) ([]FileInfo, error) {
+	var infos []FileInfo
+
+	err := c.checkAuthorization()
+	if err != nil {
+		return []FileInfo{}, err
+	}
+
+	tInfo, err := c.GetInfo(id)
+	if err != nil {
+		return []FileInfo{}, err
+	}
+
+	v := url.Values{}
+	v.Set("hash", tInfo.Hash)
+	response, err := c.post("/api/v2/torrents/files", v)
+	if err != nil {
+		return []FileInfo{}, err
+	}
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return []FileInfo{}, err
+	}
+	err = utils.Json.Unmarshal(body, &infos)
+	if err != nil {
+		return []FileInfo{}, err
+	}
+	return infos, nil
+}
+
+func (c *client) Delete(id string, deleteFiles bool) error {
+	err := c.checkAuthorization()
+	if err != nil {
+		return err
+	}
+
+	info, err := c.GetInfo(id)
+	if err != nil {
+		return err
+	}
+	v := url.Values{}
+	v.Set("hashes", info.Hash)
+	if deleteFiles {
+		v.Set("deleteFiles", "true")
+	} else {
+		v.Set("deleteFiles", "false")
+	}
+	response, err := c.post("/api/v2/torrents/delete", v)
+	if err != nil {
+		return err
+	}
+	if response.StatusCode != 200 {
+		return errors.New("failed to delete qbittorrent task")
+	}
+
+	v = url.Values{}
+	v.Set("tags", "alist-"+id)
+	response, err = c.post("/api/v2/torrents/deleteTags", v)
+	if err != nil {
+		return err
+	}
+	if response.StatusCode != 200 {
+		return errors.New("failed to delete qbittorrent tag")
+	}
+	return nil
+}
diff --git a/pkg/task/task.go b/pkg/task/task.go
index f47eb7472ce..5b634f10cdb 100644
--- a/pkg/task/task.go
+++ b/pkg/task/task.go
@@ -26,7 +26,7 @@ type Task[K comparable] struct {
 	Name     string
 	state    string // pending, running, finished, canceling, canceled, errored
 	status   string
-	progress int
+	progress float64
 
 	Error error
 
@@ -41,11 +41,11 @@ func (t *Task[K]) SetStatus(status string) {
 	t.status = status
 }
 
-func (t *Task[K]) SetProgress(percentage int) {
+func (t *Task[K]) SetProgress(percentage float64) {
 	t.progress = percentage
 }
 
-func (t Task[K]) GetProgress() int {
+func (t Task[K]) GetProgress() float64 {
 	return t.progress
 }
 
diff --git a/pkg/utils/io.go b/pkg/utils/io.go
index d106531bd3d..6852e28a83d 100644
--- a/pkg/utils/io.go
+++ b/pkg/utils/io.go
@@ -5,10 +5,11 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"golang.org/x/exp/constraints"
 	"io"
 	"time"
 
+	"golang.org/x/exp/constraints"
+
 	log "github.com/sirupsen/logrus"
 )
 
@@ -21,7 +22,7 @@ func (rf readerFunc) Read(p []byte) (n int, err error) { return rf(p) }
 // CopyWithCtx slightly modified function signature:
 // - context has been added in order to propagate cancellation
 // - I do not return the number of bytes written, has it is not useful in my use case
-func CopyWithCtx(ctx context.Context, out io.Writer, in io.Reader, size int64, progress func(percentage int)) error {
+func CopyWithCtx(ctx context.Context, out io.Writer, in io.Reader, size int64, progress func(percentage float64)) error {
 	// Copy will call the Reader and Writer interface multiple time, in order
 	// to copy by chunk (avoiding loading the whole file in memory).
 	// I insert the ability to cancel before read time as it is the earliest
@@ -40,7 +41,7 @@ func CopyWithCtx(ctx context.Context, out io.Writer, in io.Reader, size int64, p
 			n, err := in.Read(p)
 			if s > 0 && (err == nil || err == io.EOF) {
 				finish += int64(n)
-				progress(int(finish / s))
+				progress(float64(finish) / float64(s))
 			}
 			return n, err
 		}
diff --git a/server/handles/aria2.go b/server/handles/aria2.go
deleted file mode 100644
index 325367a796a..00000000000
--- a/server/handles/aria2.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package handles
-
-import (
-	"github.com/alist-org/alist/v3/internal/aria2"
-	"github.com/alist-org/alist/v3/internal/conf"
-	"github.com/alist-org/alist/v3/internal/model"
-	"github.com/alist-org/alist/v3/internal/op"
-	"github.com/alist-org/alist/v3/server/common"
-	"github.com/gin-gonic/gin"
-)
-
-type SetAria2Req struct {
-	Uri    string `json:"uri" form:"uri"`
-	Secret string `json:"secret" form:"secret"`
-}
-
-func SetAria2(c *gin.Context) {
-	var req SetAria2Req
-	if err := c.ShouldBind(&req); err != nil {
-		common.ErrorResp(c, err, 400)
-		return
-	}
-	items := []model.SettingItem{
-		{Key: conf.Aria2Uri, Value: req.Uri, Type: conf.TypeString, Group: model.ARIA2, Flag: model.PRIVATE},
-		{Key: conf.Aria2Secret, Value: req.Secret, Type: conf.TypeString, Group: model.ARIA2, Flag: model.PRIVATE},
-	}
-	if err := op.SaveSettingItems(items); err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-	version, err := aria2.InitClient(2)
-	if err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-	common.SuccessResp(c, version)
-}
-
-type AddAria2Req struct {
-	Urls []string `json:"urls"`
-	Path string   `json:"path"`
-}
-
-func AddAria2(c *gin.Context) {
-	user := c.MustGet("user").(*model.User)
-	if !user.CanAddAria2Tasks() {
-		common.ErrorStrResp(c, "permission denied", 403)
-		return
-	}
-	if !aria2.IsAria2Ready() {
-		// try to init client
-		_, err := aria2.InitClient(2)
-		if err != nil {
-			common.ErrorResp(c, err, 500)
-			return
-		}
-		if !aria2.IsAria2Ready() {
-			common.ErrorStrResp(c, "aria2 still not ready after init", 500)
-			return
-		}
-	}
-	var req AddAria2Req
-	if err := c.ShouldBind(&req); err != nil {
-		common.ErrorResp(c, err, 400)
-		return
-	}
-	reqPath, err := user.JoinPath(req.Path)
-	if err != nil {
-		common.ErrorResp(c, err, 403)
-		return
-	}
-	for _, url := range req.Urls {
-		err := aria2.AddURI(c, url, reqPath)
-		if err != nil {
-			common.ErrorResp(c, err, 500)
-			return
-		}
-	}
-	common.SuccessResp(c)
-}
diff --git a/server/handles/down.go b/server/handles/down.go
index e4aec494243..d3d41e85a2b 100644
--- a/server/handles/down.go
+++ b/server/handles/down.go
@@ -52,7 +52,9 @@ func Down(c *gin.Context) {
 		c.Header("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate")
 		if setting.GetBool(conf.ForwardDirectLinkParams) {
 			query := c.Request.URL.Query()
-			query.Del("sign")
+			for _, v := range conf.SlicesMap[conf.IgnoreDirectLinkParams] {
+				query.Del(v)
+			}
 			link.URL, err = utils.InjectQuery(link.URL, query)
 			if err != nil {
 				common.ErrorResp(c, err, 500)
@@ -95,7 +97,9 @@ func Proxy(c *gin.Context) {
 		}
 		if link.URL != "" && setting.GetBool(conf.ForwardDirectLinkParams) {
 			query := c.Request.URL.Query()
-			query.Del("sign")
+			for _, v := range conf.SlicesMap[conf.IgnoreDirectLinkParams] {
+				query.Del(v)
+			}
 			link.URL, err = utils.InjectQuery(link.URL, query)
 			if err != nil {
 				common.ErrorResp(c, err, 500)
diff --git a/server/handles/offline_download.go b/server/handles/offline_download.go
new file mode 100644
index 00000000000..cf9c1775bae
--- /dev/null
+++ b/server/handles/offline_download.go
@@ -0,0 +1,111 @@
+package handles
+
+import (
+	"github.com/alist-org/alist/v3/internal/conf"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
+	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/server/common"
+	"github.com/gin-gonic/gin"
+)
+
+type SetAria2Req struct {
+	Uri    string `json:"uri" form:"uri"`
+	Secret string `json:"secret" form:"secret"`
+}
+
+func SetAria2(c *gin.Context) {
+	var req SetAria2Req
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	items := []model.SettingItem{
+		{Key: conf.Aria2Uri, Value: req.Uri, Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+		{Key: conf.Aria2Secret, Value: req.Secret, 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("aria2")
+	version, err := _tool.Init()
+	if err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+	common.SuccessResp(c, version)
+}
+
+type SetQbittorrentReq struct {
+	Url      string `json:"url" form:"url"`
+	Seedtime string `json:"seedtime" form:"seedtime"`
+}
+
+func SetQbittorrent(c *gin.Context) {
+	var req SetQbittorrentReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	items := []model.SettingItem{
+		{Key: conf.QbittorrentUrl, Value: req.Url, Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+		{Key: conf.QbittorrentSeedtime, Value: req.Seedtime, Type: conf.TypeNumber, 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("qBittorrent")
+	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) {
+	tools := tool.Tools.Names()
+	common.SuccessResp(c, tools)
+}
+
+type AddOfflineDownloadReq struct {
+	Urls []string `json:"urls"`
+	Path string   `json:"path"`
+	Tool string   `json:"tool"`
+}
+
+func AddOfflineDownload(c *gin.Context) {
+	user := c.MustGet("user").(*model.User)
+	if !user.CanAddOfflineDownloadTasks() {
+		common.ErrorStrResp(c, "permission denied", 403)
+		return
+	}
+
+	var req AddOfflineDownloadReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	reqPath, err := user.JoinPath(req.Path)
+	if err != nil {
+		common.ErrorResp(c, err, 403)
+		return
+	}
+	for _, url := range req.Urls {
+		err := tool.AddURL(c, &tool.AddURLArgs{
+			URL:        url,
+			DstDirPath: reqPath,
+			Tool:       req.Tool,
+		})
+		if err != nil {
+			common.ErrorResp(c, err, 500)
+			return
+		}
+	}
+	common.SuccessResp(c)
+}
diff --git a/server/handles/qbittorrent.go b/server/handles/qbittorrent.go
deleted file mode 100644
index b22804546ef..00000000000
--- a/server/handles/qbittorrent.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package handles
-
-import (
-	"github.com/alist-org/alist/v3/internal/conf"
-	"github.com/alist-org/alist/v3/internal/model"
-	"github.com/alist-org/alist/v3/internal/op"
-	"github.com/alist-org/alist/v3/internal/qbittorrent"
-	"github.com/alist-org/alist/v3/server/common"
-	"github.com/gin-gonic/gin"
-)
-
-type SetQbittorrentReq struct {
-	Url      string `json:"url" form:"url"`
-	Seedtime string `json:"seedtime" form:"seedtime"`
-}
-
-func SetQbittorrent(c *gin.Context) {
-	var req SetQbittorrentReq
-	if err := c.ShouldBind(&req); err != nil {
-		common.ErrorResp(c, err, 400)
-		return
-	}
-	items := []model.SettingItem{
-		{Key: conf.QbittorrentUrl, Value: req.Url, Type: conf.TypeString, Group: model.SINGLE, Flag: model.PRIVATE},
-		{Key: conf.QbittorrentSeedtime, Value: req.Seedtime, Type: conf.TypeNumber, Group: model.SINGLE, Flag: model.PRIVATE},
-	}
-	if err := op.SaveSettingItems(items); err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-	if err := qbittorrent.InitClient(); err != nil {
-		common.ErrorResp(c, err, 500)
-		return
-	}
-	common.SuccessResp(c, "ok")
-}
-
-type AddQbittorrentReq struct {
-	Urls []string `json:"urls"`
-	Path string   `json:"path"`
-}
-
-func AddQbittorrent(c *gin.Context) {
-	user := c.MustGet("user").(*model.User)
-	if !user.CanAddQbittorrentTasks() {
-		common.ErrorStrResp(c, "permission denied", 403)
-		return
-	}
-	if !qbittorrent.IsQbittorrentReady() {
-		// try to init client
-		err := qbittorrent.InitClient()
-		if err != nil {
-			common.ErrorResp(c, err, 500)
-			return
-		}
-		if !qbittorrent.IsQbittorrentReady() {
-			common.ErrorStrResp(c, "qbittorrent still not ready after init", 500)
-			return
-		}
-	}
-	var req AddQbittorrentReq
-	if err := c.ShouldBind(&req); err != nil {
-		common.ErrorResp(c, err, 400)
-		return
-	}
-	reqPath, err := user.JoinPath(req.Path)
-	if err != nil {
-		common.ErrorResp(c, err, 403)
-		return
-	}
-	for _, url := range req.Urls {
-		err := qbittorrent.AddURL(c, url, reqPath)
-		if err != nil {
-			common.ErrorResp(c, err, 500)
-			return
-		}
-	}
-	common.SuccessResp(c)
-}
diff --git a/server/handles/task.go b/server/handles/task.go
index d76bb586e27..15e8067248a 100644
--- a/server/handles/task.go
+++ b/server/handles/task.go
@@ -3,8 +3,8 @@ package handles
 import (
 	"strconv"
 
-	"github.com/alist-org/alist/v3/internal/aria2"
 	"github.com/alist-org/alist/v3/internal/fs"
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
 	"github.com/alist-org/alist/v3/internal/qbittorrent"
 	"github.com/alist-org/alist/v3/pkg/task"
 	"github.com/alist-org/alist/v3/server/common"
@@ -12,12 +12,12 @@ import (
 )
 
 type TaskInfo struct {
-	ID       string `json:"id"`
-	Name     string `json:"name"`
-	State    string `json:"state"`
-	Status   string `json:"status"`
-	Progress int    `json:"progress"`
-	Error    string `json:"error"`
+	ID       string  `json:"id"`
+	Name     string  `json:"name"`
+	State    string  `json:"state"`
+	Status   string  `json:"status"`
+	Progress float64 `json:"progress"`
+	Error    string  `json:"error"`
 }
 
 type K2Str[K comparable] func(k K) string
@@ -116,10 +116,12 @@ func taskRoute[K comparable](g *gin.RouterGroup, manager *task.Manager[K], k2Str
 }
 
 func SetupTaskRoute(g *gin.RouterGroup) {
-	taskRoute(g.Group("/aria2_down"), aria2.DownTaskManager, strK2Str, str2StrK)
-	taskRoute(g.Group("/aria2_transfer"), aria2.TransferTaskManager, uint64K2Str, str2Uint64K)
 	taskRoute(g.Group("/upload"), fs.UploadTaskManager, uint64K2Str, str2Uint64K)
 	taskRoute(g.Group("/copy"), fs.CopyTaskManager, uint64K2Str, str2Uint64K)
 	taskRoute(g.Group("/qbit_down"), qbittorrent.DownTaskManager, strK2Str, str2StrK)
 	taskRoute(g.Group("/qbit_transfer"), qbittorrent.TransferTaskManager, uint64K2Str, str2Uint64K)
+	//taskRoute(g.Group("/aria2_down"), aria2.DownTaskManager, strK2Str, str2StrK)
+	//taskRoute(g.Group("/aria2_transfer"), aria2.TransferTaskManager, uint64K2Str, str2Uint64K)
+	taskRoute(g.Group("/offline_download"), tool.DownTaskManager, strK2Str, str2StrK)
+	taskRoute(g.Group("/offline_download_transfer"), tool.TransferTaskManager, uint64K2Str, str2Uint64K)
 }
diff --git a/server/router.go b/server/router.go
index 92ede88bfde..7f179231ecd 100644
--- a/server/router.go
+++ b/server/router.go
@@ -70,6 +70,7 @@ func Init(e *gin.Engine) {
 	// no need auth
 	public := api.Group("/public")
 	public.Any("/settings", handles.PublicSettings)
+	public.Any("/offline_download_tools", handles.OfflineDownloadTools)
 
 	_fs(auth.Group("/fs"))
 	admin(auth.Group("/admin", middlewares.AuthAdmin))
@@ -155,8 +156,9 @@ func _fs(g *gin.RouterGroup) {
 	g.PUT("/put", middlewares.FsUp, handles.FsStream)
 	g.PUT("/form", middlewares.FsUp, handles.FsForm)
 	g.POST("/link", middlewares.AuthAdmin, handles.Link)
-	g.POST("/add_aria2", handles.AddAria2)
-	g.POST("/add_qbit", handles.AddQbittorrent)
+	//g.POST("/add_aria2", handles.AddOfflineDownload)
+	//g.POST("/add_qbit", handles.AddQbittorrent)
+	g.POST("/add_offline_download", handles.AddOfflineDownload)
 }
 
 func Cors(r *gin.Engine) {


Test patch:
diff --git a/internal/offline_download/tool/all_test.go b/internal/offline_download/tool/all_test.go
new file mode 100644
index 00000000000..27da5e32a89
--- /dev/null
+++ b/internal/offline_download/tool/all_test.go
@@ -0,0 +1,17 @@
+package tool_test
+
+import (
+	"testing"
+
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
+)
+
+func TestGetFiles(t *testing.T) {
+	files, err := tool.GetFiles("..")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, file := range files {
+		t.Log(file.Name, file.Size, file.Path, file.Modified)
+	}
+}