Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.
- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
привязывается к существующей записи (спящей, без обращения к qBittorrent),
а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
дедупе ведёт на страницу существующей записи.
Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
67 lines
4.9 KiB
Markdown
67 lines
4.9 KiB
Markdown
## ADDED Requirements
|
||
|
||
### Requirement: Ручное закрытие загрузки (стоп-кран)
|
||
|
||
Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss),
|
||
доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и
|
||
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
|
||
`cancelled`, убирая её из активного списка/внимания, и SHALL служить
|
||
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
|
||
дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для
|
||
загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в
|
||
`cancelled` команда SHALL быть идемпотентным no-op.
|
||
|
||
Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с
|
||
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
|
||
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
|
||
под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие
|
||
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
|
||
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
|
||
|
||
Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина —
|
||
в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от
|
||
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
|
||
существующее терминальное `cancelled` (сверка его не переоценивает). Из
|
||
`cancelled` пользователю остаётся доступной перепривязка (relink), если он
|
||
передумает.
|
||
|
||
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу
|
||
страницы загрузки), обособленно от штатных действий.
|
||
|
||
#### Scenario: Закрытие записи без цели не трогает раздачу
|
||
|
||
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
|
||
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
|
||
- **WHEN** пользователь даёт команду «Закрыть»
|
||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||
- **AND** раздача с файлами в qBittorrent не удаляется
|
||
- **AND** запись пропадает из активного списка
|
||
|
||
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
|
||
|
||
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
|
||
- **WHEN** пользователь даёт команду «Закрыть»
|
||
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
|
||
- **AND** библиотечные хардлинки не удаляются
|
||
- **AND** раздача в qBittorrent не снимается
|
||
|
||
#### Scenario: Закрытие зависшей загрузки
|
||
|
||
- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`)
|
||
- **WHEN** пользователь даёт команду «Закрыть»
|
||
- **THEN** запись переходит в `cancelled`
|
||
- **AND** источник в qBittorrent не трогается
|
||
|
||
#### Scenario: «Закрыть» недоступна для deleted
|
||
|
||
- **GIVEN** загрузка в `deleted`
|
||
- **WHEN** пользователь пытается вызвать «Закрыть»
|
||
- **THEN** команда недоступна, состояние остаётся `deleted`
|
||
|
||
#### Scenario: Закрытую запись можно привязать заново
|
||
|
||
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
|
||
- **WHEN** пользователь даёт команду «Привязать заново»
|
||
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
|
||
из `cancelled`)
|