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

1009 lines
77 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.
# web-ui Specification
## Purpose
Презентационный слой веб-интерфейса: встроенная (`go:embed`) отдача статики и
self-hosted шрифтов, единая дизайн-система (`jellybit.css`, тёмная тема по
настройке ОС), рендеринг страниц (список загрузок, ревью, просмотр) с бейджами
состояний и клиентскими взаимодействиями без сборки (копирование идентификатора
загрузки, спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`,
а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`,
команды — `worker`, чтение — `store`).
## Requirements
### Requirement: Встроенная отдача статики
Сервис SHALL отдавать статические ассеты (CSS, клиентский JS, вендорные
библиотеки, шрифты) из встроенной (`go:embed`) файловой системы под префиксом
`/static/`, без внешних зависимостей времени выполнения. Бинарь остаётся
единым и самодостаточным.
#### Scenario: Запрос встроенного ассета
- **WHEN** клиент запрашивает `GET /static/css/jellybit.css`
- **THEN** сервис отвечает `200` с телом из встроенной FS и корректным
`Content-Type`
#### Scenario: Кэширование статики
- **WHEN** сервис отдаёт ответ на запрос под `/static/`
- **THEN** ответ содержит заголовок `Cache-Control`, разрешающий кэширование
ассета браузером
#### Scenario: Несуществующий ассет
- **WHEN** клиент запрашивает несуществующий путь под `/static/`
- **THEN** сервис отвечает `404` и не раскрывает структуру файловой системы
### Requirement: Шрифты без внешних сетей
Веб-UI SHALL подключать шрифты (IBM Plex Sans, IBM Plex Mono) только из
локально встроенных файлов через `@font-face`. Страницы MUST NOT обращаться к
сторонним хостам (Google Fonts CDN и т. п.) для загрузки шрифтов или стилей.
#### Scenario: Нет внешних ссылок на шрифты
- **WHEN** браузер открывает любую страницу веб-UI
- **THEN** все используемые шрифты загружаются из-под `/static/` того же
origin, без обращений к внешним доменам
### Requirement: Единая дизайн-система
Все страницы веб-UI SHALL использовать единственный CSS-файл `jellybit.css` с
дизайн-токенами; страницы MUST NOT содержать инлайн-`<style>` или хардкод
цветов вне токенов. Тема (светлая/тёмная) SHALL определяться настройкой ОС
(`color-scheme` + `prefers-color-scheme`), без отдельного переключателя.
#### Scenario: Тёмная тема по настройке ОС
- **WHEN** в ОS пользователя выбрана тёмная тема и открывается страница
- **THEN** интерфейс отображается в тёмной палитре из токенов, без действий
пользователя
#### Scenario: Нет инлайн-стилей
- **WHEN** рендерится любая страница веб-UI
- **THEN** оформление берётся из подключённого `jellybit.css`, в разметке нет
блоков `<style>` с хардкодом цветов
### Requirement: Бейдж статуса загрузки
Веб-UI SHALL отображать состояние каждой загрузки бейджем
`<span class="badge st-{STATE}">` с человекочитаемой русской подписью; класс
определяет цвет группы. Маппинг SHALL покрывать все состояния домена
(`downloading`, `completed`, `recognizing`, `linking`, `review`, `deferred`,
`done`, `stuck`, `target_missing`, `failed`, `orphaned`, `cancelled`,
`reverted`, `deleted`).
#### Scenario: Состояние отрисовано бейджем
- **WHEN** загрузка находится в состоянии `review`
- **THEN** в списке у неё бейдж с классом `st-review` и подписью «на ревью»
#### Scenario: Полнота покрытия
- **WHEN** загрузка находится в любом из состояний домена
- **THEN** для него существует класс бейджа и русская подпись (неизвестных
состояний без оформления нет)
### 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: Порядок списка загрузок
Список загрузок SHALL сортироваться по времени добавления торрента в источник
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
неизвестно, для сортировки SHALL использоваться время создания загрузки в
jellybit (`created_at`). Порядок MUST быть согласован между страницами
пагинации.
#### Scenario: Сортировка по времени добавления в источник
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
в qBittorrent
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
#### Scenario: Фолбек порядка без времени источника
- **WHEN** у загрузки нет сохранённого времени добавления в источник
- **THEN** для её позиции в списке используется время создания загрузки в
jellybit
### 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: Действие «Обновить имя» на странице загрузки
Когда у загрузки есть распознавание, страница загрузки SHALL предлагать действие
«Обновить имя», запускающее обновление отображаемого имени по распознаванию (см.
capability `ingest`). Видимость действия SHALL определяться наличием распознавания
(а не состоянием ревью): в частности, действие SHALL быть доступно и на терминальных
состояниях с распознаванием (`done`/`orphaned`), где раздачу нужно переименовать
постфактум. Действие SHALL обновлять заголовок на месте по htmx-паттерну
(фрагмент заголовка перерисовывается ответом), без перезагрузки страницы, и SHALL
деградировать без JS (обычная форма-POST с переходом на страницу загрузки).
Действие SHALL быть идемпотентным по эффекту: повторный запуск на неизменном
распознавании даёт то же имя. Отсутствие распознавания (пустой источник имени)
SHALL приводить к отсутствию видимых изменений.
#### Scenario: Ручное обновление имени перерисовывает заголовок
- **GIVEN** страница загрузки с распознанным каноническим названием и заголовком
«Unknown»
- **WHEN** пользователь запускает действие «Обновить имя»
- **THEN** заголовок на странице перерисовывается каноническим именем `Title (Year)`
на месте (htmx), без полной перезагрузки
#### Scenario: Деградация без JS
- **GIVEN** клиент без htmx/JS
- **WHEN** пользователь отправляет действие «Обновить имя» обычной формой
- **THEN** сервер выполняет обновление и отвечает страницей загрузки с новым
заголовком
### Requirement: Матч с записью метабазы ссылкой
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
распознавания) и в едином списке источников совпадения экрана ревью (у
активного источника-кандидата). Ссылка SHALL открываться в новой вкладке с
`rel="noopener"`. Рядом со ссылкой SHALL быть видны провайдер, идентификатор
записи и (при наличии) год.
#### Scenario: Матч виден ссылкой на странице просмотра
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
запись с провайдером и id
#### Scenario: URL записи неизвестен
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
записи построить нельзя
- **THEN** матч показывается текстом (провайдер и id) без ссылки, строка списка
не ломается
### Requirement: Блок информации о торренте
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
#### Scenario: Полный источник в отдельном блоке
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
торренте», а заголовок страницы остаётся человекочитаемым именем
### Requirement: Действия соответствуют состоянию
Каждая страница SHALL показывать только действия, допустимые в текущем
состоянии загрузки, и каждое действие-кнопка SHALL отправлять форму с полями,
имена которых совпадают с ожидаемыми обработчиком (`internal/httpapi`).
#### Scenario: Набор действий по состоянию
- **WHEN** загрузка в состоянии `done`
- **THEN** доступно действие отката (undo), но недоступны «применить»/«позже»
#### Scenario: Имена полей формы
- **WHEN** пользователь отправляет форму действия (например, «уточнить»)
- **THEN** поля формы имеют `name`, которые обработчик читает без
переименования, и действие исполняется
### Requirement: Превью раскладки через единую логику именования
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
выбранного (активного) источника** — эфемерно на сервере, без записи
сохранённого матча самим показом. При смене выбранного источника превью SHALL
пересчитываться под него и обновляться частичным свопом блока. Показанные для
источника пути MUST совпадать с теми, что создались бы при применении этого
источника.
#### Scenario: Превью совпадает с реальной раскладкой
- **WHEN** на экране ревью отображается превью целевых путей для выбранного
источника
- **THEN** эти пути идентичны тем, что создаст применение этого источника (те же
правила имён, спецвыпусков, мультифайла, запрещённых символов, тега провайдера
и коллизий)
#### Scenario: Смена источника пересчитывает превью
- **GIVEN** на экране ревью показан предпросмотр раскладки активного источника
- **WHEN** пользователь выбирает другой источник в списке
- **THEN** превью пересчитывается под выбранный источник и обновляется без
полной перезагрузки страницы
#### Scenario: Переключение источника не тянет чужие поля
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
источник без собственных названия/года (нейронка или ручной кандидат)
- **WHEN** строится превью и затем выполняется применение выбранного источника
- **THEN** и превью, и применение используют название/год этого источника
(из плана распознавания), без унаследованных от прежнего кандидата
### Requirement: Клиентские взаимодействия без сборки
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
фреймворков: копирование идентификатора загрузки (vanilla JS). Основной
копируемый идентификатор в карточке списка и в шапке страницы просмотра SHALL
быть `download.id` (ULID) — тот же ключ, что пишется в логи (`download_id`). Все
действия над загрузкой SHALL выполняться через формы/htmx (раундтрип на сервер),
без клиентского пересчёта доменного состояния.
#### Scenario: Копирование идентификатора загрузки
- **WHEN** пользователь нажимает кнопку копирования рядом с идентификатором
загрузки (`download.id`) в карточке списка или шапке страницы просмотра
- **THEN** значение `download.id` копируется в буфер обмена без перезагрузки
страницы
#### Scenario: Действия только через раундтрип
- **WHEN** пользователь выполняет действие над загрузкой
- **THEN** оно исполняется формой/htmx-запросом на сервер, без клиентского
пересчёта доменного состояния
### Requirement: Обзор жизненного цикла в карточке списка
Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о
судьбе раздачи: метку `ID:` перед копируемым идентификатором загрузки, дату
добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки
MUST NOT показываться в карточке списка — он доступен на странице `/download/{id}`.
Дата добавления SHALL показываться всегда как абсолютная дата и относительная
давность (например «`2026-06-30 · 5 дней назад`»); источником SHALL быть время
добавления раздачи в источник (`source_added_at`, qBittorrent `added_on`) с
фолбэком на время создания загрузки (`created_at`), согласованным с порядком
списка.
Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в
снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».
Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при
отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки;
если неизвестно ни то, ни другое — прочерк «—».
Карточка, пришедшая **самообновлением**, SHALL показывать те же значения, что и
карточка в полном рендере списка: фоновое обновление MUST NOT подменять
известное значение прочерком.
#### Scenario: Метка идентификатора
- **WHEN** рендерится карточка загрузки в списке
- **THEN** перед значением `download.id` показана метка «ID:», а кнопка
копирования копирует именно `download.id`
#### Scenario: Дата добавления показана всегда
- **WHEN** рендерится любая карточка списка
- **THEN** в ней показана дата добавления раздачи абсолютной датой и
относительной давностью
- **AND** если `source_added_at` неизвестно, используется `created_at`
#### Scenario: Рейтинг из живого снимка
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** в карточке показан его рейтинг отдачи
- **AND** если торрента в снимке нет, рейтинг показан прочерком «—»
#### Scenario: Размер с фолбэком на разложенные файлы
- **WHEN** торрент загрузки присутствует в живом снимке
- **THEN** размер раздачи в карточке берётся из общего размера торрента
- **AND** если торрента в снимке нет, но у загрузки есть разложенные файлы —
размер берётся из суммарного размера этих файлов
#### Scenario: Самообновление не теряет размер
- **GIVEN** торрента нет в живом снимке, а файлы задачи разложены
- **WHEN** карточка пришла самообновлением, а не полным рендером списка
- **THEN** размер показан по тому же фолбэку, а не прочерком «—»
#### Scenario: Контекст не в карточке
- **WHEN** у загрузки есть переданный контекст
- **THEN** он не показывается в карточке списка, но доступен на странице
`/download/{id}`
### Requirement: Таймзона отображения времени
Веб-UI SHALL отображать все временные метки (абсолютные даты добавления и
создания, относительная давность) в **таймзоне отображения из конфигурации**
(`[general].timezone`, дефолт `UTC`). Зона MUST NOT быть зашита в код.
Метки в БД хранятся всегда в UTC (RFC 3339); конвертация в зону отображения
SHALL выполняться только на рендеринге, не затрагивая хранение и сортировку.
База зон (zoneinfo) SHALL встраиваться в бинарь (`time/tzdata`), поэтому зоны
доступны независимо от окружения контейнера. Некорректное (нераспознаваемое)
значение `timezone` в конфиге SHALL приводить к ошибке на старте приложения
(валидация конфигурации), а не к тихой подмене зоны на рендеринге.
#### Scenario: Дата в сконфигурированной зоне
- **WHEN** в конфиге `timezone = "Europe/Moscow"` и рендерится карточка загрузки
- **THEN** абсолютная дата добавления показана в зоне `Europe/Moscow`
- **AND** та же метка в БД хранится в UTC с суффиксом `Z`
#### Scenario: Зона по умолчанию — UTC
- **WHEN** `[general].timezone` в конфиге не задан
- **THEN** времена в веб-UI отображаются в UTC
#### Scenario: Невалидная зона в конфиге
- **WHEN** `[general].timezone` содержит нераспознаваемое значение
- **THEN** приложение завершается с ошибкой конфигурации на старте
### Requirement: Действия обновляют интерфейс на месте
Мутирующие действия над загрузкой в списке (`/`) и на странице `/download/{id}` SHALL выполняться htmx-запросом и обновлять затронутую область HTML на месте (partial swap), без навигации на другую страницу и без сброса контекста списка (фильтр, поиск, страница пагинации, позиция прокрутки).
Сервер SHALL отвечать на такой запрос HTML-фрагментом обновлённой области, а не
редиректом.
Область свопа SHALL соответствовать поверхности действия: в списке — карточка
загрузки (`<article class="card">`) целиком, отражающая новое состояние, бейдж
и допустимый набор действий; на странице `/download/{id}` — содержимое
страницы, отражающее новое состояние загрузки. После свопа набор показанных
действий MUST соответствовать новому состоянию (см. «Действия соответствуют
состоянию»).
Поведение MUST деградировать без htmx: если запрос действия пришёл без признака
htmx (нет заголовка `HX-Request`), обработчик SHALL отвечать прежним
PRG-редиректом, и действие исполняется тем же доменным вызовом. Формы действий
остаются обычными POST-формами.
Ошибка действия (доменная или валидации) SHALL показываться на месте — в
подменённом фрагменте той же области, — а не только через параметр `?err=`
после редиректа; при ошибке активное состояние загрузки не меняется молча.
Ответ на htmx-запрос действия SHALL иметь статус `200` даже при ошибке действия
(иначе htmx не подменит фрагмент): сообщение об ошибке несёт сам фрагмент.
После свопа карточка SHALL оставаться на своём месте в списке, даже если её
новое состояние уже не подходит под активный фильтр; согласованность списка с
фильтром восстанавливается при следующей полной загрузке. Клиентского
переупорядочивания или пересчёта доменного состояния не выполняется.
#### Scenario: Откат из карточки списка обновляет карточку на месте
- **GIVEN** в списке есть загрузка в состоянии `done` с действием отката
- **WHEN** пользователь нажимает «Откатить» (htmx активен)
- **THEN** карточка этой загрузки подменяется на месте на её новое состояние
(`reverted`) с соответствующим бейджем и набором действий
- **AND** список не перезагружается: фильтр, поиск, страница и позиция прокрутки
сохраняются
#### Scenario: Действие со страницы загрузки оставляет на странице
- **GIVEN** открыта страница `GET /download/{id}` загрузки в состоянии `done`
- **WHEN** пользователь нажимает «Откатить» или «Привязать заново» (htmx активен)
- **THEN** содержимое страницы обновляется на месте под новое состояние
загрузки, без перехода на список и без прыжка прокрутки наверх
#### Scenario: Деградация без htmx — прежний редирект
- **WHEN** действие над загрузкой приходит POST-запросом без заголовка
`HX-Request` (htmx недоступен)
- **THEN** обработчик исполняет то же доменное действие и отвечает
PRG-редиректом, как раньше; поведение без JavaScript не ломается
#### Scenario: Ошибка действия показана на месте
- **GIVEN** пользователь запускает действие через htmx
- **WHEN** доменный вызов возвращает ошибку (например, состояние уже изменилось)
- **THEN** ответ имеет статус `200`, а сообщение об ошибке показывается в
подменённом фрагменте той же области, а не только на отдельной странице после
редиректа
- **AND** активное состояние загрузки не меняется
#### Scenario: Свопнутая карточка остаётся вне фильтра
- **GIVEN** список отфильтрован по группе состояний (например, `review`) и в нём
есть карточка загрузки
- **WHEN** действие через htmx переводит загрузку в состояние вне этого фильтра
(например, `cancelled`)
- **THEN** карточка подменяется на месте новым состоянием и остаётся видимой до
следующей полной загрузки списка, без клиентского переупорядочивания
### 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** ни строки, ни страница целиком самообновления не несут, и фоновых
запросов по ним не уходит
### Requirement: Отображение промежуточного состояния catched
Веб-UI SHALL отображать состояние `catched` как штатную промежуточную фазу
(«поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь
понятную человекочитаемую подпись для `catched` (а не сырое `catched`), а
загрузка в `catched` SHALL относиться к **активной** группе списка.
Пока отображаемое имя ещё не выведено (в `catched` `download.display_name`
пуст), заголовок загрузки SHALL деградировать по существующему фолбеку
(распознанное название или усечённый источник) — см. «Заголовок загрузки из
имени раздачи». Секция раздачи/живого прогресса для `catched` SHALL корректно
отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.
Самообновление карточки и страницы в `catched` — частный случай требования
«Самообновление живой задачи»: `catched` нетерминален, поэтому интерфейс
подхватывает переход в `downloading` (бейдж, выведенное имя, появившийся живой
прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой
фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс
обновляется сам.
#### Scenario: Бейдж и группа для catched
- **WHEN** загрузка находится в состоянии `catched`
- **THEN** её бейдж статуса имеет человекочитаемую подпись для `catched`
- **AND** загрузка попадает в активную группу списка
#### Scenario: Заголовок catched без имени
- **GIVEN** загрузка в `catched` с пустым `download.display_name`
- **WHEN** рендерится карточка/страница загрузки
- **THEN** заголовок берётся из фолбека (распознанное название или усечённый
источник), без ошибок отображения
- **AND** секция раздачи/живого прогресса не показывается (раздачи ещё нет)
#### Scenario: Самообновление при переходе в downloading
- **GIVEN** открытая карточка загрузки в `catched`
- **WHEN** worker перевёл загрузку в `downloading`
- **THEN** интерфейс без перезагрузки показывает состояние `downloading`
(бейдж, имя, живой прогресс)
- **AND** самообновление продолжается, потому что задача осталась наблюдаемой
### Requirement: Загрузка .torrent-файла на форме добавления
Форма добавления загрузки веб-UI SHALL позволять выбрать локальный
`.torrent`-файл рядом со строкой ввода источника (кнопка/поле выбора файла).
При отправке формы с выбранным файлом система SHALL принять его байты
(`multipart/form-data`) и провести приём по `.torrent` (см. `ingest` «Приём
источника из .torrent-файла»); при пустом файловом поле — приём по тексту
источника, как прежде.
Файловый ввод SHALL деградировать без JavaScript: обычная отправка
`multipart`-формы SHALL приводить к приёму файла и тем же результатом, что и
htmx-путь (список обновляется/происходит редирект — как у существующего
добавления). Размер принимаемого файла UI/обработчик SHALL ограничивать (см.
ограничение размера в `ingest`); превышение SHALL давать понятную ошибку без
создания загрузки.
#### Scenario: Добавление выбором .torrent-файла
- **GIVEN** пользователь открыл форму добавления и выбрал `.torrent`-файл
- **WHEN** форма отправлена
- **THEN** файл принимается байтами и заводится загрузка (`source_type =
torrent`)
- **AND** список загрузок отражает новую задачу (как при добавлении по magnet)
#### Scenario: Файл не выбран — приём по тексту
- **GIVEN** пользователь оставил файловое поле пустым и ввёл magnet/текст
- **WHEN** форма отправлена
- **THEN** выполняется приём по тексту источника, как прежде
### Requirement: Режиссёр в блоке распознавания страницы загрузки
Страница просмотра `/download/{id}` в блоке «Распознано как» SHALL показывать
режиссёра эффективного источника, разрешённого теми же слоями, что и
отображаемое имя раздачи (`display_name`): первый непустой слой `override` →
`recognition`+матч → сохранённый при приёме контекст (`parsed_context`).
Разрешение режиссёра для поля блока и для отображаемого имени SHALL идти **единой
логикой** (общий источник разрешения), а не расходящимися путями — прежняя
захардкоженная в поле заглушка-прочерк при непустом режиссёре в заголовке
устраняется. Показанное значение SHALL проходить ту же очистку (санитайзинг
управляющих символов/пробелов), что и режиссёр внутри отображаемого имени, чтобы
присутствие/отсутствие режиссёра в поле и в заголовке определялось одинаково.
Когда режиссёр недоступен ни в одном слое, поле SHALL показывать прочерк, не
ломая вёрстку.
#### Scenario: Режиссёр из распознавания показан в блоке
- **GIVEN** загрузка, чей эффективный план несёт режиссёра (из матча метабазы или
закреплённого источника)
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** в блоке «Распознано как» в поле «Режиссёр» показан этот режиссёр
#### Scenario: Режиссёр из контекста при распознавании без матча
- **GIVEN** загрузка без режиссёра в плане, но с режиссёром в сохранённом
контексте (`parsed_context`)
- **WHEN** клиент открывает `GET /download/{id}`
- **THEN** в поле «Режиссёр» показан режиссёр из контекста
- **AND** он разрешён тем же нижним слоем контекста, что и режиссёр в
отображаемом имени раздачи (единая логика, не расходящиеся пути)
#### Scenario: Режиссёр неизвестен — прочерк
- **GIVEN** загрузка, для которой режиссёр не разрешается ни одним слоем
- **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** её строка несёт нейтральное сообщение публичного канала, а не текст
ошибки внешнего сервиса