package download import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) type QBittorrentClient struct { baseURL string username string password string client *http.Client mu sync.Mutex sid *http.Cookie } func NewQBittorrentClient(baseURL, password string) *QBittorrentClient { return &QBittorrentClient{ baseURL: strings.TrimRight(baseURL, "/"), username: "admin", password: password, client: &http.Client{Timeout: 30 * time.Second}, } } func (q *QBittorrentClient) login(ctx context.Context) error { q.mu.Lock() defer q.mu.Unlock() form := url.Values{} form.Set("username", q.username) form.Set("password", q.password) req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode())) if err != nil { return fmt.Errorf("qbittorrent login request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := q.client.Do(req) if err != nil { return fmt.Errorf("qbittorrent login: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "Ok.") { return fmt.Errorf("qbittorrent login failed: %s", string(body)) } for _, cookie := range resp.Cookies() { if cookie.Name == "SID" { q.sid = cookie return nil } } return fmt.Errorf("qbittorrent login: no SID cookie in response") } func (q *QBittorrentClient) doAuthenticated(ctx context.Context, method, path string, body url.Values) (*http.Response, error) { var bodyReader io.Reader if body != nil { bodyReader = strings.NewReader(body.Encode()) } req, err := http.NewRequestWithContext(ctx, method, q.baseURL+path, bodyReader) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if q.sid != nil { req.AddCookie(q.sid) } resp, err := q.client.Do(req) if err != nil { return nil, err } if resp.StatusCode == http.StatusForbidden { resp.Body.Close() if err := q.login(ctx); err != nil { return nil, fmt.Errorf("qbittorrent re-auth: %w", err) } req2, err := http.NewRequestWithContext(ctx, method, q.baseURL+path, bodyReader) if err != nil { return nil, err } if body != nil { req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") } req2.AddCookie(q.sid) return q.client.Do(req2) } return resp, nil } func (q *QBittorrentClient) Add(ctx context.Context, torrentURL string, category string) (string, error) { if category == "" { category = "umm" } form := url.Values{} form.Set("urls", torrentURL) form.Set("category", category) resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/add", form) if err != nil { return "", fmt.Errorf("qbittorrent add: %w", err) } resp.Body.Close() resp, err = q.doAuthenticated(ctx, http.MethodGet, fmt.Sprintf("/api/v2/torrents/info?sort=added_on&reverse=true&category=%s", url.QueryEscape(category)), nil) if err != nil { return "", fmt.Errorf("qbittorrent add verify: %w", err) } defer resp.Body.Close() var torrents []qbTorrentInfo if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { return "", fmt.Errorf("qbittorrent add decode: %w", err) } for _, t := range torrents { if t.MagnetURI == torrentURL || strings.Contains(t.Tracker, torrentURL) { return t.Hash, nil } } if len(torrents) > 0 { return torrents[0].Hash, nil } return "", fmt.Errorf("qbittorrent add: torrent not found after adding") } func (q *QBittorrentClient) GetProgress(ctx context.Context, hash string) (*DownloadProgress, error) { resp, err := q.doAuthenticated(ctx, http.MethodGet, fmt.Sprintf("/api/v2/torrents/info?hashes=%s", url.QueryEscape(hash)), nil) if err != nil { return nil, fmt.Errorf("qbittorrent progress: %w", err) } defer resp.Body.Close() var torrents []qbTorrentInfo if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { return nil, fmt.Errorf("qbittorrent progress decode: %w", err) } if len(torrents) == 0 { return nil, fmt.Errorf("qbittorrent: torrent %s not found", hash) } t := torrents[0] return &DownloadProgress{ ID: t.Hash, Name: t.Name, Status: qbStateToStatus(t.State), Progress: t.Progress * 100, Speed: t.DLSpeed, ETA: t.ETA, Size: t.Size, }, nil } func (q *QBittorrentClient) GetCompleted(ctx context.Context) ([]CompletedDownload, error) { resp, err := q.doAuthenticated(ctx, http.MethodGet, "/api/v2/torrents/info?filter=completed", nil) if err != nil { return nil, fmt.Errorf("qbittorrent completed: %w", err) } defer resp.Body.Close() var torrents []qbTorrentInfo if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { return nil, fmt.Errorf("qbittorrent completed decode: %w", err) } var completed []CompletedDownload for _, t := range torrents { completed = append(completed, CompletedDownload{ ID: t.Hash, Name: t.Name, OutputPath: t.ContentPath, Size: t.Size, Status: qbStateToStatus(t.State), }) } return completed, nil } func (q *QBittorrentClient) Remove(ctx context.Context, hash string) error { form := url.Values{} form.Set("hashes", hash) form.Set("deleteFiles", "true") resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/delete", form) if err != nil { return fmt.Errorf("qbittorrent remove: %w", err) } resp.Body.Close() return nil } func (q *QBittorrentClient) Pause(ctx context.Context, hash string) error { form := url.Values{} form.Set("hashes", hash) resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/pause", form) if err != nil { return fmt.Errorf("qbittorrent pause: %w", err) } resp.Body.Close() return nil } func (q *QBittorrentClient) Resume(ctx context.Context, hash string) error { form := url.Values{} form.Set("hashes", hash) resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/resume", form) if err != nil { return fmt.Errorf("qbittorrent resume: %w", err) } resp.Body.Close() return nil } func qbStateToStatus(state string) string { switch state { case "downloading", "stalledDL", "forcedDL", "metaDL", "forcedMetaDL": return "downloading" case "uploading", "stalledUP", "forcedUP": return "completed" case "pausedDL", "pausedUP": return "paused" case "queuedDL", "queuedUP": return "queued" case "checkingDL", "checkingUP", "moving": return "checking" case "error", "missingFiles", "unknown": return "error" default: return "unknown" } } type qbTorrentInfo struct { Hash string `json:"hash"` Name string `json:"name"` State string `json:"state"` Progress float64 `json:"progress"` DLSpeed int64 `json:"dlspeed"` ETA int `json:"eta"` Size int64 `json:"size"` ContentPath string `json:"content_path"` AddedOn int64 `json:"added_on"` Tracker string `json:"tracker"` MagnetURI string `json:"magnet_uri"` } var _ = strconv.Atoi var _ io.Reader = nil