Приём: добавление загрузки по .torrent-файлу
Принимаем .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>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
"git.vakhrushev.me/av/jellybit/web"
|
||||
)
|
||||
@@ -395,14 +397,34 @@ func pageWindow(page, pages int) []int {
|
||||
}
|
||||
|
||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
redirectErr(w, r, "не удалось разобрать форму")
|
||||
// Форма — multipart (файл-пикер .torrent). Лимит тела: размер торрента +
|
||||
// небольшой запас на прочие поля. ParseMultipartForm сперва разбирает
|
||||
// urlencoded-тело (ParseForm), поэтому на обычной (не-multipart) отправке —
|
||||
// напр. из curl или устаревшей страницы — вернёт ErrNotMultipart, но поля
|
||||
// уже в PostForm: такую ошибку глотаем, текстовый путь не ломается.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, ingest.MaxTorrentSize+1<<20)
|
||||
if err := r.ParseMultipartForm(ingest.MaxTorrentSize + 1<<20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||
redirectErr(w, r, "не удалось разобрать форму (возможно, файл слишком большой)")
|
||||
return
|
||||
}
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
||||
Source: r.PostForm.Get("source"),
|
||||
Context: r.PostForm.Get("context"),
|
||||
})
|
||||
|
||||
req := ingest.Request{
|
||||
Source: r.FormValue("source"),
|
||||
Context: r.FormValue("context"),
|
||||
}
|
||||
// Выбран .torrent-файл — приём по байтам (в приоритете над текстом).
|
||||
if file, header, err := r.FormFile("torrent"); err == nil {
|
||||
defer func() { _ = file.Close() }()
|
||||
data, rerr := io.ReadAll(file)
|
||||
if rerr != nil {
|
||||
redirectErr(w, r, "не удалось прочитать .torrent-файл")
|
||||
return
|
||||
}
|
||||
req.TorrentData = data
|
||||
req.TorrentName = header.Filename // фолбек для source_ref
|
||||
}
|
||||
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
|
||||
if err != nil {
|
||||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||
return
|
||||
@@ -732,7 +754,7 @@ func classifyErr(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return http.StatusNotFound, "не найдено"
|
||||
case errors.Is(err, magnet.ErrNotMagnet):
|
||||
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
||||
return http.StatusBadRequest, "некорректный источник"
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// Веб-форма с выбранным .torrent-файлом → приём по байтам (TorrentData).
|
||||
func TestUIAddTorrentFile(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
_ = mw.WriteField("source", "") // источник пуст — используется файл
|
||||
fw, _ := mw.CreateFormFile("torrent", "dune.torrent")
|
||||
torrentBytes := []byte("d8:announce…bytes")
|
||||
_, _ = fw.Write(torrentBytes)
|
||||
_ = mw.Close()
|
||||
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if !bytes.Equal(ing.lastReq.TorrentData, torrentBytes) {
|
||||
t.Errorf("TorrentData не проброшен в ingest: %q", ing.lastReq.TorrentData)
|
||||
}
|
||||
}
|
||||
|
||||
// Без файла — прежний текстовый путь (Source), TorrentData пуст.
|
||||
func TestUIAddTextNoFile(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
_ = mw.WriteField("source", "magnet:?xt=urn:btih:abc")
|
||||
_ = mw.Close()
|
||||
|
||||
resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads", mw.FormDataContentType(), &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if len(ing.lastReq.TorrentData) != 0 {
|
||||
t.Errorf("TorrentData должен быть пуст без файла")
|
||||
}
|
||||
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
|
||||
t.Errorf("source не проброшен: %q", ing.lastReq.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Обычная (не-multipart) urlencoded-отправка тоже работает (ErrNotMultipart
|
||||
// глотается, поля уже в PostForm) — устойчивость к curl/старой странице.
|
||||
func TestUIAddUrlencoded(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateCatched}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads", url.Values{
|
||||
"source": {"magnet:?xt=urn:btih:abc"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if !strings.HasPrefix(ing.lastReq.Source, "magnet:") {
|
||||
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
|
||||
}
|
||||
}
|
||||
+87
-25
@@ -15,6 +15,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
)
|
||||
|
||||
// capIngest — стадия приёма для поля capability в логах.
|
||||
@@ -28,7 +29,9 @@ type Store interface {
|
||||
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
|
||||
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
|
||||
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
// torrentBlob (для source_type=torrent) пишется в той же транзакции только
|
||||
// на ветке создания; при дедупе не пишется. Для magnet — nil.
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
|
||||
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
|
||||
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
@@ -45,10 +48,13 @@ func New(st Store, log *slog.Logger) *Service {
|
||||
return &Service{store: st, log: log}
|
||||
}
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
// Request — входной запрос приёма. Источник задаётся либо строкой (magnet), либо
|
||||
// байтами `.torrent` (TorrentData); при непустом TorrentData он в приоритете.
|
||||
type Request struct {
|
||||
Source string // пока — magnet-ссылка
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
Source string // magnet-ссылка (когда TorrentData пуст)
|
||||
TorrentData []byte // байты .torrent-файла (опц.); при наличии — источник torrent
|
||||
TorrentName string // имя загруженного файла (опц.); фолбек для source_ref
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
}
|
||||
|
||||
// Result — итог приёма.
|
||||
@@ -64,39 +70,39 @@ type Result struct {
|
||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||
// имени выполняет worker (см. download-tracking).
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
source := strings.TrimSpace(req.Source)
|
||||
info, err := magnet.Parse(source)
|
||||
src, err := s.parse(req)
|
||||
if err != nil {
|
||||
// Ф1: поддержан только magnet. .torrent/url — следующий заход.
|
||||
return Result{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
// Scoped-логгер стадии приёма: download_id допишется после CreateDownload.
|
||||
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
|
||||
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
|
||||
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
|
||||
// гибридный magnet несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
|
||||
// гибридный несёт и v1, и v2.
|
||||
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
|
||||
} else if existing != nil {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return s.attached(ctx, info, existing), nil
|
||||
return s.attached(ctx, src, existing), nil
|
||||
}
|
||||
|
||||
// Контекст распознавания дополняем фактами из полей самой magnet-ссылки
|
||||
// (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым.
|
||||
// Контекст распознавания дополняем фактами из полей источника (magnet-поля
|
||||
// или дерево `.torrent`) — без сети. Пользовательский текст идёт первым.
|
||||
// DisplayName пуст: имя выведет worker на шаге добавления (rename действует
|
||||
// только при добавлении, а тут медленный LLM в пути ответа недопустим).
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
Context: mergeContext(req.Context, info.Context()),
|
||||
SourceType: src.sourceType,
|
||||
SourceRef: src.sourceRef,
|
||||
Context: mergeContext(req.Context, src.synthContext),
|
||||
State: store.StateCatched,
|
||||
}
|
||||
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
|
||||
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
||||
// torrentBlob непуст только для source_type=torrent — пишется в той же
|
||||
// транзакции лишь на ветке создания.
|
||||
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, src.infohashes, src.torrentBlob)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("ingest: create download: %w", err)
|
||||
}
|
||||
@@ -107,7 +113,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}, nil
|
||||
@@ -116,11 +122,67 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log.Info("download catched", "download_id", d.ID)
|
||||
return Result{
|
||||
DownloadID: d.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: store.StateCatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaxTorrentSize — предел размера принимаемого `.torrent` (защита от разбухания
|
||||
// БД и oversized-загрузок). Реальные торрент-файлы много меньше; крупные (много
|
||||
// файлов → много piece-хешей) отсекаются здесь.
|
||||
const MaxTorrentSize = 8 << 20 // 8 MiB
|
||||
|
||||
// parsedSource — нормализованный источник приёма (magnet или .torrent).
|
||||
type parsedSource struct {
|
||||
sourceType store.SourceType
|
||||
sourceRef string // референс для человека/логов (magnet-URI или имя torrent)
|
||||
infohashes []string // все хеши источника; v1 раньше v2
|
||||
synthContext string // синтез контекста из полей источника (без сети)
|
||||
torrentBlob []byte // байты .torrent (для source_type=torrent); иначе nil
|
||||
}
|
||||
|
||||
// parse разбирает источник запроса: при непустом TorrentData — как `.torrent`
|
||||
// (в приоритете), иначе — как magnet. Синтез контекста и хеши берутся из полей
|
||||
// источника, без сети.
|
||||
func (s *Service) parse(req Request) (parsedSource, error) {
|
||||
if len(req.TorrentData) > 0 {
|
||||
if len(req.TorrentData) > MaxTorrentSize {
|
||||
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize)
|
||||
}
|
||||
info, err := torrent.Parse(req.TorrentData)
|
||||
if err != nil {
|
||||
return parsedSource{}, fmt.Errorf("ingest: parse torrent: %w", err)
|
||||
}
|
||||
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
|
||||
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
|
||||
// Фолбек на имя файла, если у раздачи нет содержательного имени
|
||||
// (пустое или NoName-сентинел "-").
|
||||
ref := strings.TrimSpace(info.DisplayName)
|
||||
if ref == "" || ref == "-" {
|
||||
ref = strings.TrimSpace(req.TorrentName)
|
||||
}
|
||||
return parsedSource{
|
||||
sourceType: store.SourceTorrent,
|
||||
sourceRef: ref,
|
||||
infohashes: info.Infohashes,
|
||||
synthContext: info.Context(),
|
||||
torrentBlob: req.TorrentData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
source := strings.TrimSpace(req.Source)
|
||||
info, err := magnet.Parse(source)
|
||||
if err != nil {
|
||||
return parsedSource{}, fmt.Errorf("ingest: parse source: %w", err)
|
||||
}
|
||||
return parsedSource{
|
||||
sourceType: store.SourceMagnet,
|
||||
sourceRef: source,
|
||||
infohashes: info.Infohashes,
|
||||
synthContext: info.Context(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||
@@ -140,15 +202,15 @@ func mergeContext(userText, synth string) string {
|
||||
// принести хеш, которого задача ещё не знает; guarded-путь через
|
||||
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
|
||||
// хеша с другой активной задачей логируется, приём не валится.
|
||||
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
|
||||
if len(info.Infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
|
||||
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
|
||||
if len(src.infohashes) > len(existing.Infohashes) {
|
||||
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
|
||||
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
DownloadID: existing.ID,
|
||||
Infohashes: info.Infohashes,
|
||||
Infohashes: src.infohashes,
|
||||
State: existing.State,
|
||||
Deduplicated: true,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
hashes [][]string
|
||||
blobs [][]byte
|
||||
toppedUp []string
|
||||
}
|
||||
|
||||
@@ -26,13 +27,14 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||
if f.active != nil {
|
||||
return f.active, nil
|
||||
}
|
||||
d.ID = ident.NewID()
|
||||
f.created = append(f.created, *d)
|
||||
f.hashes = append(f.hashes, hashes)
|
||||
f.blobs = append(f.blobs, torrentBlob)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// buildTorrent собирает валидные байты .torrent и ожидаемый v1-инфохэш.
|
||||
func buildTorrent(t *testing.T, name, announce string) (data []byte, infohash string) {
|
||||
t.Helper()
|
||||
info := metainfo.Info{Name: name, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal info: %v", err)
|
||||
}
|
||||
sum := sha1.Sum(infoBytes)
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write metainfo: %v", err)
|
||||
}
|
||||
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestIngestTorrentFile(t *testing.T) {
|
||||
data, wantHash := buildTorrent(t, "Dune.Part.Two.2024.mkv", "http://bt.rutracker.org/ann")
|
||||
fs := &fakeStore{}
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data, Context: "мой текст"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if res.State != store.StateCatched || res.Deduplicated {
|
||||
t.Errorf("res = %+v", res)
|
||||
}
|
||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != wantHash {
|
||||
t.Errorf("infohashes = %v, want [%s]", res.Infohashes, wantHash)
|
||||
}
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d", len(fs.created))
|
||||
}
|
||||
d := fs.created[0]
|
||||
if d.SourceType != store.SourceTorrent {
|
||||
t.Errorf("source_type = %q, want torrent", d.SourceType)
|
||||
}
|
||||
if d.SourceRef != "Dune.Part.Two.2024.mkv" {
|
||||
t.Errorf("source_ref = %q (имя раздачи, не URL)", d.SourceRef)
|
||||
}
|
||||
// Контекст: текст пользователя первым, затем синтез из полей файла.
|
||||
if !strings.HasPrefix(d.Context, "мой текст") {
|
||||
t.Errorf("context не с текста пользователя: %q", d.Context)
|
||||
}
|
||||
if !strings.Contains(d.Context, "Dune.Part.Two") || !strings.Contains(d.Context, "rutracker.org") {
|
||||
t.Errorf("context без синтеза из файла: %q", d.Context)
|
||||
}
|
||||
// Байты сохранены в транзакции создания.
|
||||
if len(fs.blobs) != 1 || !bytes.Equal(fs.blobs[0], data) {
|
||||
t.Errorf("байты .torrent не переданы в CreateDownloadIfNoActive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentDedupNoBlob(t *testing.T) {
|
||||
data, _ := buildTorrent(t, "X", "http://t/ann")
|
||||
// Активная задача уже есть → дедуп; байты писаться не должны.
|
||||
fs := &fakeStore{active: &store.Download{ID: "existing", State: store.StateDownloading}}
|
||||
res, err := newService(fs).Ingest(context.Background(), Request{TorrentData: data})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated || res.DownloadID != "existing" {
|
||||
t.Errorf("ожидался дедуп, res = %+v", res)
|
||||
}
|
||||
if len(fs.created) != 0 || len(fs.blobs) != 0 {
|
||||
t.Errorf("при дедупе не должно быть создания/записи байтов")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentTooLarge(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
big := make([]byte, MaxTorrentSize+1)
|
||||
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка превышения размера")
|
||||
}
|
||||
if len(fs.created) != 0 {
|
||||
t.Errorf("при превышении размера задача не создаётся")
|
||||
}
|
||||
}
|
||||
|
||||
// У раздачи без имени source_ref берётся из имени файла (фолбек).
|
||||
func TestIngestTorrentNameFallback(t *testing.T) {
|
||||
// Info без name → BestName() == "" → фолбек на TorrentName.
|
||||
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fs := &fakeStore{}
|
||||
_, err = newService(fs).Ingest(context.Background(),
|
||||
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTorrentInvalid(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
_, err := newService(fs).Ingest(context.Background(), Request{TorrentData: []byte("not a torrent")})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка разбора .torrent")
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,12 @@ func NullString(s string) sql.NullString {
|
||||
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
|
||||
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
|
||||
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
|
||||
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) {
|
||||
//
|
||||
// torrentBlob (если непуст) — исходные байты `.torrent`; пишутся в
|
||||
// download_torrent В ТОЙ ЖЕ транзакции только на ветке создания (для
|
||||
// source_type=torrent, чтобы воркер добавил раздачу файлом). При дедупе байты
|
||||
// не пишутся. Для magnet/url — nil.
|
||||
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string, torrentBlob []byte) (*Download, error) {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return nil, fmt.Errorf("create download: no infohash")
|
||||
@@ -301,12 +306,35 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
}
|
||||
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
||||
}
|
||||
if len(torrentBlob) > 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO download_torrent (download_id, data) VALUES (?, ?)`,
|
||||
d.ID, torrentBlob); err != nil {
|
||||
return nil, fmt.Errorf("insert download torrent: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("create download: commit: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetTorrentData возвращает сохранённые байты `.torrent` загрузки (для
|
||||
// добавления раздачи файлом воркером). ErrNotFound — если байтов нет (не
|
||||
// torrent-источник или запись отсутствует).
|
||||
func (s *Store) GetTorrentData(ctx context.Context, downloadID string) ([]byte, error) {
|
||||
var data []byte
|
||||
err := s.DB.QueryRowxContext(ctx,
|
||||
`SELECT data FROM download_torrent WHERE download_id = ?`, downloadID).Scan(&data)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("torrent data for %s: %w", downloadID, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get torrent data: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
||||
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
|
||||
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
|
||||
|
||||
@@ -44,11 +44,11 @@ func TestCatchedIsActiveForDedup(t *testing.T) {
|
||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||
|
||||
d1 := newCatched()
|
||||
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}); err != nil || existing != nil {
|
||||
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}, nil); err != nil || existing != nil {
|
||||
t.Fatalf("первый catched: existing=%v err=%v", existing, err)
|
||||
}
|
||||
// Повторный приём того же хеша → дедуп на активную catched-задачу.
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("повторный приём: %v", err)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestPromoteCatched(t *testing.T) {
|
||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||
|
||||
d := newCatched()
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
|
||||
@@ -90,7 +90,7 @@ func TestPromoteCatched(t *testing.T) {
|
||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
t.Helper()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
|
||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func TestActiveDuplicateDeduplicated(t *testing.T) {
|
||||
id := mustCreate(t, st, ih)
|
||||
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func TestDedupByAnyHash(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
|
||||
id := mustCreate(t, st, v1)
|
||||
|
||||
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -333,7 +333,7 @@ func TestCreateDedupTopsUpHashes(t *testing.T) {
|
||||
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
||||
}
|
||||
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -437,7 +437,7 @@ func TestConcurrentCreateDedup(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
|
||||
@@ -16,7 +16,7 @@ func mkDownload(t *testing.T, st *Store, n int, state State, display string) str
|
||||
ctx := context.Background()
|
||||
d := newDownloading()
|
||||
d.DisplayName = display
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", n, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Байты исходного `.torrent`-файла, привязанные к загрузке. Нужны воркеру на
|
||||
-- шаге добавления в qBittorrent (быстрый приём лишь сохраняет `catched`):
|
||||
-- torrent добавляется файлом, а не magnet-хешем — иначе на закрытых трекерах и
|
||||
-- без DHT метаданные не докачаются. Отдельная таблица, а не колонка на
|
||||
-- download: блоб не тянется в выборки списка/детали (SELECT * по download).
|
||||
-- Байты живут весь срок строки загрузки (нужны для retry повторного добавления);
|
||||
-- ON DELETE CASCADE — страховка на будущий delete-путь (сейчас загрузки не
|
||||
-- удаляются).
|
||||
CREATE TABLE download_torrent (
|
||||
download_id TEXT PRIMARY KEY REFERENCES download (id) ON DELETE CASCADE,
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE download_torrent;
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Запись байтов .torrent на ветке создания и чтение их воркером.
|
||||
func TestTorrentBlobWriteRead(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
blob := []byte("d8:announce…fake torrent bytes")
|
||||
|
||||
d := &Download{SourceType: SourceTorrent, SourceRef: "Some.Release", State: StateCatched}
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, blob)
|
||||
if err != nil || existing != nil {
|
||||
t.Fatalf("create: err=%v existing=%v", err, existing)
|
||||
}
|
||||
got, err := st.GetTorrentData(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if string(got) != string(blob) {
|
||||
t.Errorf("blob = %q, want %q", got, blob)
|
||||
}
|
||||
}
|
||||
|
||||
// При дедупе на активную задачу байты не пишутся (второй приём того же хеша).
|
||||
func TestTorrentBlobNotWrittenOnDedup(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := &Download{SourceType: SourceTorrent, SourceRef: "A", State: StateCatched}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, first, []string{hashN(2)}, []byte("first")); err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
// Второй приём того же инфохэша — дедуп; его байты писаться не должны.
|
||||
second := &Download{SourceType: SourceTorrent, SourceRef: "B", State: StateCatched}
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, second, []string{hashN(2)}, []byte("second"))
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
if existing == nil || existing.ID != first.ID {
|
||||
t.Fatalf("ожидался дедуп на первую задачу, got %v", existing)
|
||||
}
|
||||
got, err := st.GetTorrentData(ctx, first.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get first: %v", err)
|
||||
}
|
||||
if string(got) != "first" {
|
||||
t.Errorf("байты первой задачи перезаписаны: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Нет байтов (magnet-источник) → ErrNotFound.
|
||||
func TestTorrentBlobMissing(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + hashN(3), State: StateCatched}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(3)}, nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := st.GetTorrentData(ctx, d.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("ожидался ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func seedState(t *testing.T, st *Store, ih string, state State) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d := &Download{SourceType: SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ih, State: StateCatched}
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
|
||||
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}, nil); err != nil {
|
||||
t.Fatalf("seed create: %v", err)
|
||||
}
|
||||
if state != StateCatched {
|
||||
|
||||
+120
-12
@@ -2,10 +2,15 @@ package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
@@ -21,6 +26,9 @@ type teleAPI interface {
|
||||
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
|
||||
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
|
||||
StopReceivingUpdates()
|
||||
// GetFileDirectURL — прямой URL файла Telegram по file_id (для скачивания
|
||||
// присланного .torrent-документа).
|
||||
GetFileDirectURL(fileID string) (string, error)
|
||||
}
|
||||
|
||||
// Ingestor принимает загрузку (ingest.Service).
|
||||
@@ -56,6 +64,8 @@ type Bot struct {
|
||||
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||
|
||||
httpClient *http.Client // для скачивания .torrent-документов Telegram
|
||||
}
|
||||
|
||||
// New собирает бота поверх клиента Telegram.
|
||||
@@ -65,13 +75,14 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
||||
allowed[id] = true
|
||||
}
|
||||
return &Bot{
|
||||
api: client,
|
||||
ingestor: ing,
|
||||
reviewer: rev,
|
||||
allowed: allowed,
|
||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||
log: log,
|
||||
pending: map[int64]string{},
|
||||
api: client,
|
||||
ingestor: ing,
|
||||
reviewer: rev,
|
||||
allowed: allowed,
|
||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||
log: log,
|
||||
pending: map[int64]string{},
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +132,15 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, "Доступ запрещён.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Документ (.torrent) — до ветки pending/текста: у документа m.Text пуст,
|
||||
// иначе pending-подсказка «съела» бы его как пустую строку. Контекст — из
|
||||
// подписи (m.Caption).
|
||||
if m.Document != nil {
|
||||
b.handleDocument(ctx, m)
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(m.Text)
|
||||
|
||||
// Ждём подсказку для перераспознавания?
|
||||
@@ -140,25 +160,113 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
|
||||
source, context, ok := ParseMessage(text)
|
||||
if !ok {
|
||||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil)
|
||||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
|
||||
return
|
||||
}
|
||||
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
|
||||
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{Source: source, Context: context})
|
||||
}
|
||||
|
||||
// handleDocument принимает присланный .torrent-документ: проверяет тип/размер,
|
||||
// скачивает байты через Bot API и подаёт в приём (подпись — контекстом).
|
||||
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
|
||||
doc := m.Document
|
||||
if !isTorrentDoc(doc) {
|
||||
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
|
||||
return
|
||||
}
|
||||
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
|
||||
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
|
||||
return
|
||||
}
|
||||
data, err := b.downloadFile(ctx, doc.FileID)
|
||||
if err != nil {
|
||||
b.log.Warn("telegram document download failed", "error", err)
|
||||
b.send(m.Chat.ID, "Не удалось скачать файл из Telegram, попробуйте ещё раз.", nil)
|
||||
return
|
||||
}
|
||||
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{
|
||||
TorrentData: data,
|
||||
TorrentName: doc.FileName, // фолбек для source_ref, если у раздачи нет имени
|
||||
Context: m.Caption,
|
||||
})
|
||||
}
|
||||
|
||||
// stripURL убирает URL из ошибки *url.Error (URL файла Telegram содержит токен
|
||||
// бота), оставляя только первопричину — защита от утечки секрета в логи.
|
||||
func stripURL(err error) error {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return ue.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
|
||||
func isTorrentDoc(doc *tgbotapi.Document) bool {
|
||||
if doc == nil {
|
||||
return false
|
||||
}
|
||||
if doc.MimeType == "application/x-bittorrent" {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent")
|
||||
}
|
||||
|
||||
// downloadFile скачивает файл Telegram по file_id (прямой URL + HTTP GET),
|
||||
// ограничивая объём MaxTorrentSize.
|
||||
//
|
||||
// ВАЖНО: прямой URL файла Telegram содержит токен бота
|
||||
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
|
||||
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
|
||||
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
|
||||
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||
fileURL, err := b.api.GetFileDirectURL(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file url: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
|
||||
}
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("telegram file GET: status %d", resp.StatusCode)
|
||||
}
|
||||
// LimitReader на 1 байт больше лимита — чтобы отличить «ровно лимит» от
|
||||
// «больше лимита».
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, ingest.MaxTorrentSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > ingest.MaxTorrentSize {
|
||||
return nil, fmt.Errorf("telegram file exceeds %d bytes", ingest.MaxTorrentSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ingestAndReply принимает загрузку и отвечает пользователю единообразно для
|
||||
// всех источников (текст/magnet и .torrent-документ).
|
||||
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
|
||||
res, err := b.ingestor.Ingest(ctx, req)
|
||||
if err != nil {
|
||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||||
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||||
// него (детали всё равно в логах на доменной границе).
|
||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
||||
if res.Deduplicated {
|
||||
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
|
||||
}
|
||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||
b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||
}
|
||||
|
||||
const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
||||
|
||||
// --- Колбэки (кнопки) ---
|
||||
|
||||
@@ -22,6 +22,8 @@ type fakeAPI struct {
|
||||
sent []sentMsg
|
||||
edits []sentMsg
|
||||
answers []string
|
||||
fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
|
||||
fileErr error
|
||||
}
|
||||
|
||||
type sentMsg struct {
|
||||
@@ -47,6 +49,7 @@ func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
|
||||
}
|
||||
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
|
||||
func (f *fakeAPI) StopReceivingUpdates() {}
|
||||
func (f *fakeAPI) GetFileDirectURL(string) (string, error) { return f.fileURL, f.fileErr }
|
||||
|
||||
type fakeIngestor struct {
|
||||
lastReq ingest.Request
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func docMsg(userID int64, doc *tgbotapi.Document, caption string) *tgbotapi.Message {
|
||||
return &tgbotapi.Message{
|
||||
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID},
|
||||
Document: doc, Caption: caption,
|
||||
}
|
||||
}
|
||||
|
||||
// .torrent-документ: скачиваем байты и подаём в приём (подпись — контекстом).
|
||||
func TestBot_IngestFromDocument(t *testing.T) {
|
||||
torrentBytes := []byte("d8:announce…bytes")
|
||||
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(torrentBytes)
|
||||
}))
|
||||
defer fileSrv.Close()
|
||||
|
||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||
api.fileURL = fileSrv.URL + "/file.torrent"
|
||||
|
||||
doc := &tgbotapi.Document{FileID: "abc", FileName: "dune.torrent", MimeType: "application/x-bittorrent"}
|
||||
b.handleMessage(context.Background(), docMsg(7, doc, "Дюна 2"))
|
||||
|
||||
if string(ing.lastReq.TorrentData) != string(torrentBytes) {
|
||||
t.Errorf("TorrentData = %q, want %q", ing.lastReq.TorrentData, torrentBytes)
|
||||
}
|
||||
if ing.lastReq.Context != "Дюна 2" {
|
||||
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// Документ по расширению .torrent (mime может отсутствовать) тоже принимается.
|
||||
func TestBot_DocumentByExtension(t *testing.T) {
|
||||
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("bytes"))
|
||||
}))
|
||||
defer fileSrv.Close()
|
||||
|
||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||
api.fileURL = fileSrv.URL
|
||||
|
||||
doc := &tgbotapi.Document{FileID: "x", FileName: "Release.TORRENT"} // без mime, регистр
|
||||
b.handleMessage(context.Background(), docMsg(7, doc, ""))
|
||||
|
||||
if len(ing.lastReq.TorrentData) == 0 {
|
||||
t.Error("документ .torrent по расширению не принят")
|
||||
}
|
||||
_ = api
|
||||
}
|
||||
|
||||
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
|
||||
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
|
||||
// что stripURL его убрал.
|
||||
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
|
||||
b, api, _, _ := newTestBot(t, []int64{7})
|
||||
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
|
||||
api.fileURL = "http://127.0.0.1:1/file/botSECRET123:AAtoken/x"
|
||||
_, err := b.downloadFile(context.Background(), "fid")
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка транспорта")
|
||||
}
|
||||
if strings.Contains(err.Error(), "SECRET123") || strings.Contains(err.Error(), "AAtoken") {
|
||||
t.Errorf("токен утёк в текст ошибки: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Не-.torrent документ → подсказка, приёма нет.
|
||||
func TestBot_NonTorrentDocumentRejected(t *testing.T) {
|
||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||
doc := &tgbotapi.Document{FileID: "x", FileName: "photo.jpg", MimeType: "image/jpeg"}
|
||||
b.handleMessage(context.Background(), docMsg(7, doc, ""))
|
||||
|
||||
if len(ing.lastReq.TorrentData) != 0 || ing.lastReq.Source != "" {
|
||||
t.Errorf("не-.torrent не должен идти в приём: %+v", ing.lastReq)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не .torrent") {
|
||||
t.Errorf("нет подсказки об ошибке: %+v", api.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// Документ приходит ДО ветки pending-подсказки: ожидающая refine-подсказка не
|
||||
// «съедает» документ (у документа m.Text пуст).
|
||||
func TestBot_DocumentBeforePendingHint(t *testing.T) {
|
||||
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("bytes"))
|
||||
}))
|
||||
defer fileSrv.Close()
|
||||
|
||||
b, api, ing, rev := newTestBot(t, []int64{7})
|
||||
api.fileURL = fileSrv.URL
|
||||
b.setPending(7, tid) // ждём подсказку для перераспознавания
|
||||
|
||||
doc := &tgbotapi.Document{FileID: "x", FileName: "a.torrent", MimeType: "application/x-bittorrent"}
|
||||
b.handleMessage(context.Background(), docMsg(7, doc, ""))
|
||||
|
||||
if len(rev.refined) != 0 {
|
||||
t.Errorf("документ ошибочно принят как refine-подсказка: %v", rev.refined)
|
||||
}
|
||||
if len(ing.lastReq.TorrentData) == 0 {
|
||||
t.Error("документ не принят как .torrent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package torrent разбирает `.torrent`-файлы (bencode): извлекает инфохэш(и) и
|
||||
// метаданные раздачи для приёма загрузки байтами.
|
||||
//
|
||||
// Инфохэши нормализуются к нижнему hex — в этом же виде их отдаёт qBittorrent
|
||||
// (поля hash/infohash_v1/infohash_v2), что позволяет сопоставлять задачи с
|
||||
// раздачами при поллинге. v1-хеш берётся как SHA1 ИСХОДНЫХ байтов info-словаря
|
||||
// (библиотека хранит их и не переэнкодит), иначе хеш разошёлся бы с qBittorrent.
|
||||
package torrent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
infohashv2 "github.com/anacrolix/torrent/types/infohash-v2"
|
||||
)
|
||||
|
||||
// ErrNotTorrent возвращается, если байты не разбираются как `.torrent`.
|
||||
var ErrNotTorrent = errors.New("not a torrent file")
|
||||
|
||||
// File — один файл раздачи.
|
||||
type File struct {
|
||||
Path string // относительный путь ("dir/file.mkv")
|
||||
Length int64 // размер в байтах
|
||||
}
|
||||
|
||||
// Info — разобранный `.torrent`.
|
||||
type Info struct {
|
||||
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
|
||||
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
|
||||
DisplayName string // имя раздачи (info.name)
|
||||
Files []File // файлы раздачи (для одиночного — один элемент)
|
||||
TotalLength int64 // суммарный размер
|
||||
Trackers []string // announce + announce-list (плоско, без дублей)
|
||||
Comment string // comment, если задан
|
||||
}
|
||||
|
||||
// Parse разбирает байты `.torrent`. Извлекает те инфохэши, которые файл реально
|
||||
// несёт: v1 (для v1/гибрида) и v2 (для v2/гибрида, BEP52) — каждый в том виде,
|
||||
// в каком его сообщает qBittorrent. Байты info не переэнкодятся.
|
||||
func Parse(data []byte) (Info, error) {
|
||||
mi, err := metainfo.Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
|
||||
}
|
||||
meta, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("%w: info: %v", ErrNotTorrent, err)
|
||||
}
|
||||
|
||||
var hashes []string
|
||||
if meta.HasV1() {
|
||||
hashes = append(hashes, strings.ToLower(mi.HashInfoBytes().HexString()))
|
||||
}
|
||||
if meta.HasV2() {
|
||||
v2 := infohashv2.HashBytes(mi.InfoBytes)
|
||||
hashes = append(hashes, strings.ToLower(v2.HexString()))
|
||||
}
|
||||
if len(hashes) == 0 {
|
||||
return Info{}, fmt.Errorf("%w: no usable infohash", ErrNotTorrent)
|
||||
}
|
||||
|
||||
fs, total := files(&meta)
|
||||
return Info{
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: meta.BestName(),
|
||||
Files: fs,
|
||||
TotalLength: total,
|
||||
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
|
||||
Comment: mi.Comment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
|
||||
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
|
||||
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
|
||||
// ноль при piece length 0). Дерево файлов — лишь контекст-сигнал, не критично,
|
||||
// поэтому паники гасим: инфохэш и имя уже извлечены, приём не валим.
|
||||
func files(meta *metainfo.Info) (out []File, total int64) {
|
||||
defer func() { _ = recover() }()
|
||||
for _, fi := range meta.UpvertedFiles() {
|
||||
seg := fi.Path
|
||||
if len(seg) == 0 {
|
||||
seg = fi.PathUtf8
|
||||
}
|
||||
p := path.Join(seg...)
|
||||
if p == "" {
|
||||
p = meta.BestName()
|
||||
}
|
||||
out = append(out, File{Path: p, Length: fi.Length})
|
||||
total += fi.Length
|
||||
}
|
||||
return out, total
|
||||
}
|
||||
|
||||
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
|
||||
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
|
||||
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
|
||||
|
||||
// Context синтезирует человекочитаемый контекст распознавания из полей файла
|
||||
// (без сети): имя раздачи, суммарный размер, сигнал по дереву файлов, домен
|
||||
// трекера, комментарий. Строки-факты через "\n"; пустая строка — если полей
|
||||
// нет. Предназначен для download.Context (recognition, веб-UI), но НЕ для
|
||||
// вывода отображаемого имени.
|
||||
func (i Info) Context() string {
|
||||
var lines []string
|
||||
|
||||
if name := strings.TrimSpace(i.DisplayName); name != "" {
|
||||
lines = append(lines, name)
|
||||
}
|
||||
if i.TotalLength > 0 {
|
||||
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
|
||||
}
|
||||
if s := filesSignal(i.Files); s != "" {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
if d := originDomains(i.Trackers); d != "" {
|
||||
lines = append(lines, "Трекер: "+d)
|
||||
}
|
||||
if c := strings.TrimSpace(i.Comment); c != "" {
|
||||
lines = append(lines, "Комментарий: "+oneLine(c))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// filesSignal — сигнал по дереву файлов для recognition: число файлов и
|
||||
// характерные расширения (много видеофайлов → вероятно сериал). Для одиночного
|
||||
// файла ничего не добавляет (имя раздачи уже есть строкой названия).
|
||||
func filesSignal(files []File) string {
|
||||
if len(files) <= 1 {
|
||||
return ""
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var exts []string
|
||||
for _, f := range files {
|
||||
ext := strings.ToLower(strings.TrimPrefix(path.Ext(f.Path), "."))
|
||||
if ext == "" || seen[ext] {
|
||||
continue
|
||||
}
|
||||
seen[ext] = true
|
||||
if len(exts) < 5 {
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
}
|
||||
s := fmt.Sprintf("Файлов: %d", len(files))
|
||||
if len(exts) > 0 {
|
||||
s += " (" + strings.Join(exts, ", ") + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
|
||||
func humanSize(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
f := float64(n)
|
||||
i := -1
|
||||
for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
|
||||
f /= unit
|
||||
i++
|
||||
if f < unit {
|
||||
return fmt.Sprintf("≈ %.1f %s", f, u)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("≈ %.1f PiB", f)
|
||||
}
|
||||
|
||||
// originDomains извлекает уникальные домены происхождения из URL трекеров
|
||||
// (http(s)/udp с хостом), убирая служебные поддомены анонса. Порядок
|
||||
// сохраняется; результат — через ", ".
|
||||
func originDomains(trackers []string) string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range trackers {
|
||||
u, err := url.Parse(strings.TrimSpace(ref))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "udp":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
|
||||
host = s
|
||||
}
|
||||
if seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
out = append(out, host)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// oneLine схлопывает переводы строк в пробелы (комментарий бывает многострочным).
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package torrent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
// writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты
|
||||
// .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры,
|
||||
// у которых info собран вручную.
|
||||
func writeTorrent(t *testing.T, infoBytes []byte) []byte {
|
||||
t.Helper()
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write metainfo: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем
|
||||
// (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent.
|
||||
func TestParseV2OnlyHash(t *testing.T) {
|
||||
// Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true.
|
||||
info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(infoBytes)
|
||||
wantV2 := hex.EncodeToString(sum[:])
|
||||
|
||||
got, err := Parse(writeTorrent(t, infoBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got.Infohashes) != 1 {
|
||||
t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes)
|
||||
}
|
||||
if got.Infohash != wantV2 {
|
||||
t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2)
|
||||
}
|
||||
if len(got.Infohash) != 64 {
|
||||
t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash))
|
||||
}
|
||||
}
|
||||
|
||||
// Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex).
|
||||
func TestParseHybridHashes(t *testing.T) {
|
||||
info := metainfo.Info{
|
||||
Name: "Hybrid", Length: 100, PieceLength: 1024,
|
||||
Pieces: make([]byte, 20), MetaVersion: 2,
|
||||
}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
sha1sum := sha1.Sum(infoBytes)
|
||||
sha256sum := sha256.Sum256(infoBytes)
|
||||
wantV1 := hex.EncodeToString(sha1sum[:])
|
||||
wantV2 := hex.EncodeToString(sha256sum[:])
|
||||
|
||||
got, err := Parse(writeTorrent(t, infoBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got.Infohashes) != 2 {
|
||||
t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes)
|
||||
}
|
||||
if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 {
|
||||
t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2)
|
||||
}
|
||||
}
|
||||
|
||||
// build собирает валидные байты `.torrent` из info и метаданных, возвращая их
|
||||
// вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест
|
||||
// проверяет извлечение, не переизобретая хеширование.
|
||||
func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) {
|
||||
t.Helper()
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal info: %v", err)
|
||||
}
|
||||
sum := sha1.Sum(infoBytes)
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce, Comment: comment}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write metainfo: %v", err)
|
||||
}
|
||||
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// v1-piece заглушка (20 байт) — присутствие Pieces делает info v1.
|
||||
func pieces(n int) []byte { return make([]byte, 20*n) }
|
||||
|
||||
func TestParseSingleFile(t *testing.T) {
|
||||
data, wantV1 := build(t, metainfo.Info{
|
||||
Name: "Dune.Part.Two.2024.2160p.mkv",
|
||||
Length: 2100,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(3),
|
||||
}, "http://bt.rutracker.org/announce", "")
|
||||
|
||||
info, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if info.Infohash != wantV1 {
|
||||
t.Errorf("infohash = %q, want %q", info.Infohash, wantV1)
|
||||
}
|
||||
if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 {
|
||||
t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1)
|
||||
}
|
||||
if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" {
|
||||
t.Errorf("name = %q", info.DisplayName)
|
||||
}
|
||||
if info.TotalLength != 2100 {
|
||||
t.Errorf("total = %d, want 2100", info.TotalLength)
|
||||
}
|
||||
if len(info.Files) != 1 {
|
||||
t.Errorf("files = %v, want 1", info.Files)
|
||||
}
|
||||
ctx := info.Context()
|
||||
if !strings.Contains(ctx, "Dune.Part.Two") {
|
||||
t.Errorf("context без имени: %q", ctx)
|
||||
}
|
||||
if !strings.Contains(ctx, "Размер:") {
|
||||
t.Errorf("context без размера: %q", ctx)
|
||||
}
|
||||
if !strings.Contains(ctx, "rutracker.org") {
|
||||
t.Errorf("context без домена трекера: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultiFile(t *testing.T) {
|
||||
data, _ := build(t, metainfo.Info{
|
||||
Name: "Fargo.S01",
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(5),
|
||||
Files: []metainfo.FileInfo{
|
||||
{Length: 700, Path: []string{"Fargo.S01E01.mkv"}},
|
||||
{Length: 800, Path: []string{"Fargo.S01E02.mkv"}},
|
||||
{Length: 5, Path: []string{"subs", "e01.srt"}},
|
||||
},
|
||||
}, "udp://tracker.example.com:80", "")
|
||||
|
||||
info, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(info.Files) != 3 {
|
||||
t.Fatalf("files = %d, want 3", len(info.Files))
|
||||
}
|
||||
if info.TotalLength != 1505 {
|
||||
t.Errorf("total = %d, want 1505", info.TotalLength)
|
||||
}
|
||||
// путь из сегментов через "/"
|
||||
if info.Files[2].Path != "subs/e01.srt" {
|
||||
t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path)
|
||||
}
|
||||
ctx := info.Context()
|
||||
if !strings.Contains(ctx, "Файлов: 3") {
|
||||
t.Errorf("нет сигнала файлов: %q", ctx)
|
||||
}
|
||||
if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") {
|
||||
t.Errorf("нет расширений в сигнале: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrackersDedupAndComment(t *testing.T) {
|
||||
data, _ := build(t, metainfo.Info{
|
||||
Name: "X",
|
||||
Length: 10,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(1),
|
||||
}, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий")
|
||||
|
||||
info, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(info.Trackers) == 0 {
|
||||
t.Fatal("нет трекеров")
|
||||
}
|
||||
ctx := info.Context()
|
||||
// служебный поддомен bt2. срезается
|
||||
if !strings.Contains(ctx, "t-ru.org") {
|
||||
t.Errorf("домен не нормализован: %q", ctx)
|
||||
}
|
||||
// комментарий схлопнут в одну строку
|
||||
if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") {
|
||||
t.Errorf("комментарий: %q", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInvalidBytes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"garbage", []byte("this is not a torrent")},
|
||||
{"bencode-not-dict", []byte("i42e")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := Parse(tc.data); err == nil {
|
||||
t.Errorf("ожидалась ошибка для %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextEmptyWhenNoFields(t *testing.T) {
|
||||
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
|
||||
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
|
||||
// функция — сетевых вызовов в коде нет по построению).
|
||||
data, _ := build(t, metainfo.Info{
|
||||
Name: "-", // metainfo.NoName
|
||||
Length: 0,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(1),
|
||||
}, "", "")
|
||||
info, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_ = info.Context() // не паникует; состав строк — по полям
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes, nil)
|
||||
if err != nil {
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
|
||||
@@ -227,6 +227,7 @@ type memStore struct {
|
||||
overrides map[string]map[string]string
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
torrents map[string][]byte
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
@@ -286,7 +287,7 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -298,11 +299,24 @@ func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Downlo
|
||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
m.downloads[id] = &cp
|
||||
if len(torrentBlob) > 0 {
|
||||
if m.torrents == nil {
|
||||
m.torrents = map[string][]byte{}
|
||||
}
|
||||
m.torrents[id] = torrentBlob
|
||||
}
|
||||
d.ID = id
|
||||
d.Infohashes = cp.Infohashes
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
|
||||
if data, ok := m.torrents[downloadID]; ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := m.downloads[id]
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// buildTorrent — валидные байты .torrent и их v1-инфохэш.
|
||||
func buildTorrent(t *testing.T, name string) (data []byte, infohash string) {
|
||||
t.Helper()
|
||||
info := metainfo.Info{Name: name, Length: 2048, PieceLength: 1024, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal info: %v", err)
|
||||
}
|
||||
sum := sha1.Sum(infoBytes)
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write metainfo: %v", err)
|
||||
}
|
||||
return buf.Bytes(), hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// catchedTorrentStore — пойманная torrent-загрузка с сохранёнными байтами.
|
||||
func catchedTorrentStore(id string, data []byte, infohash string) *fakeStore {
|
||||
return &fakeStore{
|
||||
downloads: map[string]*store.Download{
|
||||
id: {
|
||||
ID: id,
|
||||
State: store.StateCatched,
|
||||
SourceType: store.SourceTorrent,
|
||||
SourceRef: "Some.Release.Name", // человекочитаемый референс, не URL
|
||||
Infohashes: hashesOf(id, infohash),
|
||||
CreatedAt: nowStr,
|
||||
},
|
||||
},
|
||||
torrents: map[string][]byte{id: data},
|
||||
}
|
||||
}
|
||||
|
||||
// Пойманная torrent-загрузка добавляется в qBittorrent ФАЙЛОМ (Torrents), не URL.
|
||||
func TestProcessCatchedTorrentAddsAsFile(t *testing.T) {
|
||||
data, ih := buildTorrent(t, "Dune.mkv")
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
w.SetNamer(&fakeNamer{name: "Дюна (2024)"})
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
if len(qb.added) != 1 {
|
||||
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||||
}
|
||||
add := qb.added[0]
|
||||
if len(add.URLs) != 0 {
|
||||
t.Errorf("torrent не должен добавляться по URLs: %v", add.URLs)
|
||||
}
|
||||
if len(add.Torrents) != 1 || !bytes.Equal(add.Torrents[0], data) {
|
||||
t.Errorf("байты .torrent не переданы в Add.Torrents")
|
||||
}
|
||||
if add.Rename != "Дюна (2024)" {
|
||||
t.Errorf("rename = %q", add.Rename)
|
||||
}
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry torrent-загрузки без живой раздачи добавляет её ФАЙЛОМ (регрессия
|
||||
// BLOCKER 1: раньше add гейтился на magnet и torrent активировался без раздачи).
|
||||
func TestRetryTorrentAddsAsFile(t *testing.T) {
|
||||
data, ih := buildTorrent(t, "Fargo.mkv")
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
qb := &fakeQbt{} // раздачи в qBittorrent нет → должен добавить заново
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if len(qb.added) != 1 {
|
||||
t.Fatalf("ожидалось повторное добавление, got %d", len(qb.added))
|
||||
}
|
||||
if len(qb.added[0].Torrents) != 1 || len(qb.added[0].URLs) != 0 {
|
||||
t.Errorf("retry torrent должен добавляться файлом, add = %+v", qb.added[0])
|
||||
}
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry torrent-загрузки без сохранённых байтов — откат активации (не оставляем
|
||||
// «качающуюся» задачу без раздачи).
|
||||
func TestRetryTorrentMissingBytesRollsBack(t *testing.T) {
|
||||
_, ih := buildTorrent(t, "X.mkv")
|
||||
st := catchedTorrentStore("1", nil, ih) // байтов нет
|
||||
delete(st.torrents, "1")
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидалась ошибка (нет байтов torrent)")
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("add не должен вызываться без байтов")
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
+58
-18
@@ -28,6 +28,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/torrent"
|
||||
)
|
||||
|
||||
// Стадии (capability) — адресуют запись к подсистеме при корреляции по
|
||||
@@ -53,7 +54,10 @@ type Store interface {
|
||||
|
||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error)
|
||||
// GetTorrentData — сохранённые байты `.torrent` (для добавления раздачи
|
||||
// файлом на шаге processCatched/Retry у source_type=torrent).
|
||||
GetTorrentData(ctx context.Context, downloadID string) ([]byte, error)
|
||||
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
|
||||
@@ -352,21 +356,21 @@ func (w *Worker) processCatched(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Вне w.mu: вывод имени (потенциально медленный LLM) и добавление.
|
||||
// Вне w.mu: сбор параметров добавления по типу источника (для torrent —
|
||||
// чтение байтов), вывод имени (потенциально медленный LLM) и добавление.
|
||||
hint, addReq, prepErr := w.sourceAddParts(cctx, d)
|
||||
if prepErr != nil {
|
||||
// Байты torrent недоступны (не должно быть при штатном приёме) —
|
||||
// остаёмся в catched, повтор на следующем тике.
|
||||
logctx.From(cctx).Warn("catched prepare add failed, will retry", "error", prepErr)
|
||||
continue
|
||||
}
|
||||
var rename string
|
||||
if w.namer != nil {
|
||||
hint := ""
|
||||
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
|
||||
hint = info.DisplayName
|
||||
}
|
||||
rename = w.namer.DeriveName(cctx, d.Context, hint)
|
||||
}
|
||||
addErr := w.qbt.Add(cctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
Rename: rename,
|
||||
})
|
||||
addReq.Rename = rename
|
||||
addErr := w.qbt.Add(cctx, addReq)
|
||||
if addErr != nil {
|
||||
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
|
||||
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
|
||||
@@ -400,6 +404,33 @@ func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
|
||||
return w.now().Sub(created), true
|
||||
}
|
||||
|
||||
// sourceAddParts собирает параметры добавления раздачи в qBittorrent по типу
|
||||
// источника и подсказку имени (hint) для namer. magnet/url — ссылкой (URLs),
|
||||
// hint из полей ссылки; torrent — байтами файла (Torrents), hint из имени
|
||||
// раздачи. Общий для обоих add-путей воркера (processCatched и Retry), чтобы
|
||||
// диспетч по source_type был в одном месте. Rename вызывающий проставляет сам
|
||||
// (после namer). Ошибку возвращает лишь torrent-ветка (байты недоступны).
|
||||
func (w *Worker) sourceAddParts(ctx context.Context, d store.Download) (hint string, req qbt.AddRequest, err error) {
|
||||
req = qbt.AddRequest{Category: w.cfg.Category, SavePath: w.cfg.SavePath}
|
||||
if d.SourceType == store.SourceTorrent {
|
||||
data, derr := w.store.GetTorrentData(ctx, d.ID)
|
||||
if derr != nil {
|
||||
return "", qbt.AddRequest{}, fmt.Errorf("torrent bytes: %w", derr)
|
||||
}
|
||||
if info, perr := torrent.Parse(data); perr == nil {
|
||||
hint = info.DisplayName
|
||||
}
|
||||
req.Torrents = [][]byte{data}
|
||||
return hint, req, nil
|
||||
}
|
||||
// magnet/url: SourceRef — добавляемая ссылка, и источник hint для namer.
|
||||
if info, perr := magnet.Parse(d.SourceRef); perr == nil {
|
||||
hint = info.DisplayName
|
||||
}
|
||||
req.URLs = []string{d.SourceRef}
|
||||
return hint, req, nil
|
||||
}
|
||||
|
||||
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||
@@ -674,12 +705,21 @@ func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
}
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if !alive && d.SourceType == store.SourceMagnet {
|
||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
}); err != nil {
|
||||
if !alive {
|
||||
// Добавляем заново по типу источника (magnet — ссылкой, torrent —
|
||||
// сохранёнными байтами файлом). Rename при retry не выводим (namer здесь
|
||||
// не зовём — имя уже могло быть выведено при первом добавлении).
|
||||
_, addReq, prepErr := w.sourceAddParts(ctx, *d)
|
||||
if prepErr != nil {
|
||||
// Байты torrent недоступны — откатываем активацию, задача не должна
|
||||
// «качаться» без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: prepare add: %w", prepErr)
|
||||
}
|
||||
if err := w.qbt.Add(ctx, addReq); err != nil {
|
||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
type fakeStore struct {
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
torrents map[string][]byte // download_id → байты .torrent
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
@@ -99,7 +100,7 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
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
|
||||
}
|
||||
@@ -111,11 +112,24 @@ func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Downl
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user