Приём: добавление загрузки по .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:
av
2026-07-08 10:52:31 +03:00
co-authored by Claude Opus 4.8
parent 90fd8640ed
commit 14d615a7c2
38 changed files with 2519 additions and 100 deletions
+29 -7
View File
@@ -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):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
+87
View File
@@ -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)
}
}