Приём: добавление загрузки по .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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-08
|
||||
@@ -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).
|
||||
@@ -0,0 +1,95 @@
|
||||
## Why
|
||||
|
||||
Приём сейчас умеет только magnet-ссылку (`ingest.Request.Source` — строка;
|
||||
`magnet.Parse` — единственный парсер; при неудаче — ошибка «.torrent/url —
|
||||
следующий заход»). Но пользователи часто получают с трекера именно `.torrent`-
|
||||
файл, а не magnet, и у файла есть два преимущества, которых нет у голого
|
||||
magnet:
|
||||
|
||||
- **Работает там, где magnet не резолвится.** Раздачи закрытых трекеров и
|
||||
торренты без DHT по magnet-хешу метаданные не докачают (`metaDL` навсегда).
|
||||
`.torrent` несёт полный info-словарь — qBittorrent стартует сразу, без
|
||||
докачки метаданных.
|
||||
- **Максимум контекста без сети.** В файле уже лежат реальное имя раздачи,
|
||||
дерево файлов с размерами, суммарный размер, трекеры, комментарий — гораздо
|
||||
богаче полей magnet. Это прямой сигнал для recognition ещё до старта
|
||||
скачивания.
|
||||
|
||||
Инфраструктура почти готова: `qbt.AddRequest.Torrents [][]byte` и
|
||||
`qbt.Client.Add` уже умеют слать `.torrent` байтами (multipart-upload),
|
||||
константа `store.SourceTorrent` уже объявлена. Не хватает трёх вещей: парсера
|
||||
байтов в инфохэш+контекст, персистентности байтов между быстрым приёмом
|
||||
(`catched`) и добавлением воркером, и путей приёма файла в транспортах
|
||||
(Telegram-документ, файл-пикер в вебе).
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Новый парсер `internal/torrent`** поверх `github.com/anacrolix/torrent/
|
||||
metainfo`: `Parse(data []byte) (Info, error)` вытаскивает инфохэш(и) (v1
|
||||
обязательно, v2 при наличии), имя раздачи, список файлов с размерами,
|
||||
суммарный размер, трекеры, комментарий. Инфохэш v1 — SHA1 **исходных** байтов
|
||||
info-словаря (без переэнкода). `func (Info) Context() string` синтезирует
|
||||
человекочитаемый контекст распознавания из этих полей — в том же стиле, что
|
||||
`magnet.Info.Context()`.
|
||||
- **Приём (`ingest`) принимает байты `.torrent`.** `ingest.Request` получает
|
||||
путь для байтов торрент-файла; при их наличии источник парсится
|
||||
`torrent.Parse` (иначе — как сейчас, `magnet.Parse`). Загрузка заводится с
|
||||
`SourceType = SourceTorrent`, инфохэши и дедуп — тем же атомарным путём
|
||||
(`CreateDownloadIfNoActive`), контекст — синтез из полей файла, слитый с
|
||||
текстом транспорта.
|
||||
- **Персистентность байтов.** Байты `.torrent` нужны воркеру на шаге добавления
|
||||
(быстрый приём лишь сохраняет `catched`). Заводим таблицу-спутник
|
||||
`download_torrent(download_id, data)` — байты живут и удаляются вместе с
|
||||
загрузкой, не раздувая выборки `download`.
|
||||
- **Воркер добавляет по типу источника.** `processCatched` ветвится по
|
||||
`SourceType`: magnet/url — как сейчас (`URLs`, hint из `magnet.Parse`);
|
||||
torrent — грузит байты из `download_torrent`, hint из имени раздачи,
|
||||
добавляет через `qbt.AddRequest.Torrents`.
|
||||
- **Веб-UI: файл-пикер.** Рядом со строкой ввода источника — `<input
|
||||
type="file" accept=".torrent">` и `enctype="multipart/form-data"` на форме.
|
||||
Выбран файл — приём по байтам; иначе — по тексту. Деградация без JS
|
||||
сохраняется (обычный multipart-POST).
|
||||
- **Telegram: приём документа.** `handleMessage` распознаёт `.torrent`-
|
||||
документ (mime `application/x-bittorrent` / расширение), скачивает его байты
|
||||
через Bot API и подаёт в приём; подпись сообщения идёт контекстом.
|
||||
|
||||
Вне объёма (сознательно):
|
||||
|
||||
- **Торрент по http(s)-ссылке** (URL на `.torrent`) — отдельная задача: фетч
|
||||
тянет сеть в fast-path приёма, требует SSRF-гарда, таймаутов и обработки
|
||||
ошибок загрузки.
|
||||
- Изменения механизма вывода отображаемого имени и распознавания — не
|
||||
трогаем; они лишь получают более богатый контекст.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_Нет._ Изменение укладывается в существующие capability.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `ingest`: добавляется приём источника из байтов `.torrent` (парс метаданных,
|
||||
извлечение инфохэшей, синтез контекста из полей файла, `SourceType =
|
||||
torrent`, персистентность байтов до добавления). Существующий magnet-путь и
|
||||
инварианты дедупа/инфохэшей — без изменений.
|
||||
- `download-tracking`: шаг «добавление пойманной загрузки в qBittorrent»
|
||||
ветвится по типу источника — для torrent источник добавляется байтами файла,
|
||||
hint отображаемого имени берётся из метаданных торрента.
|
||||
- `web-ui`: форма добавления получает файловый ввод `.torrent`
|
||||
(`multipart/form-data`), с деградацией без JS.
|
||||
|
||||
## Impact
|
||||
|
||||
- Код: новый `internal/torrent` (парсер + `Context()`); `internal/ingest`
|
||||
(ветка байтов, `SourceTorrent`, запись байтов); `internal/store` (таблица
|
||||
`download_torrent`, чтение/запись байтов, миграция); `internal/worker`
|
||||
(`processCatched` — диспетч по `SourceType`); `internal/httpapi` (multipart в
|
||||
`handleUIAdd`, размерный лимит); `internal/tgbot` (обработка `m.Document`,
|
||||
скачивание файла).
|
||||
- Зависимости: `+ github.com/anacrolix/torrent` (пакет `metainfo`) в go.mod.
|
||||
- Данные: новая таблица `download_torrent` (миграция goose) + обновление
|
||||
ER-схемы `docs/specs/database.md`. Таблица `download` не меняется.
|
||||
- Конфиг: размерный лимит `.torrent` — константа с разумным дефолтом (вынос в
|
||||
конфиг — при необходимости, отдельно).
|
||||
- Совместимость: аддитивно; существующие magnet-загрузки не затронуты.
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Добавление пойманной загрузки в qBittorrent
|
||||
|
||||
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
|
||||
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
|
||||
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
|
||||
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
|
||||
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
|
||||
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
||||
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
||||
|
||||
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||
(`source_type`):
|
||||
|
||||
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||
magnet-хешу вместо файла система SHALL NOT.
|
||||
|
||||
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
||||
`catched` для повторной попытки на следующем тике; переход в терминальное
|
||||
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
||||
тиками поллинга).
|
||||
|
||||
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
|
||||
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
|
||||
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
|
||||
downloading` (см. «Переходы состояний сериализуются воркером»), с
|
||||
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
||||
например, при параллельной отмене).
|
||||
|
||||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||
нашей категорией и `rename`
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||
сохранёнными байтами файла
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Временный сбой добавления — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
||||
- **WHEN** worker пытается добавить источник и `add` не удался
|
||||
- **THEN** загрузка остаётся в `catched`
|
||||
- **AND** на следующем тике попытка добавления повторяется
|
||||
|
||||
#### Scenario: Отмена во время добавления
|
||||
|
||||
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
|
||||
блокировки
|
||||
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
|
||||
worker берёт блокировку для записи перехода
|
||||
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
|
||||
`downloading` не применяется
|
||||
@@ -0,0 +1,85 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Приём источника из .torrent-файла
|
||||
|
||||
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
||||
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
|
||||
непустые байты торрента, система SHALL разобрать их локально (без сети),
|
||||
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
|
||||
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
|
||||
добавление делает воркер, см. `download-tracking`).
|
||||
|
||||
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
|
||||
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
|
||||
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
|
||||
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
|
||||
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
|
||||
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
|
||||
«Множество инфохэшей загрузки».
|
||||
|
||||
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
|
||||
`catched` + записи `download_infohash`) и инвариант «не более одной активной
|
||||
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
|
||||
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
|
||||
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
|
||||
|
||||
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
|
||||
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
||||
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
||||
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
||||
заведение загрузки; при дедупликации (новая загрузка не создана) байты
|
||||
сохраняться SHALL NOT. Размер принимаемого `.torrent` система SHALL ограничивать
|
||||
на границе транспорта (защита от разбухания хранилища).
|
||||
|
||||
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
|
||||
из полей magnet (пользовательский текст первым; при пустом тексте — только
|
||||
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
|
||||
Синтез SHALL выполняться без сетевых запросов.
|
||||
|
||||
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
|
||||
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
|
||||
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
|
||||
NOT.
|
||||
|
||||
#### Scenario: Быстрый приём .torrent-файла
|
||||
|
||||
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
|
||||
`catched` (`source_type = torrent`) с записями `download_infohash`
|
||||
- **AND** байты файла сохраняются привязанными к загрузке
|
||||
- **AND** ответ транспорту отдан без обращения к qBittorrent
|
||||
|
||||
#### Scenario: Инфохэш из исходных байтов info
|
||||
|
||||
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
|
||||
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
|
||||
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
|
||||
|
||||
#### Scenario: v2-only файл записывается под v2-хешем
|
||||
|
||||
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
|
||||
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
|
||||
qBittorrent
|
||||
- **AND** сопоставление раздачи работает по нему
|
||||
|
||||
#### Scenario: Дубль .torrent по активной задаче
|
||||
|
||||
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
- **AND** байты торрента не сохраняются (дубль)
|
||||
|
||||
#### Scenario: Контекст из полей файла
|
||||
|
||||
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
|
||||
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
|
||||
файлам, домен трекера), дополняющий текст транспорта
|
||||
- **AND** синтез выполнен без сетевых запросов
|
||||
|
||||
#### Scenario: Слишком большой .torrent отклоняется
|
||||
|
||||
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
||||
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов
|
||||
|
||||
Система SHALL предоставлять пользователю команду повторной попытки (retry)
|
||||
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
|
||||
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
|
||||
немедленного повторного падения по таймауту: базис отсчёта таймаута SHALL
|
||||
сбрасываться (отсчёт ведётся от факта в qBittorrent, а не от старого
|
||||
`created_at`).
|
||||
|
||||
Если источник задачи уже жив в qBittorrent, retry SHALL перецепляться к
|
||||
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
||||
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
||||
|
||||
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
|
||||
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
|
||||
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
|
||||
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
|
||||
|
||||
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||
|
||||
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
||||
- **WHEN** пользователь нажимает retry в веб-UI
|
||||
- **THEN** задача возвращается в `downloading` без повторного `Add`
|
||||
- **AND** не падает снова на ближайшем тике сверки по таймауту
|
||||
|
||||
#### Scenario: Retry доступен в Telegram
|
||||
|
||||
- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
|
||||
Telegram-боте
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
|
||||
#### Scenario: Retry без живого источника добавляет источник заново
|
||||
|
||||
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
|
||||
torrent сохранёнными байтами файлом
|
||||
- **AND** задача переходит в `downloading`
|
||||
|
||||
#### Scenario: Retry torrent-загрузки без живого источника
|
||||
|
||||
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||
нет, байты `.torrent` сохранены
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом
|
||||
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Загрузка .torrent-файла на форме добавления
|
||||
|
||||
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
|
||||
`.torrent`-файл рядом со строкой ввода источника (кнопка/поле выбора файла).
|
||||
При отправке формы с выбранным файлом система SHALL принять его байты
|
||||
(`multipart/form-data`) и провести приём по `.torrent` (см. `ingest` «Приём
|
||||
источника из .torrent-файла»); при пустом файловом поле — приём по тексту
|
||||
источника, как прежде.
|
||||
|
||||
Файловый ввод SHALL деградировать без JavaScript: обычная отправка
|
||||
`multipart`-формы SHALL приводить к приёму файла и тем же результатом, что и
|
||||
htmx-путь (список обновляется/происходит редирект — как у существующего
|
||||
добавления). Размер принимаемого файла UI/обработчик SHALL ограничивать (см.
|
||||
ограничение размера в `ingest`); превышение SHALL давать понятную ошибку без
|
||||
создания загрузки.
|
||||
|
||||
#### Scenario: Добавление выбором .torrent-файла
|
||||
|
||||
- **GIVEN** пользователь открыл форму добавления и выбрал `.torrent`-файл
|
||||
- **WHEN** форма отправлена
|
||||
- **THEN** файл принимается байтами и заводится загрузка (`source_type =
|
||||
torrent`)
|
||||
- **AND** список загрузок отражает новую задачу (как при добавлении по magnet)
|
||||
|
||||
#### Scenario: Файл не выбран — приём по тексту
|
||||
|
||||
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
|
||||
- **WHEN** форма отправлена
|
||||
- **THEN** выполняется приём по тексту источника, как прежде
|
||||
@@ -0,0 +1,93 @@
|
||||
## 1. Парсер `internal/torrent`
|
||||
|
||||
- [x] 1.1 Добавить зависимость `github.com/anacrolix/torrent` (пакет
|
||||
`metainfo`); `task tidy`
|
||||
- [x] 1.2 Пакет `internal/torrent`: `Parse(data []byte) (Info, error)` через
|
||||
`metainfo.Load`/`UnmarshalInfo`; `Info{Infohash, Infohashes, DisplayName,
|
||||
Files, TotalLength, Trackers, Comment}`. Инфохэш v1 — из `HashInfoBytes`
|
||||
(SHA1 исходных байтов info, нижний hex); v2/гибрид — извлечь v2 (64-hex,
|
||||
как `infohash_v2` qBit), для v2-only хранить именно v2. Уточнить точный v2-API
|
||||
anacrolix при apply
|
||||
- [x] 1.3 `func (Info) Context() string` — строки-факты (`\n`): имя раздачи,
|
||||
`Размер:` из `TotalLength`, сигнал по файлам (число/расширения), `Трекер:`
|
||||
домен из announce/announce-list, комментарий (если содержателен). Без сети
|
||||
- [x] 1.4 Хелперы размера/домена: дублировать тривиальные из `internal/magnet`
|
||||
или вынести общий (решение по месту)
|
||||
- [x] 1.5 Тесты: одиночный файл; много файлов; гибрид v1+v2; трекеры из
|
||||
announce-list; повреждённые/не-torrent байты → ошибка; `Context()` —
|
||||
содержательный и пустой случай; отсутствие сетевых вызовов
|
||||
|
||||
## 2. Хранилище: байты торрента
|
||||
|
||||
- [x] 2.1 Goose-миграция: таблица `download_torrent(download_id TEXT PRIMARY KEY
|
||||
REFERENCES download(id) ON DELETE CASCADE, data BLOB NOT NULL)`
|
||||
- [x] 2.2 Обновить ER-схему `docs/specs/database.md` (новая таблица + связь)
|
||||
- [x] 2.3 Изменить сигнатуру `CreateDownloadIfNoActive(ctx, d, hashes,
|
||||
torrentBlob []byte)`: на ветке создания `INSERT INTO download_torrent` в той
|
||||
же tx (при дедупе — не пишет). Обновить интерфейсы `Store` в `internal/ingest`
|
||||
и `internal/worker` и вызовы magnet-приёма/`discover.go` (передают `nil`).
|
||||
Добавить `GetTorrentData(ctx, downloadID) ([]byte, error)`
|
||||
- [x] 2.4 Тесты store: запись/чтение байтов; при дедупе байты не пишутся;
|
||||
CASCADE-уборка при (гипотетическом) удалении загрузки. Байты НЕ удаляются при
|
||||
промоушене `catched → downloading` (нужны для retry)
|
||||
|
||||
## 3. Приём: ветка байтов в `ingest`
|
||||
|
||||
- [x] 3.1 Расширить `ingest.Request` полем `TorrentData []byte` (опц.)
|
||||
- [x] 3.2 В `Ingest`: при непустом `TorrentData` — `torrent.Parse`,
|
||||
`SourceType = SourceTorrent`, `SourceRef` = имя раздачи/файла (не URL),
|
||||
контекст = `mergeContext(req.Context, tinfo.Context())`, запись байтов (2.3)
|
||||
в транзакции создания; иначе — прежний magnet-путь без изменений
|
||||
- [x] 3.3 Ограничение размера входа на границе (константа, дефолт напр. 8 MiB)
|
||||
- [x] 3.4 Тесты ingest: приём `.torrent` → `catched`, `source_type=torrent`,
|
||||
инфохэши, байты сохранены, контекст синтезирован; дедуп по инфохэшу (байты не
|
||||
пишутся); превышение лимита → ошибка
|
||||
|
||||
## 4. Воркер: диспетч добавления по типу источника (оба add-пути)
|
||||
|
||||
- [x] 4.1 Общий хелпер `addSource(ctx, d, rename)`: ветвь по `d.SourceType` —
|
||||
magnet/url (`URLs`, hint из `magnet.Parse`); torrent (`GetTorrentData`, hint
|
||||
из `torrent.Parse(data).DisplayName`, `qbt.Add(AddRequest{Torrents:
|
||||
[][]byte{data}, …})`)
|
||||
- [x] 4.2 Применить хелпер в `processCatched` (worker.go:359-365) И в `Retry`
|
||||
(worker.go:677-691): снять magnet-only гейт в Retry — torrent без живой
|
||||
раздачи должен добавляться байтами, а не активироваться без `Add`. Сохранить
|
||||
откат активации при сбое add в Retry
|
||||
- [x] 4.3 Общими остаются: ре-валидация перехода, обработка сбоя `add`
|
||||
(`processCatched` — остаётся `catched`; Retry — откат), namer/qbt вне
|
||||
блокировки
|
||||
- [x] 4.4 Тесты воркера: (а) `catched` torrent → `qbt.Add` с `Torrents` (не
|
||||
`URLs`), переход в `downloading`, сбой add → остаётся `catched`; (б) `Retry`
|
||||
torrent без живой раздачи → `Add` с `Torrents`, переход в `downloading`; сбой
|
||||
add → откат в прежнее состояние
|
||||
|
||||
## 5. Транспорт: веб-форма (multipart)
|
||||
|
||||
- [x] 5.1 Шаблон формы: `enctype="multipart/form-data"`, `<input type="file"
|
||||
name="torrent" accept=".torrent,application/x-bittorrent">` рядом со строкой
|
||||
источника; строка источника не обязательна при выбранном файле
|
||||
- [x] 5.2 `handleUIAdd`: `http.MaxBytesReader`/лимит, `ParseMultipartForm`; есть
|
||||
файл `torrent` → `Ingest(Request{TorrentData, Context})`; иначе — прежний
|
||||
путь. PRG-редирект/htmx как сейчас
|
||||
- [x] 5.3 Деградация без JS: обычный multipart-POST приводит к тому же
|
||||
результату
|
||||
- [x] 5.4 Тест обработчика: multipart с `.torrent` → загрузка заведена; без
|
||||
файла → прежний текстовый путь; превышение размера → ошибка
|
||||
|
||||
## 6. Транспорт: Telegram-документ
|
||||
|
||||
- [x] 6.1 В `handleMessage` обрабатывать `m.Document` **до** ветки pending-hint
|
||||
и текстовой ветки (у документа `m.Text` пуст): mime `application/x-bittorrent`
|
||||
или расширение `.torrent` → скачать байты (`GetFileDirectURL` + HTTP GET,
|
||||
лимит), `Ingest(Request{TorrentData, Context: m.Caption})`
|
||||
- [x] 6.2 Документ не-`.torrent` → прежний ответ-подсказка; сохранить
|
||||
allowlist-проверку. `AllowedUpdates` не менять (документ — внутри `message`)
|
||||
- [x] 6.3 Тест парса/маршрутизации документа (mime/расширение → ветка байтов);
|
||||
скачивание — за интерфейсом/мок
|
||||
|
||||
## 7. Проверка
|
||||
|
||||
- [x] 7.1 `task test` и `task lint` зелёные
|
||||
- [x] 7.2 `openspec validate torrent-file-ingest --strict` проходит
|
||||
- [ ] 7.3 Ручная проверка: `.torrent` через веб-пикер и Telegram-документ →
|
||||
`catched → downloading`, задача видна в qBittorrent с `rename`
|
||||
@@ -102,6 +102,18 @@ Worker SHALL периодически (в поллинг-цикле, под ед
|
||||
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
|
||||
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
|
||||
|
||||
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
|
||||
(`source_type`):
|
||||
|
||||
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
|
||||
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
|
||||
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
|
||||
загрузке при приёме) и передавать их **файлом** (`torrents` API
|
||||
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
|
||||
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
|
||||
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
|
||||
magnet-хешу вместо файла система SHALL NOT.
|
||||
|
||||
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
|
||||
`catched` для повторной попытки на следующем тике; переход в терминальное
|
||||
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
|
||||
@@ -114,14 +126,23 @@ downloading` (см. «Переходы состояний сериализуют
|
||||
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
|
||||
например, при параллельной отмене).
|
||||
|
||||
#### Scenario: Пойманная загрузка добавляется в qBittorrent
|
||||
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched`
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** выводится отображаемое имя, источник добавляется в qBittorrent с
|
||||
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
|
||||
нашей категорией и `rename`
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
|
||||
|
||||
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
|
||||
сохранёнными байтами файла
|
||||
- **WHEN** worker обрабатывает тик
|
||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
|
||||
нашей категорией и `rename`, без обращения к magnet-хешу
|
||||
- **AND** загрузка переходит в `downloading`
|
||||
|
||||
#### Scenario: Временный сбой добавления — повтор
|
||||
|
||||
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
|
||||
|
||||
@@ -314,3 +314,87 @@ recognition (LLM-промпт) и веб-UI (страница загрузки).
|
||||
- **THEN** `download.Context` остаётся пустым
|
||||
- **AND** приём проходит штатно (пустой контекст допустим)
|
||||
|
||||
|
||||
### Requirement: Приём источника из .torrent-файла
|
||||
|
||||
Приём SHALL принимать источник в виде **байтов `.torrent`-файла** (наряду с
|
||||
magnet-ссылкой) — тем же быстрым use-case, общим для транспортов. Получив
|
||||
непустые байты торрента, система SHALL разобрать их локально (без сети),
|
||||
извлечь инфохэш(и) и завести загрузку с `source_type = torrent`, после чего
|
||||
сразу вернуть ответ транспорту (синхронный путь к qBittorrent не обращается —
|
||||
добавление делает воркер, см. `download-tracking`).
|
||||
|
||||
Инфохэши система SHALL извлекать такими, какими их сообщает qBittorrent, чтобы
|
||||
сопоставление раздач и дедупликация работали: v1-хеш (для v1/гибридного файла)
|
||||
SHALL вычисляться как SHA1 **исходных** байтов info-словаря (без переэнкода);
|
||||
v2-хеш (для v2/гибридного файла, BEP52) SHALL извлекаться как 64-hex `infohash_v2`.
|
||||
Для чистого v2-only файла система SHALL записывать v2-хеш (v1 у него нет).
|
||||
Извлечение всех известных хешей и дозапись недостающих подчиняются требованию
|
||||
«Множество инфохэшей загрузки».
|
||||
|
||||
Дедупликацию по активной задаче, атомарное заведение (`download` в состоянии
|
||||
`catched` + записи `download_infohash`) и инвариант «не более одной активной
|
||||
загрузки на infohash» torrent-приём SHALL проходить тем же атомарным путём, что
|
||||
и magnet (см. «Приём источника и заведение загрузки», «Дедупликация приёма по
|
||||
любому из хешей», «Атомарность возврата загрузки в активное состояние»).
|
||||
|
||||
Байты `.torrent` система SHALL сохранять персистентно, привязанными к загрузке,
|
||||
чтобы воркер мог добавить источник в qBittorrent именно файлом (не по magnet):
|
||||
раздачи закрытых трекеров и торренты без DHT по magnet-хешу метаданные не
|
||||
получат. Сохранение байтов SHALL выполняться в той же write-транзакции, что и
|
||||
заведение загрузки; при дедупликации (новая загрузка не создана) байты
|
||||
сохраняться SHALL NOT. Размер принимаемого `.torrent` система SHALL ограничивать
|
||||
на границе транспорта (защита от разбухания хранилища).
|
||||
|
||||
Из полей `.torrent` система SHALL синтезировать контекст распознавания (имя
|
||||
раздачи, суммарный размер, сигнал по дереву файлов, домен трекера, комментарий)
|
||||
и **дополнять** им контекст транспорта — тем же правилом слияния, что и синтез
|
||||
из полей magnet (пользовательский текст первым; при пустом тексте — только
|
||||
синтез). Обогащённый контекст система SHALL сохранять в `download.Context`.
|
||||
Синтез SHALL выполняться без сетевых запросов.
|
||||
|
||||
`source_ref` у torrent-загрузки SHALL быть человекочитаемым референсом (имя
|
||||
раздачи или файла), а НЕ адресом добавления: добавление в qBittorrent идёт
|
||||
байтами, и трактовать `source_ref` как magnet/URL для добавления система SHALL
|
||||
NOT.
|
||||
|
||||
#### Scenario: Быстрый приём .torrent-файла
|
||||
|
||||
- **GIVEN** валидные байты `.torrent`-файла и (опц.) текст контекста
|
||||
- **WHEN** вызывается приём
|
||||
- **THEN** из файла извлекаются инфохэши и создаётся `download` в состоянии
|
||||
`catched` (`source_type = torrent`) с записями `download_infohash`
|
||||
- **AND** байты файла сохраняются привязанными к загрузке
|
||||
- **AND** ответ транспорту отдан без обращения к qBittorrent
|
||||
|
||||
#### Scenario: Инфохэш из исходных байтов info
|
||||
|
||||
- **WHEN** система разбирает v1/гибридный `.torrent`-файл
|
||||
- **THEN** инфохэш v1 вычисляется как SHA1 исходных байтов info-словаря
|
||||
- **AND** совпадает с хешем, по которому qBittorrent позже сопоставит раздачу
|
||||
|
||||
#### Scenario: v2-only файл записывается под v2-хешем
|
||||
|
||||
- **WHEN** система разбирает `.torrent` только с метаданными v2 (без v1)
|
||||
- **THEN** у загрузки записывается v2-хеш (64-hex), совпадающий с `infohash_v2`
|
||||
qBittorrent
|
||||
- **AND** сопоставление раздачи работает по нему
|
||||
|
||||
#### Scenario: Дубль .torrent по активной задаче
|
||||
|
||||
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
|
||||
- **WHEN** принимается `.torrent` с тем же инфохэшем
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
- **AND** байты торрента не сохраняются (дубль)
|
||||
|
||||
#### Scenario: Контекст из полей файла
|
||||
|
||||
- **WHEN** принят `.torrent` с именем раздачи, деревом файлов и трекерами
|
||||
- **THEN** в `download.Context` добавляется синтез (имя, размер, сигнал по
|
||||
файлам, домен трекера), дополняющий текст транспорта
|
||||
- **AND** синтез выполнен без сетевых запросов
|
||||
|
||||
#### Scenario: Слишком большой .torrent отклоняется
|
||||
|
||||
- **WHEN** принимаемый `.torrent`-файл превышает ограничение размера
|
||||
- **THEN** приём отклоняется с ошибкой, загрузка не создаётся
|
||||
|
||||
@@ -129,6 +129,13 @@ Retry SHALL переводить задачу обратно в `downloading`,
|
||||
существующему торренту, а не добавлять источник повторно вслепую; повторный
|
||||
`Add` выполняется, только когда раздачи в qBittorrent нет.
|
||||
|
||||
Повторный `Add` при retry система SHALL выполнять **по типу источника**
|
||||
(`source_type`), как и добавление пойманной загрузки (см. `download-tracking`
|
||||
«Добавление пойманной загрузки в qBittorrent»): magnet/url — ссылкой; torrent —
|
||||
сохранёнными байтами `.torrent` файлом. Для torrent-источника retry БЕЗ живой
|
||||
раздачи система SHALL добавлять раздачу байтами и SHALL NOT активировать задачу
|
||||
в `downloading`, не добавив её (иначе задача повиснет как «нет в qBittorrent»).
|
||||
|
||||
#### Scenario: Retry упавшей magnet-загрузки из веб-UI
|
||||
|
||||
- **GIVEN** задача в `failed`, её торрент жив в qBittorrent
|
||||
@@ -142,13 +149,22 @@ Retry SHALL переводить задачу обратно в `downloading`,
|
||||
Telegram-боте
|
||||
- **THEN** задача возвращается в `downloading`
|
||||
|
||||
#### Scenario: Retry без живого источника добавляет торрент заново
|
||||
#### Scenario: Retry без живого источника добавляет источник заново
|
||||
|
||||
- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** источник (magnet) добавляется в qBittorrent заново
|
||||
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
|
||||
torrent сохранёнными байтами файлом
|
||||
- **AND** задача переходит в `downloading`
|
||||
|
||||
#### Scenario: Retry torrent-загрузки без живого источника
|
||||
|
||||
- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
|
||||
нет, байты `.torrent` сохранены
|
||||
- **WHEN** пользователь инициирует retry
|
||||
- **THEN** сохранённые байты добавляются в qBittorrent файлом
|
||||
- **AND** задача переходит в `downloading` (не остаётся без раздачи)
|
||||
|
||||
### Requirement: Принудительная проверка источника/цели перед действием
|
||||
|
||||
Команда workflow, требующая наличия источника или цели, SHALL синхронно
|
||||
|
||||
@@ -473,3 +473,33 @@ htmx-поллингом (см. конвенцию веб-UI): по перехо
|
||||
(бейдж, имя, живой прогресс)
|
||||
- **AND** поллинг фазы `catched` завершается
|
||||
|
||||
|
||||
### Requirement: Загрузка .torrent-файла на форме добавления
|
||||
|
||||
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
|
||||
`.torrent`-файл рядом со строкой ввода источника (кнопка/поле выбора файла).
|
||||
При отправке формы с выбранным файлом система SHALL принять его байты
|
||||
(`multipart/form-data`) и провести приём по `.torrent` (см. `ingest` «Приём
|
||||
источника из .torrent-файла»); при пустом файловом поле — приём по тексту
|
||||
источника, как прежде.
|
||||
|
||||
Файловый ввод SHALL деградировать без JavaScript: обычная отправка
|
||||
`multipart`-формы SHALL приводить к приёму файла и тем же результатом, что и
|
||||
htmx-путь (список обновляется/происходит редирект — как у существующего
|
||||
добавления). Размер принимаемого файла UI/обработчик SHALL ограничивать (см.
|
||||
ограничение размера в `ingest`); превышение SHALL давать понятную ошибку без
|
||||
создания загрузки.
|
||||
|
||||
#### Scenario: Добавление выбором .torrent-файла
|
||||
|
||||
- **GIVEN** пользователь открыл форму добавления и выбрал `.torrent`-файл
|
||||
- **WHEN** форма отправлена
|
||||
- **THEN** файл принимается байтами и заводится загрузка (`source_type =
|
||||
torrent`)
|
||||
- **AND** список загрузок отражает новую задачу (как при добавлении по magnet)
|
||||
|
||||
#### Scenario: Файл не выбран — приём по тексту
|
||||
|
||||
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
|
||||
- **WHEN** форма отправлена
|
||||
- **THEN** выполняется приём по тексту источника, как прежде
|
||||
|
||||
Reference in New Issue
Block a user