alist/pkg/qbittorrent/client.go

367 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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
}