Files
MeBox/internal/service/qbittorrent_adp.go
T
2026-06-17 00:06:51 +08:00

339 lines
8.7 KiB
Go

// Package service — qBittorrent 下载适配器。
//
// QBitAdapter 实现了 DownloadAdapter 接口,通过 qBittorrent WebUI API
// 管理下载任务。底层使用与 QBitClient 相同的 HTTP API 调用逻辑。
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// QBitAdapter 是 qBittorrent 的 DownloadAdapter 实现。
type QBitAdapter struct {
mu sync.Mutex
cfg DownloadClientConfig
client *http.Client
LoggedIn bool
}
// NewQBitAdapter 创建新的 qBittorrent 适配器。
func NewQBitAdapter() *QBitAdapter {
jar, _ := cookiejar.New(nil)
client := NewInternalHTTPClient(20 * time.Second)
client.Jar = jar
return &QBitAdapter{
client: client,
}
}
// Initialize 配置并初始化 qBittorrent 连接。
func (a *QBitAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
a.mu.Lock()
defer a.mu.Unlock()
endpoint, err := normalizeDownloadClientEndpoint("qbittorrent", cfg.Host)
if err != nil {
return err
}
cfg.Host = endpoint
a.cfg = cfg
a.LoggedIn = false
jar, _ := cookiejar.New(nil)
a.client.Jar = jar
return a.loginLocked(ctx)
}
// Ping 测试连接。
func (a *QBitAdapter) Ping(ctx context.Context) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.loginLocked(ctx)
}
// AddTorrent 通过 URL 添加种子。
func (a *QBitAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.ensureAuthLocked(ctx); err != nil {
return "", err
}
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("urls", torrentURL)
if savePath != "" {
_ = w.WriteField("savepath", savePath)
}
_ = w.Close()
baseURL := strings.TrimRight(a.cfg.Host, "/")
req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost,
baseURL+"/api/v2/torrents/add", body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Referer", baseURL)
req.Header.Set("Origin", baseURL)
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", fmt.Errorf("qbittorrent add torrent: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
if strings.EqualFold(strings.TrimSpace(string(raw)), "Fails.") {
return "", fmt.Errorf("qbittorrent add torrent: rejected by downloader")
}
return "", nil
}
// AddMagnet 通过磁力链接添加种子。
func (a *QBitAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) {
return a.AddTorrent(ctx, magnet, savePath)
}
// Pause 暂停种子。
func (a *QBitAdapter) Pause(ctx context.Context, hash string) error {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.ensureAuthLocked(ctx); err != nil {
return err
}
return a.postTorrentActionLocked(ctx, hash, "pause", "stop")
}
// Resume 恢复种子。
func (a *QBitAdapter) Resume(ctx context.Context, hash string) error {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.ensureAuthLocked(ctx); err != nil {
return err
}
return a.postTorrentActionLocked(ctx, hash, "resume", "start")
}
func (a *QBitAdapter) postTorrentActionLocked(ctx context.Context, hash string, primary, fallback string) error {
baseURL := strings.TrimRight(a.cfg.Host, "/")
form := url.Values{}
form.Set("hashes", hash)
var lastErr error
for _, action := range []string{primary, fallback} {
if action == "" {
continue
}
req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost,
baseURL+"/api/v2/torrents/"+action, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", baseURL)
req.Header.Set("Origin", baseURL)
resp, err := a.client.Do(req)
if err != nil {
return err
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode < 400 {
return nil
}
lastErr = fmt.Errorf("qbittorrent %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body)))
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed {
break
}
}
return lastErr
}
// Remove 删除种子。
func (a *QBitAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.ensureAuthLocked(ctx); err != nil {
return err
}
baseURL := strings.TrimRight(a.cfg.Host, "/")
form := url.Values{}
form.Set("hashes", hash)
if deleteFiles {
form.Set("deleteFiles", "true")
} else {
form.Set("deleteFiles", "false")
}
req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost,
baseURL+"/api/v2/torrents/delete", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", baseURL)
req.Header.Set("Origin", baseURL)
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode)
}
return nil
}
// List 列出种子。
func (a *QBitAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.ensureAuthLocked(ctx); err != nil {
return nil, err
}
baseURL := strings.TrimRight(a.cfg.Host, "/")
u := baseURL + "/api/v2/torrents/info"
req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
if filter != "" {
query := req.URL.Query()
query.Set("filter", filter)
req.URL.RawQuery = query.Encode()
}
req.Header.Set("Referer", baseURL)
resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode)
}
// qBittorrent 返回的字段名与 TorrentInfo 不同,需要转换
type qbTorrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
State string `json:"state"`
Progress float32 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
UPSpeed int64 `json:"upspeed"`
NumSeeds int `json:"num_seeds"`
NumLeechs int `json:"num_leechs"`
Size int64 `json:"size"`
SavePath string `json:"save_path"`
AddedOn int64 `json:"added_on"`
Category string `json:"category"`
Tags string `json:"tags"`
}
var qbList []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&qbList); err != nil {
return nil, err
}
result := make([]TorrentInfo, 0, len(qbList))
for _, t := range qbList {
result = append(result, TorrentInfo{
Hash: t.Hash,
Name: t.Name,
Size: t.Size,
Progress: float64(t.Progress),
DLSpeed: t.DLSpeed,
UPSpeed: t.UPSpeed,
State: t.State,
SavePath: t.SavePath,
NumSeeds: t.NumSeeds,
NumLeechs: t.NumLeechs,
AddedOn: time.Unix(t.AddedOn, 0),
Category: t.Category,
Tags: t.Tags,
})
}
return result, nil
}
// GetInfo 获取单个种子信息。
func (a *QBitAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) {
list, err := a.List(ctx, "")
if err != nil {
return nil, err
}
for _, t := range list {
if t.Hash == hash {
return &t, nil
}
}
return nil, fmt.Errorf("torrent %s not found", hash)
}
// loginLocked 执行登录(调用者必须持有锁)。
func (a *QBitAdapter) loginLocked(ctx context.Context) error {
if a.cfg.Host == "" {
return fmt.Errorf("qbittorrent host not configured")
}
if err := qbitLogin(ctx, a.client, a.cfg.Host, a.cfg.Username, a.cfg.Password); err != nil {
return err
}
a.LoggedIn = true
return nil
}
// ensureAuthLocked 确保已认证(调用者必须持有锁)。
func (a *QBitAdapter) ensureAuthLocked(ctx context.Context) error {
if a.LoggedIn {
return nil
}
return a.loginLocked(ctx)
}
// --- 为了与现有的 QBitClient 兼容,添加转换辅助函数 ---
// QBitTorrentToInfo 将旧的 QBitTorrent 转换为新的 TorrentInfo。
func QBitTorrentToInfo(q QBitTorrent) TorrentInfo {
return TorrentInfo{
Hash: q.Hash,
Name: q.Name,
Size: q.Size,
Progress: float64(q.Progress),
DLSpeed: q.DLSpeed,
UPSpeed: q.UpSpeed,
State: q.State,
SavePath: q.SavePath,
NumSeeds: q.NumSeeds,
NumLeechs: q.NumLeech,
}
}
// TorrentInfoToQBit 将 TorrentInfo 转换回旧的 QBitTorrent 格式(兼容性)。
func TorrentInfoToQBit(t TorrentInfo) QBitTorrent {
return QBitTorrent{
Hash: t.Hash,
Name: t.Name,
State: t.State,
Progress: float32(t.Progress),
DLSpeed: t.DLSpeed,
UpSpeed: t.UPSpeed,
NumSeeds: t.NumSeeds,
NumLeech: t.NumLeechs,
Size: t.Size,
SavePath: t.SavePath,
}
}
// unused import guard
var _ = strconv.Itoa