mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
339 lines
8.7 KiB
Go
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
|