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
+5 -2
View File
@@ -37,8 +37,11 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module
Исключения два, и оба — не наши операции с файловой системой, а вызов Исключения два, и оба — не наши операции с файловой системой, а вызов
`torrents/delete` qBittorrent с `deleteFiles=true`: (1) `Delete` из `torrents/delete` qBittorrent с `deleteFiles=true`: (1) `Delete` из
`done`/`orphaned`/`target_missing` по явному подтверждению человека — гард `done`/`orphaned`/`target_missing` по явному подтверждению человека — гард
последней копии там выключен сознательно последней копии там выключен сознательно; подтверждение допустимо **одно на
([state-reconciliation](openspec/specs/state-reconciliation/spec.md)); пачку**, если называет каждую загрузку поимённо, и условия допуска групповой
путь не смягчает
([state-reconciliation](openspec/specs/state-reconciliation/spec.md),
[web-ui](openspec/specs/web-ui/spec.md));
(2) уборка воркером **собственного** торрента, добавленного этим же `add` (2) уборка воркером **собственного** торрента, добавленного этим же `add`
секундами ранее, когда закрытие **любым** путём (`Cancel` или `Dismiss`) секундами ранее, когда закрытие **любым** путём (`Cancel` или `Dismiss`)
увело задачу из `catched` в окне после `add` — уборка привязана к состоянию, увело задачу из `catched` в окне после `add` — уборка привязана к состоянию,
@@ -0,0 +1,52 @@
# Отметка «последняя копия» на подтверждении удаления выводится из состояния, а не из файловой системы
- **Дата:** 2026-08-10
- **Источник:** openspec/changes/archive/2026-08-10-bulk-delete-page/design.md
## Решение
Экран подтверждения группового удаления помечает загрузку как последнюю копию
данных по её **состоянию** (`orphaned`), не спрашивая файловую систему. Случай,
когда байты источника исчезли с диска, а раздача осталась в списке qBittorrent,
такой отметки не получает — и это записано границей в спеке `web-ui`, а не
оставлено умолчанием.
## Почему
Гард последней копии в `Delete` выключен сознательно (инвариант «источник
неприкосновенен», исключение 1), поэтому осведомлённость человека — единственный
оставшийся предохранитель. Отсюда решение D2 источника:
> Признак берётся из состояния (`orphaned` по определению значит «источник
> пропал, цель — последняя копия»), а не обходом файловой системы.
Враждебный проход ревью построил путь, где это неверно: сверка берёт присутствие
источника из ответа `torrents/info`, а не с диска, поэтому задача с пропавшими
байтами остаётся `done` сколько угодно долго и отметки не получает. Дыра
признана и оставлена открытой по решению человека: поштучное удаление такой
отметки не несёт **вовсе**, то есть групповой путь не ухудшил положение, а
улучшил его не до конца. Закрывать её обходом файловой системы на экране
подтверждения значит завести чтение диска в транспорте ради предупреждения,
которое и сегодня лучше прежнего.
## Рассмотренные варианты
- **Спрашивать файловую систему на подтверждении** (`nlink` по живым ссылкам
последнего батча) — отметка стала бы правдой, но транспорт начал бы ходить в
файловую систему ради показа, а пачка ограничена двадцатью строками только
сегодня.
- **Вернуть гард последней копии в `Delete`** — отменяет само назначение
команды: она затем и существует, чтобы снять последнюю копию осознанно.
- **Убрать отметку совсем** — честно, но теряет полезный сигнал про пропавший
источник, который в подавляющем большинстве случаев и есть последняя копия.
## Последствия
- `+` Подтверждение предупреждает о последней копии там, где раньше не
предупреждало ничто; признак берётся из домена, второго перечня состояний не
заводится.
- `+` Транспорт не ходит в файловую систему ради показа.
- `` Случай «`done` с пропавшими байтами источника» отметки не получает. Дыра
названа в спеке прямо, чтобы отметка не читалась как гарантия.
- `` Пока сверка берёт присутствие источника из списка раздач, а не с диска,
закрыть дыру нельзя ни на одном экране.
+1
View File
@@ -42,6 +42,7 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-10 | [Отметка «последняя копия» на подтверждении удаления выводится из состояния, а не из файловой системы](ADR-2026-08-10-last-copy-warning-from-state.md) | — |
| 2026-08-10 | [Наблюдаемость поверхности не выводится из терминальности задачи](ADR-2026-08-10-observability-is-not-terminality.md) | — | | 2026-08-10 | [Наблюдаемость поверхности не выводится из терминальности задачи](ADR-2026-08-10-observability-is-not-terminality.md) | — |
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — | | 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — | | 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
+1
View File
@@ -118,6 +118,7 @@
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью | | Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) | | Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
| Условие допуска полного удаления | `store.State.CanDelete()` — «из этого состояния удаление с файлами разрешено»; своего перечня состояний не заводит ни один транспорт (веб-UI, Telegram, страница группового удаления), а проверку в ядре предикат не заменяет: допуск держится без транспорта |
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) | | Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
+1
View File
@@ -86,6 +86,7 @@ jellybit — **приложение, а не библиотека**: внешн
| `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» | | `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
| `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки | | `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки | | `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
| `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
| `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
| `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» | | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
| `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» | | `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
+3
View File
@@ -214,6 +214,9 @@ erDiagram
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) | | `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
| `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа | | `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа |
| `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже | | `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже |
| `httpapi.maxBulkDelete` | `20` загрузок | предел размера одной пачки группового удаления. Подтверждение, перечисляющее больше, человек не читает — то есть перестаёт быть подтверждением; плюс один синхронный запрос упирается в столько же последовательных вызовов qBittorrent. Предел называет сама страница выбора; отказ по пределу возвращает выбор с сохранёнными отметками |
| `httpapi.bulkFailThreshold` | `3` отказа подряд | сколько подряд идущих отказов внешнего сервиса прекращают проход группового удаления. Удаление снимает библиотечные ссылки раньше, чем сносит раздачу: при недоступном qBittorrent каждая единица успевает выполнить необратимый локальный шаг и упасть на внешнем. Счётчик сбрасывается на успехе; конфликт состояния системным отказом не считается |
| `httpapi.bulkBudget` | `2` минуты | потолок времени на один проход группового удаления. Удаление держит общий замок воркера на всё время обращения к qBittorrent, поэтому медленно, но успешно отвечающий сосед остановил бы фоновую работу целиком, а порог отказов такого не ловит. Проверяется между единицами: начатое удаление не обрывается, иначе оно встанет между снятием ссылок и сносом раздачи |
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой | | `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
+52 -2
View File
@@ -179,8 +179,8 @@ Go-сервиса и что здесь уже проскакивало. Устр
- `requirements`: не завелось ли поведение, которого спека не заказывала — тихий - `requirements`: не завелось ли поведение, которого спека не заказывала — тихий
дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле? дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
- `security`: читается ли тело ответа внешнего сервиса целиком без предела — - `security`: читается ли тело ответа внешнего сервиса целиком без предела —
лимита на размер ответа LLM в проекте нет, и это единственный недоверенный у LLM (8 MiB) и метабаз (4 MiB) предел стоит, и новый исходящий вызов обязан
канал, где предел не стоит ([security.md](security.md) → «Что вне модели») заводить свой ([security.md](security.md) → «Что вне модели»)
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на - `operations`: гарантия, которую вводит изменение, поставлена на запись или на
чтение — и что будет с данными, записанными до деплоя, которые обычный путь чтение — и что будет с данными, записанными до деплоя, которые обычный путь
не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
@@ -231,6 +231,13 @@ Go-сервиса и что здесь уже проскакивало. Устр
(`internal/metadata`, `metadata-match`); merge-раскладка при повторном (`internal/metadata`, `metadata-match`); merge-раскладка при повторном
добавлении раздачи. добавлении раздачи.
- **новая поверхность поверх необратимой операции** — вторая точка входа в
команду, которая удаляет файлы или снимает последнюю копию. Форма решения
здесь нащупывается по ходу: подтверждение, порядок отказов, остаток,
наблюдаемость. Выведено по факту на `bulk-delete-page` (журнал, 2026-08-10):
метка `medium` не дала ни враждебного прохода, ни замера, а именно они нашли
четыре дефекта класса «необратимо».
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram** - «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram**
для единственного пользователя это и есть интерфейс. для единственного пользователя это и есть интерфейс.
@@ -325,6 +332,49 @@ Go-сервиса и что здесь уже проскакивало. Устр
случаи до этой даты не восстанавливались — восстановленная постфактум причина случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна. непоймания недостоверна, а именно она и нужна.
## 2026-08-10 — метка занижена: новая поверхность поверх необратимой операции прошла как среднее знакомое [пойман]
- **Где:** конвейер, а не код — разметка задачи `bulk-delete-page`
- **Симптом:** прогон по метке `medium` закончился шестью находками, из них ни
одной про необратимое. Сигнал «метка, вероятно, занижена» вернули два прохода
из четырёх — `code` и `basics`, с одинаковым основанием: дифф трогает `store`,
`worker`, `httpapi`, `tgbot` и шаблоны и заводит новую точку входа поверх
команды, удаляющей файлы
- **Причина:** обе оси считались по объёму и по знакомости узлов, и по ним
изменение честно выходило средним и знакомым — цикл над готовым `Delete`. Ни
один триггер `large` не описывал случай «поверхность новая, а операция за ней
необратимая»
- **Чем воспроизведён:** повторная разметка после правок дельта-спек вернула
`large`; догнанные проходы дали четыре находки класса «необратимо», из них три
с прогнанными падающими тестами (`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload`) и одна с замером
удержания общего замка воркера (`280.450631ms` при задержке соседа `300ms`)
- **Что меняем:** в «Триггеры метки», ось «незнакомое», добавлен пункт про новую
поверхность поверх необратимой операции
## 2026-08-10 — три дефекта поштучного удаления жили незамеченными, пока рядом не появилась пачка [проскочил]
- **Где:** `internal/worker/review.go` (`Delete`), `internal/worker/worker.go`
(`Poll`)
- **Симптом:** враждебный и эксплуатационный проходы на задаче
`bulk-delete-page` нашли три дефекта, ни один из которых эта задача не
вносила: удаление сносит раздачу, которой владеет **другая активная** загрузка
с тем же инфохэшем; удаление держит общий замок воркера через сетевой вызов и
останавливает фоновую работу на это время; при недоступном qBittorrent задача остаётся `done` весь простой
соседа, потому что сверка возвращается на первой же ошибке и до коррекции не
доходит
- **Причина:** поштучное удаление ни разу не проверялось меткой `large` — ни
построенного пути, ни замера против него не гонял никто
- **Чем воспроизведён:** тесты и замеры перечислены в
`openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`,
находки 24
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `large`, а
задачи, заводившие и правившие `Delete`, шли ниже. Дефект не «пропустил
проход» — проход не запускался
- **Что меняем:** три записи в беклоге со ссылкой на оракулы; триггер метки
дополнен (см. запись выше), чтобы следующая поверхность над необратимой
операцией шла сразу с доказательными проходами
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман] ## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
- **Где:** `internal/httpapi/live_test.go``TestFragProgressStopsWhenNotDownloading` - **Где:** `internal/httpapi/live_test.go``TestFragProgressStopsWhenNotDownloading`
+3 -2
View File
@@ -32,6 +32,7 @@ REST API работают **без авторизации** осознанно;
| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь; чистятся наравне с выходом LLM на каждой точке входа в план ([ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md)) | | Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь; чистятся наравне с выходом LLM на каждой точке входа в план ([ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md)) |
| Ответы qBittorrent | HTTP | пути, состояния, размеры | | Ответы qBittorrent | HTTP | пути, состояния, размеры |
| Запросы веб-UI и REST | LAN | идентификаторы, параметры действий | | Запросы веб-UI и REST | LAN | идентификаторы, параметры действий |
| Пачка идентификаторов и признак подтверждения на групповом удалении | форма веб-UI | необратимое действие сразу по многим загрузкам; разбор, схлопывание дублей и предел размера стоят на **обеих** границах — подтверждении и исполнении, потому что вторая получает пачку формой заново |
**Выход LLM не отвечает за безопасность.** Инъекция в промпт считается **Выход LLM не отвечает за безопасность.** Инъекция в промпт считается
состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути. состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути.
@@ -103,8 +104,8 @@ REST API работают **без авторизации** осознанно;
- **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов, - **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов,
бесконечный ответ LLM — это вопросы устойчивости и ресурсов бесконечный ответ LLM — это вопросы устойчивости и ресурсов
([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности. ([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности.
Лимита на размер ответа LLM нет — известный пробел Тело ответа внешнего сервиса при этом читается с пределом: LLM — 8 MiB
([architecture.md](architecture.md) → «Открытые вопросы»). (`internal/llm`), метабазы — 4 MiB (`internal/metadata`).
- **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота. - **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота.
- **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга. - **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга.
- **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и - **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и
+289
View File
@@ -0,0 +1,289 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// maxBulkDelete — верхний предел числа загрузок в одной пачке. Подтверждение,
// перечисляющее больше, человек не читает — то есть перестаёт быть
// подтверждением; плюс один синхронный запрос упирается в столько же
// последовательных вызовов qBittorrent. Число названо на самой странице выбора:
// предел, о котором узнают только из отказа, отнимает уже сделанную работу.
const maxBulkDelete = 20
// bulkFailThreshold — сколько подряд идущих отказов внешнего сервиса
// прекращают проход. Удаление снимает библиотечные ссылки раньше, чем сносит
// раздачу: при лежащем qBittorrent каждая единица успевает выполнить
// необратимый локальный шаг и упасть на внешнем, оставив тайтл без раскладки и
// не освободив места. Счётчик сбрасывается на успехе — одиночная сетевая
// ошибка пачку не рвёт.
const bulkFailThreshold = 3
// bulkBudget — потолок времени на один проход пачки. Удаление держит общий
// замок воркера на всё время обращения к qBittorrent, поэтому медленно, но
// успешно отвечающий сосед останавливает фоновую работу целиком, а порог
// отказов такого не ловит — он считает только ошибки. Проверяется МЕЖДУ
// единицами, а не отменой контекста: начатое удаление обрывать нельзя, иначе
// оно встанет между снятием библиотечных ссылок и сносом раздачи.
//
// Переменная, а не константа, ровно по одной причине: тест укорачивает её —
// иначе проверка потолка стоила бы двух минут прогона.
var bulkBudget = 2 * time.Minute
// Отказы разбора пачки. Текст — публичного канала: он показывается человеку
// как есть, как у прочих sentinel'ов транспорта. Трансляция в статус и
// сообщение живёт в единой точке `classifyErr`, а не рядом.
var (
errBatchEmpty = errors.New("ни одна загрузка не выбрана")
errBatchTooLarge = errors.New("за один раз можно удалить не больше " +
strconv.Itoa(maxBulkDelete) + " загрузок")
errBatchBadID = errors.New("некорректный идентификатор загрузки — запрос отклонён целиком")
errBatchForm = errors.New("форма запроса не разобрана — запрос отклонён целиком")
)
// bulkRow — строка загрузки на любом из трёх экранов группового удаления.
type bulkRow struct {
ID string
Title string
State string
Selected bool // отметка сохранена при возврате отказа
LastCopy bool // orphaned: библиотечная ссылка осталась последней копией
Missing bool // записи в хранилище нет
Unread bool // состояние прочитать не удалось (отказ хранилища)
Reason string // причина отказа (только на экране результата)
}
// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
type bulkSelectView struct {
Error string
Max int
Rows []bulkRow
}
// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
type bulkConfirmView struct {
Rows []bulkRow
}
// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
// проход остановлен системным отказом.
type bulkResultView struct {
Deleted []bulkRow
Failed []bulkRow
Skipped []bulkRow
StopReason string
}
// handleBulkDeletePage — страница выбора. Самообновления не несёт сознательно:
// своп разметки унёс бы отметки, и человек подтвердил бы необратимое удаление
// по выбору, которого уже не видит (см. openspec/specs/web-ui).
func (s *server) handleBulkDeletePage(w http.ResponseWriter, r *http.Request) {
s.renderBulkSelect(w, r, "", nil)
}
// renderBulkSelect отрисовывает страницу выбора, помечая отмеченными те строки,
// чьи идентификаторы человек уже выбрал (selected). Общий путь для чистого
// открытия страницы и для любого отказа разбора.
func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg string, selected []string) {
ds, err := s.deps.Reader.ListDeletableDownloads(r.Context())
if err != nil {
s.deps.Logger.Error("list deletable downloads", "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
mark := make(map[string]bool, len(selected))
for _, id := range selected {
mark[id] = true
}
view := bulkSelectView{Error: msg, Max: maxBulkDelete}
for _, d := range ds {
view.Rows = append(view.Rows, bulkRow{
ID: d.ID,
Title: downloadTitle(d),
State: string(d.State),
Selected: mark[d.ID],
LastCopy: d.State == store.StateOrphaned,
})
}
s.render(w, "delete.html", view)
}
// handleBulkDeleteConfirm — экран подтверждения. Ничего не меняет: разбирает
// вход, читает выбранные загрузки и называет каждую поимённо.
func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request) {
ids, err := s.parseBulkBatch(r)
if err != nil {
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
return
}
s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)})
}
// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
// разбора и до единого вызова удаления: подтверждение — условие операции, а не
// украшение экрана.
func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil || r.PostForm.Get("confirm") != "1" {
s.renderBulkSelect(w, r, "Удаление уходит только со страницы подтверждения.", nil)
return
}
// Исполняющий запрос — самостоятельная входная граница: идентификаторы
// приходят формой заново, состояния между шагами сервис не хранит.
ids, err := s.parseBulkBatch(r)
if err != nil {
s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
return
}
// Контекст исполнения отвязан от запроса: обрыв связи не вправе оборвать
// необратимую операцию на середине — в том числе внутри одной загрузки,
// между снятием библиотечных ссылок и сносом раздачи. Строки отчёта читаются
// тем же контекстом: собранные отменённым, они превратили бы весь отчёт в
// «загрузка не найдена» ровно там, где удаление идёт штатно.
ctx := context.WithoutCancel(r.Context())
rows := s.bulkRows(ctx, ids)
var res bulkResultView
streak := 0
deadline := store.Now().Add(bulkBudget)
for i, row := range rows {
if res.StopReason != "" {
res.Skipped = append(res.Skipped, rows[i])
continue
}
if store.Now().After(deadline) {
res.StopReason = "Проход занял дольше отведённого времени и остановлен: " +
"пока идёт пачка, остальная работа сервиса ждёт."
res.Skipped = append(res.Skipped, rows[i])
continue
}
err := s.deps.Reviewer.Delete(ctx, row.ID)
if err == nil {
streak = 0
res.Deleted = append(res.Deleted, row)
continue
}
row.Reason = userErr(r, err, row.ID)
res.Failed = append(res.Failed, row)
// Конфликт состояния и отсутствие записи — про саму задачу, а не про
// доступность соседа: счётчик системных отказов они не двигают.
if errors.Is(err, worker.ErrConflict) || errors.Is(err, store.ErrNotFound) {
continue
}
streak++
if streak >= bulkFailThreshold {
res.StopReason = "Внешний сервис отказывает подряд — проход остановлен, " +
"чтобы не снимать раскладку у остальных без освобождения места."
}
}
// Исход каждой единицы поимённо: ответ мог не дойти (вкладку закрыли), и
// журнал — единственное, по чему потом видно, что снесено, что отказало и до
// чего проход не дошёл. На воркер полагаться нельзя: отказы по конфликту и
// отсутствию записи он пишет на DEBUG.
for _, row := range res.Deleted {
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "deleted")
}
for _, row := range res.Failed {
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "failed",
"reason", row.Reason)
}
for _, row := range res.Skipped {
s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "skipped")
}
s.deps.Logger.Info("bulk delete finished",
"requested", len(rows), "deleted", len(res.Deleted),
"failed", len(res.Failed), "skipped", len(res.Skipped),
"stopped", res.StopReason != "")
s.render(w, "delete_result.html", res)
}
// bulkRows читает выбранные загрузки для показа поимённо. Идентификатор без
// записи в хранилище не выбрасывается молча — он идёт своей строкой: человек
// подтверждает пачку, и она обязана совпадать с тем, что он выбрал.
func (s *server) bulkRows(ctx context.Context, ids []string) []bulkRow {
rows := make([]bulkRow, 0, len(ids))
for _, id := range ids {
d, err := s.deps.Reader.GetDownload(ctx, id)
switch {
case errors.Is(err, store.ErrNotFound):
rows = append(rows, bulkRow{ID: id, Missing: true, Title: "загрузка не найдена"})
continue
case err != nil || d == nil:
// Отказ хранилища — это НЕ «записи нет». Выдав одно за другое, экран
// сказал бы «удалять нечего» о загрузке, которую пачка снесёт
// по-настоящему, и для orphaned унёс бы отметку последней копии —
// единственный оставшийся предохранитель. Приватный канал: пишем
// здесь, потому что выше эта ошибка не всплывает.
s.deps.Logger.Error("bulk delete: read download", "download_id", id, "error", err)
rows = append(rows, bulkRow{ID: id, Unread: true, Title: "состояние прочитать не удалось"})
continue
}
rows = append(rows, bulkRow{
ID: d.ID,
Title: downloadTitle(*d),
State: string(d.State),
LastCopy: d.State == store.StateOrphaned,
})
}
return rows
}
// parseBulkBatch разбирает пачку идентификаторов с формы. Проверки одинаковы на
// обеих границах — подтверждения и исполнения. Возвращает разобранные
// идентификаторы даже вместе с отказом: страница выбора возвращает по ним
// отметки, чтобы отказ не стирал проделанную работу.
func (s *server) parseBulkBatch(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
// Приватный канал: выше эта ошибка не всплывает, а человеку про
// идентификаторы говорить нечего — тело не прочиталось целиком.
s.deps.Logger.Error("bulk delete: parse form", "error", err)
return nil, errBatchForm
}
// Только тело: признак подтверждения и пачка приходят формой, и принимать
// их из строки запроса значит принимать подтверждение оттуда, откуда
// требование его не заказывало.
raw := r.PostForm["id"]
seen := make(map[string]bool, len(raw))
ids := make([]string, 0, len(raw))
for _, v := range raw {
id, err := ident.Parse(v)
if err != nil {
// Молча пропустить нельзя: человек подтвердил удаление поимённо, и
// выброшенный идентификатор развёл бы подтверждённое с исполненным.
return ids, errBatchBadID
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, errBatchEmpty
}
if len(ids) > maxBulkDelete {
return ids, errBatchTooLarge
}
return ids, nil
}
// bulkErrMsg — сообщение человеку об отказе разбора. Сам текст берётся из
// единой точки трансляции (`classifyErr`); здесь добавляется только подсказка,
// что делать дальше, — она осмысленна ровно на этой странице.
func bulkErrMsg(err error) string {
_, msg := classifyErr(err)
if errors.Is(err, errBatchTooLarge) {
return msg + ". Отметки сохранены — сними лишние."
}
return msg + "."
}
+611
View File
@@ -0,0 +1,611 @@
package httpapi
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Валидные lowercase-ULID для форм (parseBulkBatch прогоняет через ident.Parse).
const (
bid1 = "01arz3ndektsv4rrffq69g5fa1"
bid2 = "01arz3ndektsv4rrffq69g5fa2"
bid3 = "01arz3ndektsv4rrffq69g5fa3"
bid4 = "01arz3ndektsv4rrffq69g5fa4"
)
// bulkReviewer — Reviewer, который считает вызовы удаления и умеет отказать по
// конкретному идентификатору. Указатель: тест проверяет «ни одного вызова», а
// значение-копия этого не покажет.
type bulkReviewer struct {
stubReviewer
calls *[]string
errs map[string]error
allErr error // отказ по любому идентификатору (лежащий внешний сервис)
sleep time.Duration // медленный, но исправный сосед
}
func (b bulkReviewer) Delete(ctx context.Context, id string) error {
*b.calls = append(*b.calls, id)
// Контекст исполнения обязан пережить отмену запроса: необратимую операцию
// нельзя обрывать на середине. Проверяем во всех тестах, а не только в том,
// что об этом, — гарантия одна на все пути.
if err := ctx.Err(); err != nil {
return fmt.Errorf("bulk delete stub: %w", err)
}
time.Sleep(b.sleep)
if b.allErr != nil {
return b.allErr
}
return b.errs[id]
}
func newBulkReviewer(errs map[string]error) (bulkReviewer, *[]string) {
calls := &[]string{}
return bulkReviewer{calls: calls, errs: errs}, calls
}
func dl(id string, st store.State, title string) store.Download {
return store.Download{ID: id, State: st, DisplayName: title}
}
// byID собирает карту для поштучного чтения на экранах подтверждения и отчёта.
func byID(ds ...store.Download) map[string]store.Download {
m := make(map[string]store.Download, len(ds))
for _, d := range ds {
m[d.ID] = d
}
return m
}
// postCancelled отправляет POST с уже отменённым контекстом запроса — так
// выглядит закрытая вкладка или оборванная связь на середине пачки.
func postCancelled(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx, cancel := context.WithCancel(req.Context())
req = req.WithContext(ctx)
cancel()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func idForm(ids ...string) url.Values {
f := url.Values{}
for _, id := range ids {
f.Add("id", id)
}
return f
}
// П1: страница показывает строки только тех загрузок, для которых удаление
// разрешено поштучно. Читатель отдаёт задачи во всех состояниях — на странице
// оказываются ровно разрешённые.
func TestBulkDeletePageShowsOnlyDeletable(t *testing.T) {
all := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDone, store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateCancelled, store.StateReverted,
store.StateTargetMissing, store.StateOrphaned, store.StateDeleted,
}
// Читатель отдаёт всё подряд — отбирает страница по домену, а не тест.
var rows []store.Download
for i, st := range all {
if st.CanDelete() {
rows = append(rows, dl(fmt.Sprintf("%026d", i), st, "задача "+string(st)))
}
}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
for _, st := range all {
marker := "задача " + string(st)
if got := strings.Contains(body, marker); got != st.CanDelete() {
t.Errorf("%s: строка на странице=%v, CanDelete=%v", st, got, st.CanDelete())
}
}
}
// Страница не опрашивает сервер: своп разметки стёр бы отметки, и человек
// подтвердил бы необратимое удаление по выбору, которого уже не видит.
func TestBulkDeletePageDoesNotSelfPoll(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t,
stubReader{deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}},
rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
if strings.Contains(body, `hx-trigger="every`) {
t.Error("страница выбора не должна самообновляться")
}
// Предел пачки назван до отправки — иначе отказ по нему отнимает работу.
if !strings.Contains(body, strconv.Itoa(maxBulkDelete)) {
t.Errorf("предел пачки не назван на странице:\n%s", body)
}
}
// Пустое состояние: разрешённых нет — удаление не предлагается.
func TestBulkDeletePageEmpty(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{}, rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
if strings.Contains(body, `action="/ui/delete/confirm"`) {
t.Error("на пустой странице не должно быть формы удаления")
}
if !strings.Contains(body, "Удалять нечего") {
t.Errorf("нет пустого состояния:\n%s", body)
}
}
// П2 (первая половина): подтверждение называет каждую выбранную поимённо и не
// делает ни одного вызова удаления.
func TestBulkConfirmNamesRowsAndDeletesNothing(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2), false).Body.String()
for _, want := range []string{"Дюна", "Фарго", bid1, bid2} {
if !strings.Contains(body, want) {
t.Errorf("подтверждение не называет %q:\n%s", want, body)
}
}
if len(*calls) != 0 {
t.Errorf("подтверждение не должно удалять, вызовы: %v", *calls)
}
}
// Строка orphaned на подтверждении предупреждает о последней копии данных: гард
// последней копии в удалении выключен сознательно, и осведомлённость человека —
// единственный оставшийся предохранитель.
func TestBulkConfirmWarnsAboutLastCopy(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if !strings.Contains(body, "последняя копия данных") {
t.Errorf("нет предупреждения о последней копии:\n%s", body)
}
}
// П2 (вторая половина): без признака подтверждения не удаляется ничего.
func TestBulkDeleteWithoutConfirmDeletesNothing(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete", idForm(bid1), false).Body.String()
if len(*calls) != 0 {
t.Errorf("без подтверждения не должно быть вызовов удаления: %v", *calls)
}
if !strings.Contains(body, "страницы подтверждения") {
t.Errorf("отказ не объяснён:\n%s", body)
}
}
// Гарды разбора одинаковы на обеих границах: исполняющий запрос получает
// идентификаторы формой заново и на проверки подтверждения опираться не вправе.
func TestBulkBatchGuardsOnBothBoundaries(t *testing.T) {
over := make([]string, 0, maxBulkDelete+1)
for i := range maxBulkDelete + 1 {
over = append(over, fmt.Sprintf("%026d", i))
}
cases := []struct {
name string
form url.Values
want string
}{
{"неразобранный идентификатор", idForm(bid1, "не-ulid"), "некорректный идентификатор"},
{"пачка сверх предела", idForm(over...), "не больше"},
{"пустой набор", url.Values{}, "ни одна загрузка не выбрана"},
}
for _, c := range cases {
for _, path := range []string{"/ui/delete/confirm", "/ui/delete"} {
t.Run(c.name+" "+path, func(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
byID: byID(dl(bid1, store.StateDone, "Дюна")),
}, rv, stubCommander{}, stubLive{})
form := url.Values{}
for k, v := range c.form {
form[k] = v
}
if path == "/ui/delete" {
form.Set("confirm", "1")
}
body := post(t, h, path, form, false).Body.String()
if len(*calls) != 0 {
t.Errorf("отказ разбора не должен удалять: %v", *calls)
}
if !strings.Contains(body, c.want) {
t.Errorf("нет объяснения %q:\n%s", c.want, body)
}
})
}
}
}
// Отказ по пределу возвращает страницу выбора с сохранёнными отметками: иначе
// проверка отнимает всю проделанную человеком работу.
func TestBulkOverLimitKeepsSelection(t *testing.T) {
rows := []store.Download{dl(bid1, store.StateDone, "Дюна")}
over := []string{bid1}
for i := range maxBulkDelete {
over = append(over, fmt.Sprintf("%026d", i))
}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(over...), false).Body.String()
if !strings.Contains(body, `value="`+bid1+`" checked`) {
t.Errorf("отметка выбора не сохранена:\n%s", body)
}
}
// Дубликаты в пачке схлопываются: повторный вызов по той же задаче дал бы
// ложный конфликт во второй строке отчёта.
func TestBulkDeleteCollapsesDuplicates(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid1, bid1)
form.Set("confirm", "1")
post(t, h, "/ui/delete", form, false)
if len(*calls) != 1 {
t.Errorf("дубликаты не схлопнуты: %v", *calls)
}
}
// П3: отказ на одной загрузке не отменяет остальных, отчёт называет обе
// половины поимённо.
func TestBulkDeletePartialFailure(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: errors.New("qbittorrent: connection refused"),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 3 {
t.Fatalf("удаление должно уйти по всем трём: %v", *calls)
}
for _, want := range []string{"Дюна", "Фарго", "Оппенгеймер", "Удалено — 2", "Отказ — 1"} {
if !strings.Contains(body, want) {
t.Errorf("отчёт не называет %q:\n%s", want, body)
}
}
// Сырой текст ошибки внешнего сервиса наружу не идёт — только публичный канал.
if strings.Contains(body, "connection refused") {
t.Errorf("сырая ошибка просочилась в разметку:\n%s", body)
}
}
// П4: групповой путь прав поштучного не расширяет — недопустимое состояние
// отклоняется тем же конфликтом, остальные выбранные удаляются.
func TestBulkDeleteConflictDoesNotWidenRights(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: fmt.Errorf("delete: download in state downloading: %w", worker.ErrConflict),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDownloading, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 3 {
t.Fatalf("допуск проверяет ядро — звать надо все три: %v", *calls)
}
if !strings.Contains(body, "Удалено — 2") || !strings.Contains(body, "Отказ — 1") {
t.Errorf("отчёт не разделил исходы:\n%s", body)
}
}
// Все удалены — отчёт называет обе загрузки и отказов не содержит.
func TestBulkDeleteAllSucceed(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if !strings.Contains(body, "Удалено — 2") || strings.Contains(body, "Отказ — ") {
t.Errorf("ожидались две удалённые без отказов:\n%s", body)
}
}
// Идентификатор без записи в хранилище назван строкой и на подтверждении, и в
// отчёте: молча выброшенный, он развёл бы подтверждённое с исполненным.
func TestBulkMissingDownloadIsNamed(t *testing.T) {
rv, _ := newBulkReviewer(map[string]error{
bid2: fmt.Errorf("delete: %w", store.ErrNotFound),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
confirm := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2, bid3), false).Body.String()
if !strings.Contains(confirm, bid2) || !strings.Contains(confirm, "не найдена") {
t.Errorf("подтверждение не назвало ненайденную загрузку:\n%s", confirm)
}
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
res := post(t, h, "/ui/delete", form, false).Body.String()
if !strings.Contains(res, bid2) {
t.Errorf("отчёт не назвал ненайденную загрузку:\n%s", res)
}
if !strings.Contains(res, "Удалено — 2") {
t.Errorf("остальные должны быть удалены:\n%s", res)
}
}
// Системный отказ останавливает пачку: удаление снимает библиотечные ссылки
// раньше, чем сносит раздачу, поэтому при лежащем qBittorrent проход без
// остановки оставил бы без раскладки все выбранные тайтлы разом.
func TestBulkDeleteStopsOnConsecutiveSystemFailures(t *testing.T) {
calls := &[]string{}
rv := bulkReviewer{calls: calls, allErr: errors.New("qbittorrent: unreachable")}
ids := []string{bid1, bid2, bid3, bid4}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
dl(bid4, store.StateDone, "Интерстеллар"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(ids...)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != bulkFailThreshold {
t.Fatalf("проход должен остановиться после %d отказов, вызовов: %v",
bulkFailThreshold, *calls)
}
if !strings.Contains(body, "Не выполнено — 1") {
t.Errorf("остаток пачки не назван невыполненным:\n%s", body)
}
if !strings.Contains(body, "проход остановлен") {
t.Errorf("причина остановки не названа:\n%s", body)
}
}
// Одиночный отказ пачку не рвёт: счётчик подряд идущих отказов сбрасывается на
// каждом успехе, иначе случайная сетевая ошибка обрывала бы всю уборку.
func TestBulkDeleteSingleFailureDoesNotStop(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: errors.New("qbittorrent: temporary"),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
dl(bid4, store.StateDone, "Интерстеллар"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3, bid4)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 4 {
t.Fatalf("одиночный отказ не должен останавливать проход: %v", *calls)
}
if strings.Contains(body, "Не выполнено") {
t.Errorf("остановки быть не должно:\n%s", body)
}
}
// Отмена запроса не прекращает необратимую операцию: закрытая вкладка не вправе
// оборвать пачку на середине, в том числе внутри одной загрузки.
func TestBulkDeleteSurvivesRequestCancel(t *testing.T) {
calls := &[]string{}
// Reviewer, который проверяет, что контекст исполнения жив, хотя контекст
// запроса уже отменён.
rv := bulkReviewer{calls: calls, errs: map[string]error{}}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2)
form.Set("confirm", "1")
rr := postCancelled(t, h, "/ui/delete", form)
if len(*calls) != 2 {
t.Fatalf("пачка должна дойти до конца при обрыве: %v", *calls)
}
if !strings.Contains(rr.Body.String(), "Удалено — 2") {
t.Errorf("исход не собран:\n%s", rr.Body.String())
}
}
// Формы страниц удаления работают без JavaScript: обычный POST с рабочим action
// и никаких hx-атрибутов на пути к необратимому действию.
func TestBulkDeleteWorksWithoutJS(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
byID: byID(dl(bid1, store.StateDone, "Дюна")),
}, rv, stubCommander{}, stubLive{})
sel := get(t, h, "/delete").Body.String()
if !strings.Contains(sel, `<form method="post" action="/ui/delete/confirm">`) {
t.Errorf("страница выбора должна нести обычную POST-форму:\n%s", sel)
}
conf := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if !strings.Contains(conf, `action="/ui/delete"`) || !strings.Contains(conf, `name="confirm" value="1"`) {
t.Errorf("подтверждение должно нести форму исполнения:\n%s", conf)
}
for _, page := range []string{sel, conf} {
if strings.Contains(page, "hx-post") || strings.Contains(page, "hx-get") {
t.Error("страницы группового удаления не должны зависеть от htmx")
}
}
}
// Р1: исполняется ровно подтверждённое множество — список идентификаторов, а не
// предикат «всё, что сейчас разрешено». Задача, ставшая разрешённой уже после
// показа подтверждения, в пачку не попадает.
func TestBulkDeleteRunsOnlyConfirmedSet(t *testing.T) {
rv, calls := newBulkReviewer(nil)
// Читатель отдаёт две разрешённые, но подтверждена одна.
h := testRouterAction(t, stubReader{
deletable: []store.Download{
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
},
byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
),
}, rv, stubCommander{}, stubLive{})
form := idForm(bid1)
form.Set("confirm", "1")
post(t, h, "/ui/delete", form, false)
if len(*calls) != 1 || (*calls)[0] != bid1 {
t.Errorf("удалено не подтверждённое множество: %v", *calls)
}
}
// Ссылка на страницу группового удаления есть в шапке любой страницы веб-UI.
// Без этой проверки удаление пункта из навигации закрыло бы единственный вход
// на страницу молча: сама страница жива, тесты зелёные, попасть некуда.
func TestHeaderLinksToBulkDelete(t *testing.T) {
dl := store.Download{ID: bid1, State: store.StateReview, DisplayName: "Дюна"}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{list: []store.Download{dl}, one: &dl},
rv, stubCommander{}, stubLive{})
for _, path := range []string{"/", "/delete"} {
body := get(t, h, path).Body.String()
if !strings.Contains(body, `href="/delete"`) {
t.Errorf("%s: в шапке нет ссылки на групповое удаление:\n%s", path, body)
}
}
}
// Отказ хранилища не выдаётся за «записи нет»: строка говорит, что состояние
// прочитать не удалось, и отметка о последней копии не теряется молча. Иначе
// человек подтверждает пачку, где строка обещала «удалять нечего», а загрузка
// сносится с файлами по-настоящему.
func TestBulkConfirmDistinguishesReadFailure(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
getErr: errors.New("database is locked"),
}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if strings.Contains(body, "записи нет") || strings.Contains(body, "удалять нечего") {
t.Errorf("отказ чтения выдан за отсутствие записи:\n%s", body)
}
if !strings.Contains(body, "состояние прочитать не удалось") {
t.Errorf("отказ чтения не назван строкой:\n%s", body)
}
}
// Потолок времени останавливает проход: пока идёт пачка, общий замок ядра
// удерживается на каждом обращении к qBittorrent, и медленный (но рабочий)
// сосед иначе остановил бы фон целиком, не дав ни одного отказа.
func TestBulkDeleteStopsOnTimeBudget(t *testing.T) {
calls := &[]string{}
// Каждое удаление «идёт» дольше всего бюджета — второй единице стартовать
// уже нельзя.
// Бюджет укорочен на время теста — иначе проверка стоила бы двух минут.
orig := bulkBudget
bulkBudget = time.Millisecond
t.Cleanup(func() { bulkBudget = orig })
slow := bulkReviewer{calls: calls, sleep: 3 * time.Millisecond}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, slow, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 1 {
t.Fatalf("после исчерпания бюджета новых удалений быть не должно: %v", *calls)
}
if !strings.Contains(body, "Не выполнено — 2") {
t.Errorf("остаток не назван невыполненным:\n%s", body)
}
if !strings.Contains(body, "дольше отведённого времени") {
t.Errorf("причина остановки не названа:\n%s", body)
}
}
// Форматирующие символы юникода в имени раздачи не доезжают до экрана: с ними
// заголовок читается не так, как хранится, а поимённое чтение заголовков — и
// есть предохранитель необратимого группового удаления.
func TestBulkTitleStripsFormattingRunes(t *testing.T) {
const rtl = "\u202e" // RIGHT-TO-LEFT OVERRIDE
d := dl(bid1, store.StateDone, "Дюна"+rtl+"vkm.iso \U0001F468\u200D\U0001F469\u200D\U0001F467")
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{d},
byID: byID(d),
}, rv, stubCommander{}, stubLive{})
pages := map[string]string{
"/delete": get(t, h, "/delete").Body.String(),
"/ui/delete/confirm": post(t, h, "/ui/delete/confirm",
idForm(bid1), false).Body.String(),
}
for path, body := range pages {
if strings.Contains(body, rtl) {
t.Errorf("%s: bidi-символ уехал в разметку дословно", path)
}
if !strings.Contains(body, "Дюна") {
t.Errorf("%s: заголовок потерялся целиком:\n%s", path, body)
}
// Составные эмодзи не рассыпаются: снимается класс bidi, а не весь Cf.
if !strings.Contains(body, "\U0001F468\u200D\U0001F469\u200D\U0001F467") {
t.Errorf("%s: соединитель составного эмодзи снят вместе с bidi:\n%s", path, body)
}
}
}
+1 -2
View File
@@ -137,8 +137,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
d.State == store.StateTargetMissing, d.State == store.StateTargetMissing,
Retriable: d.State == store.StateFailed || d.State == store.StateStuck, Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned || Deletable: d.State.CanDelete(),
d.State == store.StateTargetMissing,
Dismissable: d.State.IsTerminal() && Dismissable: d.State.IsTerminal() &&
d.State != store.StateDeleted && d.State != store.StateCancelled, d.State != store.StateDeleted && d.State != store.StateCancelled,
} }
+41 -3
View File
@@ -18,6 +18,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8" "unicode/utf8"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -52,6 +53,9 @@ type Reader interface {
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из // LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке). // загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
// постраничной выдачи (страница группового удаления показывает их разом).
ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
} }
// Deps — зависимости транспорта. // Deps — зависимости транспорта.
@@ -113,6 +117,9 @@ func NewRouter(d Deps) (http.Handler, error) {
// Веб-UI. // Веб-UI.
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload) r.Get("/download/{id}", s.handleDownload)
// Групповое удаление: выбор → подтверждение → исполнение. Отдельная
// страница, потому что живая перерисовка списка стёрла бы выбор человека.
r.Get("/delete", s.handleBulkDeletePage)
// Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями // Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
// вкладок, отрисованных прошлой версией (см. handleFragProgress). // вкладок, отрисованных прошлой версией (см. handleFragProgress).
@@ -124,6 +131,8 @@ func NewRouter(d Deps) (http.Handler, error) {
// Тело ревью для поллинга recognizing (htmx-своп до готового плана). // Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview) r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads", s.handleUIAdd)
r.Post("/ui/delete/confirm", s.handleBulkDeleteConfirm)
r.Post("/ui/delete", s.handleBulkDelete)
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry) r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
@@ -692,12 +701,36 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
// несколько строк заголовка. // несколько строк заголовка.
func downloadTitle(d store.Download) string { func downloadTitle(d store.Download) string {
if d.DisplayName != "" { if d.DisplayName != "" {
return d.DisplayName return displaySafe(d.DisplayName)
} }
if d.RecTitle.Valid && d.RecTitle.String != "" { if d.RecTitle.Valid && d.RecTitle.String != "" {
return d.RecTitle.String return displaySafe(d.RecTitle.String)
} }
return shorten(oneLine(d.SourceRef), 80) return displaySafe(shorten(oneLine(d.SourceRef), 80))
}
// displaySafe готовит заголовок к показу: снимает управляющие символы
// направления письма (`unicode.Bidi_Control` — с ними строка читается не в том
// порядке, в каком хранится) и заменяет управляющие пробелом (перевод строки в
// заголовке склеил бы слова). Имя раздачи — недоверенный вход, а
// `html/template` экранирует разметку, но эти символы пропускает. Цена высока
// на экране подтверждения группового удаления: там поимённое чтение заголовков
// и есть предохранитель необратимой операции.
//
// Снимается ровно этот класс, не весь `unicode.Cf`: в `Cf` лежат и ZWJ/ZWNJ,
// без которых рассыпаются составные эмодзи и меняется написание персидских и
// индийских имён. Чистим на показе, а не на записи — хранение дословное, и
// поиск по списку идёт по сохранённому имени.
func displaySafe(s string) string {
return strings.Map(func(r rune) rune {
if unicode.Is(unicode.Bidi_Control, r) {
return -1
}
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, s)
} }
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в // oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
@@ -814,6 +847,11 @@ func classifyErr(err error) (int, string) {
return http.StatusBadRequest, errManualSource.Error() return http.StatusBadRequest, errManualSource.Error()
case errors.Is(err, errInvalidCandidate): case errors.Is(err, errInvalidCandidate):
return http.StatusBadRequest, errInvalidCandidate.Error() return http.StatusBadRequest, errInvalidCandidate.Error()
case errors.Is(err, errBatchEmpty), errors.Is(err, errBatchTooLarge),
errors.Is(err, errBatchBadID):
// Отказы разбора пачки группового удаления — промах ввода, не сбой:
// текст sentinel'а показывается человеку как есть.
return http.StatusBadRequest, err.Error()
default: default:
return http.StatusInternalServerError, "внутренняя ошибка" return http.StatusInternalServerError, "внутренняя ошибка"
} }
+3
View File
@@ -94,6 +94,9 @@ func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download,
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) { func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
return nil, nil return nil, nil
} }
func (f *fakeReader) ListDeletableDownloads(_ context.Context) ([]store.Download, error) {
return nil, nil
}
func newServer(t *testing.T, d httpapi.Deps) *httptest.Server { func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
t.Helper() t.Helper()
+17 -1
View File
@@ -20,21 +20,37 @@ type stubReader struct {
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
getErr error // отказ чтения задачи (не ErrNotFound) getErr error // отказ чтения задачи (не ErrNotFound)
sizesErr error // отказ чтения размеров раскладки sizesErr error // отказ чтения размеров раскладки
// Для страницы группового удаления: список разрешённых к удалению и
// поштучное чтение по id (страница подтверждения называет каждую поимённо).
deletable []store.Download
deletableErr error
byID map[string]store.Download
} }
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil } func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) { func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
return s.list, len(s.list), nil return s.list, len(s.list), nil
} }
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) { func (s stubReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
if s.getErr != nil { if s.getErr != nil {
return nil, s.getErr return nil, s.getErr
} }
if s.byID != nil {
d, ok := s.byID[id]
if !ok {
return nil, store.ErrNotFound
}
return &d, nil
}
if s.one == nil { if s.one == nil {
return nil, store.ErrNotFound return nil, store.ErrNotFound
} }
return s.one, nil return s.one, nil
} }
func (s stubReader) ListDeletableDownloads(context.Context) ([]store.Download, error) {
return s.deletable, s.deletableErr
}
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) { func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
if s.sizesErr != nil { if s.sizesErr != nil {
return nil, s.sizesErr return nil, s.sizesErr
+17
View File
@@ -84,6 +84,23 @@ func (s State) IsObservable() bool {
return !s.IsTerminal() || slices.Contains(selfHealingStates, s) return !s.IsTerminal() || slices.Contains(selfHealingStates, s)
} }
// deletableStates — состояния, из которых человеку доступно полное удаление
// (снять библиотечные ссылки + снести раздачу с файлами из qBittorrent).
// Единственный дом перечня: на него смотрят и допуск в ядре (worker.Delete), и
// все транспорты, решающие, показывать действие или нет.
var deletableStates = []State{
StateDone, StateOrphaned, StateTargetMissing,
}
// CanDelete сообщает, допускает ли состояние полное удаление загрузки
// пользователем. Транспорт спрашивает его, чтобы не завести своего перечня
// состояний; проверку в ядре это не заменяет — допуск обязан держаться без
// транспорта (см. openspec/specs/state-reconciliation, «Полное удаление
// загрузки пользователем»).
func (s State) CanDelete() bool {
return slices.Contains(deletableStates, s)
}
// allowedTransitions — декларативный граф легальных переходов машины состояний // allowedTransitions — декларативный граф легальных переходов машины состояний
// (from → множество допустимых to). Единственный источник истины о легальности // (from → множество допустимых to). Единственный источник истины о легальности
// рёбер: покрывает все переходы, которые worker выполняет по всем capability // рёбер: покрывает все переходы, которые worker выполняет по всем capability
+19
View File
@@ -683,3 +683,22 @@ func TestIsObservable(t *testing.T) {
t.Error("failed должно быть терминальным и при этом наблюдаемым") t.Error("failed должно быть терминальным и при этом наблюдаемым")
} }
} }
// TestCanDelete: допуск полного удаления — единый дом перечня состояний, на
// который смотрят и ядро, и все транспорты. Перебираем ВСЕ состояния: новое
// состояние, попавшее в перечень молча, тест покажет.
func TestCanDelete(t *testing.T) {
deletable := []State{StateDone, StateOrphaned, StateTargetMissing}
for _, s := range allStates {
want := slices.Contains(deletable, s)
if got := s.CanDelete(); got != want {
t.Errorf("%s: CanDelete=%v, want %v", s, got, want)
}
}
// deleted терминально и удалению не подлежит — иначе повторная отправка
// формы группового удаления сносила бы уже снесённое второй раз.
if StateDeleted.CanDelete() {
t.Error("deleted не должно допускать удаление")
}
}
+36 -4
View File
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
return " WHERE " + strings.Join(conds, " AND "), args return " WHERE " + strings.Join(conds, " AND "), args
} }
// listSelect и listOrder — общая форма выборки списка загрузок: LEFT JOIN
// текущей попытки распознавания (фолбэк заголовка и значок типа) и порядок по
// времени добавления с tie-break по id. Держим одним куском, чтобы страница
// списка и страница группового удаления не разъехались порядком.
const listSelect = `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1`
const listOrder = `
ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC`
// ListDeletableDownloads возвращает все загрузки, для которых домен допускает
// полное удаление, без постраничной выдачи: страница группового удаления
// показывает их разом, а разбиение по страницам лишило бы возможности выбрать
// пачку. Перечень состояний берётся из единой точки домена — своего списка
// запрос не держит.
func (s *Store) ListDeletableDownloads(ctx context.Context) ([]Download, error) {
ph := make([]string, len(deletableStates))
args := make([]any, len(deletableStates))
for i, st := range deletableStates {
ph[i] = "?"
args[i] = string(st)
}
q := listSelect + " WHERE download.state IN (" + strings.Join(ph, ",") + ")" + listOrder
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
return out, nil
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число // ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени // строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break // добавления в источник (source_added_at) с фолбеком на created_at и tie-break
@@ -104,10 +139,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
return nil, 0, fmt.Errorf("list downloads page count: %w", err) return nil, 0, fmt.Errorf("list downloads page count: %w", err)
} }
q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type q := listSelect + where + listOrder + `
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset) pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
+36
View File
@@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) {
t.Fatalf("display_name = %q", d.DisplayName) t.Fatalf("display_name = %q", d.DisplayName)
} }
} }
// Выборка для страницы группового удаления отдаёт ровно те состояния, где домен
// допускает полное удаление, — и ничего сверх них. Перебираем все состояния:
// разъезд запроса с предикатом иначе виден только на проде.
func TestListDeletableDownloads(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
all := []State{
StateCatched, StateDownloading, StateCompleted, StateRecognizing,
StateReview, StateLinking, StateDone, StateDeferred, StateStuck,
StateFailed, StateCancelled, StateReverted,
StateTargetMissing, StateOrphaned, StateDeleted,
}
byID := map[string]State{}
for i, s := range all {
byID[mkDownload(t, st, i+1, s, "задача "+string(s))] = s
}
got, err := st.ListDeletableDownloads(ctx)
if err != nil {
t.Fatal(err)
}
seen := map[State]bool{}
for _, d := range got {
if !d.State.CanDelete() {
t.Errorf("в выборке состояние %s, удаление из которого недоступно", d.State)
}
seen[d.State] = true
}
for _, s := range all {
if s.CanDelete() && !seen[s] {
t.Errorf("состояние %s допускает удаление, но в выборку не попало", s)
}
}
}
+17 -7
View File
@@ -48,22 +48,20 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
case store.StateLinking: case store.StateLinking:
return "⏳ Раскладываю #" + idCode(id) + "…", nil return "⏳ Раскладываю #" + idCode(id) + "…", nil
case store.StateDone: case store.StateDone:
return b.renderDone(rd), b.deletableKeyboard(id) return b.renderDone(rd), b.stateKeyboard(id, state)
default: default:
// state — внутренний enum состояния (не внешний ввод), экранировать не нужно. // state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state) text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
if msg := rd.Download.ErrorMsg.String; msg != "" { if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + esc(msg) text += "\n" + esc(msg)
} }
switch state { if state == store.StateFailed || state == store.StateStuck {
case store.StateFailed, store.StateStuck:
// failed/stuck — даём кнопку повтора. // failed/stuck — даём кнопку повтора.
return text, b.retryKeyboard(id) return text, b.retryKeyboard(id)
case store.StateOrphaned, store.StateTargetMissing:
// Рассинхрон — можно подчистить остатки полным удалением.
return text, b.deletableKeyboard(id)
} }
return text, b.webOnly(id) // Рассинхрон (orphaned/target_missing) — можно подчистить остатки полным
// удалением; какие именно состояния это допускают, знает домен.
return text, b.stateKeyboard(id, state)
} }
} }
@@ -292,6 +290,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb return &kb
} }
// stateKeyboard выбирает клавиатуру по допуску удаления: своего перечня
// состояний транспорт не держит, предикат живёт в домене (см.
// openspec/specs/state-reconciliation, «Полное удаление загрузки
// пользователем»). Допуск в ядре это не заменяет — worker.Delete проверяет его
// сам.
func (b *Bot) stateKeyboard(id string, state store.State) *tgbotapi.InlineKeyboardMarkup {
if state.CanDelete() {
return b.deletableKeyboard(id)
}
return b.webOnly(id)
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление // deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь // (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка // статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
+43
View File
@@ -0,0 +1,43 @@
package tgbot
import (
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// Кнопка «Удалить» приходит ровно в тех состояниях, где домен допускает полное
// удаление. Транспорт своего перечня состояний не держит: разъехавшись с
// доменом, он показал бы кнопку, ведущую в конфликт, — или спрятал бы
// доступное действие.
func TestRenderCard_DeleteButtonFollowsCanDelete(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
all := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDone, store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateCancelled, store.StateReverted,
store.StateTargetMissing, store.StateOrphaned, store.StateDeleted,
}
for _, st := range all {
rd := reviewData(st)
_, kb := b.renderCard(rd)
found := false
if kb != nil {
for _, row := range kb.InlineKeyboard {
for _, btn := range row {
if btn.CallbackData != nil && strings.HasPrefix(*btn.CallbackData, "delete:") {
found = true
}
}
}
}
if want := st.CanDelete(); found != want {
t.Errorf("%s: кнопка удаления=%v, CanDelete=%v", st, found, want)
}
}
}
+1 -3
View File
@@ -628,9 +628,7 @@ func (w *Worker) Delete(ctx context.Context, id string) (err error) {
if err != nil { if err != nil {
return fmt.Errorf("delete: %w", err) return fmt.Errorf("delete: %w", err)
} }
switch d.State { if !d.State.CanDelete() {
case store.StateDone, store.StateOrphaned, store.StateTargetMissing:
default:
return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict) return fmt.Errorf("delete: download %s is in state %s (expected done/orphaned/target_missing): %w", id, d.State, ErrConflict)
} }
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash()) ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10
@@ -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`) системным отказом не считается — он про задачу, а не про соседа,
и на счётчик не влияет.
Отвергнуто: остановка на первом же отказе — одна случайная сетевая ошибка
обрывала бы всю пачку, и человек проходил бы подтверждение заново.
Отвергнуто: «каждая сама за себя» без исключений — проще и предсказуемее, но
ценой того самого ущерба, ради ограничения которого стоит порог пачки.
@@ -0,0 +1,59 @@
## Why
Удалить раздачу вместе с файлами сегодня можно только со страницы одной
загрузки, из её danger-секции. Чтобы освободить место после десяти закрытых
раздач, человек десять раз проходит путь «список → карточка → подтверждение».
Работа механическая, а цена ошибки высокая: на каждом проходе он подтверждает
необратимое действие заново и легко теряет, какие раздачи уже снёс.
## What Changes
- Появляется отдельная страница веб-UI «Удаление», доступная из шапки. На ней
перечислены только те загрузки, для которых удаление с файлами разрешено
поштучно (`done`, `orphaned`, `target_missing`), у каждой строки — чекбокс.
- Одна кнопка «Удалить выбранные» ведёт на **страницу подтверждения**, где
выбранные раздачи названы поимённо. Удаление уходит только оттуда: POST без
признака подтверждения отклоняется, ни одного удаления не делает.
- Результат показывается поимённо: что удалено и что отказало, с причиной
каждого отказа. Отказ на одной загрузке не отменяет остальных.
- Групповой путь прав не расширяет: каждая выбранная загрузка проходит ту же
проверку состояния, что и поштучное удаление, и отклоняется тем же конфликтом.
- Условие «удаление разрешено» переезжает в единую точку домена
(`store.State`): сейчас оно записано дважды — в проверке воркера и в сборке
представления страницы загрузки, — а групповая страница была бы третьим
местом.
- Страница намеренно **не самообновляется**: живая перерисовка карточек стёрла
бы выбор человека. Это и есть причина, по которой режим выбора не вешается на
основной список.
Не входит: групповое закрытие без файлов (`Dismiss`), автоматическая чистка по
сроку хранения, изменение самой операции удаления одной загрузки.
## Capabilities
### New Capabilities
Новых нет: групповое удаление — это новая поверхность существующего поведения,
а не новое поведение системы.
### Modified Capabilities
- `web-ui`: добавляется страница группового удаления с обязательным поимённым
подтверждением и поимённым отчётом об исходе; ссылка на неё в шапке.
- `state-reconciliation`: требование «Полное удаление загрузки пользователем»
уточняется — подтверждение может быть одно на пачку, но остаётся обязательным
и поимённым, а условия допустимости у каждой загрузки прежние и проверяются
для каждой отдельно.
## Impact
- `internal/httpapi`: страница выбора, страница подтверждения, обработчик
группового удаления и страница результата; ссылка в шапке.
- `web/templates`: новый шаблон страницы и её партиалы,
`partials/header.html`.
- `internal/store`: единая точка «в этом состоянии удаление разрешено».
- `internal/worker`: `Reviewer.Delete` зовётся по каждой выбранной загрузке;
сама операция не меняется.
- qBittorrent `torrents/delete` с `deleteFiles=true` — внешний сервис,
необратимая операция; число вызовов за один запрос человека растёт с одного
до числа выбранных.
@@ -0,0 +1,315 @@
# Ревью кода: `bulk-delete-page` — итог триажа (стадия `large`)
## Сводка
- **Размер / сложность / метка:** крупное / незнакомое / **large**. Метка
поднята с `medium` повторной разметкой после правок дельта-спек: изменение
трогает `store` + `worker` + `httpapi` + `tgbot` + шаблоны и заводит новую
точку входа поверх необратимой операции.
- **Режим прогона:** по графу.
- **Гейт:** зелёный после всех правок, diff-coverage 89 %.
- **Находок на входе:** 10 новых (`architecture` A1A3, `adversary` S1S4,
`ops` O1–O3) + 6 перенесённых из отчёта `medium`-стадии. **На выходе:** 6.
Прогон шёл в две стадии. Сперва `medium` (autotests, specs, code, basics,
triage) — его находки отработаны:
- отказ чтения хранилища отличён от отсутствия записи (строка «состояние
прочитать не удалось», лог, сценарий спеки);
- строки отчёта читаются неотменяемым контекстом;
- отсутствие записи явно названо в спеке не-системным отказом;
- три sentinel'а разбора пачки переехали в `classifyErr`, таблица
`docs/conventions/errors.md` дополнена;
- добавлен оракул на ссылку `/delete` в шапке;
- по решению человека введён потолок времени на проход (`bulkBudget`);
- по решению человека спека перестала обещать, что повтор отправки ничего не
делает.
### Тема без отчёта на этой стадии — находка о прогоне
**Три прохода `medium`-стадии (`autotests`, `specs`, `code`) после правок не
перезапускались, а правки затронули их предмет.**
- `specs` (тема **requirements**) отчитывался по **прежнему** тексту дельта-спек.
После него дельты изменены трижды. Соответствие кода изменённому тексту не
проверял никто.
- `code` (тема **conventions**) отчитывался до переезда sentinel'ов в
`classifyErr` и до правки `docs/conventions/errors.md`.
- `autotests` — гейт перепрогнан целиком и зелёный; вопрос темы «есть ли тест,
который упал бы без этой правки» на новых строках заново не задавался.
Это не «проходы не нашли», а «проходы не смотрели».
### План разметки с исходом по каждой теме
| тема | дом | глубина | закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/{web-ui,state-reconciliation}` + дельты | разбор | `specs` | отчёта на этой стадии нет; закрыта на `medium` (3 находки), текст дельт после этого изменён |
| autotests | `CLAUDE.md` → «Гейт» | — | `autotests` | отчёта на этой стадии нет; гейт перепрогнан, зелёный |
| conventions | `docs/conventions/` | разбор | `code` | отчёта на этой стадии нет; закрыта на `medium` (3 находки) |
| architecture | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта**, 3 находки (A1A3) |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 4 находки (S1S4) |
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта**, 3 находки (O1O3) |
| тема проекта | своих тем в `docs/` нет | — | — | дома у темы нет |
---
## Блокирует мердж
### 1. Строка `done` уезжает в пачку без отметки последней копии — единственная копия медиафайла стирается по подтверждению, которое об этом промолчало
- Файл: `internal/httpapi/bulkdelete.go:113,220`; причина —
`internal/worker/reconcile.go:28-41,56-67`, `internal/layout/layout.go:490-510`
- Severity: `major`; Confidence: high; проход `adversary` (S1)
- **Оракул** — два падающих теста (`go test -overlay`):
`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
`TestAdversaryDoneRowConfirmedWithoutLastCopyWarning` («ни страница выбора, ни
подтверждение не сказали о последней копии… отчёт "Удалено — 1"»).
Механизм сверен триажем: `LastCopy: d.State == store.StateOrphaned`;
`sourceSeen` берётся исключительно из присутствия хеша в ответе
`torrents/info`, байты на диске сверка не щупает, поэтому
`deriveState(true, true) == done` держится сколько угодно долго;
`layout.Remove` не зовёт `isLastCopy` вовсе.
- Последствие: задача в `done`, у которой байты источника исчезли с диска, а
раздача осталась в списке qBittorrent (ручная уборка каталога, отвалившийся
том, торрент в `missingFiles`), предлагается к удалению без единственного
предохранителя, который дельта-спека для этого и заводит. `Undo` на том же
входе отказал бы целиком. Позиция №1 шкалы `docs/security.md`, инвариант
«последняя копия не снимается» — **необратимо**. Спека и решение D2 исходят из
тождества «`orphaned``nlink<=1`»; оно неверно в одну сторону.
- **Действие: развилка.** → **решение человека (2026-08-10): оставить как есть,
назвать границу в спеке.** Отметка выводится из состояния, случай «`done` с
пропавшими байтами источника» ею не покрыт, и это записано прямо в требовании
о поимённом подтверждении. Довод: поштучный путь такой отметки не несёт
вовсе — пачка не ухудшила положение, а улучшила его не до конца.
### 2. Подтверждённое удаление старой закрытой задачи сносит источник у другой, живой загрузки с тем же инфохэшем
- Файл: `internal/worker/review.go:665-669`; причина —
`internal/store/download.go:325-371`
- Severity: `major`; Confidence: high; проход `adversary` (S2)
- **Оракул** — два падающих теста:
`TestAdversaryDeletablePageOffersHashOwnedByActiveDownload`,
`TestAdversaryDeleteWipesSourceOfAnotherActiveDownload``Delete(A)` снёс
раздачу с файлами, хотя тем же инфохэшем владеет активная загрузка B в
состоянии `downloading`»). `CreateDownloadIfNoActive` ищет владельца только
среди активных, поэтому терминальная A и активная B по одному хешу — легальное
состояние; `ListDeletableDownloads` фильтрует только по состоянию;
`qbt.Delete` вызывается без проверки активного владения, хотя предикат в
домене есть — `store.FindActiveByInfohash`.
- Последствие: человек подтверждает удаление одной сущности, необратимое
действие применяется к другой — класс из журнала `docs/review.md`
(2026-08-06). **Дефект предсуществует в поштучном `Delete`**; изменение его не
вносит, но снимает естественную остановку «человек открыл карточку и
посмотрел».
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
задачей.** Дефект целиком в поштучном `Delete`, изменение его не вносит;
чинить его здесь значит превратить задачу про новую страницу в переборку
удаления.
### 3. Пачка удалений морозит весь сервис до ~145 секунд, и предохранитель от этого стоит в транспорте, компенсируя свойство ядра
- Файл: `internal/httpapi/bulkdelete.go:30-39,156-167`; причина —
`internal/worker/review.go:619-621`
- Severity: `major`; Confidence: high; проходы `ops` (O1), `architecture` (A1) и
`basics` прошлой стадии — три раза одна причина
- **Оракул** — замер `ops` на настоящем `*worker.Worker`: при задержке
`qbt.Delete` 300 мс конкурентная `Cancel(unknown-id)` через тот же worker
заблокирована на `280.450631ms`. Плюс падающий тест прошлой стадии
`TestTriageDeleteHoldsWorkerLockAcrossQbt`. Сверено триажем:
`internal/worker/worker.go:9-10` и `docs/architecture.md` называют `w.mu`
**per-download**, фактически это один мьютекс на весь воркер; `worker.go:387-390`
— «Медленные вызовы идут ВНЕ `w.mu`»; таймаут клиента qBittorrent — зашитые
30 с, поля в конфиге нет. Отсюда арифметика: `bulkBudget` проверяется между
единицами, значит реальный потолок — 120 + до 30 секунд.
- Последствие: всё это время стоят `Poll` (→ сверка), `processCatched`,
уведомления и команды остальных транспортов. Плюс архитектурная цена: в
`httpapi` живут три доменных правила и понятие «невыполненный остаток»;
поштучный путь той же защиты не имеет; групповой `Dismiss` либо скопирует
~60 строк, либо тихо обойдётся без них.
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
задачей.** Свойство целиком пре-существующее (поштучное удаление морозит
сервис до 30 с той же причиной); бюджет времени остаётся как ограничение
сверху, сужение замка — отдельная работа со своей спекой.
---
## Стоит исправить сейчас
### 4. При недоступном qBittorrent задача остаётся `done`, хотя раскладки в Jellyfin уже нет, и сама не выправится до подъёма соседа
- Файл: `internal/worker/review.go:634-670`; причина —
`internal/worker/worker.go:635-639`
- Severity: `major`; Confidence: high; проход `ops` (O2)
- **Оракул** — замер: `down (unreachable) Delete: elapsed=524.545µs
err=connection refused`; `bulkFailThreshold=3` останавливает пачку за
миллисекунды (6 ERROR-строк, шторма нет). Сверено триажем: `Poll` начинается с
`w.qbt.Torrents(ctx, "")` и при ошибке возвращается на первой же строке,
поэтому `reconcileDesync` — а с ним коррекция `done → target_missing`, на
которую рассчитывают D9 и спека, — не вызывается ни разу, пока сосед лежит.
- Последствие: ссылки сняты и `file_link` удалены, раздача не снесена, место не
освобождено, состояние говорит `done`. Расхождение живёт весь простой соседа.
- **Действие: развилка.** → **решение человека (2026-08-10): в беклог отдельной
задачей.** Порядок шагов и ранний выход `Poll` — пре-существующие, поштучный
путь ведёт себя ровно так же.
### 5. Bidi-символы в имени раздачи уезжают в экран подтверждения дословно
- Файл: `internal/worker/discover.go:55`, `internal/httpapi/httpapi.go:701-709`
- Severity: `minor`; Confidence: high; проход `adversary` (S3)
- **Оракул** — падающий тест `TestAdversaryConfirmRendersBidiTitleVerbatim`:
U+202E уехал дословно и на `/delete`, и на `/ui/delete/confirm`.
- Последствие: поимённое чтение заголовков — единственный предохранитель
необратимой пачки; подменённый порядок символов делает его ненадёжным.
- **Действие: инлайн.****исправлено**: `displaySafe` снимает `unicode.Cf` и
управляющие на показе (не на записи), оракул добавлен.
### 6. `docs/security.md` объявляет несуществующий пробел
- Файл: `docs/security.md:106-107`
- Severity: `minor`; Confidence: high; проход `adversary` (наблюдение)
- **Оракул** — сверено по исходникам: `internal/llm/openai.go:23`
`maxResponseBody = 8 << 20`, `internal/metadata/http.go:41` — 4 MiB. Документ
утверждал «Лимита на размер ответа LLM нет — известный пробел».
- **Действие: инлайн.****исправлено**: оба лимита названы числами в
`docs/security.md`, вопрос темы в `docs/review.md` переформулирован.
---
## Гипотезы без доказательства
- **S4 (`adversary`, `major` → гипотеза): исходный путь раскладки не проверяется
на принадлежность песочнице.** Тест `TestAdversarySourceEscapesDownloadsSandbox`
разложил `…/config/config.toml` как `…/movies/Дюна (2021)/Дюна (2021).toml`
через `..` в имени файла раздачи. **Вне диффа целиком** и с недостающим
звеном, названным самим проходом: согласится ли qBittorrent отдать в
`/torrents/files` имя с `..` (libtorrent такие пути санитизирует). Уезжает в
урожай задачей.
- **O3 (`ops`, `minor` → гипотеза): страница `/delete` раздувается без
ретеншена.** Замер: 5000 строк → 2 241 604 байта, 29.7 мс. На обозримом
горизонте предела не надо; отсутствие пагинации заказано спекой дословно;
смежная причина стоит задачей `db-retention-cleanup`.
- **A2 (`architecture`, `minor` → снято): «третий способ выбрать загрузки по
набору состояний».** Перечень берётся из единой точки; `ListDownloadsByState`
делает `SELECT *` без `LEFT JOIN recognition` и с другим `ORDER BY`
переиспользованию не подлежит без правки обоих вызывающих.
- **A3 (`architecture`, `minor` → promote): строка загрузки размножена по трём
шаблонам.** Норма говорит о паре «страница ↔ htmx-фрагмент одного
обработчика»; здесь три разные страницы с разной семантикой строки и без htmx.
Второй независимый провенанс повышает приоритет правила, `confidence` — нет.
---
## Promote candidates
- Партиал строки — один на все экраны многостраничной формы (`code`,
`architecture`, независимо).
- Таймаут клиента qBittorrent — из конфига, а не дефолт транспорта: в
`[qbittorrent]` поля нет, в `[llm]`, `[metadata.*]`, `[jellyfin]` — есть
(триаж).
- Комментарий пакета `worker` и `docs/architecture.md` называют `w.mu`
per-download блокировкой, а это единый мьютекс на весь воркер (`ops`).
- `store.State.CanDelete()` — в `docs/architecture.md` → «Единые точки проекта»
рядом с `IsObservable()` (`architecture`).
- Правило именования маршрутов веб-UI: глагол-первым `/delete` против семьи
`/downloads/{id}/…` (`architecture`).
- Дубль построения `IN (?,…)` в `store` — хелпер при третьем появлении
(`architecture`).
- Отвязка необратимой операции от контекста запроса — правило, а не приём:
групповой путь делает `context.WithoutCancel`, поштучный нет (`code`).
- Отказы `store` не проверяются ни одним тестом — как класс (`autotests`).
---
## Границы покрытия
**Метка `large`, режим по графу.** Запущено на этой стадии: `architecture`,
`adversary`, `ops` — все на глубине «доказательство», все вернули отчёт.
**Что не запускалось и почему:** `autotests`, `specs`, `code` — сознательно не
перезапускались после правок `medium`-стадии; цена названа первой строкой
сводки. `basics` — не запускался по плану: все шесть тем ядра разобраны
именными проходами, поэтому возражений о метке от корректора прийти не могло.
**Чего запущенные проходы не могли проверить в принципе:** `architecture` не
судит корректность кода и не строит путей отказа; `adversary` показывает
достижимость, но не частоту; `ops` меряет на синтетическом входе своего
прогона, а не на рабочем потоке.
**Потолки проходов.** Ни один из трёх проходов не сообщил своего потолка.
Молчание неотличимо от «срезать было нечего» — это находка о прогоне. Триажу
пришли сжатые пересказы выводов, а не дословные блоки `Coverage of this pass`.
Потолком триажа не срезано ничего: на входе 10 новых находок, в первые две
секции ушло 6, остальные названы поимённо.
**Что остаётся целиком на человеке:** история инцидентов на umbar; поведение
SQLite под реальным объёмом и профилем нагрузки; завязка внешних потребителей на
новые маршруты; суждение «этой функциональности не должно существовать»;
качество распознавания.
**Перестали проверять сознательно:** идиоматичность Go (проход `idiom`
упразднён 2026-08-04); на метках `small`/`medium` не проверяется ничто,
требующее запуска — здесь метка `large`, и именно поэтому появились находки 1–4;
**на этом прогоне добавилось третье, разовое** — темы `requirements`,
`conventions`, `autotests` не перепроверялись после правок.
**Каких документов не хватило:** `docs/security.md` содержал устаревший факт
(находка 6, исправлено); `docs/architecture.md` и комментарий пакета `worker`
описывают `w.mu` неверно — проходы рассуждали против описания, а не против кода,
и это пришлось сверять триажу. `docs/review.md` → «Типовые ложноположительные» и
журнал дефектов есть и применены.
**Чего в конвейере нет вовсе:** решения проекта (`docs/adr/`) не сверялись —
процессный документ, расхождение ловит сверка документации; записанные
наблюдения (`docs/research/`) не использовались — всякое число снято на этом
прогоне; поимённая сверка с руководствами по стилю языка не задавалась ни одним
проходом; альтернативной реализации, с которой можно сдиффить решения, у
конвейера нет.
**Формулировка «критичных проблем не обнаружено» к этому отчёту не применима:**
проверено ровно то, что перечислено выше, и не проверено ровно то, что
перечислено выше.
---
## Досверка `specs` после правок (закрывает названную выше дыру прогона)
Проход `requirements` перезапущен на изменённом тексте дельт и изменённом коде —
дыра «соответствие кода изменённому тексту спеки не проверял никто» закрыта.
Покрытие: все 6 требований обеих дельт по под-пунктам, `openspec validate
--strict` — valid, тесты затронутых пакетов зелёные.
**Д1 (major, high). Чистка заголовка задела все заголовки веб-UI и снимала
лишнее.** `internal/httpapi/httpapi.go`. Оракул — прогон копии функции:
`"👨‍👩‍👧 family"``"👨👩👧 family"`, `"shah\u200Cname"``"shahname"`,
`"Duna\nchast 2"``"Dunachast 2"`. `unicode.Cf` — это не только
bidi-override, но и ZWJ/ZWNJ; поиск по списку идёт по сохранённому имени, и
скопированное с экрана название своей же записи не нашло бы. Правило нигде не
записано и применено непоследовательно (третья ветка заголовка чистку не
проходила). → **исправлено**: снимается только `unicode.Bidi_Control`,
управляющие заменяются пробелом, чистка применяется ко всем трём веткам, и
заведено MODIFIED-требование «Заголовок загрузки из имени раздачи» со сценарием.
Тест расширен проверкой, что составные эмодзи остаются целыми.
**Д2 (minor, high). Потерянный ответ пачки не восстанавливался по журналу.**
Требование «Исход каждой единицы SHALL попадать в журнал» держалось на воркере,
который отказы по конфликту и отсутствию записи пишет на `DEBUG`, а не начатые
единицы не пишет вовсе; итоговая строка несла только счётчики. → **исправлено**:
транспорт пишет строку `bulk delete item` на `INFO` по каждой единице с исходом
`deleted|failed|skipped` и причиной отказа.
**Д3 (minor, high). Отказ разбора тела формы диагностировался как
«некорректный идентификатор», исходная ошибка не доезжала ни до человека, ни до
лога.** → **исправлено**: отдельный sentinel с своим текстом, исходная ошибка
уходит в приватный канал.
**Поведение вне спеки, снятое заодно:** признак подтверждения и пачка
принимались и из строки запроса (`r.Form`), тогда как требование говорит «в
форме» — читаем только тело (`r.PostForm`).
**Осталось открытым и названо:** текст MODIFIED-требования
`state-reconciliation` обещает, что повторное удаление опирается на «приведённый
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
сверки ожидаемо)». При лежащем qBittorrent это неверно — сверка не доходит до
коррекции (находка 4). Фраза пре-существующая; по решению человека дефект уходит
в беклог отдельной задачей, а текст вливается в спеку как есть.
@@ -0,0 +1,115 @@
## MODIFIED Requirements
### Requirement: Полное удаление загрузки пользователем
Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
Перечень состояний, из которых удаление допустимо, SHALL иметь **единственный
дом** — предикат состояния в модели данных. Транспорты, решающие, показывать
действие или нет, SHALL опираться на него, а не на собственный список. Проверку
допуска в ядре это SHALL NOT отменять: транспорт решает, что показать, ядро — что
допустить, и допуск SHALL держаться без транспорта.
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
данных (`nlink <= 1`). Это осознанный выход за
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
загрузки строго под `paths.movies`/`series`; файлы источника под
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
вызову API с `deleteFiles=true`.
Подтверждение SHALL быть допустимо **одно на пачку** загрузок, когда транспорт
даёт групповое удаление: ослаблением требования это не является, если
подтверждение называет каждую загрузку пачки поимённо. Условия допустимости
групповой путь смягчать SHALL NOT — каждая загрузка пачки проходит те же
проверки и тот же отказ по конфликту, что и при поштучном удалении, а отказ на
одной остальных отменять SHALL NOT.
В отличие от прочих команд, требующих источника, delete синхронный source-preflight
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
сверки ожидаемо).
Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).
#### Scenario: Удаление из done снимает обе стороны и освобождает место
- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
хардлинки существуют
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечные ссылки последнего батча снимаются
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`
#### Scenario: Удаление из orphaned снимает последнюю копию осознанно
- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
единственной копией данных (`nlink <= 1`)
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
(гард последней копии выключен, в отличие от `Undo`)
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
- **AND** задача переходит в `deleted`
#### Scenario: Удаление из target_missing сносит остаточную раздачу
- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
вручную
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** снятие цели идемпотентно (живых ссылок нет)
- **AND** раздача с файлами удаляется из qBittorrent
- **AND** задача переходит в `deleted`
#### Scenario: Удаление требует подтверждения
- **GIVEN** задача в `done`
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`
#### Scenario: Удаление недоступно из прочих состояний
- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
`target_missing`)
- **WHEN** приходит команда «Удалить»
- **THEN** команда отклоняется с конфликтом, состояние не меняется
#### Scenario: Ошибка qBittorrent не метит deleted ложно
- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
на удаление
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** задача в `deleted` не переходит (место не освобождено)
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- **AND** повторный delete идемпотентно дожимает удаление
#### Scenario: Одно подтверждение на пачку не смягчает допуска
- **GIVEN** транспорт даёт групповое удаление, и человек подтвердил пачку, где
каждая загрузка названа поимённо
- **WHEN** одна из загрузок пачки находится в состоянии, из которого удаление
недоступно
- **THEN** по ней приходит тот же отказ по конфликту, что и при поштучном
удалении
- **AND** остальные загрузки пачки удаляются
@@ -0,0 +1,498 @@
## MODIFIED Requirements
### Requirement: Страницы веб-UI
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
добавления, **серверными фильтром по группе состояний, поиском и постраничной
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`),
страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
задач — секцией живой статистики раздачи, а также **страницу группового
удаления загрузок** (`/delete`). Карточки активных (downloading)
загрузок в списке SHALL содержать индикатор прогресса. Карточка загрузки с
распознанным типом SHALL нести значок типа (фильм/сериал). Фильтр, поиск и номер
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
и SHALL работать без клиентского JavaScript. Терминальные состояния `deleted` и
`cancelled` SHALL быть скрыты в списке по умолчанию (переключатель «показать
всё» раскрывает оба). Шапка SHALL нести ссылки на список загрузок и на страницу
группового удаления. Механика живого
обновления прогресса и наполнение секции раздачи определяются capability
`live-status`.
#### Scenario: Просмотр одной загрузки
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
историей
#### Scenario: Прогресс активной загрузки в списке
- **WHEN** в списке есть загрузка в состоянии `downloading`
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
и ETA)
#### Scenario: Отменённые и удалённые скрыты по умолчанию
- **WHEN** в списке есть загрузки в состоянии `deleted` или `cancelled` и фильтр
«показать всё» не включён
- **THEN** они не отображаются, но доступны при включённом переключателе
#### Scenario: Значок типа в карточке списка
- **WHEN** загрузка в списке имеет распознанный тип (`movie` или `series`)
- **THEN** её карточка показывает значок типа (🎬 фильм / 📺 сериал); при
отсутствии распознанного типа значок не показывается
#### Scenario: Пагинация списка
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
страницу, и клиент запрашивает `GET /?page=N`
- **THEN** возвращается N-я страница результатов и элементы навигации по
страницам, сохраняющие текущие фильтр и поисковый запрос
#### Scenario: Серверный фильтр и поиск
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
строкой поиска (`GET /?f=review&q=дюна`)
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
совпадению строки в названии, любом идентификаторе загрузки — `download.id`
ИЛИ infohash — и контексте), отфильтрованные на стороне БД, а не на клиенте
#### Scenario: Ссылка на групповое удаление в шапке
- **WHEN** клиент открывает любую страницу веб-UI
- **THEN** в шапке есть ссылка на страницу группового удаления (`/delete`)
### Requirement: Заголовок загрузки из имени раздачи
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
несколько строк сырым magnet.
Имя раздачи — недоверенный вход, поэтому **на показе** заголовок SHALL терять
управляющие символы направления письма (с ними строка читается не в том
порядке, в каком хранится) и SHALL заменять прочие управляющие пробелом. Прочие
форматирующие символы юникода система снимать SHALL NOT: без соединителей
рассыпаются составные эмодзи и меняется написание имён на ряде письменностей.
Хранимое значение эта чистка менять SHALL NOT — поиск по списку идёт по
сохранённому имени.
#### Scenario: Заголовок из имени раздачи
- **WHEN** у загрузки сохранено отображаемое имя раздачи
- **THEN** карточка и страница показывают это имя заголовком
#### Scenario: Фолбек до распознавания и без имени
- **WHEN** отображаемого имени нет, но есть распознанное название
- **THEN** заголовком служит распознанное название
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
строки сырой источник, а не многострочный magnet
#### Scenario: Переворачивающий символ до показа не доезжает
- **GIVEN** имя раздачи содержит символ переопределения направления письма
- **WHEN** заголовок показывается на любой странице веб-UI
- **THEN** этого символа в разметке нет
- **AND** составные эмодзи в том же имени остаются целыми
### Requirement: Самообновление живой задачи
Карточка списка и страница `/download/{id}` SHALL самообновляться, пока задача
**наблюдаема**, и SHALL прекращать самообновление, как только она наблюдаемой
быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те,
которые фоновая сверка возвращает в поток сама: `failed`, `target_missing`,
`orphaned`. Задача, которую с места двигает только человек (`done`, `cancelled`,
`reverted`, `deleted`), наблюдаемой не является. Признак SHALL жить в домене
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
заводить.
Требование распространяется на **карточку списка `/` и страницу
`/download/{id}`** и на страницу группового удаления (`/delete`)
распространяться SHALL NOT: там строки несут выбор человека, а своп корня унёс
бы отметки вместе с разметкой — и человек подтвердил бы необратимое удаление по
выбору, которого уже не видит. Наблюдаемость самих загрузок этого не отменяет:
строки `/delete` перечисляют в том числе `orphaned` и `target_missing`.
Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST
становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает,
кто изменил состояние.
У одной поверхности SHALL быть **ровно один** источник самообновления. Вложенные
живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT
опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его
поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и
опрашивают одно и то же дважды.
Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых
цифр качания: у поверхности с таким блоком интервал SHALL быть **строго меньше**,
чем у поверхности без него. Числовые значения интервалов живут в документации
проекта, не в спеке.
Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище
отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел
и **не несёт** самообновления: неуспешный ответ не заменяет разметку, поэтому
поверхность осталась бы прежней, а опрос продолжался бы бесконечно.
#### Scenario: Завершение качания видно без перезагрузки
- **GIVEN** открыт список загрузок и в нём есть задача в `downloading`
- **WHEN** qBittorrent довёл раздачу до конца и воркер увёл задачу в
`recognizing` и дальше в `review`
- **THEN** карточка без перезагрузки страницы показывает бейдж ревью и кнопку
«Ревью →»
- **AND** блок живого прогресса с неё исчезает
#### Scenario: Переход, сделанный не из этого браузера
- **GIVEN** открыт список загрузок и в нём есть задача в `review`
- **WHEN** человек подтвердил план из Telegram и задача прошла `linking` в `done`
- **THEN** карточка без перезагрузки страницы показывает бейдж `done` и действия
терминальной задачи
#### Scenario: Ненаблюдаемая задача не опрашивается
- **WHEN** задача находится в `done`, `cancelled`, `reverted` или `deleted`
- **THEN** её карточка и страница `/download/{id}` не несут самообновления, и
фоновых запросов по ним не уходит
#### Scenario: Задача, оживлённая сверкой, видна без перезагрузки
- **GIVEN** открыт список, и в нём есть задача в `failed` (магнет не добрал
метаданные за отведённое время)
- **WHEN** источник ожил и фоновая сверка вернула задачу в `downloading`
- **THEN** карточка без перезагрузки страницы показывает состояние качания
#### Scenario: Один источник обновления на поверхность
- **WHEN** отрисована карточка задачи в `downloading` или страница задачи, чья
раздача сидирует
- **THEN** самообновление объявлено ровно в одном месте поверхности, а вложенные
живые регионы своего опроса не ведут
#### Scenario: Быстрее обновляется то, где есть живые цифры
- **WHEN** рядом отрисованы карточка задачи в `downloading` и карточка задачи в
`review`
- **THEN** объявленный интервал самообновления первой строго меньше, чем у второй
#### Scenario: Тик, который не смог прочитать задачу
- **GIVEN** открыта карточка наблюдаемой задачи
- **WHEN** очередной тик самообновления не нашёл записи или получил отказ
хранилища
- **THEN** ответ успешен и несёт фрагмент с объяснением
- **AND** фрагмент не несёт самообновления, поэтому опрос прекращается
#### Scenario: Группа и фильтр списка пересчитываются навигацией
- **GIVEN** открыт список и в нём есть задача в `downloading`
- **WHEN** задача дошла до терминального состояния на глазах у смотрящего
- **THEN** карточка показывает новое состояние и остаётся на своём месте в
прежней группе списка
- **AND** группа и фильтр пересчитываются при следующей навигации или
перезагрузке — список целиком самообновлением не пересобирается
#### Scenario: Страница группового удаления не самообновляется
- **GIVEN** открыта страница `/delete`, и среди её строк есть загрузки в
`orphaned` и `target_missing` (наблюдаемые состояния)
- **THEN** ни строки, ни страница целиком самообновления не несут, и фоновых
запросов по ним не уходит
## ADDED Requirements
### Requirement: Страница группового удаления загрузок
Веб-UI SHALL предоставлять отдельную страницу (`GET /delete`), перечисляющую
**только** те загрузки, для которых полное удаление с файлами разрешено
поштучно (состояния `done`, `orphaned`, `target_missing` — см.
`state-reconciliation`, «Полное удаление загрузки пользователем»). Загрузки в
прочих состояниях страница показывать SHALL NOT. У каждой строки SHALL быть
чекбокс выбора, отображаемый заголовок загрузки и её состояние; страница SHALL
предлагать одно действие — «Удалить выбранные».
Условие «в этом состоянии удаление разрешено» SHALL вычисляться единой точкой
домена, общей со страницей одной загрузки и с проверкой допуска в ядре;
собственного перечня состояний страница держать SHALL NOT.
Страница SHALL показывать все разрешённые к удалению загрузки без постраничной
выдачи: разбиение на страницы лишило бы возможности выбрать пачку. Верхний
предел размера одной пачки SHALL быть назван **на самой странице**, рядом с
действием: предел, о котором человек узнаёт только из отказа, отнимает уже
сделанный выбор.
Страница SHALL NOT самообновляться опросом сервера — своп разметки стёр бы
выбор человека.
Страница и все её действия SHALL работать без клиентского JavaScript.
#### Scenario: Показаны только разрешённые к удалению
- **WHEN** клиент открывает `GET /delete`, а в хранилище есть загрузки во всех
состояниях
- **THEN** страница содержит строки загрузок в `done`, `orphaned` и
`target_missing`
- **AND** не содержит строк загрузок в прочих состояниях
#### Scenario: Ни одной разрешённой загрузки
- **WHEN** клиент открывает `GET /delete`, а разрешённых к удалению загрузок
нет
- **THEN** страница показывает пустое состояние и не предлагает удаление
#### Scenario: Предел пачки назван до отправки
- **WHEN** клиент открывает `GET /delete` и на странице есть хотя бы одна
строка
- **THEN** страница называет верхний предел числа загрузок в одной пачке
#### Scenario: Страница не опрашивает сервер
- **WHEN** клиент открывает `GET /delete`
- **THEN** разметка страницы не содержит самообновления (`hx-trigger="every …"`)
### Requirement: Групповое удаление требует поимённого подтверждения
Групповое удаление SHALL идти двумя шагами: выбор и **подтверждение**. Шаг
подтверждения SHALL называть каждую выбранную загрузку поимённо — отображаемым
заголовком, идентификатором и **состоянием**, — и SHALL нести признак
подтверждения в форме исполняющего запроса. Для загрузки в состоянии
`orphaned` подтверждение SHALL нести явную отметку, что источник уже пропал и
библиотечная ссылка осталась последней копией данных: гард последней копии в
удалении выключен сознательно, и осведомлённость человека — единственный
оставшийся предохранитель.
**Граница этой отметки названа прямо: она выводится из состояния, а не из
файловой системы.** Случай, когда байты источника исчезли с диска, но раздача
осталась в списке qBittorrent, сверка `done` не переоценивает (присутствие
источника она берёт из списка раздач, а не с диска) — такая загрузка остаётся
`done`, и отметки не получает, хотя библиотечная ссылка уже последняя копия.
Требовать обхода файловой системы на экране подтверждения система SHALL NOT;
непокрытый случай назван здесь, чтобы отметка не читалась как гарантия.
Поштучный путь удаления такой отметки не несёт вовсе.
Запрос группового удаления без признака подтверждения система SHALL отклонять и
SHALL NOT выполнять ни одного удаления. Одно подтверждение SHALL покрывать
ровно ту пачку, которая на нём перечислена.
**Оба запроса — и подтверждение, и исполнение — суть входные границы**, и
проверки входа на них одинаковы: исполняющий запрос получает идентификаторы
формой заново, а не из состояния сервера, поэтому опираться на проверки,
сделанные на шаге подтверждения, он SHALL NOT.
На каждой из этих границ система SHALL:
- разбирать каждый идентификатор; идентификатор, который не разобрался, SHALL
отклонять запрос целиком, а молча пропускать его система SHALL NOT — человек
подтвердил удаление поимённо, и пропуск был бы расхождением с
подтверждённым;
- схлопывать повторы одного идентификатора до одного;
- отклонять запрос целиком при превышении верхнего предела размера пачки, без
единого удаления;
- отклонять запрос с пустым набором идентификаторов: страницу подтверждения без
единой названной загрузки система показывать SHALL NOT, команду удаления не
зовёт ни разу.
Отказ по превышению предела SHALL возвращать страницу выбора с **сохранёнными**
отметками и объяснением, а не пустой экран отказа: иначе проверка отнимает всю
проделанную человеком работу.
Идентификатор, который разобрался, но записи в хранилище не имеет, система
SHALL называть отдельной строкой — на подтверждении и в отчёте — и молча
выбрасывать его SHALL NOT.
Отказ чтения хранилища система SHALL отличать от отсутствия записи и SHALL NOT
выдавать одно за другое: строка, о которой сказано «удалять нечего», а на деле
снесённая с файлами, разводит подтверждённое с исполненным, а для `orphaned`
уносит с экрана отметку о последней копии — единственный оставшийся
предохранитель. Такой идентификатор SHALL получать собственную строку,
называющую, что состояние прочитать не удалось, а сам отказ SHALL уходить в
журнал.
#### Scenario: Подтверждение называет выбранные поимённо
- **WHEN** человек выбирает несколько загрузок и отправляет форму выбора
- **THEN** открывается страница подтверждения, где каждая выбранная загрузка
названа заголовком, идентификатором и состоянием
- **AND** удаление ещё не выполнено
#### Scenario: Подтверждение предупреждает о последней копии
- **GIVEN** среди выбранных есть загрузка в состоянии `orphaned`
- **WHEN** открывается страница подтверждения
- **THEN** её строка несёт отметку, что библиотечная ссылка осталась последней
копией данных
#### Scenario: Без подтверждения не удаляется ничего
- **GIVEN** выбраны разрешённые к удалению загрузки
- **WHEN** приходит запрос группового удаления без признака подтверждения
- **THEN** запрос отклоняется с объяснением
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Неразобранный идентификатор отклоняет запрос
- **WHEN** в пачке приходит идентификатор, который не разбирается
- **THEN** запрос отклоняется целиком
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Гарды исполняющего запроса не слабее гардов подтверждения
- **WHEN** исполняющий запрос приходит с признаком подтверждения, но с
неразобранным идентификатором, либо с пачкой сверх предела, либо с пустым
набором
- **THEN** он отклоняется тем же отказом, что и на шаге подтверждения
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Пачка сверх предела отклоняется и не стирает выбор
- **WHEN** в пачке приходит больше идентификаторов, чем допускает предел
- **THEN** запрос отклоняется с указанием предела
- **AND** команда удаления не вызывается ни по одной загрузке
- **AND** ответ возвращает страницу выбора с сохранёнными отметками
#### Scenario: Пустой выбор
- **WHEN** человек отправляет форму, не отметив ни одной загрузки
- **THEN** страница подтверждения не показывается, ответ объясняет, что выбирать
нечего
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Отказ чтения не выдаётся за отсутствие записи
- **GIVEN** в пачке есть идентификатор, чтение которого отказало (не «записи
нет», а отказ хранилища)
- **WHEN** открывается страница подтверждения
- **THEN** его строка говорит, что состояние прочитать не удалось, и не
утверждает, что удалять нечего
- **AND** отказ записан в журнал
#### Scenario: Идентификатор без записи назван строкой
- **GIVEN** в пачке из трёх идентификаторов один не имеет записи в хранилище
- **WHEN** открывается страница подтверждения, а затем выполняется удаление
- **THEN** этот идентификатор назван отдельной строкой и на подтверждении, и в
отчёте
- **AND** остальные две загрузки удалены
### Requirement: Исход группового удаления назван поимённо
Групповое удаление SHALL выполнять команду удаления по каждой подтверждённой
загрузке **последовательно и независимо**: отказ на одной загрузке остальных
отменять SHALL NOT. По завершении система SHALL показать страницу результата,
называющую поимённо удалённые загрузки и отказавшие — каждую с причиной отказа.
Отчёт SHALL отдаваться **ответом на исполняющий запрос**, а не перенаправлением:
поимённый исход нечем передать через параметры адреса, а сессий у сервиса нет.
Повторная отправка той же формы удалённые загрузки повторно сносить SHALL NOT —
они находятся в терминальном `deleted`, удаление им недоступно, и повторный
запрос даёт по ним отказ по конфликту. Остаток, не выполненный из-за остановки
прохода, повторная отправка **доисполняет**, и это ожидаемо: эти загрузки
человек подтвердил тем же подтверждением, а браузер о повторной отправке
переспрашивает сам. Утверждать, что повтор ничего не делает, система SHALL NOT.
Исполнение пачки система SHALL доводить до конца независимо от того, дождался
ли клиент ответа: отмена HTTP-запроса (закрытая вкладка, обрыв связи)
прекращать необратимую операцию на середине SHALL NOT. Исход каждой единицы
SHALL попадать в журнал, чтобы факт «что именно снесено» пережил потерю ответа.
**Проход ограничен сверху временем.** Удаление удерживает общий замок ядра на
всё время обращения к qBittorrent, поэтому медленно, но **успешно** отвечающий
внешний сервис останавливает фоновую работу сервиса целиком, а порог отказов
такого не ловит — он считает только ошибки. Система SHALL держать потолок
времени на один проход и по его исчерпании SHALL прекращать проход, называя
остаток в отчёте невыполненным. Потолок SHALL проверяться **между** единицами:
начатое удаление обрывать SHALL NOT — оборванное, оно встанет между снятием
библиотечных ссылок и сносом раздачи.
**Системный отказ пачку останавливает.** Удаление снимает библиотечные ссылки
раньше, чем сносит раздачу, поэтому при недоступном qBittorrent каждая единица
успевает выполнить необратимый локальный шаг и падает на внешнем: тайтл уходит
из библиотеки, а место не освобождается. Поэтому после **порога подряд идущих
отказов внешнего сервиса** (отказ, который не является конфликтом состояния)
система SHALL прекращать проход, а остаток подтверждённой пачки SHALL называть
в отчёте невыполненным с причиной остановки. Счётчик подряд идущих отказов
SHALL сбрасываться на каждом успешном удалении: одиночная сетевая ошибка пачку
прерывать SHALL NOT. Системными SHALL NOT считаться два класса отказа — конфликт
состояния и отсутствие записи: оба про саму задачу, а не про доступность соседа,
и до внешнего сервиса такой вызов вообще не доходит.
Причина отказа SHALL передаваться публичным каналом (нейтральное сообщение),
сырой текст ошибки наружу уходить SHALL NOT.
Групповой путь прав поштучного расширять SHALL NOT: допуск по состоянию
проверяет ядро в момент операции, и загрузка в недопустимом состоянии SHALL
отклоняться тем же конфликтом, что и при поштучном удалении.
#### Scenario: Отказ одной не отменяет остальных
- **GIVEN** подтверждены три загрузки, и удаление второй из них отказывает
- **WHEN** выполняется групповое удаление
- **THEN** первая и третья удалены
- **AND** страница результата называет вторую и причину её отказа
#### Scenario: Недопустимое состояние отклоняется тем же конфликтом
- **GIVEN** в подтверждённой пачке есть загрузка в состоянии, из которого
удаление недоступно
- **WHEN** выполняется групповое удаление
- **THEN** по этой загрузке приходит отказ по конфликту состояния, и она
попадает в отчёт строкой отказа
- **AND** остальные подтверждённые загрузки удалены
#### Scenario: Все удалены успешно
- **GIVEN** подтверждены две загрузки, обе в разрешённом состоянии
- **WHEN** выполняется групповое удаление
- **THEN** страница результата называет обе как удалённые и не содержит отказов
#### Scenario: Обрыв связи не останавливает пачку
- **GIVEN** подтверждена пачка загрузок, и исполнение началось
- **WHEN** клиент обрывает запрос до получения ответа
- **THEN** удаление доводится по всем подтверждённым загрузкам
- **AND** исход каждой из них остаётся в журнале
#### Scenario: Потолок времени останавливает проход
- **GIVEN** подтверждена пачка, а удаление каждой единицы идёт долго
- **WHEN** отведённое на проход время исчерпано
- **THEN** новых удалений не начинается, а остаток назван в отчёте
невыполненным с причиной остановки
- **AND** удаление, начатое до исчерпания, доводится до конца
#### Scenario: Повтор доисполняет невыполненный остаток
- **GIVEN** проход был остановлен, и часть пачки осталась невыполненной
- **WHEN** человек отправляет ту же форму повторно
- **THEN** уже удалённые загрузки повторно не сносятся (отказ по конфликту)
- **AND** невыполненный остаток удаляется
#### Scenario: Недоступный внешний сервис останавливает пачку
- **GIVEN** подтверждена пачка загрузок, а qBittorrent недоступен
- **WHEN** выполняется групповое удаление и отказы внешнего сервиса идут подряд
- **THEN** после достижения порога подряд идущих отказов проход прекращается
- **AND** остаток пачки назван в отчёте невыполненным с причиной остановки
#### Scenario: Одиночный отказ пачку не прерывает
- **GIVEN** подтверждены четыре загрузки, и отказ внешнего сервиса приходит
только по второй
- **WHEN** выполняется групповое удаление
- **THEN** проход доходит до конца, удалены первая, третья и четвёртая
- **AND** отчёт называет отказавшей только вторую
#### Scenario: Сырая ошибка наружу не уходит
- **GIVEN** удаление одной из загрузок отказало ошибкой внешнего сервиса
- **WHEN** отрисовывается страница результата
- **THEN** её строка несёт нейтральное сообщение публичного канала, а не текст
ошибки внешнего сервиса
@@ -0,0 +1,151 @@
## 1. Единая точка допуска
- [x] 1.1 Завести `(store.State).CanDelete()` рядом с `IsTerminal`/
`IsObservable`, перечень `done`/`orphaned`/`target_missing` — одним списком
- [x] 1.2 Перевести `switch` в `worker.Delete` на `CanDelete` (проверку в ядре
не снимать)
- [x] 1.3 Перевести сборку `Deletable` в `internal/httpapi/download.go` на
`CanDelete`
- [x] 1.4 Перевести выбор клавиатуры в `internal/tgbot/render.go` на
`CanDelete` — иначе Telegram остаётся четвёртым перечнем состояний
- [x] 1.5 Тест: `CanDelete` истинно ровно для трёх состояний и ложно для
остальных (перебор всех состояний)
- [x] 1.6 Тест `internal/tgbot`: клавиатура с действием удаления приходит ровно
для тех состояний, где `CanDelete` истинно
## 2. Чтение списка разрешённых к удалению
- [x] 2.1 Добавить в `Reader` метод выборки загрузок, разрешённых к удалению,
без пагинации; порядок — как в основном списке
- [x] 2.2 Реализовать выборку в `store` через существующую механику фильтра по
состояниям (перечень состояний берётся из единой точки, второго списка не
заводить)
- [x] 2.3 Тест `store`: выборка возвращает только `done`/`orphaned`/
`target_missing`
## 3. Страница выбора
- [x] 3.1 Шаблон страницы `/delete`: строки с чекбоксами, заголовок, состояние
каждой загрузки, кнопка «Удалить выбранные», названный предел размера пачки
рядом с кнопкой, пустое состояние
- [x] 3.2 Ссылка на страницу в `web/templates/partials/header.html`, активный
пункт навигации
- [x] 3.3 Обработчик `GET /delete`: сборка представления, без `hx-*`
самообновления
- [x] 3.4 Тест: подставной читатель отдаёт задачи во всех состояниях — в
разметке есть строки только у `done`/`orphaned`/`target_missing`
- [x] 3.5 Тест: в разметке страницы нет `hx-trigger="every`
- [x] 3.6 Тест: страница называет предел размера пачки
- [x] 3.7 Тест: пустое состояние — нет разрешённых, удаление не предлагается
## 4. Разбор входа (общий для обеих границ)
- [x] 4.1 Общая функция разбора пачки: `ident.Parse` по каждому идентификатору
с отказом всего запроса, схлопывание дублей, проверка предела, отказ на пустом
наборе. Предел — именованная константа, значение **20**
- [x] 4.2 Обработчик `POST /ui/delete/confirm`: разбор через общую функцию,
чтение выбранных загрузок; ненайденная запись — строка «загрузка не найдена»,
а не молчаливый пропуск
- [x] 4.3 Отказ по пределу возвращает страницу выбора с сохранёнными отметками
и объяснением
- [x] 4.4 Шаблон страницы подтверждения: каждая выбранная загрузка названа
заголовком, идентификатором и состоянием; для `orphaned` — отметка «источник
пропал, библиотечная ссылка — последняя копия данных»; скрытые поля с
идентификаторами, признак подтверждения, кнопка исполнения и ссылка возврата
- [x] 4.5 Тест: страница подтверждения содержит заголовки всех выбранных и не
делает ни одного вызова `Delete`
- [x] 4.6 Тест: строка `orphaned` на подтверждении несёт отметку о последней
копии
- [x] 4.7 Тест: пачка сверх предела отклоняется целиком, `Delete` не зовётся, а
ответ несёт страницу выбора с сохранёнными отметками
- [x] 4.8 Тест: неразобранный идентификатор отклоняет запрос целиком
- [x] 4.9 Тест: пустой выбор — подтверждение не показывается, `Delete` не
зовётся
- [x] 4.10 Тест: идентификатор без записи назван строкой на подтверждении и в
отчёте, остальные загрузки удалены
## 5. Исполнение и отчёт
- [x] 5.1 Обработчик `POST /ui/delete`: отказ без признака подтверждения **до**
цикла, ни одного вызова `Delete`
- [x] 5.2 Тот же разбор входа, что и на подтверждении (пункт 4.1): исполняющий
запрос — самостоятельная входная граница
- [x] 5.3 Последовательный вызов `Reviewer.Delete` по каждой подтверждённой
загрузке, сбор исхода по каждой; контекст исполнения отвязан от `r.Context()`
(`context.WithoutCancel`); ошибка — через публичный канал (`userErr`)
- [x] 5.3a Потолок времени на проход (`bulkBudget`, 2 минуты), проверяемый
между единицами: начатое удаление не обрывается. Остаток — в отчёт строками
«не выполнено»
- [x] 5.4 Остановка прохода после **трёх подряд** отказов внешнего сервиса
(`ErrConflict` системным не считается и счётчик не двигает; успех счётчик
сбрасывает); остаток пачки — в отчёт строками «не выполнено» с причиной
остановки. Порог — именованная константа
- [x] 5.5 Шаблон страницы результата: удалённые поимённо, отказавшие поимённо с
причиной, невыполненный остаток и причина остановки, ссылка обратно на
`/delete`
- [x] 5.6 Тест: POST без признака подтверждения — отказ, ноль вызовов `Delete`
- [x] 5.7 Тест: гарды исполняющего запроса не слабее гардов подтверждения —
неразобранный идентификатор, пачка сверх предела и пустой набор отклоняются с
признаком подтверждения тоже, `Delete` не зовётся
- [x] 5.8 Тест: второй `Delete` возвращает ошибку — первая и третья удалены,
страница результата называет вторую и её причину
- [x] 5.9 Тест: `Delete` для `downloading` возвращает `ErrConflict` — страница
показывает отказ по ней, остальные выбранные удалены
- [x] 5.10 Тест: все удалены — отчёт называет обе как удалённые, отказов нет
- [x] 5.11 Тест: отмена запроса не прекращает пачку — `Delete` вызван по всем
подтверждённым
- [x] 5.12 Тест: сырой текст ошибки внешнего сервиса в разметку не попадает
- [x] 5.13 Тест: страница и действия работают без htmx (обычные POST-формы,
`action` рабочий)
## 6. Сдача
- [x] 6.1 `openspec validate --strict bulk-delete-page`
- [x] 6.2 `task gate` зелёный
- [x] 6.3 Поведенческая проверка вживую: поднять `task run`, пройти путь
список → подтверждение → результат
- [x] 6.4 Три числовых порога (размер пачки, число подряд идущих отказов,
потолок времени на проход) — в `docs/database.md`, раздел настроек с
числовым значением
## Критерии приёмки (из постановки)
- [x] П1 Страница открывается из шапки и показывает только те загрузки, для
которых удаление разрешено поштучно (оракул: тест `internal/httpapi`
подставной читатель отдаёт задачи во всех состояниях, в разметке строки есть
у `done`/`orphaned`/`target_missing` и нет у остальных)
- [x] П2 Удаление уходит только после явного подтверждения, и подтверждение
называет каждую выбранную раздачу поимённо (оракул: тест — POST без признака
подтверждения отвечает отказом и не делает ни одного вызова `Delete` у
подставного воркера; ответ подтверждения содержит заголовки всех выбранных)
- [x] П3 Отказ на одной загрузке не отменяет остальных, а результат называет
удалённые и отказавшие поимённо с причиной (оракул: тест, где второй `Delete`
возвращает ошибку — первая и третья удалены, страница результата называет
вторую и её причину)
- [x] П4 Групповой путь не расширяет прав поштучного (оракул: тест — `Delete`
для `downloading` возвращает `ErrConflict`, страница показывает отказ,
остальные выбранные не затронуты)
- [x] П5 Поведение страницы записано дельта-спекой и проходит валидацию
(оракул: `openspec validate --strict` и `task gate`)
## Приёмочные критерии из рубрики ревью дизайна
- [x] Р1 Исполняется ровно подтверждённое множество, названное списком
идентификаторов, а не предикатом «всё, что сейчас в состоянии X» (оракул:
тест — задача, ставшая разрешённой после показа подтверждения, не удаляется)
- [x] Р2 Множество допустимых состояний имеет единственный дом, и групповой путь
прав не расширяет (оракул: пункты 1.5, 1.6, 5.9)
- [x] Р3 Экран подтверждения называет не только предмет, но и последствие —
состояние и снятие последней копии для `orphaned` (оракул: пункт 4.6)
- [x] Р4 Вход валидируется целиком до первого эффекта, на обеих границах
(оракул: пункты 4.7–4.9, 5.7)
- [x] Р5 Частичный отказ не отменяет остальных, повтор идемпотентен (оракул:
пункты 5.8, 5.9; повтор — уже `deleted`, `CanDelete` ложно)
- [x] Р6 Обрыв или отмена запроса не оставляет пачку на середине, исход каждой
единицы переживает потерю ответа (оракул: пункт 5.11 и `logCmd` по каждому
вызову)
- [x] Р7 Предел пачки виден там, где формируется вход, и отказ по нему не стирает
выбор (оракул: пункты 3.6, 4.7)
- [x] Р8 Транспорт доменной логики не содержит: зовётся тот же
`Reviewer.Delete`, переход состояния — в воркере (оракул: чтение диффа на
ревью кода)
@@ -455,6 +455,12 @@ SHALL задевать раскладку в полёте.
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT. задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.
Перечень состояний, из которых удаление допустимо, SHALL иметь **единственный
дом** — предикат состояния в модели данных. Транспорты, решающие, показывать
действие или нет, SHALL опираться на него, а не на собственный список. Проверку
допуска в ядре это SHALL NOT отменять: транспорт решает, что показать, ядро — что
допустить, и допуск SHALL держаться без транспорта.
Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`: Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с `superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
@@ -467,6 +473,13 @@ SHALL задевать раскладку в полёте.
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по `paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
вызову API с `deleteFiles=true`. вызову API с `deleteFiles=true`.
Подтверждение SHALL быть допустимо **одно на пачку** загрузок, когда транспорт
даёт групповое удаление: ослаблением требования это не является, если
подтверждение называет каждую загрузку пачки поимённо. Условия допустимости
групповой путь смягчать SHALL NOT — каждая загрузка пачки проходит те же
проверки и тот же отказ по конфликту, что и при поштучном удалении, а отказ на
одной остальных отменять SHALL NOT.
В отличие от прочих команд, требующих источника, delete синхронный source-preflight В отличие от прочих команд, требующих источника, delete синхронный source-preflight
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
@@ -538,6 +551,16 @@ SHALL задевать раскладку в полёте.
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех) - **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- **AND** повторный delete идемпотентно дожимает удаление - **AND** повторный delete идемпотентно дожимает удаление
#### Scenario: Одно подтверждение на пачку не смягчает допуска
- **GIVEN** транспорт даёт групповое удаление, и человек подтвердил пачку, где
каждая загрузка названа поимённо
- **WHEN** одна из загрузок пачки находится в состоянии, из которого удаление
недоступно
- **THEN** по ней приходит тот же отказ по конфликту, что и при поштучном
удалении
- **AND** остальные загрузки пачки удаляются
### Requirement: Ручное закрытие загрузки (стоп-кран) ### Requirement: Ручное закрытие загрузки (стоп-кран)
Система SHALL давать пользователю возможность **закрыть** загрузку — перевести Система SHALL давать пользователю возможность **закрыть** загрузку — перевести
+328 -4
View File
@@ -89,16 +89,18 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
добавления, **серверными фильтром по группе состояний, поиском и постраничной добавления, **серверными фильтром по группе состояний, поиском и постраничной
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`) выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`),
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием, страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
задач — секцией живой статистики раздачи. Карточки активных (downloading) задач — секцией живой статистики раздачи, а также **страницу группового
удаления загрузок** (`/delete`). Карточки активных (downloading)
загрузок в списке SHALL содержать индикатор прогресса. Карточка загрузки с загрузок в списке SHALL содержать индикатор прогресса. Карточка загрузки с
распознанным типом SHALL нести значок типа (фильм/сериал). Фильтр, поиск и номер распознанным типом SHALL нести значок типа (фильм/сериал). Фильтр, поиск и номер
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`) страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
и SHALL работать без клиентского JavaScript. Терминальные состояния `deleted` и и SHALL работать без клиентского JavaScript. Терминальные состояния `deleted` и
`cancelled` SHALL быть скрыты в списке по умолчанию (переключатель «показать `cancelled` SHALL быть скрыты в списке по умолчанию (переключатель «показать
всё» раскрывает оба). Механика живого всё» раскрывает оба). Шапка SHALL нести ссылки на список загрузок и на страницу
группового удаления. Механика живого
обновления прогресса и наполнение секции раздачи определяются capability обновления прогресса и наполнение секции раздачи определяются capability
`live-status`. `live-status`.
@@ -141,6 +143,11 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
совпадению строки в названии, любом идентификаторе загрузки — `download.id` совпадению строки в названии, любом идентификаторе загрузки — `download.id`
ИЛИ infohash — и контексте), отфильтрованные на стороне БД, а не на клиенте ИЛИ infohash — и контексте), отфильтрованные на стороне БД, а не на клиенте
#### Scenario: Ссылка на групповое удаление в шапке
- **WHEN** клиент открывает любую страницу веб-UI
- **THEN** в шапке есть ссылка на страницу группового удаления (`/delete`)
### Requirement: Порядок списка загрузок ### Requirement: Порядок списка загрузок
Список загрузок SHALL сортироваться по времени добавления торрента в источник Список загрузок SHALL сортироваться по времени добавления торрента в источник
@@ -170,6 +177,14 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
несколько строк сырым magnet. несколько строк сырым magnet.
Имя раздачи — недоверенный вход, поэтому **на показе** заголовок SHALL терять
управляющие символы направления письма (с ними строка читается не в том
порядке, в каком хранится) и SHALL заменять прочие управляющие пробелом. Прочие
форматирующие символы юникода система снимать SHALL NOT: без соединителей
рассыпаются составные эмодзи и меняется написание имён на ряде письменностей.
Хранимое значение эта чистка менять SHALL NOT — поиск по списку идёт по
сохранённому имени.
#### Scenario: Заголовок из имени раздачи #### Scenario: Заголовок из имени раздачи
- **WHEN** у загрузки сохранено отображаемое имя раздачи - **WHEN** у загрузки сохранено отображаемое имя раздачи
@@ -182,6 +197,13 @@ jellybit (`created_at`). Порядок MUST быть согласован ме
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной - **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
строки сырой источник, а не многострочный magnet строки сырой источник, а не многострочный magnet
#### Scenario: Переворачивающий символ до показа не доезжает
- **GIVEN** имя раздачи содержит символ переопределения направления письма
- **WHEN** заголовок показывается на любой странице веб-UI
- **THEN** этого символа в разметке нет
- **AND** составные эмодзи в том же имени остаются целыми
### Requirement: Действие «Обновить имя» на странице загрузки ### Requirement: Действие «Обновить имя» на странице загрузки
Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие
@@ -491,6 +513,13 @@ PRG-редиректом, и действие исполняется тем же
рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT
заводить. заводить.
Требование распространяется на **карточку списка `/` и страницу
`/download/{id}`** и на страницу группового удаления (`/delete`)
распространяться SHALL NOT: там строки несут выбор человека, а своп корня унёс
бы отметки вместе с разметкой — и человек подтвердил бы необратимое удаление по
выбору, которого уже не видит. Наблюдаемость самих загрузок этого не отменяет:
строки `/delete` перечисляют в том числе `orphaned` и `target_missing`.
Самообновление SHALL приносить смену состояния целиком — бейдж статуса, Самообновление SHALL приносить смену состояния целиком — бейдж статуса,
заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT
сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия
@@ -573,6 +602,13 @@ PRG-редиректом, и действие исполняется тем же
- **AND** группа и фильтр пересчитываются при следующей навигации или - **AND** группа и фильтр пересчитываются при следующей навигации или
перезагрузке — список целиком самообновлением не пересобирается перезагрузке — список целиком самообновлением не пересобирается
#### Scenario: Страница группового удаления не самообновляется
- **GIVEN** открыта страница `/delete`, и среди её строк есть загрузки в
`orphaned` и `target_missing` (наблюдаемые состояния)
- **THEN** ни строки, ни страница целиком самообновления не несут, и фоновых
запросов по ним не уходит
### Requirement: Отображение промежуточного состояния catched ### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
@@ -682,3 +718,291 @@ htmx-путь (список обновляется/происходит реди
- **WHEN** клиент открывает `GET /download/{id}` - **WHEN** клиент открывает `GET /download/{id}`
- **THEN** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается - **THEN** поле «Режиссёр» показывает прочерк, а вёрстка блока не ломается
### Requirement: Страница группового удаления загрузок
Веб-UI SHALL предоставлять отдельную страницу (`GET /delete`), перечисляющую
**только** те загрузки, для которых полное удаление с файлами разрешено
поштучно (состояния `done`, `orphaned`, `target_missing` — см.
`state-reconciliation`, «Полное удаление загрузки пользователем»). Загрузки в
прочих состояниях страница показывать SHALL NOT. У каждой строки SHALL быть
чекбокс выбора, отображаемый заголовок загрузки и её состояние; страница SHALL
предлагать одно действие — «Удалить выбранные».
Условие «в этом состоянии удаление разрешено» SHALL вычисляться единой точкой
домена, общей со страницей одной загрузки и с проверкой допуска в ядре;
собственного перечня состояний страница держать SHALL NOT.
Страница SHALL показывать все разрешённые к удалению загрузки без постраничной
выдачи: разбиение на страницы лишило бы возможности выбрать пачку. Верхний
предел размера одной пачки SHALL быть назван **на самой странице**, рядом с
действием: предел, о котором человек узнаёт только из отказа, отнимает уже
сделанный выбор.
Страница SHALL NOT самообновляться опросом сервера — своп разметки стёр бы
выбор человека.
Страница и все её действия SHALL работать без клиентского JavaScript.
#### Scenario: Показаны только разрешённые к удалению
- **WHEN** клиент открывает `GET /delete`, а в хранилище есть загрузки во всех
состояниях
- **THEN** страница содержит строки загрузок в `done`, `orphaned` и
`target_missing`
- **AND** не содержит строк загрузок в прочих состояниях
#### Scenario: Ни одной разрешённой загрузки
- **WHEN** клиент открывает `GET /delete`, а разрешённых к удалению загрузок
нет
- **THEN** страница показывает пустое состояние и не предлагает удаление
#### Scenario: Предел пачки назван до отправки
- **WHEN** клиент открывает `GET /delete` и на странице есть хотя бы одна
строка
- **THEN** страница называет верхний предел числа загрузок в одной пачке
#### Scenario: Страница не опрашивает сервер
- **WHEN** клиент открывает `GET /delete`
- **THEN** разметка страницы не содержит самообновления (`hx-trigger="every …"`)
### Requirement: Групповое удаление требует поимённого подтверждения
Групповое удаление SHALL идти двумя шагами: выбор и **подтверждение**. Шаг
подтверждения SHALL называть каждую выбранную загрузку поимённо — отображаемым
заголовком, идентификатором и **состоянием**, — и SHALL нести признак
подтверждения в форме исполняющего запроса. Для загрузки в состоянии
`orphaned` подтверждение SHALL нести явную отметку, что источник уже пропал и
библиотечная ссылка осталась последней копией данных: гард последней копии в
удалении выключен сознательно, и осведомлённость человека — единственный
оставшийся предохранитель.
**Граница этой отметки названа прямо: она выводится из состояния, а не из
файловой системы.** Случай, когда байты источника исчезли с диска, но раздача
осталась в списке qBittorrent, сверка `done` не переоценивает (присутствие
источника она берёт из списка раздач, а не с диска) — такая загрузка остаётся
`done`, и отметки не получает, хотя библиотечная ссылка уже последняя копия.
Требовать обхода файловой системы на экране подтверждения система SHALL NOT;
непокрытый случай назван здесь, чтобы отметка не читалась как гарантия.
Поштучный путь удаления такой отметки не несёт вовсе.
Запрос группового удаления без признака подтверждения система SHALL отклонять и
SHALL NOT выполнять ни одного удаления. Одно подтверждение SHALL покрывать
ровно ту пачку, которая на нём перечислена.
**Оба запроса — и подтверждение, и исполнение — суть входные границы**, и
проверки входа на них одинаковы: исполняющий запрос получает идентификаторы
формой заново, а не из состояния сервера, поэтому опираться на проверки,
сделанные на шаге подтверждения, он SHALL NOT.
На каждой из этих границ система SHALL:
- разбирать каждый идентификатор; идентификатор, который не разобрался, SHALL
отклонять запрос целиком, а молча пропускать его система SHALL NOT — человек
подтвердил удаление поимённо, и пропуск был бы расхождением с
подтверждённым;
- схлопывать повторы одного идентификатора до одного;
- отклонять запрос целиком при превышении верхнего предела размера пачки, без
единого удаления;
- отклонять запрос с пустым набором идентификаторов: страницу подтверждения без
единой названной загрузки система показывать SHALL NOT, команду удаления не
зовёт ни разу.
Отказ по превышению предела SHALL возвращать страницу выбора с **сохранёнными**
отметками и объяснением, а не пустой экран отказа: иначе проверка отнимает всю
проделанную человеком работу.
Идентификатор, который разобрался, но записи в хранилище не имеет, система
SHALL называть отдельной строкой — на подтверждении и в отчёте — и молча
выбрасывать его SHALL NOT.
Отказ чтения хранилища система SHALL отличать от отсутствия записи и SHALL NOT
выдавать одно за другое: строка, о которой сказано «удалять нечего», а на деле
снесённая с файлами, разводит подтверждённое с исполненным, а для `orphaned`
уносит с экрана отметку о последней копии — единственный оставшийся
предохранитель. Такой идентификатор SHALL получать собственную строку,
называющую, что состояние прочитать не удалось, а сам отказ SHALL уходить в
журнал.
#### Scenario: Подтверждение называет выбранные поимённо
- **WHEN** человек выбирает несколько загрузок и отправляет форму выбора
- **THEN** открывается страница подтверждения, где каждая выбранная загрузка
названа заголовком, идентификатором и состоянием
- **AND** удаление ещё не выполнено
#### Scenario: Подтверждение предупреждает о последней копии
- **GIVEN** среди выбранных есть загрузка в состоянии `orphaned`
- **WHEN** открывается страница подтверждения
- **THEN** её строка несёт отметку, что библиотечная ссылка осталась последней
копией данных
#### Scenario: Без подтверждения не удаляется ничего
- **GIVEN** выбраны разрешённые к удалению загрузки
- **WHEN** приходит запрос группового удаления без признака подтверждения
- **THEN** запрос отклоняется с объяснением
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Неразобранный идентификатор отклоняет запрос
- **WHEN** в пачке приходит идентификатор, который не разбирается
- **THEN** запрос отклоняется целиком
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Гарды исполняющего запроса не слабее гардов подтверждения
- **WHEN** исполняющий запрос приходит с признаком подтверждения, но с
неразобранным идентификатором, либо с пачкой сверх предела, либо с пустым
набором
- **THEN** он отклоняется тем же отказом, что и на шаге подтверждения
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Пачка сверх предела отклоняется и не стирает выбор
- **WHEN** в пачке приходит больше идентификаторов, чем допускает предел
- **THEN** запрос отклоняется с указанием предела
- **AND** команда удаления не вызывается ни по одной загрузке
- **AND** ответ возвращает страницу выбора с сохранёнными отметками
#### Scenario: Пустой выбор
- **WHEN** человек отправляет форму, не отметив ни одной загрузки
- **THEN** страница подтверждения не показывается, ответ объясняет, что выбирать
нечего
- **AND** команда удаления не вызывается ни по одной загрузке
#### Scenario: Отказ чтения не выдаётся за отсутствие записи
- **GIVEN** в пачке есть идентификатор, чтение которого отказало (не «записи
нет», а отказ хранилища)
- **WHEN** открывается страница подтверждения
- **THEN** его строка говорит, что состояние прочитать не удалось, и не
утверждает, что удалять нечего
- **AND** отказ записан в журнал
#### Scenario: Идентификатор без записи назван строкой
- **GIVEN** в пачке из трёх идентификаторов один не имеет записи в хранилище
- **WHEN** открывается страница подтверждения, а затем выполняется удаление
- **THEN** этот идентификатор назван отдельной строкой и на подтверждении, и в
отчёте
- **AND** остальные две загрузки удалены
### Requirement: Исход группового удаления назван поимённо
Групповое удаление SHALL выполнять команду удаления по каждой подтверждённой
загрузке **последовательно и независимо**: отказ на одной загрузке остальных
отменять SHALL NOT. По завершении система SHALL показать страницу результата,
называющую поимённо удалённые загрузки и отказавшие — каждую с причиной отказа.
Отчёт SHALL отдаваться **ответом на исполняющий запрос**, а не перенаправлением:
поимённый исход нечем передать через параметры адреса, а сессий у сервиса нет.
Повторная отправка той же формы удалённые загрузки повторно сносить SHALL NOT —
они находятся в терминальном `deleted`, удаление им недоступно, и повторный
запрос даёт по ним отказ по конфликту. Остаток, не выполненный из-за остановки
прохода, повторная отправка **доисполняет**, и это ожидаемо: эти загрузки
человек подтвердил тем же подтверждением, а браузер о повторной отправке
переспрашивает сам. Утверждать, что повтор ничего не делает, система SHALL NOT.
Исполнение пачки система SHALL доводить до конца независимо от того, дождался
ли клиент ответа: отмена HTTP-запроса (закрытая вкладка, обрыв связи)
прекращать необратимую операцию на середине SHALL NOT. Исход каждой единицы
SHALL попадать в журнал, чтобы факт «что именно снесено» пережил потерю ответа.
**Проход ограничен сверху временем.** Удаление удерживает общий замок ядра на
всё время обращения к qBittorrent, поэтому медленно, но **успешно** отвечающий
внешний сервис останавливает фоновую работу сервиса целиком, а порог отказов
такого не ловит — он считает только ошибки. Система SHALL держать потолок
времени на один проход и по его исчерпании SHALL прекращать проход, называя
остаток в отчёте невыполненным. Потолок SHALL проверяться **между** единицами:
начатое удаление обрывать SHALL NOT — оборванное, оно встанет между снятием
библиотечных ссылок и сносом раздачи.
**Системный отказ пачку останавливает.** Удаление снимает библиотечные ссылки
раньше, чем сносит раздачу, поэтому при недоступном qBittorrent каждая единица
успевает выполнить необратимый локальный шаг и падает на внешнем: тайтл уходит
из библиотеки, а место не освобождается. Поэтому после **порога подряд идущих
отказов внешнего сервиса** (отказ, который не является конфликтом состояния)
система SHALL прекращать проход, а остаток подтверждённой пачки SHALL называть
в отчёте невыполненным с причиной остановки. Счётчик подряд идущих отказов
SHALL сбрасываться на каждом успешном удалении: одиночная сетевая ошибка пачку
прерывать SHALL NOT. Системными SHALL NOT считаться два класса отказа — конфликт
состояния и отсутствие записи: оба про саму задачу, а не про доступность соседа,
и до внешнего сервиса такой вызов вообще не доходит.
Причина отказа SHALL передаваться публичным каналом (нейтральное сообщение),
сырой текст ошибки наружу уходить SHALL NOT.
Групповой путь прав поштучного расширять SHALL NOT: допуск по состоянию
проверяет ядро в момент операции, и загрузка в недопустимом состоянии SHALL
отклоняться тем же конфликтом, что и при поштучном удалении.
#### Scenario: Отказ одной не отменяет остальных
- **GIVEN** подтверждены три загрузки, и удаление второй из них отказывает
- **WHEN** выполняется групповое удаление
- **THEN** первая и третья удалены
- **AND** страница результата называет вторую и причину её отказа
#### Scenario: Недопустимое состояние отклоняется тем же конфликтом
- **GIVEN** в подтверждённой пачке есть загрузка в состоянии, из которого
удаление недоступно
- **WHEN** выполняется групповое удаление
- **THEN** по этой загрузке приходит отказ по конфликту состояния, и она
попадает в отчёт строкой отказа
- **AND** остальные подтверждённые загрузки удалены
#### Scenario: Все удалены успешно
- **GIVEN** подтверждены две загрузки, обе в разрешённом состоянии
- **WHEN** выполняется групповое удаление
- **THEN** страница результата называет обе как удалённые и не содержит отказов
#### Scenario: Обрыв связи не останавливает пачку
- **GIVEN** подтверждена пачка загрузок, и исполнение началось
- **WHEN** клиент обрывает запрос до получения ответа
- **THEN** удаление доводится по всем подтверждённым загрузкам
- **AND** исход каждой из них остаётся в журнале
#### Scenario: Потолок времени останавливает проход
- **GIVEN** подтверждена пачка, а удаление каждой единицы идёт долго
- **WHEN** отведённое на проход время исчерпано
- **THEN** новых удалений не начинается, а остаток назван в отчёте
невыполненным с причиной остановки
- **AND** удаление, начатое до исчерпания, доводится до конца
#### Scenario: Повтор доисполняет невыполненный остаток
- **GIVEN** проход был остановлен, и часть пачки осталась невыполненной
- **WHEN** человек отправляет ту же форму повторно
- **THEN** уже удалённые загрузки повторно не сносятся (отказ по конфликту)
- **AND** невыполненный остаток удаляется
#### Scenario: Недоступный внешний сервис останавливает пачку
- **GIVEN** подтверждена пачка загрузок, а qBittorrent недоступен
- **WHEN** выполняется групповое удаление и отказы внешнего сервиса идут подряд
- **THEN** после достижения порога подряд идущих отказов проход прекращается
- **AND** остаток пачки назван в отчёте невыполненным с причиной остановки
#### Scenario: Одиночный отказ пачку не прерывает
- **GIVEN** подтверждены четыре загрузки, и отказ внешнего сервиса приходит
только по второй
- **WHEN** выполняется групповое удаление
- **THEN** проход доходит до конца, удалены первая, третья и четвёртая
- **AND** отчёт называет отказавшей только вторую
#### Scenario: Сырая ошибка наружу не уходит
- **GIVEN** удаление одной из загрузок отказало ошибкой внешнего сервиса
- **WHEN** отрисовывается страница результата
- **THEN** её строка несёт нейтральное сообщение публичного канала, а не текст
ошибки внешнего сервиса
+21 -1
View File
@@ -473,7 +473,27 @@ table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap
.tl-item time{color:var(--text-faint);font-size:var(--fs-xs);font-variant-numeric:tabular-nums} .tl-item time{color:var(--text-faint);font-size:var(--fs-xs);font-variant-numeric:tabular-nums}
.tl-item .tl-note{color:var(--text-dim);margin-top:2px} .tl-item .tl-note{color:var(--text-dim);margin-top:2px}
/* ---------- 18. Адаптив ---------- */ /* ---------- 18. Страница группового удаления ---------- */
.del-list{display:flex;flex-direction:column;gap:var(--sp-2);margin-bottom:var(--sp-4)}
.del-row{display:flex;align-items:flex-start;gap:var(--sp-3);padding:var(--sp-3);
border:1px solid var(--border);border-radius:var(--r-md);background:var(--surface);cursor:pointer}
.del-row:hover{border-color:var(--accent-border)}
.del-row input[type=checkbox]{margin-top:3px;width:16px;height:16px;flex:none;accent-color:var(--accent)}
.del-row .body{min-width:0;flex:1}
.del-row .name{font-weight:600;overflow-wrap:anywhere}
.del-row .meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;
color:var(--text-dim);font-size:var(--fs-sm)}
.del-row .meta .id{font-family:var(--mono);font-size:var(--fs-xs);color:var(--text-faint)}
.last-copy{color:var(--st-err);font-size:var(--fs-sm);font-weight:600}
.del-limit{color:var(--text-dim);font-size:var(--fs-sm);margin-top:var(--sp-2)}
.del-bar{display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap}
.del-warn{background:var(--st-err-bg);border:1px solid color-mix(in srgb,var(--st-err) 35%,transparent);
border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-4)}
.del-warn h2{margin:0 0 var(--sp-2);font-size:var(--fs-md);color:var(--st-err)}
.del-group{margin-bottom:var(--sp-5)}
.del-group h2{font-size:var(--fs-md);margin:0 0 var(--sp-3)}
/* ---------- 19. Адаптив ---------- */
@media (max-width:640px){ @media (max-width:640px){
.main{padding:var(--sp-4) var(--sp-3) var(--sp-6)} .main{padding:var(--sp-4) var(--sp-3) var(--sp-6)}
.page-title{font-size:var(--fs-lg)} .page-title{font-size:var(--fs-lg)}
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — удаление</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
</head>
<body>
{{template "header" "delete"}}
<main class="main">
<h1 class="page-title">Удаление загрузок</h1>
<p class="page-sub">
Отметь раздачи и нажми «Удалить выбранные» — на следующем экране они будут
названы поимённо, и только оттуда уходит удаление. Оно снимает библиотечные
хардлинки <strong>и</strong> сносит раздачу с файлами из qBittorrent.
Действие необратимо.
</p>
{{if .Error}}
<div class="del-warn"><h2>{{.Error}}</h2></div>
{{end}}
{{if .Rows}}
<!-- Обычная POST-форма: страница работает без JavaScript, htmx здесь не нужен.
Самообновления страница не несёт сознательно — своп разметки стёр бы
отметки, и человек подтвердил бы удаление по выбору, которого не видит. -->
<form method="post" action="/ui/delete/confirm">
<div class="del-list">
{{range .Rows}}
<label class="del-row">
<input type="checkbox" name="id" value="{{.ID}}"{{if .Selected}} checked{{end}}>
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
{{template "status_badge" .State}}
<span class="id">{{.ID}}</span>
{{if .LastCopy}}<span class="last-copy">⚠ последняя копия данных</span>{{end}}
</span>
</span>
</label>
{{end}}
</div>
<div class="del-bar">
<button class="btn btn-danger btn-lg" type="submit">Удалить выбранные</button>
<span class="del-limit">За один раз — не больше {{.Max}} загрузок.</span>
</div>
</form>
{{else}}
<div class="empty-state">
<div class="big">🧹</div>
Удалять нечего: ни одной загрузки в состоянии, где удаление с файлами
разрешено.
</div>
{{end}}
</main>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — подтверждение удаления</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
</head>
<body>
{{template "header" "delete"}}
<main class="main">
<h1 class="page-title">Подтверждение удаления</h1>
<div class="del-warn">
<h2>Будут снесены раздачи с файлами и сняты библиотечные хардлинки</h2>
<p class="muted" style="margin:0">
Действие необратимо: восстановить снесённые файлы нечем. Проверь список
ниже — удалено будет ровно то, что в нём названо.
</p>
</div>
<div class="del-list">
{{range .Rows}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
{{if .Missing}}<span class="last-copy">записи нет — удалять нечего</span>
{{else if .Unread}}<span class="last-copy">⚠ состояние прочитать не удалось — что именно будет снесено, неизвестно</span>
{{else}}{{template "status_badge" .State}}{{end}}
<span class="id">{{.ID}}</span>
{{if .LastCopy}}<span class="last-copy">⚠ источник пропал: библиотечная ссылка — последняя копия данных</span>{{end}}
</span>
</span>
</div>
{{end}}
</div>
<!-- Признак подтверждения едет полем формы и проверяется сервером до единого
вызова удаления. Идентификаторы отправляются заново: исполняющий запрос —
самостоятельная входная граница, состояний между шагами сервис не хранит. -->
<form method="post" action="/ui/delete" class="del-bar">
{{range .Rows}}<input type="hidden" name="id" value="{{.ID}}">{{end}}
<input type="hidden" name="confirm" value="1">
<button class="btn btn-danger btn-lg" type="submit">Да, удалить</button>
<a class="btn" href="/delete">Отмена</a>
</form>
</main>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit — результат удаления</title>
<link rel="icon" href="{{asset "favicon.svg"}}" type="image/svg+xml">
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
</head>
<body>
{{template "header" "delete"}}
<main class="main">
<h1 class="page-title">Результат удаления</h1>
{{if .StopReason}}
<div class="del-warn"><h2>{{.StopReason}}</h2></div>
{{end}}
{{if .Deleted}}
<section class="del-group">
<h2>Удалено — {{len .Deleted}}</h2>
<div class="del-list">
{{range .Deleted}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta"><span class="id">{{.ID}}</span></span>
</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if .Failed}}
<section class="del-group">
<h2>Отказ — {{len .Failed}}</h2>
<div class="del-list">
{{range .Failed}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
<span class="last-copy">{{.Reason}}</span>
<span class="id">{{.ID}}</span>
</span>
</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if .Skipped}}
<section class="del-group">
<h2>Не выполнено — {{len .Skipped}}</h2>
<div class="del-list">
{{range .Skipped}}
<div class="del-row" style="cursor:default">
<span class="body">
<span class="name">{{.Title}}</span>
<span class="meta">
<span class="muted">проход остановлен до этой загрузки</span>
<span class="id">{{.ID}}</span>
</span>
</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{if not (or .Deleted .Failed .Skipped)}}
<div class="empty-state"><div class="big">🤷</div>Удалять было нечего.</div>
{{end}}
<p><a class="btn" href="/delete">К списку удаления</a></p>
</main>
</body>
</html>
+1
View File
@@ -4,6 +4,7 @@
<a class="brand" href="/"><span class="logo">jb</span>jellybit</a> <a class="brand" href="/"><span class="logo">jb</span>jellybit</a>
<nav class="nav"> <nav class="nav">
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a> <a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
<a href="/delete"{{if eq . "delete"}} class="active"{{end}}>Удаление</a>
</nav> </nav>
<span class="header-spacer"></span> <span class="header-spacer"></span>
</div> </div>