Приём: добавление загрузки по .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:
@@ -0,0 +1,262 @@
|
||||
## Context
|
||||
|
||||
Приём — быстрый (`fast-catch-ingest`): `Ingest` синхронно парсит источник,
|
||||
синтезирует контекст, атомарно дедуплицирует и пишет `download` в `catched`,
|
||||
сразу отвечая транспорту. Вывод имени (LLM) и `qbt.Add` делает воркер на шаге
|
||||
`processCatched` (`internal/worker/worker.go:355-369`), беря источник из
|
||||
`d.SourceRef` и добавляя его через `qbt.AddRequest.URLs`.
|
||||
|
||||
Готовое к переиспользованию:
|
||||
|
||||
- `qbt.AddRequest.Torrents [][]byte` + `qbt.Client.Add` уже шлют `.torrent`
|
||||
байтами (multipart `torrents=fileN.torrent`, `internal/qbt/qbt.go:203`). Клиент
|
||||
не трогаем.
|
||||
- `store.SourceTorrent` уже объявлен (`internal/store/download.go:137`), но не
|
||||
используется.
|
||||
- `CreateDownloadIfNoActive(ctx, d, hashes)` (`:249`) атомарно держит инвариант
|
||||
«≤1 активная загрузка на infohash» и пишет `download` + `download_infohash`.
|
||||
Требует инфохэши **до** вызова.
|
||||
|
||||
Ключевое отличие magnet от файла: у magnet `SourceRef` (строка ссылки) — и
|
||||
идентификатор, и то, что добавляется в qBittorrent. У `.torrent` «источник» —
|
||||
**байты**, и без них qBittorrent на закрытых трекерах/без DHT метаданные не
|
||||
получит. Значит, байты нельзя схлопнуть в magnet — их надо сохранить и
|
||||
скормить воркеру как есть.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Принять `.torrent` как загруженные байты (Telegram-документ, файл-пикер
|
||||
веба), извлечь из файла максимум контекста без сети, добавить в qBittorrent
|
||||
байтами (метаданные не теряем).
|
||||
- Переиспользовать существующий быстрый путь и инвариант дедупа: инфохэш из
|
||||
файла → тот же `CreateDownloadIfNoActive`.
|
||||
- Ветвление по типу источника локализовать (ingest — парс; worker — add), не
|
||||
расползаясь по коду.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Торрент по http(s)-ссылке (фетч, SSRF-гард) — отдельная задача.
|
||||
- Изменение `naming.DeriveName` / recognition — только богатеет вход
|
||||
(`download.Context`).
|
||||
- Изменение схемы `download`, magnet-пути, клиента qBittorrent.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Р1. Парсер `internal/torrent` поверх `anacrolix/torrent/metainfo`
|
||||
|
||||
Новый пакет по образцу `internal/magnet`: `Parse(data []byte) (Info, error)`.
|
||||
API (проверено сборкой): `metainfo.Load(r)` хранит сырые `InfoBytes`;
|
||||
`mi.HashInfoBytes()` даёт **v1**-инфохэш (SHA1 **исходных** байтов info-словаря,
|
||||
без переэнкода — критично: любое переупорядочивание ключей/переформат целых
|
||||
поменяло бы хеш и сломало сопоставление с qBittorrent и дедуп);
|
||||
`mi.UnmarshalInfo()` → `info.Name`, `info.Files`, `info.TotalLength()`;
|
||||
трекеры — из `mi.Announce` + плоский `mi.AnnounceList`; `mi.Comment`.
|
||||
|
||||
**Инфохэши — как их сообщает qBittorrent, не «всегда v1».** Формулировка
|
||||
«всегда считаем v1» неверна для v2-only торрентов (BEP52): у них v1-хеша нет, а
|
||||
qBittorrent отдаёт раздачу под 64-hex `infohash_v2`. Поэтому парсер извлекает те
|
||||
хеши, которые файл реально несёт: для v1/гибрида — v1 (`HashInfoBytes`), для
|
||||
v2/гибрида — дополнительно v2 (SHA256 info при `meta version 2`). Для чистого
|
||||
v2-only мы храним именно v2, иначе `torrentFor` (`worker.go:512`) не совпадёт и
|
||||
задача повиснет как «нет в qBittorrent». Точное имя v2-API anacrolix
|
||||
(`HashInfoBytes` — только v1; v2 — через v2-тип библиотеки) **уточняется при
|
||||
apply** и проверяется тестом на v2-only/гибридном файле; современные раздачи в
|
||||
основном v1, но абсолют «SHALL v1» из спеки убран.
|
||||
|
||||
`Info` (аналогично `magnet.Info`):
|
||||
|
||||
```
|
||||
type Info struct {
|
||||
Infohash string // v1 приоритетно (нижний hex)
|
||||
Infohashes []string // v1 (+ v2, если гибрид) — как в magnet.Info
|
||||
DisplayName string // info.name
|
||||
Files []File // {Path, Length}; для одиночного файла — один элемент
|
||||
TotalLength int64 // сумма длин
|
||||
Trackers []string // announce + announce-list (flatten)
|
||||
Comment string // comment (опц.)
|
||||
}
|
||||
```
|
||||
|
||||
`func (i Info) Context() string` синтезирует строки-факты (через `\n`, тем же
|
||||
стилем, что `magnet.Context()`): имя раздачи; `Размер:` человекочитаемо из
|
||||
`TotalLength`; `Файлы:` — число и характерные расширения/крупнейший файл (даёт
|
||||
recognition сигнал «фильм vs сериал» по числу видеофайлов); `Трекер:` — домен
|
||||
происхождения; комментарий, если содержателен. Без сети.
|
||||
|
||||
_Совместное с magnet:_ хелперы `humanSize`/`originDomains` в `internal/magnet`
|
||||
неэкспортируемы. Дублируем их тривиальные версии в `internal/torrent` (пакеты
|
||||
независимы, дрейф маловероятен для «байты→GiB» и «url→домен»), либо — если при
|
||||
apply дублирование ощущается лишним — выносим в маленький общий
|
||||
`internal/mediactx`. Решение — при apply; на спеку не влияет.
|
||||
|
||||
_Альтернатива (отклонена пользователем):_ свой bencode-декодер без зависимости.
|
||||
Выбрана проверенная библиотека ради корректного извлечения сырых info-байтов.
|
||||
|
||||
### Р2. Персистентность байтов — таблица-спутник `download_torrent`
|
||||
|
||||
Байты нужны воркеру на шаге добавления, а между приёмом и добавлением загрузка
|
||||
живёт в `catched`. Храним байты в БД (атомарно с загрузкой, чистятся вместе с
|
||||
ней, не требуют управления файлами на диске — в духе «минимум компонентов»):
|
||||
|
||||
```
|
||||
CREATE TABLE download_torrent (
|
||||
download_id TEXT PRIMARY KEY
|
||||
REFERENCES download(id) ON DELETE CASCADE,
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Отдельная таблица, а не колонка на `download`: выборки списка/детали читают
|
||||
`download` целиком (`SELECT *`), а блоб (десятки КБ, изредка единицы МБ) там ни
|
||||
к чему — тянули бы его на каждый рендер списка.
|
||||
|
||||
**Запись байтов — внутри транзакции создания (см. Р5, изменение API).** Тх
|
||||
`CreateDownloadIfNoActive` внутренняя и коммитится сама (`download.go:256-304`) —
|
||||
снаружи к ней не подцепиться. Чтобы байты писались атомарно с заведением
|
||||
загрузки (иначе — окно краха: `catched`-строка без байтов, воркер её никогда не
|
||||
добавит), метод получает байты параметром и пишет `download_torrent` **на ветке
|
||||
создания**; при дедупе (загрузка не создана) байты не пишутся.
|
||||
|
||||
**Жизненный цикл блоба — байты живут весь срок строки загрузки.** `ON DELETE
|
||||
CASCADE` корректна (FK включены, `store.go:40`), но сегодня `DELETE FROM
|
||||
download` в коде нет — загрузки не удаляются, уходят в терминальные состояния.
|
||||
Значит CASCADE — лишь страховка на будущий delete-путь, а не активная уборка.
|
||||
Удалять байты после промоушена `catched → downloading` мы **не** будем: они
|
||||
нужны `Retry` (Р4) — повторное добавление упавшего/застрявшего торрента идёт тем
|
||||
же файлом. Плата — байты копятся (ограничены размерным лимитом × число
|
||||
torrent-загрузок); это осознанный трейд-офф ради ретраев, не баг.
|
||||
|
||||
_Альтернатива:_ файл на data-томе, `SourceRef` = путь. Отклонено — добавляет
|
||||
управление файлами (создание, уборка, рассинхрон с БД) ради экономии, которой
|
||||
для торрент-файлов нет.
|
||||
|
||||
`.torrent` на диске бывают крупными (много файлов → много piece-хешей).
|
||||
Ограничиваем размер входа на границе транспорта (константа, дефолт напр. 8 MiB
|
||||
с запасом) — защита от разбухания БД и oversized-загрузок.
|
||||
|
||||
### Р3. `SourceRef` у torrent — человекочитаемая ссылка, не адрес добавления
|
||||
|
||||
Для magnet `SourceRef` двойного назначения (ref + то, что добавляют). Для
|
||||
torrent «то, что добавляют» — байты (Р2), поэтому `SourceRef` несёт только
|
||||
**референс для человека/логов**: имя раздачи (`info.name`) либо имя файла.
|
||||
Никакой код не должен добавлять torrent-загрузку по `SourceRef` как URL — это
|
||||
гарантирует диспетч воркера по `SourceType` (Р4). Инфохэш-референс у загрузки и
|
||||
так есть в `download_infohash`.
|
||||
|
||||
_Почему не синтезировать magnet в `SourceRef`:_ строка вида
|
||||
`magnet:?xt=urn:btih:…` выглядела бы «добавляемой», провоцируя код слать её в
|
||||
`URLs` и терять метаданные на закрытых трекерах. Явный не-URL `SourceRef` +
|
||||
диспетч по типу убирают ловушку.
|
||||
|
||||
### Р4. Воркер: диспетч по `SourceType` во **всех** add-путях (общий хелпер)
|
||||
|
||||
В воркере **два** места добавляют источник в qBittorrent, и оба сегодня
|
||||
предполагают magnet-URL — диспетч нужен в обоих:
|
||||
|
||||
1. `processCatched` (`worker.go:359-365`) — добавление пойманной загрузки.
|
||||
2. `Retry` (`worker.go:677-691`) — повторное добавление упавшей/застрявшей.
|
||||
Важно: сейчас `Retry` гейтит add на `d.SourceType == store.SourceMagnet`, то
|
||||
есть для torrent он **активирует загрузку в `downloading`, не добавив
|
||||
раздачу** → задача повиснет как «нет в qBittorrent» (нашёл ревью, BLOCKER 1).
|
||||
Особенно опасно, т.к. `errCodeQbitAdd`-фейл не воскрешается сверкой — `Retry`
|
||||
единственный выход.
|
||||
|
||||
Выносим общий хелпер `addSource(ctx, d, rename) error`, ветвящийся по
|
||||
`d.SourceType`, и зовём его из обоих мест:
|
||||
|
||||
- `magnet`/`url` — hint из `magnet.Parse(d.SourceRef)`,
|
||||
`qbt.Add(AddRequest{URLs: []string{d.SourceRef}, …})`.
|
||||
- `torrent` — байты из `store.GetTorrentData(id)`; hint из `info.DisplayName`
|
||||
(парс тех же байтов `torrent.Parse`); `qbt.Add(AddRequest{Torrents:
|
||||
[][]byte{data}, Category, SavePath, Rename})`.
|
||||
|
||||
Ре-валидация перехода под блокировкой, обработка сбоя `add` (в `processCatched`
|
||||
остаётся `catched`; в `Retry` — откат активации, `worker.go:683-689`),
|
||||
предохранитель `catch_timeout` — прежние, не дублируются. Медленные вызовы
|
||||
(namer, `qbt.Add`) — по-прежнему вне блокировки сериализации. Байты для `Retry`
|
||||
доступны, потому что мы их не удаляем после промоушена (Р2).
|
||||
|
||||
### Р5. `ingest.Request` — путь для байтов; изменение API store ради атомарности
|
||||
|
||||
`Request` расширяется полем для байтов (`TorrentData []byte`, опц.).
|
||||
Приоритет в `Ingest`: если `TorrentData` непуст — `torrent.Parse(data)`,
|
||||
`SourceType = SourceTorrent`, `SourceRef` = имя (Р3), контекст =
|
||||
`mergeContext(req.Context, tinfo.Context())`. Иначе — текущий magnet-путь без
|
||||
изменений. Инфохэши, дедуп, атомарность — общий код `CreateDownloadIfNoActive`.
|
||||
|
||||
**Изменение API `CreateDownloadIfNoActive` (нашёл ревью, BLOCKER 2).** Чтобы
|
||||
байты писались в той же транзакции (Р2), метод получает их аргументом (напр.
|
||||
`CreateDownloadIfNoActive(ctx, d, hashes, torrentBlob []byte)`) и на ветке
|
||||
создания делает `INSERT INTO download_torrent`; на ветке дедупа — не пишет.
|
||||
Это тянет правку интерфейсов `Store` в `internal/ingest` и `internal/worker` и
|
||||
существующих вызовов (magnet-приём и `discover.go` передают `nil`). Плюс метод
|
||||
чтения `GetTorrentData(ctx, downloadID) ([]byte, error)` в те же интерфейсы для
|
||||
воркера (Р4).
|
||||
|
||||
Форма транспортного ввода:
|
||||
|
||||
- **HTTP/web** (`handleUIAdd`): `multipart/form-data`; при наличии файла
|
||||
`torrent` читаем байты (через `http.MaxBytesReader`/лимит Р2) → `Request{
|
||||
TorrentData, Context}`; иначе — `Request{Source, Context}` как сейчас. Handler
|
||||
сейчас — простой PRG `303` (не htmx, `httpapi.go:397-411`); так и остаётся,
|
||||
деградация без JS сохраняется.
|
||||
- **Telegram** (`handleMessage`): распознавание `.torrent`-документа надо
|
||||
вставить **до** ветки pending-hint и текстовой ветки (`bot.go:124-146`):
|
||||
у документа `m.Text` пуст (текст — в `m.Caption`), иначе pending-hint
|
||||
«съест» документ как пустую подсказку. При `m.Document` с mime
|
||||
`application/x-bittorrent` или расширением `.torrent` — скачиваем байты через
|
||||
Bot API (`GetFileDirectURL`, есть в v5.5.1, `bot.go:269`, + HTTP GET, лимит
|
||||
Р2) → `Request{TorrentData, Context: m.Caption}`. Скачивание с серверов
|
||||
Telegram (доверенный источник, не произвольный SSRF); латентность приёмлема
|
||||
для бот-взаимодействия. Документ не-`.torrent` → прежний ответ-подсказка.
|
||||
`AllowedUpdates` менять не нужно — документ приходит внутри `message`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Блоб в SQLite раздувает БД] → Лимит размера на границе (Р2), отдельная
|
||||
таблица (не тянется в выборках), CASCADE-уборка вместе с загрузкой. Торрент-
|
||||
файлы малы; крупные (много файлов) отсекаются лимитом.
|
||||
- [Зависимость `anacrolix/torrent` тяжёлая] → Импортируем только подпакет
|
||||
`metainfo`; сетевой/DHT-код не тянется в бинарь (только в go.sum как граф
|
||||
требований). Осознанный выбор ради корректного info-хеша.
|
||||
- [`SourceRef`-не-URL ломает код, ждущий magnet] → Оба add-пути воркера
|
||||
(`processCatched` и `Retry`) диспетчат по `SourceType` через общий хелпер
|
||||
(Р4); `magnet.Parse(SourceRef)` зовётся только в magnet-ветке. Дисплейные
|
||||
читатели `SourceRef` (`httpapi.go:653`, `download.go:99`, `review.go:104`,
|
||||
`tgbot/render.go:217`) толерантны к не-URL-строке — регресса нет.
|
||||
- [v2-only/гибрид torrent] → v1-хеш обязателен, v2 — best-effort; дозапись
|
||||
недостающего v2 из qBittorrent уже покрыта требованием «Множество инфохэшей».
|
||||
- [Telegram-документ большого размера/не-торрент] → Проверка mime/расширения +
|
||||
лимит размера до скачивания; аккуратный ответ на не-`.torrent`.
|
||||
- [Дубль контекста: имя из файла и подпись пользователя совпадают] →
|
||||
`mergeContext` ставит текст пользователя первым; recognition толерантен к
|
||||
повтору (известный трейд-офф, как в magnet-синтезе).
|
||||
- [Утечка токена бота в логи при скачивании из Telegram] → Прямой URL файла
|
||||
Telegram содержит токен (`…/file/bot<TOKEN>/…`); ошибки транспорта
|
||||
(`*url.Error`) встраивают URL. `downloadFile` пропускает такие ошибки через
|
||||
`stripURL` (оставляет только первопричину без URL) — токен в логи не попадает
|
||||
(нашёл code-review; инвариант «секреты не в логи», logging.md).
|
||||
- [Паника парсера на кривом/вырожденном `.torrent`] → Байты недоверенные;
|
||||
библиотека (`UpvertedFiles`/`FileTree`) может паниковать (деление на ноль при
|
||||
`piece length` 0). Извлечение дерева файлов обёрнуто `recover` — это лишь
|
||||
контекст-сигнал; инфохэш и имя уже извлечены, приём не падает.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Аддитивно и обратносовместимо: новая таблица `download_torrent` (goose-миграция,
|
||||
SQL DDL) + обновление ER-схемы `docs/specs/database.md`; таблица `download` не
|
||||
меняется. Существующие magnet-загрузки не затронуты (у них строки в
|
||||
`download_torrent` нет; воркер их не читает — ветка magnet). Откат — ревертом
|
||||
кода; осиротевшая таблица безвредна (или down-миграция `DROP TABLE`).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Точный состав строки `Файлы:` (число + расширения vs крупнейший файл) —
|
||||
уточняем при apply, на спеку не влияет (спека фиксирует «сигнал по файлам»,
|
||||
не формулировку).
|
||||
- Дефолт лимита размера `.torrent` (8 MiB?) и нужен ли он в конфиге — решаем
|
||||
при apply; на нормативную часть не влияет.
|
||||
- Дублировать ли `humanSize`/`originDomains` в `internal/torrent` или вынести в
|
||||
общий хелпер — мелочь реализации (Р1).
|
||||
Reference in New Issue
Block a user