Cloudreve/pkg/downloader/qbittorrent/qbittorrent.go

396 lines
11 KiB
Go

package qbittorrent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"path/filepath"
"strings"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
"github.com/samber/lo"
)
const (
apiPrefix = "/api/v2"
successResponse = "Ok."
crTagPrefix = "cr-"
downloadPrioritySkip = 0
downloadPriorityDownload = 1
)
var (
downloadOptionFormatTypes = map[string]string{
"cookie": "%s",
"skip_checking": "%s",
"root_folder": "%s",
"rename": "%s",
"upLimit": "%.0f",
"dlLimit": "%.0f",
"ratioLimit": "%f",
"seedingTimeLimit": "%.0f",
"autoTMM": "%t",
"sequentialDownload": "%s",
"firstLastPiecePrio": "%t",
}
)
type qbittorrentClient struct {
c request.Client
settings setting.Provider
l logging.Logger
options *types.QBittorrentSetting
}
func NewClient(l logging.Logger, c request.Client, setting setting.Provider, options *types.QBittorrentSetting) (downloader.Downloader, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
server, err := url.Parse(options.Server)
if err != nil {
return nil, fmt.Errorf("invalid qbittorrent server URL: %w", err)
}
base, _ := url.Parse(apiPrefix)
c.Apply(
request.WithCookieJar(jar),
request.WithLogger(l),
request.WithEndpoint(options.Server),
request.WithEndpoint(server.ResolveReference(base).String()),
)
return &qbittorrentClient{c: c, options: options, l: l, settings: setting}, nil
}
func (c *qbittorrentClient) SetFilesToDownload(ctx context.Context, handle *downloader.TaskHandle, args ...*downloader.SetFileToDownloadArgs) error {
downloadId := make([]int, 0, len(args))
skipId := make([]int, 0, len(args))
for _, arg := range args {
if arg.Download {
downloadId = append(downloadId, arg.Index)
} else {
skipId = append(skipId, arg.Index)
}
}
if len(downloadId) > 0 {
if err := c.setFilePriority(ctx, handle.Hash, downloadPriorityDownload, downloadId...); err != nil {
return fmt.Errorf("failed to set file priority to download: %w", err)
}
}
if len(skipId) > 0 {
if err := c.setFilePriority(ctx, handle.Hash, downloadPrioritySkip, skipId...); err != nil {
return fmt.Errorf("failed to set file priority to skip: %w", err)
}
}
return nil
}
func (c *qbittorrentClient) Cancel(ctx context.Context, handle *downloader.TaskHandle) error {
buffer := bytes.Buffer{}
formWriter := multipart.NewWriter(&buffer)
_ = formWriter.WriteField("hashes", handle.Hash)
_ = formWriter.WriteField("deleteFiles", "true")
headers := http.Header{
"Content-Type": []string{formWriter.FormDataContentType()},
}
_, err := c.request(ctx, http.MethodPost, "torrents/delete", buffer.String(), &headers)
if err != nil {
return fmt.Errorf("failed to cancel task with hash %q: %w", handle.Hash, err)
}
// Delete tags
buffer = bytes.Buffer{}
formWriter = multipart.NewWriter(&buffer)
_ = formWriter.WriteField("tags", crTagPrefix+handle.ID)
headers = http.Header{
"Content-Type": []string{formWriter.FormDataContentType()},
}
_, err = c.request(ctx, http.MethodPost, "torrents/deleteTags", buffer.String(), &headers)
if err != nil {
return fmt.Errorf("failed to delete tag with id %q: %w", handle.ID, err)
}
return nil
}
func (c *qbittorrentClient) Info(ctx context.Context, handle *downloader.TaskHandle) (*downloader.TaskStatus, error) {
buffer := bytes.Buffer{}
formWriter := multipart.NewWriter(&buffer)
_ = formWriter.WriteField("tag", crTagPrefix+handle.ID)
headers := http.Header{
"Content-Type": []string{formWriter.FormDataContentType()},
}
// Get task info
resp, err := c.request(ctx, http.MethodPost, "torrents/info", buffer.String(), &headers)
if err != nil {
return nil, fmt.Errorf("failed to get task info with tag %q: %w", crTagPrefix+handle.ID, err)
}
var torrents []Torrent
if err := json.Unmarshal([]byte(resp), &torrents); err != nil {
return nil, fmt.Errorf("failed to unmarshal info response: %w", err)
}
if len(torrents) == 0 {
return nil, fmt.Errorf("no torrent under tag %q: %w", crTagPrefix+handle.ID, downloader.ErrTaskNotFount)
}
// Get file info
buffer = bytes.Buffer{}
formWriter = multipart.NewWriter(&buffer)
_ = formWriter.WriteField("hash", torrents[0].Hash)
headers = http.Header{
"Content-Type": []string{formWriter.FormDataContentType()},
}
resp, err = c.request(ctx, http.MethodPost, "torrents/files", buffer.String(), &headers)
if err != nil {
return nil, fmt.Errorf("failed to get torrent files with hash %q: %w", torrents[0].Hash, err)
}
var files []File
if err := json.Unmarshal([]byte(resp), &files); err != nil {
return nil, fmt.Errorf("failed to unmarshal files response: %w", err)
}
// Get piece status
resp, err = c.request(ctx, http.MethodPost, "torrents/pieceStates", buffer.String(), &headers)
if err != nil {
return nil, fmt.Errorf("failed to get torrent pieceStates with hash %q: %w", torrents[0].Hash, err)
}
var pieceStates []int
if err := json.Unmarshal([]byte(resp), &pieceStates); err != nil {
return nil, fmt.Errorf("failed to unmarshal pieceStates response: %w", err)
}
// Combining and converting all info
state := downloader.StatusDownloading
switch torrents[0].State {
case "downloading", "pausedDL", "allocating", "metaDL", "queuedDL", "stalledDL", "checkingDL", "forcedDL", "checkingResumeData", "moving", "forcedMetaDL":
state = downloader.StatusDownloading
case "uploading", "queuedUP", "stalledUP", "checkingUP", "forcedUP":
state = downloader.StatusSeeding
case "pausedUP", "stoppedUP":
state = downloader.StatusCompleted
case "error", "missingFiles":
state = downloader.StatusError
default:
state = downloader.StatusUnknown
}
status := &downloader.TaskStatus{
Name: torrents[0].Name,
Total: torrents[0].Size,
Downloaded: torrents[0].Completed,
DownloadSpeed: torrents[0].Dlspeed,
Uploaded: torrents[0].Uploaded,
UploadSpeed: torrents[0].Upspeed,
SavePath: filepath.ToSlash(torrents[0].SavePath),
State: state,
Hash: torrents[0].Hash,
Files: lo.Map(files, func(item File, index int) downloader.TaskFile {
return downloader.TaskFile{
Index: item.Index,
Name: filepath.ToSlash(item.Name),
Size: item.Size,
Progress: item.Progress,
Selected: item.Priority > 0,
}
}),
}
if handle.Hash != torrents[0].Hash {
handle.Hash = torrents[0].Hash
status.FollowedBy = handle
}
// Convert piece states to hex bytes array, The highest bit corresponds to the piece at index 0.
status.NumPieces = len(pieceStates)
pieces := make([]byte, 0, len(pieceStates)/8+1)
for i := 0; i < len(pieceStates); i += 8 {
var b byte
for j := 0; j < 8; j++ {
if i+j >= len(pieceStates) {
break
}
pieceStatus := 0
if pieceStates[i+j] == 2 {
pieceStatus = 1
}
b |= byte(pieceStatus) << uint(7-j)
}
pieces = append(pieces, b)
}
status.Pieces = pieces
return status, nil
}
func (c *qbittorrentClient) CreateTask(ctx context.Context, url string, options map[string]interface{}) (*downloader.TaskHandle, error) {
guid, _ := uuid.NewV4()
// Generate a unique path for the task
base := util.RelativePath(c.options.TempPath)
if c.options.TempPath == "" {
base = util.DataPath(c.settings.TempPath(ctx))
}
path := filepath.Join(
base,
"qbittorrent",
guid.String(),
)
c.l.Info("Creating QBitTorrent task with url %q saving to %q...", url, path)
buffer := bytes.Buffer{}
formWriter := multipart.NewWriter(&buffer)
_ = formWriter.WriteField("urls", url)
_ = formWriter.WriteField("savepath", path)
_ = formWriter.WriteField("tags", crTagPrefix+guid.String())
// Apply global options
for k, v := range c.options.Options {
if _, ok := downloadOptionFormatTypes[k]; ok {
_ = formWriter.WriteField(k, fmt.Sprintf(downloadOptionFormatTypes[k], v))
}
}
// Apply group options
for k, v := range options {
if _, ok := downloadOptionFormatTypes[k]; ok {
_ = formWriter.WriteField(k, fmt.Sprintf(downloadOptionFormatTypes[k], v))
}
}
// Send request
headers := http.Header{
"Content-Type": []string{formWriter.FormDataContentType()},
}
resp, err := c.request(ctx, http.MethodPost, "torrents/add", buffer.String(), &headers)
if err != nil {
return nil, fmt.Errorf("create task qbittorrent failed: %w", err)
}
if resp != successResponse {
return nil, fmt.Errorf("create task qbittorrent failed: %s", resp)
}
return &downloader.TaskHandle{
ID: guid.String(),
}, nil
}
func (c *qbittorrentClient) setFilePriority(ctx context.Context, hash string, priority int, id ...int) error {
buffer := bytes.Buffer{}
formWriter := multipart.NewWriter(&buffer)
_ = formWriter.WriteField("hash", hash)
_ = formWriter.WriteField("id", strings.Join(
lo.Map(id, func(item int, index int) string {
return fmt.Sprintf("%d", item)
}), "|"))
_ = formWriter.WriteField("priority", fmt.Sprintf("%d", priority))
headers := http.Header{
"Content-Type": []string{formWriter.FormDataContentType()},
}
_, err := c.request(ctx, http.MethodPost, "torrents/filePrio", buffer.String(), &headers)
if err != nil {
return fmt.Errorf("failed to set file priority: %w", err)
}
return nil
}
func (c *qbittorrentClient) Test(ctx context.Context) (string, error) {
res, err := c.request(ctx, http.MethodGet, "app/version", "", nil)
if err != nil {
return "", fmt.Errorf("test qbittorrent failed: %w", err)
}
return res, nil
}
func (c *qbittorrentClient) login(ctx context.Context) error {
form := url.Values{}
form.Add("username", c.options.User)
form.Add("password", c.options.Password)
res, err := c.c.Request(http.MethodPost, "auth/login",
strings.NewReader(form.Encode()),
request.WithContext(ctx),
request.WithHeader(http.Header{
"Content-Type": []string{"application/x-www-form-urlencoded"},
}),
).CheckHTTPResponse(http.StatusOK).GetResponse()
if err != nil {
return fmt.Errorf("login failed with unexpected status code: %w", err)
}
if res != successResponse {
return fmt.Errorf("login failed with response: %s, possibly inccorrect credential is provided", res)
}
return nil
}
func (c *qbittorrentClient) request(ctx context.Context, method, path string, body string, headers *http.Header) (string, error) {
opts := []request.Option{
request.WithContext(ctx),
}
if headers != nil {
opts = append(opts, request.WithHeader(*headers))
}
res := c.c.Request(method, path, strings.NewReader(body), opts...)
if res.Err != nil {
return "", fmt.Errorf("send request failed: %w", res.Err)
}
switch res.Response.StatusCode {
case http.StatusForbidden:
c.l.Info("QBittorrent cookie expired, sending login request...")
if err := c.login(ctx); err != nil {
return "", fmt.Errorf("login failed: %w", err)
}
return c.request(ctx, method, path, body, headers)
case http.StatusOK:
respContent, err := res.GetResponse()
if err != nil {
return "", fmt.Errorf("failed reading response: %w", err)
}
return respContent, nil
case http.StatusUnsupportedMediaType:
return "", fmt.Errorf("invalid torrent file")
default:
content, _ := res.GetResponse()
return "", fmt.Errorf("unexpected status code: %d, content: %s", res.Response.StatusCode, content)
}
}