Принимаем .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>
94 lines
7.0 KiB
Markdown
94 lines
7.0 KiB
Markdown
## 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`
|