306 lines
8.9 KiB
Go
306 lines
8.9 KiB
Go
package torrent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type QbittorrentClient struct {
|
|
baseURL string
|
|
client *http.Client
|
|
sid string
|
|
}
|
|
|
|
func NewQbittorrentClient(baseURL string) *QbittorrentClient {
|
|
return &QbittorrentClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
client: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *QbittorrentClient) Login(username string, password string) (string, error) {
|
|
log.Trace().Str("url", c.baseURL).Str("username", username).Msg("qbittorrent login attempt")
|
|
|
|
data := url.Values{
|
|
"username": {username},
|
|
"password": {password},
|
|
}
|
|
|
|
start := time.Now()
|
|
resp, err := c.client.PostForm(c.baseURL+"/api/v2/auth/login", data)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("url", c.baseURL).Msg("qbittorrent login request failed")
|
|
return "", fmt.Errorf("login request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent login response")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent reading login response failed")
|
|
return "", fmt.Errorf("reading login response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
log.Warn().Msg("qbittorrent login forbidden: too many failed attempts")
|
|
return "", fmt.Errorf("login forbidden: too many failed attempts")
|
|
}
|
|
|
|
if string(body) != "Ok." {
|
|
log.Warn().Str("response", string(body)).Msg("qbittorrent login rejected")
|
|
return "", fmt.Errorf("login failed: %s", string(body))
|
|
}
|
|
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == "SID" {
|
|
c.sid = cookie.Value
|
|
log.Info().Str("url", c.baseURL).Msg("qbittorrent login successful")
|
|
log.Trace().Str("sid", c.sid).Msg("qbittorrent session ID acquired")
|
|
return c.sid, nil
|
|
}
|
|
}
|
|
|
|
log.Error().Msg("qbittorrent login succeeded but no SID cookie returned")
|
|
return "", fmt.Errorf("login succeeded but no SID cookie returned")
|
|
}
|
|
|
|
func (c *QbittorrentClient) List() ([]TorrentInfo, error) {
|
|
return c.Find(FindOptions{})
|
|
}
|
|
|
|
func (c *QbittorrentClient) Find(opts FindOptions) ([]TorrentInfo, error) {
|
|
log.Trace().
|
|
Str("hash", opts.Hash).
|
|
Str("name", opts.Name).
|
|
Str("category", opts.Category).
|
|
Str("tag", opts.Tag).
|
|
Str("state", opts.State).
|
|
Msg("qbittorrent finding torrents")
|
|
|
|
params := url.Values{}
|
|
if opts.Hash != "" {
|
|
params.Set("hashes", opts.Hash)
|
|
}
|
|
if opts.Category != "" {
|
|
params.Set("category", opts.Category)
|
|
}
|
|
if opts.Tag != "" {
|
|
params.Set("tag", opts.Tag)
|
|
}
|
|
if opts.State != "" {
|
|
params.Set("filter", opts.State)
|
|
}
|
|
|
|
reqURL := c.baseURL + "/api/v2/torrents/info"
|
|
if len(params) > 0 {
|
|
reqURL += "?" + params.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", reqURL, nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent creating find request failed")
|
|
return nil, fmt.Errorf("creating find request: %w", err)
|
|
}
|
|
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
|
|
|
start := time.Now()
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent find request failed")
|
|
return nil, fmt.Errorf("find request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent find response")
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Error().Int("status", resp.StatusCode).Msg("qbittorrent find returned non-OK status")
|
|
return nil, fmt.Errorf("find request returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var items []QbittorrentListItem
|
|
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent decoding find response failed")
|
|
return nil, fmt.Errorf("decoding find response: %w", err)
|
|
}
|
|
|
|
torrents := make([]TorrentInfo, len(items))
|
|
for i, item := range items {
|
|
torrents[i] = item.toTorrentInfo()
|
|
}
|
|
|
|
torrents = filterLocally(torrents, opts)
|
|
|
|
log.Debug().Int("count", len(torrents)).Msg("qbittorrent find results")
|
|
|
|
return torrents, nil
|
|
}
|
|
|
|
func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
|
|
var result []TorrentInfo
|
|
|
|
for _, t := range torrents {
|
|
if opts.Name != "" && !strings.Contains(strings.ToLower(t.Name), strings.ToLower(opts.Name)) {
|
|
continue
|
|
}
|
|
if opts.Hash != "" && !strings.EqualFold(t.Hash, opts.Hash) {
|
|
continue
|
|
}
|
|
if opts.Category != "" && !strings.EqualFold(t.Category, opts.Category) {
|
|
continue
|
|
}
|
|
if opts.Tag != "" && !strings.Contains(strings.ToLower(t.Tags), strings.ToLower(opts.Tag)) {
|
|
continue
|
|
}
|
|
if opts.State != "" && !strings.EqualFold(t.State, opts.State) {
|
|
continue
|
|
}
|
|
result = append(result, t)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
|
|
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent file")
|
|
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
|
|
part, err := writer.CreateFormFile("torrents", file.Filename)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent creating multipart form failed")
|
|
return fmt.Errorf("creating multipart form: %w", err)
|
|
}
|
|
|
|
if _, err := part.Write(file.Data); err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent writing torrent data failed")
|
|
return fmt.Errorf("writing torrent data: %w", err)
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return fmt.Errorf("closing multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", &buf)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent creating add request failed")
|
|
return fmt.Errorf("creating add request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
|
|
|
return c.doAdd(req, file.Filename)
|
|
}
|
|
|
|
func (c *QbittorrentClient) AddMagnet(magnetURI string) error {
|
|
truncated := magnetURI
|
|
if len(truncated) > 80 {
|
|
truncated = truncated[:80] + "..."
|
|
}
|
|
log.Trace().Str("magnet", truncated).Msg("qbittorrent adding magnet")
|
|
|
|
data := url.Values{"urls": {magnetURI}}
|
|
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
|
|
return fmt.Errorf("creating magnet add request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
|
|
|
return c.doAdd(req, truncated)
|
|
}
|
|
|
|
func (c *QbittorrentClient) doAdd(req *http.Request, label string) error {
|
|
start := time.Now()
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent add request failed")
|
|
return fmt.Errorf("add request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("qbittorrent reading add response failed")
|
|
return fmt.Errorf("reading add response: %w", err)
|
|
}
|
|
|
|
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent add response")
|
|
|
|
if resp.StatusCode != http.StatusOK || string(body) != "Ok." {
|
|
log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("qbittorrent add failed")
|
|
return fmt.Errorf("add failed: status %d, body: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
log.Info().Str("label", label).Msg("qbittorrent torrent added")
|
|
return nil
|
|
}
|
|
|
|
type QbittorrentListItem struct {
|
|
Hash string `json:"hash"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
Progress float64 `json:"progress"`
|
|
DlSpeed int64 `json:"dlspeed"`
|
|
UpSpeed int64 `json:"upspeed"`
|
|
NumSeeds int32 `json:"num_seeds"`
|
|
NumLeechs int32 `json:"num_leechs"`
|
|
State string `json:"state"`
|
|
ETA int64 `json:"eta"`
|
|
Ratio float64 `json:"ratio"`
|
|
Category string `json:"category"`
|
|
Tags string `json:"tags"`
|
|
AddedOn int64 `json:"added_on"`
|
|
CompletionOn int64 `json:"completion_on"`
|
|
SavePath string `json:"save_path"`
|
|
ContentPath string `json:"content_path"`
|
|
Downloaded int64 `json:"downloaded"`
|
|
Uploaded int64 `json:"uploaded"`
|
|
Tracker string `json:"tracker"`
|
|
SeedingTime int64 `json:"seeding_time"`
|
|
AmountLeft int64 `json:"amount_left"`
|
|
Availability float64 `json:"availability"`
|
|
}
|
|
|
|
func (t *QbittorrentListItem) toTorrentInfo() TorrentInfo {
|
|
return TorrentInfo{
|
|
Hash: t.Hash,
|
|
Name: t.Name,
|
|
Size: t.Size,
|
|
Progress: t.Progress,
|
|
DlSpeed: t.DlSpeed,
|
|
UpSpeed: t.UpSpeed,
|
|
NumSeeds: t.NumSeeds,
|
|
NumLeechs: t.NumLeechs,
|
|
State: t.State,
|
|
ETA: t.ETA,
|
|
Ratio: t.Ratio,
|
|
Category: t.Category,
|
|
Tags: t.Tags,
|
|
AddedOn: t.AddedOn,
|
|
CompletionOn: t.CompletionOn,
|
|
SavePath: t.SavePath,
|
|
ContentPath: t.ContentPath,
|
|
Downloaded: t.Downloaded,
|
|
Uploaded: t.Uploaded,
|
|
Tracker: t.Tracker,
|
|
SeedingTime: t.SeedingTime,
|
|
AmountLeft: t.AmountLeft,
|
|
Availability: t.Availability,
|
|
}
|
|
}
|