Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в 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>
This commit is contained in:
@@ -179,10 +179,12 @@ SHALL NOT проваливать обновление — `download.display_name
|
||||
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
|
||||
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
|
||||
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
|
||||
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
|
||||
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
|
||||
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
|
||||
выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
|
||||
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
|
||||
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
|
||||
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
|
||||
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
|
||||
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
|
||||
состояние»).
|
||||
|
||||
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
|
||||
@@ -238,13 +240,33 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
||||
|
||||
### Requirement: Дедупликация приёма по любому из хешей
|
||||
|
||||
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
|
||||
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
|
||||
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
|
||||
более одной активной загрузки на infohash». Отдельного снимаемого/
|
||||
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
|
||||
выводится только из `state`.
|
||||
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
|
||||
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
|
||||
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
|
||||
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
|
||||
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
|
||||
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
|
||||
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
|
||||
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
|
||||
новую загрузку.
|
||||
|
||||
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
|
||||
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
|
||||
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
|
||||
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
|
||||
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
|
||||
перепривязки либо закрытия.
|
||||
|
||||
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
|
||||
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
|
||||
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
|
||||
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
|
||||
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
|
||||
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
|
||||
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
|
||||
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
|
||||
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
|
||||
NOT — активность выводится только из `state`.
|
||||
|
||||
#### Scenario: Повторный приём при активной загрузке
|
||||
|
||||
@@ -252,12 +274,46 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая
|
||||
|
||||
#### Scenario: Повторный приём при записи без цели
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
|
||||
удалена)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
|
||||
`Deduplicated`
|
||||
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
|
||||
qBittorrent обращения нет)
|
||||
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
|
||||
заново или закрыть
|
||||
|
||||
#### Scenario: Повторный приём при осиротевшей записи
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
|
||||
- **WHEN** принимается magnet/torrent с тем же `h`
|
||||
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
|
||||
`Deduplicated`
|
||||
|
||||
#### Scenario: Повторный приём после завершения
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
|
||||
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
#### Scenario: Повторный приём после закрытия записи
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
|
||||
`target_missing`)
|
||||
- **WHEN** принимается magnet с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
|
||||
|
||||
#### Scenario: Повторный приём при прочих терминальных состояниях
|
||||
|
||||
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
|
||||
источник ради незакрытого намерения)
|
||||
- **WHEN** принимается magnet/torrent с тем же `h`
|
||||
- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём —
|
||||
свежая попытка; старая терминальная запись хешем не владеет)
|
||||
|
||||
### Requirement: Атомарность возврата загрузки в активное состояние
|
||||
|
||||
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
|
||||
|
||||
@@ -534,3 +534,68 @@ SHALL задевать раскладку в полёте.
|
||||
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
|
||||
- **AND** повторный delete идемпотентно дожимает удаление
|
||||
|
||||
### 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`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user