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 + "." }