Files
jellybit/internal/qbt/qbt.go
T
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены 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>
2026-07-23 18:17:40 +03:00

415 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}