From c28168c970faa8d56c8cfe96d22607d42360eb82 Mon Sep 17 00:00:00 2001 From: kdxcxs Date: Tue, 14 Feb 2023 15:20:45 +0800 Subject: [PATCH] feat: support qbittorrent (close #3087 in #3333) * feat(qbittorrent): authorization and logging in support * feat(qbittorrent/client): support `AddFromLink` * refactor(qbittorrent/client): check authorization when getting a new client * feat(qbittorrent/client): support `GetInfo` * test(qbittorrent/client): update test cases * feat(qbittorrent): init qbittorrent client on bootstrap * feat(qbittorrent): support setting webui url via gin * feat(qbittorrent/client): support deleting * feat(qbittorrent/client): parse `TorrentStatus` enum when unmarshalling json in `GetInfo()` * feat(qbittorrent/client): support getting files by id * feat(qbittorrent): support adding qbittorrent tasks via gin * refactor(qbittorrent/client): return a `Client` interface in `New()` instead of `*client` * refactor: task handle * chore: fix typo * chore: change path --------- Co-authored-by: Andy Hsu --- cmd/server.go | 1 + internal/bootstrap/data/setting.go | 3 + internal/bootstrap/qbittorrent.go | 15 ++ internal/conf/const.go | 81 +++---- internal/model/setting.go | 1 + internal/model/user.go | 25 ++- internal/qbittorrent/add.go | 58 +++++ internal/qbittorrent/client.go | 337 ++++++++++++++++++++++++++++ internal/qbittorrent/client_test.go | 152 +++++++++++++ internal/qbittorrent/monitor.go | 147 ++++++++++++ internal/qbittorrent/qbittorrent.go | 23 ++ server/handles/qbittorrent.go | 69 ++++++ server/handles/task.go | 236 ++++++------------- server/router.go | 23 +- 14 files changed, 932 insertions(+), 239 deletions(-) create mode 100644 internal/bootstrap/qbittorrent.go create mode 100644 internal/qbittorrent/add.go create mode 100644 internal/qbittorrent/client.go create mode 100644 internal/qbittorrent/client_test.go create mode 100644 internal/qbittorrent/monitor.go create mode 100644 internal/qbittorrent/qbittorrent.go create mode 100644 server/handles/qbittorrent.go diff --git a/cmd/server.go b/cmd/server.go index 0fab295ba20..2eca6074bab 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -29,6 +29,7 @@ the address is defined in config file`, Run: func(cmd *cobra.Command, args []string) { Init() bootstrap.InitAria2() + bootstrap.InitQbittorrent() bootstrap.LoadStorages() if !flags.Debug && !flags.Dev { gin.SetMode(gin.ReleaseMode) diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index af3730d8303..7d6763178c4 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -154,6 +154,9 @@ func InitialSettings() []model.SettingItem { {Key: conf.GithubClientId, Value: "", Type: conf.TypeString, Group: model.GITHUB, Flag: model.PRIVATE}, {Key: conf.GithubClientSecrets, Value: "", Type: conf.TypeString, Group: model.GITHUB, Flag: model.PRIVATE}, {Key: conf.GithubLoginEnabled, Value: "false", Type: conf.TypeBool, Group: model.GITHUB, Flag: model.PUBLIC}, + + // qbittorrent settings + {Key: conf.QbittorrentUrl, Value: "http://admin:adminadmin@localhost:8080/", Type: conf.TypeString, Group: model.QBITTORRENT, Flag: model.PRIVATE}, } if flags.Dev { initialSettingItems = append(initialSettingItems, []model.SettingItem{ diff --git a/internal/bootstrap/qbittorrent.go b/internal/bootstrap/qbittorrent.go new file mode 100644 index 00000000000..315977ebe3f --- /dev/null +++ b/internal/bootstrap/qbittorrent.go @@ -0,0 +1,15 @@ +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 b5280d31257..74fcddcb6ed 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -1,33 +1,33 @@ package conf const ( - TypeString = "string" - TypeSelect = "select" - TypeBool = "bool" - TypeText = "text" - TypeNumber = "number" + TypeString = "string" + TypeSelect = "select" + TypeBool = "bool" + TypeText = "text" + TypeNumber = "number" ) const ( - // site - VERSION = "version" - SiteTitle = "site_title" - Announcement = "announcement" - AllowIndexed = "allow_indexed" + // site + VERSION = "version" + SiteTitle = "site_title" + Announcement = "announcement" + AllowIndexed = "allow_indexed" - Logo = "logo" - Favicon = "favicon" - MainColor = "main_color" + Logo = "logo" + Favicon = "favicon" + MainColor = "main_color" - // preview - TextTypes = "text_types" - AudioTypes = "audio_types" - VideoTypes = "video_types" - ImageTypes = "image_types" - ProxyTypes = "proxy_types" - ProxyIgnoreHeaders = "proxy_ignore_headers" - AudioAutoplay = "audio_autoplay" - VideoAutoplay = "video_autoplay" + // preview + TextTypes = "text_types" + AudioTypes = "audio_types" + VideoTypes = "video_types" + ImageTypes = "image_types" + ProxyTypes = "proxy_types" + ProxyIgnoreHeaders = "proxy_ignore_headers" + AudioAutoplay = "audio_autoplay" + VideoAutoplay = "video_autoplay" // global HideFiles = "hide_files" @@ -46,26 +46,29 @@ const ( IgnorePaths = "ignore_paths" MaxIndexDepth = "max_index_depth" - // aria2 - Aria2Uri = "aria2_uri" - Aria2Secret = "aria2_secret" + // aria2 + Aria2Uri = "aria2_uri" + Aria2Secret = "aria2_secret" - // single - Token = "token" - IndexProgress = "index_progress" + // single + Token = "token" + IndexProgress = "index_progress" - //Github - GithubClientId = "github_client_id" - GithubClientSecrets = "github_client_secrets" - GithubLoginEnabled = "github_login_enabled" + //Github + GithubClientId = "github_client_id" + GithubClientSecrets = "github_client_secrets" + GithubLoginEnabled = "github_login_enabled" + + // qbittorrent + QbittorrentUrl = "qbittorrent_url" ) const ( - UNKNOWN = iota - FOLDER - //OFFICE - VIDEO - AUDIO - TEXT - IMAGE + UNKNOWN = iota + FOLDER + //OFFICE + VIDEO + AUDIO + TEXT + IMAGE ) diff --git a/internal/model/setting.go b/internal/model/setting.go index 883a8534d70..88467ef6cd9 100644 --- a/internal/model/setting.go +++ b/internal/model/setting.go @@ -9,6 +9,7 @@ const ( ARIA2 INDEX GITHUB + QBITTORRENT ) const ( diff --git a/internal/model/user.go b/internal/model/user.go index 17b9ad5b4a3..7ce4f2d696f 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -20,16 +20,17 @@ type User struct { Role int `json:"role"` // user's role Disabled bool `json:"disabled"` // Determine permissions by bit - // 0: can see hidden files - // 1: can access without password - // 2: can add aria2 tasks - // 3: can mkdir and upload - // 4: can rename - // 5: can move - // 6: can copy - // 7: can remove - // 8: webdav read - // 9: webdav write + // 0: can see hidden files + // 1: can access without password + // 2: can add aria2 tasks + // 3: can mkdir and upload + // 4: can rename + // 5: can move + // 6: can copy + // 7: can remove + // 8: webdav read + // 9: webdav write + // 10: can add qbittorrent tasks Permission int32 `json:"permission"` OtpSecret string `json:"-"` GithubID int `json:"github_id"` @@ -93,6 +94,10 @@ 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/qbittorrent/add.go b/internal/qbittorrent/add.go new file mode 100644 index 00000000000..f6a9f9f198e --- /dev/null +++ b/internal/qbittorrent/add.go @@ -0,0 +1,58 @@ +package qbittorrent + +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" +) + +func AddURL(ctx context.Context, url string, dstDirPath string) error { + // check storage + storage, dstDirActualPath, err := op.GetStorageAndActualPath(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) + } + } + // call qbittorrent + id := uuid.NewString() + tempDir := filepath.Join(conf.Conf.TempDir, "qbittorrent", id) + err = qbclient.AddFromLink(url, tempDir, id) + if err != nil { + return errors.Wrapf(err, "failed to add url %s", url) + } + DownTaskManager.Submit(task.WithCancelCtx(&task.Task[string]{ + ID: id, + Name: fmt.Sprintf("download %s to [%s](%s)", url, storage.GetStorage().MountPath, dstDirActualPath), + Func: func(tsk *task.Task[string]) error { + m := &Monitor{ + tsk: tsk, + tempDir: tempDir, + dstDirPath: dstDirPath, + } + return m.Loop() + }, + })) + return nil +} diff --git a/internal/qbittorrent/client.go b/internal/qbittorrent/client.go new file mode 100644 index 00000000000..ce7adf59119 --- /dev/null +++ b/internal/qbittorrent/client.go @@ -0,0 +1,337 @@ +package qbittorrent + +import ( + "bytes" + "errors" + "github.com/alist-org/alist/v3/pkg/utils" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" +) + +type Client interface { + AddFromLink(link string, savePath string, id string) error + GetInfo(id string) (TorrentInfo, error) + GetFiles(id string) ([]FileInfo, error) + Delete(id string) 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) + 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 int `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"` // 上传速度(字节/秒) +} + +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{}, errors.New("there should be exactly one task with tag \"alist-" + 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) 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) + 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") + } + return nil +} diff --git a/internal/qbittorrent/client_test.go b/internal/qbittorrent/client_test.go new file mode 100644 index 00000000000..237287c56a0 --- /dev/null +++ b/internal/qbittorrent/client_test.go @@ -0,0 +1,152 @@ +package qbittorrent + +import ( + "net/http" + "net/http/cookiejar" + "net/url" + "testing" +) + +func TestLogin(t *testing.T) { + // test logging in with wrong password + u, err := url.Parse("http://admin:admin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Error(err) + } + var c = &client{ + url: u, + client: http.Client{Jar: jar}, + } + err = c.login() + if err == nil { + t.Error(err) + } + + // test logging in with correct password + u, err = url.Parse("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + c.url = u + err = c.login() + if err != nil { + t.Error(err) + } +} + +// in this test, the `Bypass authentication for clients on localhost` option in qBittorrent webui should be disabled +func TestAuthorized(t *testing.T) { + // init client + u, err := url.Parse("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Error(err) + } + var c = &client{ + url: u, + client: http.Client{Jar: jar}, + } + + // test without logging in, which should be unauthorized + authorized := c.authorized() + if authorized { + t.Error("Should not be authorized") + } + + // test after logging in + err = c.login() + if err != nil { + t.Error(err) + } + authorized = c.authorized() + if !authorized { + t.Error("Should be authorized") + } +} + +func TestNew(t *testing.T) { + _, err := New("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + _, err = New("http://admin:wrong_password@127.0.0.1:8080/") + if err == nil { + t.Error("Should get an error") + } +} + +func TestAdd(t *testing.T) { + // init client + c, err := New("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + + // test add + err = c.login() + if err != nil { + t.Error(err) + } + err = c.AddFromLink( + "https://releases.ubuntu.com/22.04/ubuntu-22.04.1-desktop-amd64.iso.torrent", + "D:\\qBittorrentDownload\\alist", + "uuid-1", + ) + if err != nil { + t.Error(err) + } + err = c.AddFromLink( + "magnet:?xt=urn:btih:375ae3280cd80a8e9d7212e11dfaf7c45069dd35&dn=archlinux-2023.02.01-x86_64.iso", + "D:\\qBittorrentDownload\\alist", + "uuid-2", + ) + if err != nil { + t.Error(err) + } +} + +func TestGetInfo(t *testing.T) { + // init client + c, err := New("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + _, err = c.GetInfo("uuid-1") + if err != nil { + t.Error(err) + } +} + +func TestGetFiles(t *testing.T) { + // init client + c, err := New("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + files, err := c.GetFiles("uuid-1") + if err != nil { + t.Error(err) + } + if len(files) != 1 { + t.Error("should have exactly one file") + } +} + +func TestDelete(t *testing.T) { + // init client + c, err := New("http://admin:adminadmin@127.0.0.1:8080/") + if err != nil { + t.Error(err) + } + err = c.Delete("uuid-2") + if err != nil { + t.Error(err) + } +} diff --git a/internal/qbittorrent/monitor.go b/internal/qbittorrent/monitor.go new file mode 100644 index 00000000000..7d12e749c2a --- /dev/null +++ b/internal/qbittorrent/monitor.go @@ -0,0 +1,147 @@ +package qbittorrent + +import ( + "fmt" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/op" + "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" + "os" + "path" + "path/filepath" + "sync" + "sync/atomic" + "time" +) + +type Monitor struct { + tsk *task.Task[string] + tempDir string + dstDirPath string + finish chan struct{} +} + +func (m *Monitor) Loop() error { + var ( + err error + completed bool + ) + m.finish = make(chan struct{}) +outer: + for { + select { + case <-m.tsk.Ctx.Done(): + err = qbclient.Delete(m.tsk.ID) + return err + case <-time.After(time.Second * 2): + completed, err = m.update() + if completed { + break outer + } + } + } + if err != nil { + return err + } + m.tsk.SetStatus("qbittorrent download completed, transferring") + <-m.finish + m.tsk.SetStatus("completed") + return nil +} + +func (m *Monitor) update() (bool, error) { + info, err := qbclient.GetInfo(m.tsk.ID) + if err != nil { + m.tsk.SetStatus("qbittorrent " + string(info.State)) + return true, err + } + + progress := float64(info.Completed) / float64(info.Size) * 100 + m.tsk.SetProgress(int(progress)) + switch info.State { + case UPLOADING: + case PAUSEDUP: + case QUEUEDUP: + case STALLEDUP: + case FORCEDUP: + case CHECKINGUP: + err = m.complete() + return true, errors.WithMessage(err, "failed to transfer file") + case ALLOCATING: + case DOWNLOADING: + case METADL: + case PAUSEDDL: + case QUEUEDDL: + case STALLEDDL: + case CHECKINGDL: + case FORCEDDL: + case CHECKINGRESUMEDATA: + case MOVING: + case UNKNOWN: // or maybe should return an error for UNKNOWN? + m.tsk.SetStatus("qbittorrent downloading") + return false, nil + case ERROR: + case MISSINGFILES: + return true, errors.Errorf("failed to download %s, error: %s", m.tsk.ID, info.State) + } + return true, errors.New("unknown error occurred downloading qbittorrent") // should never happen +} + +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") + } + // get files + files, err := qbclient.GetFiles(m.tsk.ID) + if err != nil { + return errors.Wrapf(err, "failed to get files of %s", m.tsk.ID) + } + log.Debugf("files len: %d", len(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 qbittorrent temp dir: %+v", err.Error()) + } + }() + for _, file := range files { + filePath := filepath.Join(m.tempDir, file.Name) + TransferTaskManager.Submit(task.WithCancelCtx(&task.Task[uint64]{ + Name: fmt.Sprintf("transfer %s to [%s](%s)", filePath, storage.GetStorage().MountPath, dstDirActualPath), + Func: func(tsk *task.Task[uint64]) error { + defer wg.Done() + size := file.Size + mimetype := utils.GetMimeType(filePath) + f, err := os.Open(filePath) + if err != nil { + return errors.Wrapf(err, "failed to open file %s", filePath) + } + stream := &model.FileStream{ + Obj: &model.Object{ + Name: path.Base(filePath), + Size: size, + Modified: time.Now(), + IsFolder: false, + }, + ReadCloser: f, + Mimetype: mimetype, + } + newDistDir := filepath.Join(dstDirActualPath, file.Name) + return op.Put(tsk.Ctx, storage, newDistDir, stream, tsk.SetProgress) + }, + })) + } + return nil +} diff --git a/internal/qbittorrent/qbittorrent.go b/internal/qbittorrent/qbittorrent.go new file mode 100644 index 00000000000..d011717578e --- /dev/null +++ b/internal/qbittorrent/qbittorrent.go @@ -0,0 +1,23 @@ +package qbittorrent + +import ( + "github.com/alist-org/alist/v3/internal/conf" + "github.com/alist-org/alist/v3/internal/setting" + "github.com/alist-org/alist/v3/pkg/task" +) + +var DownTaskManager = task.NewTaskManager[string](3) +var qbclient Client + +func InitClient() error { + var err error + qbclient = nil + + url := setting.GetStr(conf.QbittorrentUrl) + qbclient, err = New(url) + return err +} + +func IsQbittorrentReady() bool { + return qbclient != nil +} diff --git a/server/handles/qbittorrent.go b/server/handles/qbittorrent.go new file mode 100644 index 00000000000..4de61a28137 --- /dev/null +++ b/server/handles/qbittorrent.go @@ -0,0 +1,69 @@ +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"` +} + +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.QBITTORRENT, 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() { + common.ErrorStrResp(c, "qbittorrent not ready", 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 996fa40d2ed..08f3a4b4c66 100644 --- a/server/handles/task.go +++ b/server/handles/task.go @@ -5,6 +5,7 @@ import ( "github.com/alist-org/alist/v3/internal/aria2" "github.com/alist-org/alist/v3/internal/fs" + "github.com/alist-org/alist/v3/internal/qbittorrent" "github.com/alist-org/alist/v3/pkg/task" "github.com/alist-org/alist/v3/server/common" "github.com/gin-gonic/gin" @@ -19,20 +20,19 @@ type TaskInfo struct { Error string `json:"error"` } -func getTaskInfoUint(task *task.Task[uint64]) TaskInfo { - return TaskInfo{ - ID: strconv.FormatUint(task.ID, 10), - Name: task.Name, - State: task.GetState(), - Status: task.GetStatus(), - Progress: task.GetProgress(), - Error: task.GetErrMsg(), - } +type K2Str[K comparable] func(k K) string + +func uint64K2Str(k uint64) string { + return strconv.FormatUint(k, 10) +} + +func strK2Str(str string) string { + return str } -func getTaskInfoStr(task *task.Task[string]) TaskInfo { +func getTaskInfo[K comparable](task *task.Task[K], k2Str K2Str[K]) TaskInfo { return TaskInfo{ - ID: task.ID, + ID: k2Str(task.ID), Name: task.Name, State: task.GetState(), Status: task.GetStatus(), @@ -41,172 +41,68 @@ func getTaskInfoStr(task *task.Task[string]) TaskInfo { } } -func getTaskInfosUint(tasks []*task.Task[uint64]) []TaskInfo { +func getTaskInfos[K comparable](tasks []*task.Task[K], k2Str K2Str[K]) []TaskInfo { var infos []TaskInfo for _, t := range tasks { - infos = append(infos, getTaskInfoUint(t)) + infos = append(infos, getTaskInfo(t, k2Str)) } return infos } -func getTaskInfosStr(tasks []*task.Task[string]) []TaskInfo { - var infos []TaskInfo - for _, t := range tasks { - infos = append(infos, getTaskInfoStr(t)) - } - return infos -} - -func UndoneDownTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosStr(aria2.DownTaskManager.ListUndone())) -} - -func DoneDownTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosStr(aria2.DownTaskManager.ListDone())) -} - -func CancelDownTask(c *gin.Context) { - tid := c.Query("tid") - if err := aria2.DownTaskManager.Cancel(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } -} - -func DeleteDownTask(c *gin.Context) { - tid := c.Query("tid") - if err := aria2.DownTaskManager.Remove(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } -} - -func ClearDoneDownTasks(c *gin.Context) { - aria2.DownTaskManager.ClearDone() - common.SuccessResp(c) -} - -func UndoneTransferTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosUint(aria2.TransferTaskManager.ListUndone())) -} - -func DoneTransferTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosUint(aria2.TransferTaskManager.ListDone())) -} - -func CancelTransferTask(c *gin.Context) { - id := c.Query("tid") - tid, err := strconv.ParseUint(id, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - if err := aria2.TransferTaskManager.Cancel(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { +type Str2K[K comparable] func(str string) (K, error) + +func str2Uint64K(str string) (uint64, error) { + return strconv.ParseUint(str, 10, 64) +} + +func str2StrK(str string) (string, error) { + return str, nil +} + +func taskRoute[K comparable](g *gin.RouterGroup, manager *task.Manager[K], k2Str K2Str[K], str2K Str2K[K]) { + g.GET("/undone", func(c *gin.Context) { + common.SuccessResp(c, getTaskInfos(manager.ListUndone(), k2Str)) + }) + g.GET("/done", func(c *gin.Context) { + common.SuccessResp(c, getTaskInfos(manager.ListDone(), k2Str)) + }) + g.POST("/cancel", func(c *gin.Context) { + tid := c.Query("tid") + id, err := str2K(tid) + if err != nil { + common.ErrorResp(c, err, 400) + return + } + if err := manager.Cancel(id); err != nil { + common.ErrorResp(c, err, 500) + } else { + common.SuccessResp(c) + } + }) + g.POST("/delete", func(c *gin.Context) { + tid := c.Query("tid") + id, err := str2K(tid) + if err != nil { + common.ErrorResp(c, err, 400) + return + } + if err := manager.Remove(id); err != nil { + common.ErrorResp(c, err, 500) + } else { + common.SuccessResp(c) + } + }) + g.POST("/clear_done", func(c *gin.Context) { + manager.ClearDone() common.SuccessResp(c) - } -} - -func DeleteTransferTask(c *gin.Context) { - id := c.Query("tid") - tid, err := strconv.ParseUint(id, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - if err := aria2.TransferTaskManager.Remove(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } -} - -func ClearDoneTransferTasks(c *gin.Context) { - aria2.TransferTaskManager.ClearDone() - common.SuccessResp(c) -} - -func UndoneUploadTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosUint(fs.UploadTaskManager.ListUndone())) -} - -func DoneUploadTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosUint(fs.UploadTaskManager.ListDone())) -} - -func CancelUploadTask(c *gin.Context) { - id := c.Query("tid") - tid, err := strconv.ParseUint(id, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - if err := fs.UploadTaskManager.Cancel(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } -} - -func DeleteUploadTask(c *gin.Context) { - id := c.Query("tid") - tid, err := strconv.ParseUint(id, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - if err := fs.UploadTaskManager.Remove(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } -} - -func ClearDoneUploadTasks(c *gin.Context) { - fs.UploadTaskManager.ClearDone() - common.SuccessResp(c) -} - -func UndoneCopyTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosUint(fs.CopyTaskManager.ListUndone())) -} - -func DoneCopyTask(c *gin.Context) { - common.SuccessResp(c, getTaskInfosUint(fs.CopyTaskManager.ListDone())) -} - -func CancelCopyTask(c *gin.Context) { - id := c.Query("tid") - tid, err := strconv.ParseUint(id, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - if err := fs.CopyTaskManager.Cancel(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } -} - -func DeleteCopyTask(c *gin.Context) { - id := c.Query("tid") - tid, err := strconv.ParseUint(id, 10, 64) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - if err := fs.CopyTaskManager.Remove(tid); err != nil { - common.ErrorResp(c, err, 500) - } else { - common.SuccessResp(c) - } + }) } -func ClearDoneCopyTasks(c *gin.Context) { - fs.CopyTaskManager.ClearDone() - common.SuccessResp(c) +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) } diff --git a/server/router.go b/server/router.go index 1580e254b70..987545df2f8 100644 --- a/server/router.go +++ b/server/router.go @@ -89,28 +89,10 @@ func admin(g *gin.RouterGroup) { setting.POST("/delete", handles.DeleteSetting) setting.POST("/reset_token", handles.ResetToken) setting.POST("/set_aria2", handles.SetAria2) + setting.POST("/set_qbittorrent", handles.SetQbittorrent) task := g.Group("/task") - task.GET("/down/undone", handles.UndoneDownTask) - task.GET("/down/done", handles.DoneDownTask) - task.POST("/down/cancel", handles.CancelDownTask) - task.POST("/down/delete", handles.DeleteDownTask) - task.POST("/down/clear_done", handles.ClearDoneDownTasks) - task.GET("/transfer/undone", handles.UndoneTransferTask) - task.GET("/transfer/done", handles.DoneTransferTask) - task.POST("/transfer/cancel", handles.CancelTransferTask) - task.POST("/transfer/delete", handles.DeleteTransferTask) - task.POST("/transfer/clear_done", handles.ClearDoneTransferTasks) - task.GET("/upload/undone", handles.UndoneUploadTask) - task.GET("/upload/done", handles.DoneUploadTask) - task.POST("/upload/cancel", handles.CancelUploadTask) - task.POST("/upload/delete", handles.DeleteUploadTask) - task.POST("/upload/clear_done", handles.ClearDoneUploadTasks) - task.GET("/copy/undone", handles.UndoneCopyTask) - task.GET("/copy/done", handles.DoneCopyTask) - task.POST("/copy/cancel", handles.CancelCopyTask) - task.POST("/copy/delete", handles.DeleteCopyTask) - task.POST("/copy/clear_done", handles.ClearDoneCopyTasks) + handles.SetupTaskRoute(task) ms := g.Group("/message") ms.POST("/get", message.HttpInstance.GetHandle) @@ -139,6 +121,7 @@ func _fs(g *gin.RouterGroup) { 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) } func Cors(r *gin.Engine) {