Приём: добавление загрузки по .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:
+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** выполняется приём по тексту источника, как прежде
|
||||
Reference in New Issue
Block a user