Files
jellybit/internal/qbt/qbt.go
T
avandClaude Opus 4.8 8261d5b55d Retry/stall: сброс базиса таймаута + простой от last_activity (MAJOR-1, MAJOR-2)
Два связанных бага семантики таймаутов зависания и ручного retry.

MAJOR-1: Retry живого торрента не сбрасывал базис отсчёта таймаута — задача
мгновенно снова падала в stuck на ближайшем тике. Вводим колонку
download.retried_at (миграция 0010): ручной retry фиксирует момент и
приподнимает пол обоих таймаутов (max(базис, retried_at)). Хранится в БД, а
не в памяти, чтобы сброс пережил тик поллинга и рестарт.

MAJOR-2: stuck_after мерил ВОЗРАСТ торрента (от added_on), а не ПРОСТОЙ —
долго качавшийся торрент, на миг зашедший в stalledDL, ложно уходил в stuck
со «stalled for 5h». Теперь stuck_after мерит простой от qBit last_activity
(новое поле qbt.Torrent из того же ответа /torrents/info); magnet_timeout
по-прежнему мерит возраст (семантически верно). checkTimeouts разбит на
torrentAge/stallDuration/addedBasis/retriedFloor.

NIT-10: фолбэк базиса возраста added_on→created_at сохранён и покрыт.
NIT-12: retry перестаёт перецепляться к сломанному живому торренту
(error/missingFiles) — повторно отдаёт источник (перецепка к нему
бессмысленна: reconcile тут же вернул бы в failed).

Спека: дельта state-reconciliation (MODIFIED «Восстановление зависшей
загрузки» и «Ручной повтор»), правка docs/specs/workflow.md (устранено
противоречие «возраст vs простой»), ER-схема database.md.

Тесты: TestRetryResetsTimeoutBasis (следующий тик после retry — прячется в
TestRetryReattachesNoReadd), TestStallMeasuredFromLastActivity,
TestSetRetriedAtOverwrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:18:28 +03:00

324 lines
13 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.ExtCall{Service: logging.ServiceQBittorrent, Operation: "auth/login", Start: time.Now()}
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.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/add", Start: time.Now()}
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
}
// Torrents возвращает задачи указанной категории (пустая — все).
func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) {
log := logctx.FromOr(ctx, c.log)
call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/info", Start: time.Now()}
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.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/files", Start: time.Now()}
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
}