web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
+36 -4
View File
@@ -89,6 +89,41 @@ func listWhere(f ListFilter) (string, []any) {
return " WHERE " + strings.Join(conds, " AND "), args
}
// listSelect и listOrder — общая форма выборки списка загрузок: LEFT JOIN
// текущей попытки распознавания (фолбэк заголовка и значок типа) и порядок по
// времени добавления с tie-break по id. Держим одним куском, чтобы страница
// списка и страница группового удаления не разъехались порядком.
const listSelect = `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1`
const listOrder = `
ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC`
// ListDeletableDownloads возвращает все загрузки, для которых домен допускает
// полное удаление, без постраничной выдачи: страница группового удаления
// показывает их разом, а разбиение по страницам лишило бы возможности выбрать
// пачку. Перечень состояний берётся из единой точки домена — своего списка
// запрос не держит.
func (s *Store) ListDeletableDownloads(ctx context.Context) ([]Download, error) {
ph := make([]string, len(deletableStates))
args := make([]any, len(deletableStates))
for i, st := range deletableStates {
ph[i] = "?"
args[i] = string(st)
}
q := listSelect + " WHERE download.state IN (" + strings.Join(ph, ",") + ")" + listOrder
var out []Download
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
if err := s.attachInfohashes(ctx, out); err != nil {
return nil, fmt.Errorf("list deletable downloads: %w", err)
}
return out, nil
}
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
@@ -104,10 +139,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
}
q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type
FROM download
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
q := listSelect + where + listOrder + `
LIMIT ? OFFSET ?`
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)