Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум контекста для распознавания без сети. - internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и) (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени, дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно (недоверенный вход). - Персистентность байтов: таблица-спутник download_torrent (миграция 0009); пишется в транзакции создания загрузки, только на ветке создания (не при дедупе). Байты живут весь срок строки — нужны для повторного добавления при retry. - ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref — человекочитаемый референс (имя раздачи/файла), не адрес добавления. - worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях — processCatched и Retry (torrent добавляется файлом, не magnet-хешем). - Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём Telegram-документа (скачивание с редактированием токена из ошибок — секрет не в логи; обработка до ветки pending/текста). Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью (дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
481 lines
16 KiB
Go
481 lines
16 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
)
|
|
|
|
// фиксированные метки времени для детерминированных таймаут-тестов.
|
|
const (
|
|
timeNow = "2026-06-14T10:00:00Z"
|
|
timeOld = "2026-06-14T08:00:00Z" // 2 часа назад
|
|
timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад
|
|
)
|
|
|
|
type fakeStore struct {
|
|
downloads map[string]*store.Download
|
|
transitions []transition
|
|
torrents map[string][]byte // download_id → байты .torrent
|
|
}
|
|
|
|
type transition struct {
|
|
id string
|
|
state store.State
|
|
}
|
|
|
|
func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.State) ([]store.Download, error) {
|
|
var out []store.Download
|
|
for _, d := range f.downloads {
|
|
for _, s := range states {
|
|
if d.State == s {
|
|
out = append(out, *d)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
|
|
var out []store.Download
|
|
for _, d := range f.downloads {
|
|
if d.State != store.StateFailed && d.State != store.StateStuck {
|
|
continue
|
|
}
|
|
for _, c := range codes {
|
|
if d.ErrorCode.Valid && d.ErrorCode.String == c {
|
|
out = append(out, *d)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("download %s not found", id)
|
|
}
|
|
cp := *d
|
|
return &cp, nil
|
|
}
|
|
|
|
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
|
|
func hasAnyHash(d *store.Download, hashes []string) bool {
|
|
for _, own := range d.Infohashes {
|
|
for _, h := range hashes {
|
|
if own.Infohash == store.NormalizeHash(h) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
|
|
for _, d := range f.downloads {
|
|
if hasAnyHash(d, hashes) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
|
|
for _, d := range f.downloads {
|
|
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
|
|
cp := *d
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
|
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
|
return existing, nil
|
|
}
|
|
id := fmt.Sprintf("%d", len(f.downloads)+1)
|
|
cp := *d
|
|
cp.ID = id
|
|
for _, h := range hashes {
|
|
h = store.NormalizeHash(h)
|
|
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
|
}
|
|
f.downloads[id] = &cp
|
|
if len(torrentBlob) > 0 {
|
|
if f.torrents == nil {
|
|
f.torrents = map[string][]byte{}
|
|
}
|
|
f.torrents[id] = torrentBlob
|
|
}
|
|
d.ID = id
|
|
d.Infohashes = cp.Infohashes
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
|
|
if data, ok := f.torrents[downloadID]; ok {
|
|
return data, nil
|
|
}
|
|
return nil, store.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return fmt.Errorf("download %s not found", id)
|
|
}
|
|
for _, other := range f.downloads {
|
|
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
|
|
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
|
|
}
|
|
}
|
|
return f.SetDownloadState(ctx, id, st, code, msg)
|
|
}
|
|
|
|
func hashList(d *store.Download) []string {
|
|
out := make([]string, len(d.Infohashes))
|
|
for i, h := range d.Infohashes {
|
|
out[i] = h.Infohash
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return fmt.Errorf("download %s not found", id)
|
|
}
|
|
for _, h := range hashes {
|
|
h = store.NormalizeHash(h)
|
|
if !hasAnyHash(d, []string{h}) {
|
|
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return fmt.Errorf("download %s not found", id)
|
|
}
|
|
d.State = st
|
|
d.ErrorCode = store.NullString(code)
|
|
d.ErrorMsg = store.NullString(msg)
|
|
f.transitions = append(f.transitions, transition{id, st})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) error {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return fmt.Errorf("download %s not found", id)
|
|
}
|
|
if d.State != store.StateCatched {
|
|
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
|
|
}
|
|
d.State = store.StateDownloading
|
|
d.DisplayName = displayName
|
|
f.transitions = append(f.transitions, transition{id, store.StateDownloading})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return fmt.Errorf("download %s not found", id)
|
|
}
|
|
d.SourceMissCount = n
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
|
|
d, ok := f.downloads[id]
|
|
if !ok {
|
|
return fmt.Errorf("download %s not found", id)
|
|
}
|
|
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
|
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
|
|
|
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
|
|
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
|
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
|
|
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
|
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
|
|
return nil
|
|
}
|
|
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
|
|
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
|
|
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
|
return nil
|
|
}
|
|
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
|
|
|
|
type fakeQbt struct {
|
|
torrents []qbt.Torrent
|
|
added []qbt.AddRequest
|
|
addErr error
|
|
files []qbt.File
|
|
}
|
|
|
|
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
|
|
// только торренты этой категории (как реальный qBittorrent). Это важно для
|
|
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
|
// теряться при поиске по infohash.
|
|
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
|
if category == "" {
|
|
return f.torrents, nil
|
|
}
|
|
var out []qbt.Torrent
|
|
for _, t := range f.torrents {
|
|
if t.Category == category {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
|
if f.addErr != nil {
|
|
return f.addErr
|
|
}
|
|
f.added = append(f.added, ar)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
|
|
return f.files, nil
|
|
}
|
|
|
|
func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
|
w := New(st, qb, nil, nil, Config{
|
|
Category: "jellybit",
|
|
SavePath: "/srv/media/downloads",
|
|
MagnetTimeout: 30 * time.Minute,
|
|
StuckAfter: time.Hour,
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
w.now = func() time.Time { return time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) }
|
|
return w
|
|
}
|
|
|
|
func oneDownloading(infohash, createdAt string) *fakeStore {
|
|
return &fakeStore{downloads: map[string]*store.Download{
|
|
"1": {
|
|
ID: "1",
|
|
State: store.StateDownloading,
|
|
SourceType: store.SourceMagnet,
|
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
|
Infohashes: hashesOf("1", infohash),
|
|
CreatedAt: createdAt,
|
|
},
|
|
}}
|
|
}
|
|
|
|
// hashesOf — срез хешей загрузки для литералов фикстур.
|
|
func hashesOf(id string, hashes ...string) []store.Infohash {
|
|
out := make([]store.Infohash, 0, len(hashes))
|
|
for _, h := range hashes {
|
|
h = store.NormalizeHash(h)
|
|
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestPollTransitions(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
tests := []struct {
|
|
name string
|
|
qbitState string
|
|
createdAt string
|
|
want store.State
|
|
}{
|
|
{"готов → completed", "uploading", timeRecent, store.StateCompleted},
|
|
{"stalledUP → completed", "stalledUP", timeRecent, store.StateCompleted},
|
|
{"ошибка → failed", "error", timeRecent, store.StateFailed},
|
|
{"missingFiles → failed", "missingFiles", timeRecent, store.StateFailed},
|
|
{"metaDL долго → failed", "metaDL", timeOld, store.StateFailed},
|
|
{"stalledDL долго → stuck", "stalledDL", timeOld, store.StateStuck},
|
|
{"свежий downloading → остаётся", "downloading", timeRecent, store.StateDownloading},
|
|
{"moving → остаётся", "moving", timeRecent, store.StateDownloading},
|
|
{"свежий metaDL → остаётся", "metaDL", timeRecent, store.StateDownloading},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
st := oneDownloading(ih, tc.createdAt)
|
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState}}}
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if got := st.downloads["1"].State; got != tc.want {
|
|
t.Errorf("state = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPollMatchesByInfohashV2(t *testing.T) {
|
|
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
st := oneDownloading(v2, timeRecent)
|
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: "deadbeef", InfohashV2: v2, State: "uploading"}}}
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.downloads["1"].State != store.StateCompleted {
|
|
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
|
|
}
|
|
}
|
|
|
|
func TestPollIgnoresMissingTorrent(t *testing.T) {
|
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
|
qb := &fakeQbt{torrents: nil} // торрента в qBittorrent нет
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.downloads["1"].State != store.StateDownloading {
|
|
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
|
|
}
|
|
}
|
|
|
|
func TestCancel(t *testing.T) {
|
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
|
w := newTestWorker(st, &fakeQbt{})
|
|
if err := w.Cancel(context.Background(), "1"); err != nil {
|
|
t.Fatalf("Cancel: %v", err)
|
|
}
|
|
if st.downloads["1"].State != store.StateCancelled {
|
|
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
|
|
}
|
|
// Повторная отмена терминальной задачи — ошибка.
|
|
if err := w.Cancel(context.Background(), "1"); err == nil {
|
|
t.Error("ожидалась ошибка при отмене терминальной задачи")
|
|
}
|
|
}
|
|
|
|
func TestRetry(t *testing.T) {
|
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
|
st.downloads["1"].State = store.StateStuck
|
|
qb := &fakeQbt{}
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Retry(context.Background(), "1"); err != nil {
|
|
t.Fatalf("Retry: %v", err)
|
|
}
|
|
if st.downloads["1"].State != store.StateDownloading {
|
|
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
|
}
|
|
if len(qb.added) != 1 {
|
|
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
|
|
}
|
|
}
|
|
|
|
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
|
|
// добавляется в qBittorrent повторно.
|
|
func TestRetryConflictNoAdd(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneDownloading(ih, timeRecent)
|
|
st.downloads["1"].State = store.StateFailed
|
|
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
|
// Хешем владеет другая активная задача.
|
|
st.downloads["2"] = &store.Download{
|
|
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
|
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
|
|
}
|
|
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
|
|
w := newTestWorker(st, qb)
|
|
|
|
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("ожидался ErrConflict, получили %v", err)
|
|
}
|
|
if len(qb.added) != 0 {
|
|
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
|
|
}
|
|
if st.downloads["1"].State != store.StateFailed {
|
|
t.Errorf("state = %s, want failed", st.downloads["1"].State)
|
|
}
|
|
}
|
|
|
|
// Если после активации повторный Add в qBittorrent упал — задача
|
|
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
|
|
func TestRetryRollsBackOnAddFailure(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneDownloading(ih, timeRecent)
|
|
st.downloads["1"].State = store.StateFailed
|
|
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
|
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
|
|
w := newTestWorker(st, qb)
|
|
|
|
if err := w.Retry(context.Background(), "1"); err == nil {
|
|
t.Fatal("ожидалась ошибка Add")
|
|
}
|
|
if st.downloads["1"].State != store.StateFailed {
|
|
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
|
|
}
|
|
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
|
|
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
|
|
}
|
|
}
|
|
|
|
func TestRetryRejectsActive(t *testing.T) {
|
|
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
|
w := newTestWorker(st, &fakeQbt{})
|
|
if err := w.Retry(context.Background(), "1"); err == nil {
|
|
t.Error("retry активной (downloading) задачи должен отклоняться")
|
|
}
|
|
}
|
|
|
|
func TestClassify(t *testing.T) {
|
|
cases := map[string]class{
|
|
"uploading": classReady,
|
|
"stalledUP": classReady,
|
|
"stoppedUP": classReady,
|
|
"error": classErrored,
|
|
"missingFiles": classErrored,
|
|
"moving": classBusy,
|
|
"checkingUP": classBusy,
|
|
"downloading": classDownloading,
|
|
"metaDL": classDownloading,
|
|
"stalledDL": classDownloading,
|
|
}
|
|
for state, want := range cases {
|
|
if got := classify(state); got != want {
|
|
t.Errorf("classify(%q) = %d, want %d", state, got, want)
|
|
}
|
|
}
|
|
}
|