Files
jellybit/openspec/changes/archive/2026-08-10-bulk-delete-page/specs/web-ui/spec.md
T
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00

499 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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** её строка несёт нейтральное сообщение публичного канала, а не текст
ошибки внешнего сервиса