web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
@@ -0,0 +1,223 @@
## Context
Полное удаление загрузки (`Reviewer.Delete`) снимает обе стороны — библиотечные
хардлинки и раздачу с файлами из qBittorrent — и уводит задачу в терминальное
`deleted`. Это осознанный выход за инвариант «источник неприкосновенен», и
единственное, что его оправдывает, — явное подтверждение человека. Доступна
операция из `done`, `orphaned`, `target_missing`.
Сегодня её единственная поверхность — danger-секция страницы одной загрузки
(`web/templates/partials/download_main.html`, `POST
/ui/downloads/{id}/delete`). Групповая уборка через неё превращается в обход
карточек по одной.
Ограничения, из которых растёт весь дизайн:
- удаление необратимо, и подтверждение ослаблять нельзя ни ради пачки, ни ради
удобства;
- веб-UI работает без JavaScript (инвариант `web-ui`), значит подтверждение не
может держаться на `hx-confirm`;
- карточки основного списка самообновляются (`card-live-refresh`), а своп
корня уносит вместе с разметкой состояние чекбоксов;
- выход LLM и вход человека недоверенные: идентификаторы приходят с формы и
проходят `ident.Parse` на границе.
## Goals / Non-Goals
**Goals:**
- Снести пачку раздач за один проход подтверждения, не теряя поимённости.
- Не расширить прав: групповой путь допускает ровно то же, что поштучный.
- Пережить отказ на одной загрузке, не отменяя остальных, и назвать исход по
каждой.
- Свести условие «удаление разрешено» к одной точке домена.
**Non-Goals:**
- Групповой `Dismiss` (закрытие без файлов) — отдельная задача.
- Автоматическая чистка по сроку хранения (`db-retention-cleanup`).
- Изменение самой операции `Delete`: её условия, идемпотентность и разбор
ошибок qBittorrent остаются как есть.
- Групповое удаление в Telegram и REST API — только веб-UI.
## Decisions
### D1. Отдельная страница, а не режим выбора на основном списке
Основной список живой: карточки наблюдаемых задач сами опрашивают сервер и
свопят себя целиком (`hx-swap="outerHTML"`). Выбор в чекбоксах такой своп не
переживает — htmx подставляет присланную разметку, а состояние формы в ней
отсутствует. Пометить чекбокс `hx-preserve` можно, но тогда сохранённым
окажется узел, а не соответствие «чекбокс ↔ строка», и после перерисовки список
поедет относительно отметок.
Отвергнуто: режим выбора на `/` с отключением поллинга на время выбора —
поллинг пришлось бы гасить и возвращать клиентским состоянием, то есть завести
на клиенте доменное состояние, чего конвенция веб-UI не допускает.
Страница живёт по `GET /delete` и ссылается из шапки. Своего поллинга не несёт:
её строки статичны до перезагрузки, и это осознанно — предмет страницы не
живая задача, а выбор человека.
### D2. Подтверждение — второй экран сервера, а не диалог браузера
`hx-confirm``confirm()` вообще) требует JavaScript и не может назвать
раздачи поимённо иначе как в теле алерта. Подтверждение делаем экраном:
1. `GET /delete` — список разрешённых к удалению, чекбоксы, кнопка;
2. `POST /ui/delete/confirm` — страница подтверждения: каждая выбранная
раздача названа заголовком, состоянием и идентификатором; форма несёт те же
идентификаторы скрытыми полями и признак подтверждения;
3. `POST /ui/delete` — исполнение.
Шаг 2 — POST, хотя ничего не меняет: идентификаторов может быть много, а
длина URL ограничена. PRG здесь не нужен — страница подтверждения не результат
мутации.
Строка подтверждения называет заголовок, идентификатор **и состояние**, а для
`orphaned` — отдельную отметку «источник пропал, библиотечная ссылка осталась
последней копией данных». Состояние здесь не украшение: гард последней копии в
`Delete` выключен сознательно, и на пачке из десятка заголовков человек иначе не
отличит «снимаю ссылку, раздача цела» от «снимаю единственную копию».
Признак берётся из состояния (`orphaned` по определению значит «источник
пропал, цель — последняя копия»), а не обходом файловой системы.
Отвергнуто: одна страница с раскрывающимся блоком подтверждения. Тогда
«подтвердил» и «выбрал» живут в одной отправке формы, и признак подтверждения
становится галочкой, которую браузер может восстановить автозаполнением.
### D3. Признак подтверждения проверяет сервер, и его отсутствие — отказ
`POST /ui/delete` без поля подтверждения отвечает отказом и **не зовёт `Delete`
ни разу**. Проверка стоит до цикла: подтверждение — это условие операции, а не
украшение экрана. Именно это состояние проверяет приёмка.
### D4. «Удаление разрешено» — метод состояния в `store`
Сейчас перечень `done`/`orphaned`/`target_missing` записан трижды:
`switch` в `worker.Delete`, сборка `Deletable` в `internal/httpapi/download.go`
и выбор клавиатуры в `internal/tgbot/render.go`. Групповая страница стала бы
четвёртым местом, а расхождение между ними означало бы кнопку, ведущую в
конфликт, — или наоборот, скрытую возможность.
Заводим `(store.State).CanDelete() bool` рядом с `IsTerminal`/`IsObservable`,
перечень состояний — в одном списке. Все четыре места зовут его, включая
Telegram: транспорт, оставшийся со своим перечнем, разойдётся с доменом молча
на первом же изменении списка.
Проверку в `worker.Delete` при этом **не снимаем**: транспорт решает, что
показать, а домен — что допустить, и допуск обязан держаться без транспорта.
### D5. Отказ на одной загрузке не отменяет остальных
Цикл идёт по всем выбранным, ошибка каждой попадает в её строку отчёта. Ни
транзакции, ни отката тут быть не может: удаление файлов необратимо, и
«откатить» уже снесённую раздачу нечем. Значит, единственная честная семантика
— «каждая сама за себя», а отчёт обязан назвать обе половины поимённо.
Вызовы идут **последовательно**. Параллельные ушли бы в тот же
`torrents/delete` и в тот же мьютекс воркера, выигрыш нулевой, а порядок
сообщений в логе и отчёте перестал бы совпадать с порядком действий.
Ошибка транслируется публичным каналом (`userErr`), как и на поштучном пути:
сырой текст ошибки наружу не идёт.
### D6. Результат — страница ответа на POST, без PRG
Отчёт называет удалённые и отказавшие поимённо, поэтому его нечем передать
через редирект: в query он не поместится, а сессий у сервиса нет. Отдаём
страницу результата прямо ответом `200` на `POST /ui/delete`.
Цена — предупреждение браузера при обновлении страницы. Повтор безопасен:
удалённые уже в `deleted`, `CanDelete` для них ложно, и повторная отправка
вернёт по ним конфликт, а не второе удаление.
**Исполнение не отменяется отменой запроса.** Пачка идёт с контекстом,
отвязанным от `r.Context()` (`context.WithoutCancel`). Закрытая вкладка или
обрыв связи иначе оборвали бы необратимую операцию посередине — в том числе
внутри одной загрузки, между снятием библиотечных ссылок и вызовом
qBittorrent. Отчёт при обрыве человек не увидит, поэтому исход каждой единицы
обязан оставаться в журнале: `worker.Delete` уже пишет `logCmd` по каждому
вызову, и это единственный след, переживающий потерю ответа.
### D7. Порог на размер пачки
За один запрос принимается не больше **20** идентификаторов (решение человека на
чекпоинте 2026-08-10). Причины две: подтверждение, перечисляющее три сотни
раздач, человек не читает — то есть перестаёт быть подтверждением; и один
синхронный запрос упирается в столько же последовательных вызовов qBittorrent.
Двадцать строк прочитываются целиком, и это перевесило удобство уборки сотни
раздач одним заходом. Превышение — отказ целиком, без единого удаления.
Страница выбора при этом показывает **все** разрешённые загрузки без
пагинации: разбиение по страницам сломало бы саму возможность выбрать пачку.
Поэтому порог **назван на самой странице**, рядом с кнопкой, а отказ по порогу
возвращает страницу выбора с сохранёнными отметками. Порог, о котором человек
узнаёт только из отказа, отнимает всю проделанную работу: отметив шестьдесят
строк из ста, он получил бы пустой экран и необходимость угадывать границу.
Ограничивать выбор на клиенте нечем — страница обязана работать без JS.
### D8. Вход разбирается целиком на обеих границах
Каждый пришедший `id` разбирается через `ident.Parse`. Не разобравшийся —
отказ всего запроса, а не тихий пропуск: молча выброшенный идентификатор
означал бы, что человек подтвердил удаление раздачи, которую не удалили, и
узнал бы об этом только по отсутствию строки в отчёте.
**Границ две, и проверки на них одинаковы.** Исполняющий `POST /ui/delete`
получает идентификаторы формой заново — сессий у сервиса нет, состояние между
шагами не хранится, — значит он такая же входная граница, как и подтверждение.
Разбор, схлопывание дублей, порог и отказ на пустом наборе стоят на обеих; иначе
запрос с признаком подтверждения и произвольным списком обошёл бы порог и увёл
неограниченную серию необратимых `torrents/delete` за один заход.
Дубликаты в списке схлопываются до первого вхождения — повторный `Delete` по
той же задаче дал бы ложный конфликт во второй строке отчёта.
Пустой набор — отказ: страница подтверждения без единой названной раздачи
обесценивает сам жест подтверждения.
Идентификатор, разобравшийся, но не имеющий записи (устаревшая вкладка, чужая
ссылка), выбрасывать молча нельзя по той же причине. Он идёт отдельной строкой
«загрузка не найдена» — и на подтверждении, и в отчёте: «каждая сама за себя»
из D5 распространяется и на этот исход.
## Risks / Trade-offs
- **Пачка сносит больше, чем человек имел в виду** → подтверждение поимённое и
обязательное, порог на размер пачки, отчёт называет снесённое поимённо.
- **Состояние задачи меняется между выбором и исполнением** (фоновая сверка
увела `done` в `orphaned` или наоборот) → допуск проверяет `worker.Delete`
под своим замком в момент операции; экран влиять на это не может, и отказ
придёт строкой отчёта.
- **Длинный синхронный запрос** при пачке в 20 раздач → порог, последовательный
проход и отсутствие `WriteTimeout` у сервера; уход в фон не делаем — тогда
результат перестал бы быть поимённым ответом на подтверждение.
- **Страница выбора устаревает** (не самообновляется) → к моменту отправки
часть строк может быть неактуальна; ловится тем же допуском в `worker.Delete`
и строкой отказа в отчёте.
- **Четвёртая поверхность одного действия** → условие допуска сведено в
`CanDelete`, сама операция не дублируется: групповой путь зовёт тот же
`Reviewer.Delete`.
### D9. Системный отказ останавливает пачку после трёх подряд
`Delete` снимает сначала библиотечные ссылки, потом зовёт qBittorrent. Если
qBittorrent недоступен, каждая единица пачки успевает выполнить **необратимый
локальный шаг** и падает на внешнем. Цикл «каждая сама за себя» дошёл бы до
конца: один клик оставил бы двадцать тайтлов без раскладки в Jellyfin, не
освободив ни байта. На поштучном пути человек останавливался сам после первой же
ошибки — групповой путь эту естественную остановку снимает, и её надо вернуть
машиной.
Порог — **три подряд** (решение человека на чекпоинте 2026-08-10). Счётчик
сбрасывается на каждом успехе: одиночная сетевая ошибка пачку не рвёт, а три
подряд означают, что сосед лежит, а не что не повезло. Конфликт состояния
(`ErrConflict`) системным отказом не считается — он про задачу, а не про соседа,
и на счётчик не влияет.
Отвергнуто: остановка на первом же отказе — одна случайная сетевая ошибка
обрывала бы всю пачку, и человек проходил бы подтверждение заново.
Отвергнуто: «каждая сама за себя» без исключений — проще и предсказуемее, но
ценой того самого ущерба, ради ограничения которого стоит порог пачки.