Files
music-agregator/internal/torrent/qbittorrent_client.go
T
2026-05-07 10:27:20 +02:00

284 lines
8.1 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) Add(file TorrentFile) error {
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent")
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})
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 torrent failed")
return fmt.Errorf("add torrent failed: status %d, body: %s", resp.StatusCode, string(body))
}
log.Info().Str("filename", file.Filename).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,
}
}