Files
avandClaude Opus 4.8 14d615a7c2 Приём: добавление загрузки по .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>
2026-07-08 10:52:31 +03:00

263 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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).