- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа на обеих границах, потолок времени и остановка после трёх подряд отказов внешнего сервиса - допуск полного удаления сведён в единую точку store.State.CanDelete() — worker, страница загрузки и Telegram больше не держат своих перечней
155 lines
7.7 KiB
Go
155 lines
7.7 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
|
||
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
|
||
type StateGroup string
|
||
|
||
const (
|
||
GroupAll StateGroup = "all" // все, кроме скрытых терминальных (deleted/cancelled), если не IncludeDeleted
|
||
GroupReview StateGroup = "review" // ждут человека
|
||
GroupActive StateGroup = "active" // в работе
|
||
GroupDone StateGroup = "done" // готово
|
||
GroupProblem StateGroup = "problem" // проблемы
|
||
)
|
||
|
||
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
|
||
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
|
||
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
|
||
// reverted/deleted своей группы (чипа) не имеют. Под all скрытые терминальные
|
||
// (deleted и cancelled — шум в ленте) видны только при IncludeDeleted; reverted
|
||
// остаётся видимым по умолчанию. Точечно к ним не фильтруем (см. tasks 2.1).
|
||
func statesInGroup(g StateGroup) []State {
|
||
switch g {
|
||
case GroupReview:
|
||
return []State{StateReview, StateDeferred}
|
||
case GroupActive:
|
||
return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||
case GroupDone:
|
||
return []State{StateDone}
|
||
case GroupProblem:
|
||
return []State{StateStuck, StateFailed, StateTargetMissing, StateOrphaned}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// ListFilter — параметры страницы списка загрузок.
|
||
type ListFilter struct {
|
||
Group StateGroup // группа состояний ("" / all — без сужения)
|
||
Query string // поиск по id/названию/infohash/контексту (подстрока)
|
||
IncludeDeleted bool // показывать скрытые терминальные deleted+cancelled (только под all)
|
||
Limit int // размер страницы
|
||
Offset int // смещение страницы
|
||
}
|
||
|
||
// listWhere строит условие WHERE и его аргументы по фильтру.
|
||
func listWhere(f ListFilter) (string, []any) {
|
||
var conds []string
|
||
var args []any
|
||
|
||
if states := statesInGroup(f.Group); states != nil {
|
||
ph := make([]string, len(states))
|
||
for i, st := range states {
|
||
ph[i] = "?"
|
||
args = append(args, string(st))
|
||
}
|
||
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
|
||
} else if !f.IncludeDeleted {
|
||
// Группа all: скрываем терминальные deleted и cancelled по умолчанию (шум
|
||
// в ленте; cancelled плодит универсальный стоп-кран «Закрыть»). reverted
|
||
// оставляем видимым. Оба показываются тумблером IncludeDeleted.
|
||
conds = append(conds, "state NOT IN (?, ?)")
|
||
args = append(args, string(StateDeleted), string(StateCancelled))
|
||
}
|
||
|
||
if q := strings.TrimSpace(f.Query); q != "" {
|
||
// LIKE даёт регистронезависимость только для ASCII (встроенное
|
||
// case-folding SQLite), для кириллицы поиск чувствителен к регистру —
|
||
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
|
||
// кириллические названия ищем как введены.
|
||
like := "%" + q + "%"
|
||
conds = append(conds, "(download.id LIKE ? COLLATE NOCASE "+
|
||
"OR source_ref LIKE ? COLLATE NOCASE "+
|
||
"OR display_name LIKE ? COLLATE NOCASE "+
|
||
"OR context LIKE ? COLLATE NOCASE "+
|
||
"OR EXISTS (SELECT 1 FROM download_infohash dh "+
|
||
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
|
||
args = append(args, like, like, like, like, like)
|
||
}
|
||
|
||
if len(conds) == 0 {
|
||
return "", args
|
||
}
|
||
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
|
||
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
|
||
// задваивались бы/пропадали между страницами. RecTitle и RecMediaType
|
||
// подтягиваются LEFT JOIN текущей попытки распознавания — для фолбека заголовка
|
||
// и значка типа карточки.
|
||
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
|
||
where, args := listWhere(f)
|
||
|
||
var total int
|
||
if err := s.DB.GetContext(ctx, &total, `SELECT COUNT(*) FROM download`+where, args...); err != nil {
|
||
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
|
||
}
|
||
|
||
q := listSelect + where + listOrder + `
|
||
LIMIT ? OFFSET ?`
|
||
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||
|
||
var out []Download
|
||
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||
}
|
||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||
}
|
||
return out, total, nil
|
||
}
|