From 288be8ec346667d4b58d49872ab9749b6146606c Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 10 Aug 2026 17:43:36 +0300 Subject: [PATCH] =?UTF-8?q?web-ui:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней --- CLAUDE.md | 7 +- ...2026-08-10-last-copy-warning-from-state.md | 52 ++ docs/adr/README.md | 1 + docs/architecture.md | 1 + docs/conventions/errors.md | 1 + docs/database.md | 3 + docs/review.md | 54 +- docs/security.md | 5 +- internal/httpapi/bulkdelete.go | 289 +++++++++ internal/httpapi/bulkdelete_test.go | 611 ++++++++++++++++++ internal/httpapi/download.go | 3 +- internal/httpapi/httpapi.go | 44 +- internal/httpapi/httpapi_test.go | 3 + internal/httpapi/render_test.go | 18 +- internal/store/download.go | 17 + internal/store/download_test.go | 19 + internal/store/list.go | 40 +- internal/store/list_test.go | 36 ++ internal/tgbot/render.go | 24 +- internal/tgbot/render_delete_test.go | 43 ++ internal/worker/review.go | 4 +- .../.openspec.yaml | 2 + .../2026-08-10-bulk-delete-page/design.md | 223 +++++++ .../2026-08-10-bulk-delete-page/proposal.md | 59 ++ .../review/report.md | 315 +++++++++ .../specs/state-reconciliation/spec.md | 115 ++++ .../specs/web-ui/spec.md | 498 ++++++++++++++ .../2026-08-10-bulk-delete-page/tasks.md | 151 +++++ openspec/specs/state-reconciliation/spec.md | 23 + openspec/specs/web-ui/spec.md | 332 +++++++++- web/static/css/jellybit.css | 22 +- web/templates/delete.html | 63 ++ web/templates/delete_confirm.html | 54 ++ web/templates/delete_result.html | 83 +++ web/templates/partials/header.html | 1 + 35 files changed, 3185 insertions(+), 31 deletions(-) create mode 100644 docs/adr/ADR-2026-08-10-last-copy-warning-from-state.md create mode 100644 internal/httpapi/bulkdelete.go create mode 100644 internal/httpapi/bulkdelete_test.go create mode 100644 internal/tgbot/render_delete_test.go create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/design.md create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/proposal.md create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/specs/state-reconciliation/spec.md create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/specs/web-ui/spec.md create mode 100644 openspec/changes/archive/2026-08-10-bulk-delete-page/tasks.md create mode 100644 web/templates/delete.html create mode 100644 web/templates/delete_confirm.html create mode 100644 web/templates/delete_result.html diff --git a/CLAUDE.md b/CLAUDE.md index fd4e4ca..a3214e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,8 +37,11 @@ Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module Исключения два, и оба — не наши операции с файловой системой, а вызов `torrents/delete` qBittorrent с `deleteFiles=true`: (1) `Delete` из `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` секундами ранее, когда закрытие **любым** путём (`Cancel` или `Dismiss`) увело задачу из `catched` в окне после `add` — уборка привязана к состоянию, diff --git a/docs/adr/ADR-2026-08-10-last-copy-warning-from-state.md b/docs/adr/ADR-2026-08-10-last-copy-warning-from-state.md new file mode 100644 index 0000000..b664eb6 --- /dev/null +++ b/docs/adr/ADR-2026-08-10-last-copy-warning-from-state.md @@ -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` с пропавшими байтами источника» отметки не получает. Дыра + названа в спеке прямо, чтобы отметка не читалась как гарантия. +- `−` Пока сверка берёт присутствие источника из списка раздач, а не с диска, + закрыть дыру нельзя ни на одном экране. diff --git a/docs/adr/README.md b/docs/adr/README.md index 71de7d7..b9e251b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-reason-computed-on-read.md) | — | | 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — | diff --git a/docs/architecture.md b/docs/architecture.md index c1ac0d1..04ead93 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,6 +118,7 @@ | Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки | | Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью | | Причина, по которой человек не видит плана | считается **на показе** (`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) | | Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) | | Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) | diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index b6bc511..59913d9 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -86,6 +86,7 @@ jellybit — **приложение, а не библиотека**: внешн | `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» | | `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки | | `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки | + | `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» | | `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» | diff --git a/docs/database.md b/docs/database.md index c311170..d620c7b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -214,6 +214,9 @@ erDiagram | `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.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`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой | **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, diff --git a/docs/review.md b/docs/review.md index b7c08ac..8b6b15a 100644 --- a/docs/review.md +++ b/docs/review.md @@ -179,8 +179,8 @@ Go-сервиса и что здесь уже проскакивало. Устр - `requirements`: не завелось ли поведение, которого спека не заказывала — тихий дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле? - `security`: читается ли тело ответа внешнего сервиса целиком без предела — - лимита на размер ответа LLM в проекте нет, и это единственный недоверенный - канал, где предел не стоит ([security.md](security.md) → «Что вне модели») + у LLM (8 MiB) и метабаз (4 MiB) предел стоит, и новый исходящий вызов обязан + заводить свой ([security.md](security.md) → «Что вне модели») - `operations`: гарантия, которую вводит изменение, поставлена на запись или на чтение — и что будет с данными, записанными до деплоя, которые обычный путь не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и @@ -231,6 +231,13 @@ Go-сервиса и что здесь уже проскакивало. Устр (`internal/metadata`, `metadata-match`); merge-раскладка при повторном добавлении раздачи. +- **новая поверхность поверх необратимой операции** — вторая точка входа в + команду, которая удаляет файлы или снимает последнюю копию. Форма решения + здесь нащупывается по ходу: подтверждение, порядок отказов, остаток, + наблюдаемость. Выведено по факту на `bulk-delete-page` (журнал, 2026-08-10): + метка `medium` не дала ни враждебного прохода, ни замера, а именно они нашли + четыре дефекта класса «необратимо». + - «Поведение, видимое снаружи» здесь включает **тексты и карточки 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 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман] - **Где:** `internal/httpapi/live_test.go` — `TestFragProgressStopsWhenNotDownloading` diff --git a/docs/security.md b/docs/security.md index 7e0f87a..33e49ff 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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)) | | Ответы qBittorrent | HTTP | пути, состояния, размеры | | Запросы веб-UI и REST | LAN | идентификаторы, параметры действий | +| Пачка идентификаторов и признак подтверждения на групповом удалении | форма веб-UI | необратимое действие сразу по многим загрузкам; разбор, схлопывание дублей и предел размера стоят на **обеих** границах — подтверждении и исполнении, потому что вторая получает пачку формой заново | **Выход LLM не отвечает за безопасность.** Инъекция в промпт считается состоявшейся по умолчанию; защита стоит ниже — на валидации целевого пути. @@ -103,8 +104,8 @@ REST API работают **без авторизации** осознанно; - **Отказ в обслуживании изнутри контура.** Огромная раздача, тысяча файлов, бесконечный ответ LLM — это вопросы устойчивости и ресурсов ([architecture.md](architecture.md) → «Эксплуатация»), а не безопасности. - Лимита на размер ответа LLM нет — известный пробел - ([architecture.md](architecture.md) → «Открытые вопросы»). + Тело ответа внешнего сервиса при этом читается с пределом: LLM — 8 MiB + (`internal/llm`), метабазы — 4 MiB (`internal/metadata`). - **Целостность содержимого медиафайлов.** Что в контейнере mkv — не наша забота. - **Цепочка поставки** — модули Go, базовый образ distroless, плагины тулинга. - **Приватность запросов к внешним сервисам.** Названия раздач уезжают в LLM и diff --git a/internal/httpapi/bulkdelete.go b/internal/httpapi/bulkdelete.go new file mode 100644 index 0000000..21163b9 --- /dev/null +++ b/internal/httpapi/bulkdelete.go @@ -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 + "." +} diff --git a/internal/httpapi/bulkdelete_test.go b/internal/httpapi/bulkdelete_test.go new file mode 100644 index 0000000..335c34f --- /dev/null +++ b/internal/httpapi/bulkdelete_test.go @@ -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, `
`) { + 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) + } + } +} diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 15d26a1..0cc4c60 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -137,8 +137,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || d.State == store.StateTargetMissing, Retriable: d.State == store.StateFailed || d.State == store.StateStuck, - Deletable: d.State == store.StateDone || d.State == store.StateOrphaned || - d.State == store.StateTargetMissing, + Deletable: d.State.CanDelete(), Dismissable: d.State.IsTerminal() && d.State != store.StateDeleted && d.State != store.StateCancelled, } diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 13a6860..ea8d56a 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -18,6 +18,7 @@ import ( "strconv" "strings" "time" + "unicode" "unicode/utf8" "github.com/go-chi/chi/v5" @@ -52,6 +53,9 @@ type Reader interface { // LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из // загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке). LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) + // ListDeletableDownloads — загрузки, разрешённые к полному удалению, без + // постраничной выдачи (страница группового удаления показывает их разом). + ListDeletableDownloads(ctx context.Context) ([]store.Download, error) } // Deps — зависимости транспорта. @@ -113,6 +117,9 @@ func NewRouter(d Deps) (http.Handler, error) { // Веб-UI. r.Get("/", s.handleIndex) r.Get("/download/{id}", s.handleDownload) + // Групповое удаление: выбор → подтверждение → исполнение. Отдельная + // страница, потому что живая перерисовка списка стёрла бы выбор человека. + r.Get("/delete", s.handleBulkDeletePage) // Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями // вкладок, отрисованных прошлой версией (см. handleFragProgress). @@ -124,6 +131,8 @@ func NewRouter(d Deps) (http.Handler, error) { // Тело ревью для поллинга recognizing (htmx-своп до готового плана). r.Get("/fragments/downloads/{id}/review", s.handleFragReview) 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}/retry", s.handleUIRetry) @@ -692,12 +701,36 @@ func (s *server) toView(d store.Download, now time.Time) downloadView { // несколько строк заголовка. func downloadTitle(d store.Download) string { if d.DisplayName != "" { - return d.DisplayName + return displaySafe(d.DisplayName) } 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 схлопывает переводы строк и лишние пробелы — сырой источник в @@ -814,6 +847,11 @@ func classifyErr(err error) (int, string) { return http.StatusBadRequest, errManualSource.Error() case errors.Is(err, errInvalidCandidate): 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: return http.StatusInternalServerError, "внутренняя ошибка" } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 8629992..f48b7ab 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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) { 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 { t.Helper() diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index cd226f5..b6ad7b2 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -20,21 +20,37 @@ type stubReader struct { sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк) getErr error // отказ чтения задачи (не ErrNotFound) 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) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) { 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 { 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 { return nil, store.ErrNotFound } 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) { if s.sizesErr != nil { return nil, s.sizesErr diff --git a/internal/store/download.go b/internal/store/download.go index 4918f51..5feb060 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -84,6 +84,23 @@ func (s State) IsObservable() bool { 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 — декларативный граф легальных переходов машины состояний // (from → множество допустимых to). Единственный источник истины о легальности // рёбер: покрывает все переходы, которые worker выполняет по всем capability diff --git a/internal/store/download_test.go b/internal/store/download_test.go index 33b982c..bbdbfd1 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -683,3 +683,22 @@ func TestIsObservable(t *testing.T) { 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 не должно допускать удаление") + } +} diff --git a/internal/store/list.go b/internal/store/list.go index d64d598..91049ba 100644 --- a/internal/store/list.go +++ b/internal/store/list.go @@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) { 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 возвращает страницу загрузок под фильтром и общее число // строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени // добавления в источник (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) } - q := `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` + where + - ` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC + q := listSelect + where + listOrder + ` LIMIT ? OFFSET ?` pageArgs := append(append([]any{}, args...), f.Limit, f.Offset) diff --git a/internal/store/list_test.go b/internal/store/list_test.go index 77fc594..f52ae52 100644 --- a/internal/store/list_test.go +++ b/internal/store/list_test.go @@ -333,3 +333,39 @@ func TestCreateDownloadPersistsDisplayName(t *testing.T) { 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) + } + } +} diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 16ea804..8c8efdb 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -48,22 +48,20 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar case store.StateLinking: return "⏳ Раскладываю #" + idCode(id) + "…", nil case store.StateDone: - return b.renderDone(rd), b.deletableKeyboard(id) + return b.renderDone(rd), b.stateKeyboard(id, state) default: // state — внутренний enum состояния (не внешний ввод), экранировать не нужно. text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state) if msg := rd.Download.ErrorMsg.String; msg != "" { text += "\n" + esc(msg) } - switch state { - case store.StateFailed, store.StateStuck: + if state == store.StateFailed || state == store.StateStuck { // failed/stuck — даём кнопку повтора. 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 } +// 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 — клавиатура состояний, откуда доступно полное удаление // (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь // статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка diff --git a/internal/tgbot/render_delete_test.go b/internal/tgbot/render_delete_test.go new file mode 100644 index 0000000..0c4a249 --- /dev/null +++ b/internal/tgbot/render_delete_test.go @@ -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) + } + } +} diff --git a/internal/worker/review.go b/internal/worker/review.go index 55ec4f8..9215e36 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -628,9 +628,7 @@ func (w *Worker) Delete(ctx context.Context, id string) (err error) { if err != nil { return fmt.Errorf("delete: %w", err) } - switch d.State { - case store.StateDone, store.StateOrphaned, store.StateTargetMissing: - default: + if !d.State.CanDelete() { 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()) diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/.openspec.yaml b/openspec/changes/archive/2026-08-10-bulk-delete-page/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/design.md b/openspec/changes/archive/2026-08-10-bulk-delete-page/design.md new file mode 100644 index 0000000..236c40f --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/design.md @@ -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`) системным отказом не считается — он про задачу, а не про соседа, +и на счётчик не влияет. + +Отвергнуто: остановка на первом же отказе — одна случайная сетевая ошибка +обрывала бы всю пачку, и человек проходил бы подтверждение заново. + +Отвергнуто: «каждая сама за себя» без исключений — проще и предсказуемее, но +ценой того самого ущерба, ради ограничения которого стоит порог пачки. diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/proposal.md b/openspec/changes/archive/2026-08-10-bulk-delete-page/proposal.md new file mode 100644 index 0000000..4f7aee6 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/proposal.md @@ -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` — внешний сервис, + необратимая операция; число вызовов за один запрос человека растёт с одного + до числа выбранных. diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md b/openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md new file mode 100644 index 0000000..91d7a2e --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md @@ -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). Фраза пре-существующая; по решению человека дефект уходит +в беклог отдельной задачей, а текст вливается в спеку как есть. diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..680426b --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/state-reconciliation/spec.md @@ -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** остальные загрузки пачки удаляются diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/web-ui/spec.md b/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/web-ui/spec.md new file mode 100644 index 0000000..46f504c --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/web-ui/spec.md @@ -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** её строка несёт нейтральное сообщение публичного канала, а не текст + ошибки внешнего сервиса diff --git a/openspec/changes/archive/2026-08-10-bulk-delete-page/tasks.md b/openspec/changes/archive/2026-08-10-bulk-delete-page/tasks.md new file mode 100644 index 0000000..8bb8c75 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-bulk-delete-page/tasks.md @@ -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`, переход состояния — в воркере (оракул: чтение диффа на + ревью кода) diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md index 1a1a4b3..00bd2dd 100644 --- a/openspec/specs/state-reconciliation/spec.md +++ b/openspec/specs/state-reconciliation/spec.md @@ -455,6 +455,12 @@ SHALL задевать раскладку в полёте. целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT. +Перечень состояний, из которых удаление допустимо, SHALL иметь **единственный +дом** — предикат состояния в модели данных. Транспорты, решающие, показывать +действие или нет, SHALL опираться на него, а не на собственный список. Проверку +допуска в ядре это SHALL NOT отменять: транспорт решает, что показать, ядро — что +допустить, и допуск SHALL держаться без транспорта. + Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`: `superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие @@ -467,6 +473,13 @@ SHALL задевать раскладку в полёте. `paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по вызову API с `deleteFiles=true`. +Подтверждение SHALL быть допустимо **одно на пачку** загрузок, когда транспорт +даёт групповое удаление: ослаблением требования это не является, если +подтверждение называет каждую загрузку пачки поимённо. Условия допустимости +групповой путь смягчать SHALL NOT — каждая загрузка пачки проходит те же +проверки и тот же отказ по конфликту, что и при поштучном удалении, а отказ на +одной остальных отменять SHALL NOT. + В отличие от прочих команд, требующих источника, delete синхронный source-preflight выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед действием» не подпадает: цель delete — снять источник, поэтому его отсутствие @@ -538,6 +551,16 @@ SHALL задевать раскладку в полёте. - **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех) - **AND** повторный delete идемпотентно дожимает удаление +#### Scenario: Одно подтверждение на пачку не смягчает допуска + +- **GIVEN** транспорт даёт групповое удаление, и человек подтвердил пачку, где + каждая загрузка названа поимённо +- **WHEN** одна из загрузок пачки находится в состоянии, из которого удаление + недоступно +- **THEN** по ней приходит тот же отказ по конфликту, что и при поштучном + удалении +- **AND** остальные загрузки пачки удаляются + ### Requirement: Ручное закрытие загрузки (стоп-кран) Система SHALL давать пользователю возможность **закрыть** загрузку — перевести diff --git a/openspec/specs/web-ui/spec.md b/openspec/specs/web-ui/spec.md index 724794b..dce2b72 100644 --- a/openspec/specs/web-ui/spec.md +++ b/openspec/specs/web-ui/spec.md @@ -89,16 +89,18 @@ self-hosted шрифтов, единая дизайн-система (`jellybit. Веб-UI SHALL предоставлять страницы: список загрузок с единым окном добавления, **серверными фильтром по группе состояний, поиском и постраничной -выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`) -и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием, +выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`), +страницу просмотра одной загрузки (`/download/{id}`) с распознаванием, файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих -задач — секцией живой статистики раздачи. Карточки активных (downloading) +задач — секцией живой статистики раздачи, а также **страницу группового +удаления загрузок** (`/delete`). Карточки активных (downloading) загрузок в списке SHALL содержать индикатор прогресса. Карточка загрузки с распознанным типом SHALL нести значок типа (фильм/сериал). Фильтр, поиск и номер страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`) и SHALL работать без клиентского JavaScript. Терминальные состояния `deleted` и `cancelled` SHALL быть скрыты в списке по умолчанию (переключатель «показать -всё» раскрывает оба). Механика живого +всё» раскрывает оба). Шапка SHALL нести ссылки на список загрузок и на страницу +группового удаления. Механика живого обновления прогресса и наполнение секции раздачи определяются capability `live-status`. @@ -141,6 +143,11 @@ self-hosted шрифтов, единая дизайн-система (`jellybit. совпадению строки в названии, любом идентификаторе загрузки — `download.id` ИЛИ infohash — и контексте), отфильтрованные на стороне БД, а не на клиенте +#### Scenario: Ссылка на групповое удаление в шапке + +- **WHEN** клиент открывает любую страницу веб-UI +- **THEN** в шапке есть ссылка на страницу группового удаления (`/delete`) + ### Requirement: Порядок списка загрузок Список загрузок SHALL сортироваться по времени добавления торрента в источник @@ -170,6 +177,14 @@ jellybit (`created_at`). Порядок MUST быть согласован ме усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать несколько строк сырым magnet. +Имя раздачи — недоверенный вход, поэтому **на показе** заголовок SHALL терять +управляющие символы направления письма (с ними строка читается не в том +порядке, в каком хранится) и SHALL заменять прочие управляющие пробелом. Прочие +форматирующие символы юникода система снимать SHALL NOT: без соединителей +рассыпаются составные эмодзи и меняется написание имён на ряде письменностей. +Хранимое значение эта чистка менять SHALL NOT — поиск по списку идёт по +сохранённому имени. + #### Scenario: Заголовок из имени раздачи - **WHEN** у загрузки сохранено отображаемое имя раздачи @@ -182,6 +197,13 @@ jellybit (`created_at`). Порядок MUST быть согласован ме - **AND** если нет ни того, ни другого — заголовком служит усечённый до одной строки сырой источник, а не многострочный magnet +#### Scenario: Переворачивающий символ до показа не доезжает + +- **GIVEN** имя раздачи содержит символ переопределения направления письма +- **WHEN** заголовок показывается на любой странице веб-UI +- **THEN** этого символа в разметке нет +- **AND** составные эмодзи в том же имени остаются целыми + ### Requirement: Действие «Обновить имя» на странице загрузки Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие @@ -491,6 +513,13 @@ PRG-редиректом, и действие исполняется тем же рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT заводить. +Требование распространяется на **карточку списка `/` и страницу +`/download/{id}`** и на страницу группового удаления (`/delete`) +распространяться SHALL NOT: там строки несут выбор человека, а своп корня унёс +бы отметки вместе с разметкой — и человек подтвердил бы необратимое удаление по +выбору, которого уже не видит. Наблюдаемость самих загрузок этого не отменяет: +строки `/delete` перечисляют в том числе `orphaned` и `target_missing`. + Самообновление SHALL приносить смену состояния целиком — бейдж статуса, заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия @@ -573,6 +602,13 @@ PRG-редиректом, и действие исполняется тем же - **AND** группа и фильтр пересчитываются при следующей навигации или перезагрузке — список целиком самообновлением не пересобирается +#### Scenario: Страница группового удаления не самообновляется + +- **GIVEN** открыта страница `/delete`, и среди её строк есть загрузки в + `orphaned` и `target_missing` (наблюдаемые состояния) +- **THEN** ни строки, ни страница целиком самообновления не несут, и фоновых + запросов по ним не уходит + ### Requirement: Отображение промежуточного состояния catched Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу @@ -682,3 +718,291 @@ htmx-путь (список обновляется/происходит реди - **WHEN** клиент открывает `GET /download/{id}` - **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** её строка несёт нейтральное сообщение публичного канала, а не текст + ошибки внешнего сервиса diff --git a/web/static/css/jellybit.css b/web/static/css/jellybit.css index 8af85a3..eda773d 100644 --- a/web/static/css/jellybit.css +++ b/web/static/css/jellybit.css @@ -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 .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){ .main{padding:var(--sp-4) var(--sp-3) var(--sp-6)} .page-title{font-size:var(--fs-lg)} diff --git a/web/templates/delete.html b/web/templates/delete.html new file mode 100644 index 0000000..14b6c38 --- /dev/null +++ b/web/templates/delete.html @@ -0,0 +1,63 @@ + + + + + + jellybit — удаление + + + + + +{{template "header" "delete"}} + +
+

Удаление загрузок

+

+ Отметь раздачи и нажми «Удалить выбранные» — на следующем экране они будут + названы поимённо, и только оттуда уходит удаление. Оно снимает библиотечные + хардлинки и сносит раздачу с файлами из qBittorrent. + Действие необратимо. +

+ + {{if .Error}} +

{{.Error}}

+ {{end}} + + {{if .Rows}} + + +
+ {{range .Rows}} + + {{end}} +
+ +
+ + За один раз — не больше {{.Max}} загрузок. +
+ + {{else}} +
+
🧹
+ Удалять нечего: ни одной загрузки в состоянии, где удаление с файлами + разрешено. +
+ {{end}} + +
+ + diff --git a/web/templates/delete_confirm.html b/web/templates/delete_confirm.html new file mode 100644 index 0000000..54ab4cd --- /dev/null +++ b/web/templates/delete_confirm.html @@ -0,0 +1,54 @@ + + + + + + jellybit — подтверждение удаления + + + + + +{{template "header" "delete"}} + +
+

Подтверждение удаления

+ +
+

Будут снесены раздачи с файлами и сняты библиотечные хардлинки

+

+ Действие необратимо: восстановить снесённые файлы нечем. Проверь список + ниже — удалено будет ровно то, что в нём названо. +

+
+ +
+ {{range .Rows}} +
+ + {{.Title}} + + {{if .Missing}}записи нет — удалять нечего + {{else if .Unread}}⚠ состояние прочитать не удалось — что именно будет снесено, неизвестно + {{else}}{{template "status_badge" .State}}{{end}} + {{.ID}} + {{if .LastCopy}}⚠ источник пропал: библиотечная ссылка — последняя копия данных{{end}} + + +
+ {{end}} +
+ + +
+ {{range .Rows}}{{end}} + + + Отмена +
+ +
+ + diff --git a/web/templates/delete_result.html b/web/templates/delete_result.html new file mode 100644 index 0000000..8bfe04c --- /dev/null +++ b/web/templates/delete_result.html @@ -0,0 +1,83 @@ + + + + + + jellybit — результат удаления + + + + + +{{template "header" "delete"}} + +
+

Результат удаления

+ + {{if .StopReason}} +

{{.StopReason}}

+ {{end}} + + {{if .Deleted}} +
+

Удалено — {{len .Deleted}}

+
+ {{range .Deleted}} +
+ + {{.Title}} + {{.ID}} + +
+ {{end}} +
+
+ {{end}} + + {{if .Failed}} +
+

Отказ — {{len .Failed}}

+
+ {{range .Failed}} +
+ + {{.Title}} + + {{.Reason}} + {{.ID}} + + +
+ {{end}} +
+
+ {{end}} + + {{if .Skipped}} +
+

Не выполнено — {{len .Skipped}}

+
+ {{range .Skipped}} +
+ + {{.Title}} + + проход остановлен до этой загрузки + {{.ID}} + + +
+ {{end}} +
+
+ {{end}} + + {{if not (or .Deleted .Failed .Skipped)}} +
🤷
Удалять было нечего.
+ {{end}} + +

← К списку удаления

+ +
+ + diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html index 1a82e2d..4a9a855 100644 --- a/web/templates/partials/header.html +++ b/web/templates/partials/header.html @@ -4,6 +4,7 @@ jellybit