Files
jellybit/openspec/changes/archive/2026-07-08-torrent-file-ingest/design.md
T
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

20 KiB
Raw Blame History

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}).

Ре-валидация перехода под блокировкой, обработка сбоя addprocessCatched остаётся 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).