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, savePath string) error { log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Str("save_path", savePath).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 savePath != "" { if err := writer.WriteField("savepath", savePath); err != nil { return fmt.Errorf("writing savepath field: %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, savePath string) error { truncated := magnetURI if len(truncated) > 80 { truncated = truncated[:80] + "..." } log.Trace().Str("magnet", truncated).Str("save_path", savePath).Msg("qbittorrent adding magnet") data := url.Values{"urls": {magnetURI}} if savePath != "" { data.Set("savepath", savePath) } 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, } } func (c *QbittorrentClient) DefaultSavePath() (string, error) { req, err := http.NewRequest("GET", c.baseURL+"/api/v2/app/defaultSavePath", nil) if err != nil { return "", fmt.Errorf("creating request: %w", err) } req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid}) resp, err := c.client.Do(req) if err != nil { return "", fmt.Errorf("requesting default save path: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("default save path returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading response: %w", err) } return strings.TrimSpace(string(body)), nil }