Files
jellybit/internal/store/list.go
T
avandClaude Opus 4.8 0d263270cb Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:29:28 +03:00

119 lines
5.4 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 store
import (
"context"
"fmt"
"strings"
)
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
type StateGroup string
const (
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
GroupReview StateGroup = "review" // ждут человека
GroupActive StateGroup = "active" // в работе
GroupDone StateGroup = "done" // готово
GroupProblem StateGroup = "problem" // проблемы
)
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
// только при IncludeDeleted), точечно к ним не фильтруем (см. 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 (только под 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: скрываем удалённые по умолчанию (шум в ленте).
conds = append(conds, "state <> ?")
args = append(args, string(StateDeleted))
}
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 подтягивается 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
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
}