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

290 lines
15 KiB
Go
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.
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 + "."
}