- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
224 lines
19 KiB
Markdown
224 lines
19 KiB
Markdown
## 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`) системным отказом не считается — он про задачу, а не про соседа,
|
||
и на счётчик не влияет.
|
||
|
||
Отвергнуто: остановка на первом же отказе — одна случайная сетевая ошибка
|
||
обрывала бы всю пачку, и человек проходил бы подтверждение заново.
|
||
|
||
Отвергнуто: «каждая сама за себя» без исключений — проще и предсказуемее, но
|
||
ценой того самого ущерба, ради ограничения которого стоит порог пачки.
|