Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в 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>
74 lines
6.6 KiB
Markdown
74 lines
6.6 KiB
Markdown
## Why
|
||
|
||
При повторном приёме торрента, у которого уже есть запись в `target_missing`
|
||
(«разложено, но файлов в библиотеке нет»), рождается загрузка-близнец: дедуп
|
||
приёма ищет только **активную** задачу по инфохэшу, а `target_missing`
|
||
терминально — активной нет, заводится новая загрузка, воркер усыновляет
|
||
присутствующую в qBittorrent раздачу и раскладывает её. В итоге на один торрент
|
||
две записи (`done` + `target_missing`), причём у осиротевшей единственное
|
||
действие «Привязать заново» упрётся в уже занятый целевой путь. Пользователю
|
||
нечем аккуратно закрыть лишнюю запись, не снося при этом раздачу.
|
||
|
||
## What Changes
|
||
|
||
- **Предотвращение дубля на приёме.** Повторный приём инфохэша, которым владеет
|
||
запись в `target_missing` или `orphaned`, SHALL привязываться к этой записи
|
||
(возврат существующей, `Deduplicated`), а не заводить новую загрузку. Критерий
|
||
дедупа расширяется с «активной» до «активной **или** удерживающей источник
|
||
ради незакрытого намерения» (`target_missing`/`orphaned`). `done` из дедупа
|
||
сознательно остаётся размножаемым (повторный приём завершённого = осознанное
|
||
«хочу заново»). Приём остаётся быстрым: qBittorrent не трогаем, авто-relink не
|
||
запускаем — пользователю сообщается, что запись существует и её нужно привязать
|
||
заново.
|
||
- **Команда «Закрыть» — универсальный стоп-кран.** Добавляется ручная команда,
|
||
доступная из **любого** состояния (кроме `deleted`) во всех транспортах,
|
||
переводящая запись в терминальное `cancelled` (с `error_code`-дискриминатором)
|
||
и убирающая её из активного списка. Команда **только меняет статус**: файлы под
|
||
`paths.*` не трогает (в т.ч. из `done`/`orphaned` библиотечные хардлинки
|
||
сознательно остаются на месте) и раздачу в qBittorrent не снимает (в отличие от
|
||
«Удалить»). Размещается в отдельной danger zone внизу страницы. Так
|
||
пользователь закрывает лишнего близнеца, а заодно получает страховку для любой
|
||
зависшей/спорной загрузки.
|
||
|
||
Явно вне scope: авто-схлопывание дублей в фоновой сверке (выбран ручной путь);
|
||
изменение логики усыновления в воркере; введение нового статуса (переиспользуем
|
||
`cancelled`, как Delete переиспользует `deleted`); удаление/создание каких-либо
|
||
файлов или раздач командой «Закрыть».
|
||
|
||
## Capabilities
|
||
|
||
### New Capabilities
|
||
<!-- нет новых capability -->
|
||
|
||
### Modified Capabilities
|
||
- `ingest`: критерий дедупликации приёма расширяется — блокирующими повторный
|
||
приём становятся не только активные, но и `target_missing`/`orphaned` записи
|
||
(attach вместо создания новой); повторный приём завершённой (`done`) остаётся
|
||
созданием новой. Модифицируются требования «Дедупликация приёма по любому из
|
||
хешей» и «Приём источника и заведение загрузки» (терминология «блокирующей»
|
||
задачи). Требование «Приём из .torrent-файла» текст НЕ правит: оно уже явно
|
||
делегирует критерий модифицированному требованию через inline-ссылку — оба
|
||
дедуп-упоминания там наследуют расширенный критерий без риска дрейфа.
|
||
- `state-reconciliation`: добавляется пользовательская команда «Закрыть»
|
||
(dismiss) из любого состояния (кроме `deleted`) в терминальное `cancelled`,
|
||
ничего не делающая с файлами и раздачей; фиксируется её отличие от «Удалить» и
|
||
новые рёбра перехода `<любое> → cancelled` (в т.ч. из терминальных
|
||
`done`/`failed`/`reverted`/`target_missing`/`orphaned`).
|
||
|
||
## Impact
|
||
|
||
- Код: `internal/ingest/ingest.go` (`Ingest`/`attached` — ветка attach для
|
||
desync-записей, флаг «нужен relink»), `internal/store/download.go`
|
||
(критерий поиска дедупа: reingest-blocking states = active ∪
|
||
`{target_missing, orphaned}`; новое ребро `allowedTransitions`
|
||
`target_missing → cancelled`; `error_code`-дискриминатор dismiss),
|
||
`internal/worker/review.go` (обработчик команды «Закрыть» рядом с
|
||
Delete/Undo/Relink — только setState, без файлов и qBittorrent), веб-UI
|
||
(danger zone внизу страницы с кнопкой «Закрыть»), `internal/tgbot`.
|
||
- Данные: новых таблиц/столбцов нет; терминальный набор не меняется (dismiss →
|
||
существующее `cancelled`). Обновляется граф переходов и, при необходимости,
|
||
описание статусов в `docs/specs/database.md`.
|
||
- Инварианты безопасности данных: «Закрыть» источник неприкосновенен —
|
||
qBittorrent не вызывается, файлы под `paths.downloads`/`movies`/`series` не
|
||
трогаются.
|