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 } // 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 := `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 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 }