## MODIFIED Requirements ### Requirement: Ручное закрытие загрузки (стоп-кран) Система SHALL давать пользователю возможность **закрыть** загрузку — перевести её в терминальное `cancelled`, убрав из активного списка/внимания, — из **любого** состояния, кроме `deleted`. Страница загрузки веб-UI (`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT (состояние строго терминально). Путей закрытия **два**, и они разведены гейтом терминальности: - **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться с конфликтом из терминальных, включая `cancelled`. - **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в `target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть доступна из любого состояния, кроме `deleted`, включая терминальные, где `Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего закрытия. Раскладка путей по поверхностям нормативной **не является**: поверхность выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт «Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и `cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`, `orphaned`, `target_missing`; «Отклонить» — на карточке ревью (`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи; уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking` карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня отдаёт только `cancel`). Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными ссылками: система SHALL NOT удалять либо создавать хардлинки под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT — раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно привязано к состоянию, а не к команде: если закрытие **любым** путём увело задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает только что добавленный торрент. Уборка нормирована в `openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL NOT (источник в действии не участвует). `error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их. `Dismiss` SHALL помечать переход `error_code = "user_dismiss"` (человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия, `Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния (`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно. Лог перехода закрытия его не дублирует: восстановить причину можно только по более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не закреплена нормативно: собственный непустой код у `Cancel` требованием не запрещён. Новый статус ни для одного из путей система вводить SHALL NOT — переиспользуется существующее терминальное `cancelled` (сверка его не переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка (relink), если он передумает; при этом `Undo` и `Delete` из `cancelled` доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают только с последним батчем раскладки, а повторное применение заводит новый. Снять ссылки прежнего батча система средствами не даёт. В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения требовать SHALL NOT: команда отменяет работу, которая ещё идёт. Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь закрыл активную загрузку» от «пользователь отменил её» — оба пути на не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck` закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном (`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и данные не страдают. Восстановить различение можно, дав `Cancel` собственный непустой код, — это требованием разрешено и оставлено открытым вопросом, а не отвергнуто. #### 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 не снимается - **AND** команды `Undo` и `Delete` на записи становятся недоступны, а «Привязать заново» остаётся доступной - **AND** после перепривязки и повторного применения ссылки прежнего батча остаются в библиотеке — их не снимает ни `Undo`, ни `Delete` #### Scenario: Стоп-кран замещает прежнюю диагностику состояния - **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки в `error_msg` - **WHEN** пользователь даёт команду «Закрыть» - **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` - **AND** прежний код и текст ошибки в записи не сохраняются - **AND** лог перехода закрытия их не дублирует — причина отказа восстановима только по более раннему переходу в `failed` #### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой - **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`, `review`, `deferred`, `stuck`), её страница открыта в веб-UI - **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить») - **THEN** запись переходит в `cancelled` и пропадает из активного списка - **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на этом пути нет) - **AND** ни раздача в qBittorrent, ни хардлинки не трогаются #### Scenario: Стоп-кран доступен там, где отмена уже отказывает - **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled` (`done`, `failed`, `reverted`, `target_missing`, `orphaned`) - **WHEN** пользователь даёт команду «Закрыть» и подтверждает её - **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` - **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом #### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей - **GIVEN** загрузка в `stuck` - **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть» - **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"` - **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт `cancelled` с пустым `error_code` — по коду перехода поверхности не различить от намерения #### Scenario: «Закрыть» недоступна для deleted - **GIVEN** загрузка в `deleted` - **WHEN** пользователь пытается вызвать «Закрыть» - **THEN** команда недоступна, состояние остаётся `deleted` #### Scenario: Повторное закрытие не подменяет причину прежнего - **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`) - **WHEN** приходит команда «Закрыть» - **THEN** команда проходит no-op'ом, записи состояния не происходит - **AND** `error_code` остаётся прежним (пустым), а не подменяется на `user_dismiss` - **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом #### Scenario: Закрытую запись можно привязать заново - **GIVEN** запись, закрытая командой «Закрыть» в `cancelled` - **WHEN** пользователь даёт команду «Привязать заново» - **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink из `cancelled`)