Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций на сотни строк размазывает внимание по тривиальному — модель добросовестно проверит именование полей лога и не дойдёт до формы решения. Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*, os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard (сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное время в новых миграциях, матчинг ошибки по тексту. Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера, slog.DiscardHandler в тестах. Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой осталось только то, что правилом не выражается. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
415 lines
16 KiB
Go
415 lines
16 KiB
Go
// Package qbt — клиент qBittorrent WebUI API (v2): сессия, добавление
|
||
// торрента, опрос задач по категории.
|
||
//
|
||
// Логин ленивый: cookie-сессия устанавливается при первом 403 и повторно
|
||
// при её протухании. Источник (magnet/.torrent) отдаём qBittorrent — он сам
|
||
// качает, jellybit не делает исходящих запросов на пользовательский URL.
|
||
package qbt
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||
)
|
||
|
||
// Config — параметры подключения к qBittorrent WebUI.
|
||
type Config struct {
|
||
URL string
|
||
Username string
|
||
Password string
|
||
Timeout time.Duration
|
||
}
|
||
|
||
// Client — клиент qBittorrent WebUI API.
|
||
type Client struct {
|
||
base *url.URL
|
||
hc *http.Client
|
||
user string
|
||
pass string
|
||
log *slog.Logger
|
||
mu sync.Mutex // сериализует логин
|
||
}
|
||
|
||
// Torrent — подмножество полей /torrents/info, нужное jellybit. Помимо полей
|
||
// для машины состояний и сопоставления несёт живую телеметрию (скорости, ETA,
|
||
// статистика раздачи) — её собирает снимок воркера для веб-UI; все эти поля
|
||
// приходят в том же ответе, отдельного вызова не нужно.
|
||
type Torrent struct {
|
||
Hash string `json:"hash"`
|
||
Name string `json:"name"`
|
||
State string `json:"state"`
|
||
SavePath string `json:"save_path"`
|
||
ContentPath string `json:"content_path"`
|
||
Category string `json:"category"`
|
||
Tags string `json:"tags"` // через запятую
|
||
Progress float64 `json:"progress"`
|
||
AmountLeft int64 `json:"amount_left"`
|
||
TotalSize int64 `json:"total_size"` // полный размер раздачи, байт
|
||
AddedOn int64 `json:"added_on"`
|
||
// LastActivity — Unix-время последнего движения данных по торренту (скачан/
|
||
// отдан кусок). Базис измерения простоя для stuck_after: простой = now −
|
||
// last_activity (а не возраст от added_on), поэтому долго качавшийся торрент,
|
||
// на миг зашедший в stalledDL, не помечается «зависшим».
|
||
LastActivity int64 `json:"last_activity"`
|
||
InfohashV1 string `json:"infohash_v1"`
|
||
InfohashV2 string `json:"infohash_v2"`
|
||
|
||
// Живая телеметрия (для снимка воркера и веб-UI).
|
||
Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с
|
||
Upspeed int64 `json:"upspeed"` // скорость отдачи, байт/с
|
||
Eta int64 `json:"eta"` // оценка до завершения, с (8640000 ≈ ∞)
|
||
Ratio float64 `json:"ratio"` // рейтинг отдачи (может быть -1 = ∞/н/д)
|
||
NumSeeds int `json:"num_seeds"` // подключённые сиды
|
||
NumLeechs int `json:"num_leechs"` // подключённые личи (пиры)
|
||
Uploaded int64 `json:"uploaded"` // отдано всего, байт
|
||
}
|
||
|
||
// File — элемент /torrents/files: путь файла относительно save_path
|
||
// (включая корневую папку торрента для многофайловых раздач) и его размер.
|
||
// Абсолютный путь на диске = filepath.Join(save_path, Name) — НЕ content_path:
|
||
// для многофайловой раздачи это удвоило бы корневую папку, для однофайловой
|
||
// дало бы путь под самим файлом.
|
||
type File struct {
|
||
Name string `json:"name"`
|
||
Size int64 `json:"size"`
|
||
}
|
||
|
||
// AddRequest — параметры добавления торрента.
|
||
type AddRequest struct {
|
||
URLs []string // magnet/URL-ссылки
|
||
Torrents [][]byte // .torrent-файлы (Ф1 не использует)
|
||
Category string
|
||
SavePath string
|
||
Rename string // отображаемое имя торрента (param rename); пустое — не задаём
|
||
Paused bool
|
||
}
|
||
|
||
// New создаёт клиент с собственным cookie-jar. logger nil → slog.Default().
|
||
func New(cfg Config, logger *slog.Logger) (*Client, error) {
|
||
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err)
|
||
}
|
||
jar, err := cookiejar.New(nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("cookie jar: %w", err)
|
||
}
|
||
timeout := cfg.Timeout
|
||
if timeout == 0 {
|
||
timeout = 30 * time.Second
|
||
}
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return &Client{
|
||
base: base,
|
||
hc: &http.Client{Jar: jar, Timeout: timeout},
|
||
user: cfg.Username,
|
||
pass: cfg.Password,
|
||
log: logger,
|
||
}, nil
|
||
}
|
||
|
||
func (c *Client) endpoint(path string) string { return c.base.String() + path }
|
||
|
||
// login устанавливает cookie-сессию. Сериализован, чтобы параллельные
|
||
// вызовы (поллинг + приём) не логинились наперегонки.
|
||
func (c *Client) login(ctx context.Context) error {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
form := url.Values{"username": {c.user}, "password": {c.pass}}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
c.endpoint("/api/v2/auth/login"), strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host
|
||
log := logctx.FromOr(ctx, c.log)
|
||
call := logging.StartCall(logging.ServiceQBittorrent, "auth/login")
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
call.Failure(log, err)
|
||
return fmt.Errorf("qbittorrent login: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
call.Status = resp.StatusCode
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
|
||
// Тело логина не содержит секретов (qBit отвечает "Ok."/"Fails."), но
|
||
// учётные данные (логин/пароль) в лог не идут — только факт неуспеха.
|
||
err := fmt.Errorf("qbittorrent login failed: status %d body %q",
|
||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||
call.Failure(log, err)
|
||
return err
|
||
}
|
||
call.Success(log, "authenticated", true)
|
||
return nil
|
||
}
|
||
|
||
// do выполняет запрос; при 403 один раз перелогинивается и повторяет.
|
||
// build вызывается заново для повтора, т.к. тело запроса одноразовое.
|
||
func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*http.Response, error) {
|
||
req, err := build()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resp, err := c.hc.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if resp.StatusCode == http.StatusForbidden {
|
||
_ = resp.Body.Close()
|
||
logctx.FromOr(ctx, c.log).Debug("qbittorrent session expired, re-login")
|
||
if err := c.login(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
req2, err := build()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return c.hc.Do(req2)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// Add добавляет торрент(ы) в qBittorrent.
|
||
func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
if len(ar.URLs) > 0 {
|
||
_ = mw.WriteField("urls", strings.Join(ar.URLs, "\n"))
|
||
}
|
||
if ar.Category != "" {
|
||
_ = mw.WriteField("category", ar.Category)
|
||
}
|
||
if ar.SavePath != "" {
|
||
_ = mw.WriteField("savepath", ar.SavePath)
|
||
}
|
||
if ar.Rename != "" {
|
||
_ = mw.WriteField("rename", ar.Rename)
|
||
}
|
||
_ = mw.WriteField("paused", strconv.FormatBool(ar.Paused))
|
||
for i, data := range ar.Torrents {
|
||
fw, err := mw.CreateFormFile("torrents", fmt.Sprintf("file%d.torrent", i))
|
||
if err != nil {
|
||
return fmt.Errorf("qbittorrent add: form file: %w", err)
|
||
}
|
||
if _, err := fw.Write(data); err != nil {
|
||
return fmt.Errorf("qbittorrent add: write file: %w", err)
|
||
}
|
||
}
|
||
if err := mw.Close(); err != nil {
|
||
return fmt.Errorf("qbittorrent add: close multipart: %w", err)
|
||
}
|
||
contentType := mw.FormDataContentType()
|
||
payload := buf.Bytes()
|
||
|
||
log := logctx.FromOr(ctx, c.log)
|
||
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/add")
|
||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", contentType)
|
||
req.Header.Set("Referer", c.base.String())
|
||
return req, nil
|
||
})
|
||
if err != nil {
|
||
call.Failure(log, err, "category", ar.Category)
|
||
return fmt.Errorf("qbittorrent add: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
call.Status = resp.StatusCode
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||
if resp.StatusCode != http.StatusOK {
|
||
err := fmt.Errorf("qbittorrent add: status %d body %q",
|
||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||
call.Failure(log, err, "category", ar.Category)
|
||
return err
|
||
}
|
||
if strings.TrimSpace(string(body)) == "Fails." {
|
||
err := fmt.Errorf("qbittorrent add: rejected (Fails.)")
|
||
call.Failure(log, err, "category", ar.Category,
|
||
"urls", len(ar.URLs), "torrents", len(ar.Torrents))
|
||
return err
|
||
}
|
||
call.Success(log, "category", ar.Category, "save_path", ar.SavePath,
|
||
"urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused)
|
||
return nil
|
||
}
|
||
|
||
// Delete удаляет раздачу(и) из qBittorrent. deleteFiles=true сносит и файлы на
|
||
// диске (освобождая место — библиотечный хардлинк jellybit к тому же иноду
|
||
// снимается отдельно). Идемпотентно: неизвестный хеш qBittorrent просто
|
||
// игнорирует, ошибкой это не считаем. jellybit сам файлы под downloads не
|
||
// трогает — их удаляет qBittorrent по этому вызову.
|
||
func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) error {
|
||
nonEmpty := hashes[:0:0]
|
||
for _, h := range hashes {
|
||
if h = strings.TrimSpace(h); h != "" {
|
||
nonEmpty = append(nonEmpty, h)
|
||
}
|
||
}
|
||
if len(nonEmpty) == 0 {
|
||
return fmt.Errorf("qbittorrent delete: no hashes")
|
||
}
|
||
form := url.Values{
|
||
"hashes": {strings.Join(nonEmpty, "|")},
|
||
"deleteFiles": {strconv.FormatBool(deleteFiles)},
|
||
}
|
||
body := form.Encode()
|
||
|
||
log := logctx.FromOr(ctx, c.log)
|
||
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/delete")
|
||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("Referer", c.base.String())
|
||
return req, nil
|
||
})
|
||
if err != nil {
|
||
call.Failure(log, err)
|
||
return fmt.Errorf("qbittorrent delete: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
call.Status = resp.StatusCode
|
||
if resp.StatusCode != http.StatusOK {
|
||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||
err := fmt.Errorf("qbittorrent delete: status %d body %q",
|
||
resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||
call.Failure(log, err)
|
||
return err
|
||
}
|
||
call.Success(log, "hashes", len(nonEmpty), "delete_files", deleteFiles)
|
||
return nil
|
||
}
|
||
|
||
// RenameTorrent задаёт отображаемое имя уже добавленной раздачи (в отличие от
|
||
// параметра rename при Add, действующего только в момент добавления). hash —
|
||
// ключ раздачи в qBittorrent (Torrent.Hash), а не сырой infohash загрузки:
|
||
// вызывающий резолвит его листингом. Косметика: имя раздачи не влияет на файлы.
|
||
func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error {
|
||
hash = strings.TrimSpace(hash)
|
||
if hash == "" {
|
||
return fmt.Errorf("qbittorrent rename: empty hash")
|
||
}
|
||
form := url.Values{"hash": {hash}, "name": {name}}
|
||
body := form.Encode()
|
||
|
||
log := logctx.FromOr(ctx, c.log)
|
||
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/rename")
|
||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||
c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("Referer", c.base.String())
|
||
return req, nil
|
||
})
|
||
if err != nil {
|
||
call.Failure(log, err)
|
||
return fmt.Errorf("qbittorrent rename: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
call.Status = resp.StatusCode
|
||
if resp.StatusCode != http.StatusOK {
|
||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||
err := fmt.Errorf("qbittorrent rename: status %d body %q",
|
||
resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||
call.Failure(log, err)
|
||
return err
|
||
}
|
||
call.Success(log)
|
||
return nil
|
||
}
|
||
|
||
// Torrents возвращает задачи указанной категории (пустая — все).
|
||
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
|
||
log := logctx.FromOr(ctx, c.log)
|
||
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/info")
|
||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||
u := c.endpoint("/api/v2/torrents/info")
|
||
if category != "" {
|
||
u += "?category=" + url.QueryEscape(category)
|
||
}
|
||
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||
})
|
||
if err != nil {
|
||
call.Failure(log, err)
|
||
return nil, fmt.Errorf("qbittorrent info: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
call.Status = resp.StatusCode
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||
err := fmt.Errorf("qbittorrent info: status %d body %q",
|
||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||
call.Failure(log, err)
|
||
return nil, err
|
||
}
|
||
var ts []Torrent
|
||
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
|
||
call.Failure(log, err)
|
||
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
|
||
}
|
||
// Поллинг частый — на DEBUG, чтобы не зашумлять INFO (как healthcheck).
|
||
call.SuccessDebug(log, "category", category, "count", len(ts))
|
||
return ts, nil
|
||
}
|
||
|
||
// Files возвращает список файлов торрента (имена относительно save_path,
|
||
// включая корневую папку для многофайловых раздач, и размеры). Нужен
|
||
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
||
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
||
log := logctx.FromOr(ctx, c.log)
|
||
call := logging.StartCall(logging.ServiceQBittorrent, "torrents/files")
|
||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
||
return http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||
})
|
||
if err != nil {
|
||
call.Failure(log, err)
|
||
return nil, fmt.Errorf("qbittorrent files: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
call.Status = resp.StatusCode
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||
err := fmt.Errorf("qbittorrent files: status %d body %q",
|
||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||
call.Failure(log, err)
|
||
return nil, err
|
||
}
|
||
var fs []File
|
||
if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
|
||
call.Failure(log, err)
|
||
return nil, fmt.Errorf("decode qbittorrent files: %w", err)
|
||
}
|
||
call.SuccessDebug(log, "count", len(fs))
|
||
return fs, nil
|
||
}
|