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/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/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 6afd774265f..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" @@ -142,10 +143,6 @@ func InitialSettings() []model.SettingItem { {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}, @@ -168,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/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/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) + } +} 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/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/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) {