Принимаем .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>
20 KiB
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байтами (multiparttorrents=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 — диспетч нужен в обоих:
processCatched(worker.go:359-365) — добавление пойманной загрузки.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 сейчас — простой PRG303(не htmx,httpapi.go:397-411); так и остаётся, деградация без JS сохраняется. - Telegram (
handleMessage): распознавание.torrent-документа надо вставить до ветки pending-hint и текстовой ветки (bot.go:124-146): у документаm.Textпуст (текст — вm.Caption), иначе pending-hint «съест» документ как пустую подсказку. Приm.Documentс mimeapplication/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 length0). Извлечение дерева файлов обёрнуто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).