Принимаем .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>
263 lines
20 KiB
Markdown
263 lines
20 KiB
Markdown
## 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).
|