package torrent import ( "encoding/json" "fmt" "io" "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) { log.Trace().Msg("qbittorrent listing torrents") req, err := http.NewRequest("GET", c.baseURL+"/api/v2/torrents/info", nil) if err != nil { log.Error().Err(err).Msg("qbittorrent creating list request failed") return nil, fmt.Errorf("creating list 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 list request failed") return nil, fmt.Errorf("list request failed: %w", err) } defer resp.Body.Close() log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent list response") if resp.StatusCode != http.StatusOK { log.Error().Int("status", resp.StatusCode).Msg("qbittorrent list returned non-OK status") return nil, fmt.Errorf("list 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 list response failed") return nil, fmt.Errorf("decoding list response: %w", err) } torrents := make([]TorrentInfo, len(items)) for i, item := range items { torrents[i] = item.toTorrentInfo() } log.Debug().Int("count", len(torrents)).Msg("qbittorrent torrents listed") return torrents, 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, } }