## Context Дедуп приёма (`ingest`) держит инвариант «не более одной активной загрузки на infohash», где «активная» = `state NOT IN terminalStates`. Терминальный набор (`done, cancelled, failed, reverted, target_missing, orphaned, deleted`) смешивает две разные ситуации: - **«Отработали, забыли»** — `done`/`cancelled`/`failed`/`reverted`/`deleted`. Повторный приём такого инфохэша — осознанное «хочу заново», новая загрузка легитимна (`ingest/spec.md`, сценарий «Повторный приём после завершения»). - **«Держим источник ради незакрытого намерения»** — `target_missing` (источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись держит претензию на последнюю копию данных). Дедуп трактует обе группы одинаково: терминально → не активна → плоди новую. Во второй группе это рождает близнеца (реальный прод-случай: `target_missing` + `done` на один торрент). Причём усыновление раздачи воркером (`download-tracking`, change `catched-promote-without-readd`) делает близнеца «боевым» — он раскладывается и занимает целевой путь, из-за чего у осиротевшей записи `Привязать заново` упирается в коллизию владения путём. Отдельно: закрыть лишнюю `target_missing`-запись сейчас нечем. Единственная команда, убирающая её из активного внимания, — «Удалить», но она **сносит раздачу** в qBittorrent (`deleteFiles=true`), а этого как раз не нужно: раздача общая, её ведёт `done`-близнец. ## Goals / Non-Goals **Goals:** - Повторный приём инфохэша, удерживаемого записью в `target_missing`/`orphaned`, не создаёт новую загрузку, а возвращает существующую (attach), сохраняя приём быстрым и без обращения к qBittorrent. - Пользователь может вручную закрыть **любую** зависшую/спорную загрузку (стоп-кран), ничего не делая с файлами и раздачей. - Инвариант «≤1 активной на infohash» и его атомарные гарды не ослабляются. **Non-Goals:** - Авто-схлопывание/слияние уже существующих дублей фоновой сверкой — сознательно ручной путь. - Изменение логики усыновления раздачи в воркере и общей матрицы «источник × цель» сверки. - Авто-relink при повторном приёме (это обращение к qBittorrent — противоречит «быстрому приёму без сети»); relink остаётся отдельным явным действием. - Разбор коллизии владения целевым путём при relink `target_missing`, чьи файлы уже разложены другой записью, — эту ситуацию закрывает команда «Закрыть», а не relink. - Любое удаление/создание файлов или снятие раздачи командой «Закрыть» — для этого есть «Удалить». «Закрыть» — чисто смена статуса. ## Decisions ### Р1. Дедуп: reingest-blocking states = active ∪ {target_missing, orphaned} Вводим понятие «состояний, блокирующих повторный приём»: активные состояния **плюс** `target_missing` и `orphaned`. Поиск дедупа при приёме (`FindActiveByInfohash` → по сути `FindReingestBlockingByInfohash`) ищет запись в любом из этих состояний по любому из хешей и, найдя, возвращает её вместо создания новой. Приоритет — активная (если вдруг есть и активная, и desync-запись на один хеш, что допускает текущий инвариант), иначе desync-запись. - Почему не «сделать `target_missing`/`orphaned` активными»: сломает семантику «активность выводится только из state» и потянет за собой сверку, healing, выборки активных. Дедуп — единственное место, которому нужна расширенная оптика; локализуем изменение там. - Атомарность: гард создания (`CreateDownloadIfNoActive`) остаётся про активные — он бэкстоп инварианта «≤1 активной». Расширенная проверка — это read-ветка дедупа ДО создания; она короткозамыкает на attach. Гонка «два приёма одновременно на свежий target_missing» в худшем случае даёт одну лишнюю попытку create, которую по-прежнему отсекает активный гард; близнец на `target_missing` при этом не создаётся, т.к. обе ветки видят одну и ту же desync-запись (она уже в БД, коммитнута ранее). ### Р2. Attach для desync-записи не воскрешает и не доносит хеши сам по себе Ветка attach для `target_missing`/`orphaned` возвращает запись как «спящую, требует relink» (флаг в результате приёма), НЕ переводя её в активное состояние и НЕ вызывая qBittorrent. Донесение недостающих хешей (гибридный торрент) для desync-записи допустимо и безопасно (терминальная запись не «активна», гонки за хеш нет), но подчиняется тому же правилу «не красть хеш у другой активной» (`ingest/spec.md`, «Атомарность возврата…»). Бот/веб сообщают: запись существует, приложите relink или закройте. - Почему не авто-relink: relink делает синхронный source-preflight (обращение к qBittorrent) — это нарушает инвариант «синхронный приём не ходит в qBittorrent». Явный relink пользователем сохраняет разделение шагов. Почему `failed`/`reverted` НЕ блокирующие (в отличие от `target_missing`/ `orphaned`): у них нет удерживаемого источника ради незакрытого намерения — повторный приём осознанно трактуется как **свежая попытка**. Новая активная загрузка забирает хеш, старая терминальная им не владеет; её фоновое самовосстановление (revive `failed`) корректно отклонится активным гардом «infohash занят». Дубля-призрака (как с `target_missing`) при этом не возникает: старая запись остаётся терминальной и не раскладывается повторно. Реализация не переиспользует общий active-хелпер для desync-проверки — расширенная оптика нужна только дедуп-пред-риду (см. Р1, Б-развязка с `ActivateIfNoOtherActive`/ `AddInfohashes`). ### Р3. «Закрыть» = переход в cancelled, без нового статуса (принято) Команда «Закрыть» (dismiss) переводит запись в **существующее терминальное `cancelled`** с `error_code`-дискриминатором (`user_dismiss`), человекочитаемой причиной в `error_msg` и логом перехода. Почему `cancelled`, а не новый `dismissed`: - Прецедент в коде: Delete переиспользует `deleted` + `error_code="user_delete"` и явно постулирует «новый статус вводить SHALL NOT» — терминальный набор завязан на семантику активности, любой новый статус её разъедает и тянет правки во все выборки/сверку. - `cancelled` уже значит «пользователь отказался от этой записи, источник не трогаем», из него доступен relink — естественный safety valve, если передумал. - `cancelled` не входит в reingest-blocking (Р1) → после «Закрыть» повторный приём заведёт свежую загрузку. Это осознанно: запись закрыта, дубля-призрака больше нет. Различение причины отмены (закрытие стоп-краном vs. отклонение на ревью) несёт `error_code`, а не отдельный статус. ### Р4. «Закрыть» — универсальный стоп-кран из любого состояния, кроме deleted (принято) Команда доступна из **любого** состояния, кроме `deleted` (строго терминально, сверка его не переоценивает — не воскрешаем граф). На `cancelled` — идемпотентный no-op (самопереход). Инвариант команды: **только меняет статус**, файлы под `paths.*` и раздачу в qBittorrent НЕ трогает. - Из `target_missing` (исходный прод-случай) — закрытие инертно (целевых ссылок нет, источник жив и остаётся раздаваться). - Из `done`/`orphaned` — библиотечные хардлинки **сознательно остаются** на месте (не удаляем: «Закрыть» ≠ «Удалить»). Запись перестаёт отслеживаться. - Из активных/`stuck`/`failed`/`deferred` — источник в qBittorrent остаётся как есть (докачивается/раздаётся); мы лишь снимаем запись из внимания. Отличие от существующего Cancel/«Отклонить» (review-флоу, только из нетерминальных): «Закрыть» — универсальный стоп-кран, доступный и из терминальных `done`/`failed`/`reverted`/`target_missing`/`orphaned`, и живёт в отдельной danger zone внизу страницы. Оба ведут в `cancelled`; различаются гардом источника и `error_code`. Рёбра `allowedTransitions`, которые нужно добавить (у нетерминальных `cancelled` как цель уже есть): `done → cancelled`, `failed → cancelled`, `reverted → cancelled`, `target_missing → cancelled`, `orphaned → cancelled`. После этого `cancelled` — легальная цель из любого состояния, кроме `deleted`. ## Risks / Trade-offs - **Relink `target_missing` при существующем `done`-близнеце всё ещё упрётся в коллизию пути.** → Ожидаемо и допустимо: правильное действие для лишней записи — «Закрыть», а не relink; коллизия владения путём (`state-reconciliation`, «Занятый путь даёт коллизию») отрабатывает штатно и не портит данные. - **Reingest-blocking расширен → пользователь, реально желающий переснять `target_missing`-торрент заново, получит attach, а не новую загрузку.** → Приемлемо: у него есть relink (вперёд) и «Закрыть» (закрыть и, при желании, переслать снова — новая загрузка заведётся из `cancelled`). - **Гонка двух одновременных приёмов на свежую desync-запись.** → Оба видят уже коммитнутую desync-запись → attach; активный гард отсекает случайный create. Близнец не рождается. - **`error_code=user_dismiss` в `cancelled` смешивает две причины отмены.** → Дискриминатор в `error_code` + `error_msg`/лог различают их; телеметрия по причине доступна без нового статуса. - **«Закрыть» из `done`/`orphaned` оставляет неотслеживаемые хардлинки** под `paths.movies`/`series`, чей `file_link` продолжает «владеть» путём (`cancelled` сверкой не переоценивается). → Осознанный компромисс стоп-крана «только статус»: файлы оставляем как есть, реальную зачистку делает «Удалить». Повторная закачка того же пути упрётся в штатную коллизию владения путём (`state-reconciliation`, «Занятый путь даёт коллизию»), а не в порчу данных. - **«Закрыть» из активных состояний рвёт запись из-под воркера** (напр. в `linking`/`downloading`). → Команды сериализуются воркером под единой блокировкой (как прочие команды ревью) — «Закрыть» применяется как последняя валидная команда, а не посреди операции; частично созданные ссылки остаются, что соответствует контракту «только статус». - **Восстановление `orphaned` через приём — двухшаговое.** `orphaned` (источник пропал) блокирует повторный приём (attach), но relink из `orphaned` не реализован, а приём не добавляет источник в qBittorrent. → Рабочий путь возврата источника: «Закрыть» (→ `cancelled`) → повторный приём (уже не блокируется) → свежая активная загрузка, которую воркер добавит и разложит. Прямой приём без attach создал бы близнеца с коллизией целевого пути (файл `orphaned` ещё на месте), поэтому attach выбран сознательно; транспорты в `orphaned` формулируют действие как «закройте, затем добавьте заново» (не «привяжите заново»). Прямой relink-из-`orphaned` — возможное будущее улучшение вне scope этого change. - **danger zone скрывает завершённую (`done`) запись одним действием.** → Разместить «Закрыть» в отдельной danger zone внизу страницы; для необратимо выглядящих случаев (`done` и прочие терминальные) UI SHOULD запрашивать подтверждение (относительно дёшево — из `cancelled` доступен relink). ## Migration Plan - Схема БД не меняется (нет таблиц/столбцов/статусов). Миграции не требуются. - Изменения — код + дельта-спеки; деплой обычным бинарём. Откат — откат бинаря; данные не мигрированы, несовместимости нет. - Обновить граф переходов в тесте (`cancelled` как цель из `done`/`failed`/`reverted`/`target_missing`/`orphaned`) и описание статусов/переходов в `docs/specs/database.md`. ## Open Questions - Р3 (`cancelled` + `error_code`) и Р4 (универсальный стоп-кран из любого состояния, кроме `deleted`) — **приняты**. - Требует ли «Закрыть» из терминальных/`done` подтверждения в UI (см. риск) — решить на реализации веб-UI. - Тексты для транспортов: формулировка ответа приёма при attach на desync-запись («существует как #id без цели — привяжите заново или закройте») и подпись кнопки «Закрыть» в веб/Telegram.