Приём: добавление загрузки по .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
@@ -0,0 +1,93 @@
## 1. Парсер `internal/torrent`
- [x] 1.1 Добавить зависимость `github.com/anacrolix/torrent` (пакет
`metainfo`); `task tidy`
- [x] 1.2 Пакет `internal/torrent`: `Parse(data []byte) (Info, error)` через
`metainfo.Load`/`UnmarshalInfo`; `Info{Infohash, Infohashes, DisplayName,
Files, TotalLength, Trackers, Comment}`. Инфохэш v1 — из `HashInfoBytes`
(SHA1 исходных байтов info, нижний hex); v2/гибрид — извлечь v2 (64-hex,
как `infohash_v2` qBit), для v2-only хранить именно v2. Уточнить точный v2-API
anacrolix при apply
- [x] 1.3 `func (Info) Context() string` — строки-факты (`\n`): имя раздачи,
`Размер:` из `TotalLength`, сигнал по файлам (число/расширения), `Трекер:`
домен из announce/announce-list, комментарий (если содержателен). Без сети
- [x] 1.4 Хелперы размера/домена: дублировать тривиальные из `internal/magnet`
или вынести общий (решение по месту)
- [x] 1.5 Тесты: одиночный файл; много файлов; гибрид v1+v2; трекеры из
announce-list; повреждённые/не-torrent байты → ошибка; `Context()`
содержательный и пустой случай; отсутствие сетевых вызовов
## 2. Хранилище: байты торрента
- [x] 2.1 Goose-миграция: таблица `download_torrent(download_id TEXT PRIMARY KEY
REFERENCES download(id) ON DELETE CASCADE, data BLOB NOT NULL)`
- [x] 2.2 Обновить ER-схему `docs/specs/database.md` (новая таблица + связь)
- [x] 2.3 Изменить сигнатуру `CreateDownloadIfNoActive(ctx, d, hashes,
torrentBlob []byte)`: на ветке создания `INSERT INTO download_torrent` в той
же tx (при дедупе — не пишет). Обновить интерфейсы `Store` в `internal/ingest`
и `internal/worker` и вызовы magnet-приёма/`discover.go` (передают `nil`).
Добавить `GetTorrentData(ctx, downloadID) ([]byte, error)`
- [x] 2.4 Тесты store: запись/чтение байтов; при дедупе байты не пишутся;
CASCADE-уборка при (гипотетическом) удалении загрузки. Байты НЕ удаляются при
промоушене `catched → downloading` (нужны для retry)
## 3. Приём: ветка байтов в `ingest`
- [x] 3.1 Расширить `ingest.Request` полем `TorrentData []byte` (опц.)
- [x] 3.2 В `Ingest`: при непустом `TorrentData` — `torrent.Parse`,
`SourceType = SourceTorrent`, `SourceRef` = имя раздачи/файла (не URL),
контекст = `mergeContext(req.Context, tinfo.Context())`, запись байтов (2.3)
в транзакции создания; иначе — прежний magnet-путь без изменений
- [x] 3.3 Ограничение размера входа на границе (константа, дефолт напр. 8 MiB)
- [x] 3.4 Тесты ingest: приём `.torrent` → `catched`, `source_type=torrent`,
инфохэши, байты сохранены, контекст синтезирован; дедуп по инфохэшу (байты не
пишутся); превышение лимита → ошибка
## 4. Воркер: диспетч добавления по типу источника (оба add-пути)
- [x] 4.1 Общий хелпер `addSource(ctx, d, rename)`: ветвь по `d.SourceType` —
magnet/url (`URLs`, hint из `magnet.Parse`); torrent (`GetTorrentData`, hint
из `torrent.Parse(data).DisplayName`, `qbt.Add(AddRequest{Torrents:
[][]byte{data}, …})`)
- [x] 4.2 Применить хелпер в `processCatched` (worker.go:359-365) И в `Retry`
(worker.go:677-691): снять magnet-only гейт в Retry — torrent без живой
раздачи должен добавляться байтами, а не активироваться без `Add`. Сохранить
откат активации при сбое add в Retry
- [x] 4.3 Общими остаются: ре-валидация перехода, обработка сбоя `add`
(`processCatched` — остаётся `catched`; Retry — откат), namer/qbt вне
блокировки
- [x] 4.4 Тесты воркера: (а) `catched` torrent → `qbt.Add` с `Torrents` (не
`URLs`), переход в `downloading`, сбой add → остаётся `catched`; (б) `Retry`
torrent без живой раздачи → `Add` с `Torrents`, переход в `downloading`; сбой
add → откат в прежнее состояние
## 5. Транспорт: веб-форма (multipart)
- [x] 5.1 Шаблон формы: `enctype="multipart/form-data"`, `<input type="file"
name="torrent" accept=".torrent,application/x-bittorrent">` рядом со строкой
источника; строка источника не обязательна при выбранном файле
- [x] 5.2 `handleUIAdd`: `http.MaxBytesReader`/лимит, `ParseMultipartForm`; есть
файл `torrent` → `Ingest(Request{TorrentData, Context})`; иначе — прежний
путь. PRG-редирект/htmx как сейчас
- [x] 5.3 Деградация без JS: обычный multipart-POST приводит к тому же
результату
- [x] 5.4 Тест обработчика: multipart с `.torrent` → загрузка заведена; без
файла → прежний текстовый путь; превышение размера → ошибка
## 6. Транспорт: Telegram-документ
- [x] 6.1 В `handleMessage` обрабатывать `m.Document` **до** ветки pending-hint
и текстовой ветки (у документа `m.Text` пуст): mime `application/x-bittorrent`
или расширение `.torrent` → скачать байты (`GetFileDirectURL` + HTTP GET,
лимит), `Ingest(Request{TorrentData, Context: m.Caption})`
- [x] 6.2 Документ не-`.torrent` → прежний ответ-подсказка; сохранить
allowlist-проверку. `AllowedUpdates` не менять (документ — внутри `message`)
- [x] 6.3 Тест парса/маршрутизации документа (mime/расширение → ветка байтов);
скачивание — за интерфейсом/мок
## 7. Проверка
- [x] 7.1 `task test` и `task lint` зелёные
- [x] 7.2 `openspec validate torrent-file-ingest --strict` проходит
- [ ] 7.3 Ручная проверка: `.torrent` через веб-пикер и Telegram-документ →
`catched → downloading`, задача видна в qBittorrent с `rename`