web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
@@ -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` с пропавшими байтами источника» отметки не получает. Дыра
|
||||||
|
названа в спеке прямо, чтобы отметка не читалась как гарантия.
|
||||||
|
- `−` Пока сверка берёт присутствие источника из списка раздач, а не с диска,
|
||||||
|
закрыть дыру нельзя ни на одном экране.
|
||||||
@@ -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) | — |
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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 | «целевое имя слишком длинное…» |
|
||||||
|
|||||||
@@ -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
@@ -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`,
|
||||||
|
находки 2–4
|
||||||
|
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `large`, а
|
||||||
|
задачи, заводившие и правившие `Delete`, шли ниже. Дефект не «пропустил
|
||||||
|
проход» — проход не запускался
|
||||||
|
- **Что меняем:** три записи в беклоге со ссылкой на оракулы; триггер метки
|
||||||
|
дополнен (см. запись выше), чтобы следующая поверхность над необратимой
|
||||||
|
операцией шла сразу с доказательными проходами
|
||||||
|
|
||||||
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
|
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
|
||||||
|
|
||||||
- **Где:** `internal/httpapi/live_test.go` — `TestFragProgressStopsWhenNotDownloading`
|
- **Где:** `internal/httpapi/live_test.go` — `TestFragProgressStopsWhenNotDownloading`
|
||||||
|
|||||||
+3
-2
@@ -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 и
|
||||||
|
|||||||
@@ -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 + "."
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "внутренняя ошибка"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
|
||||||
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` A1–A3, `adversary` S1–S4,
|
||||||
|
`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 находки (A1–A3) |
|
||||||
|
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 4 находки (S1–S4) |
|
||||||
|
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта**, 3 находки (O1–O3) |
|
||||||
|
| тема проекта | своих тем в `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). Фраза пре-существующая; по решению человека дефект уходит
|
||||||
|
в беклог отдельной задачей, а текст вливается в спеку как есть.
|
||||||
+115
@@ -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 давать пользователю возможность **закрыть** загрузку — перевести
|
||||||
|
|||||||
@@ -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** её строка несёт нейтральное сообщение публичного канала, а не текст
|
||||||
|
ошибки внешнего сервиса
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user