Быстрый приём: сохранение в 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>
This commit is contained in:
av
2026-07-07 21:29:28 +03:00
co-authored by Claude Opus 4.8
parent f0ce6b4bc8
commit 0d263270cb
30 changed files with 1198 additions and 348 deletions
+26
View File
@@ -20,6 +20,7 @@ import (
type State string
const (
StateCatched State = "catched" // поймано и сохранено; worker добавит в qBittorrent
StateDownloading State = "downloading"
StateCompleted State = "completed"
StateRecognizing State = "recognizing" // Ф2
@@ -503,6 +504,31 @@ func (s *Store) SetDownloadState(ctx context.Context, id string, state State, er
return setState(ctx, s.DB, id, state, errCode, errMsg, false)
}
// PromoteCatched переводит пойманную загрузку catched → downloading, попутно
// записывая выведенное отображаемое имя. Гард `state = 'catched'` — это
// ре-валидация: если загрузку успели отменить (catched → cancelled) во время
// вывода имени/добавления вне блокировки переходов, UPDATE не заденет ни строки
// и вернёт ошибку, а переход не применится. Пустое имя допустимо (rename не
// задавали) — тогда display_name так и остаётся пустым.
func (s *Store) PromoteCatched(ctx context.Context, id, displayName string) error {
res, err := s.DB.ExecContext(ctx, `
UPDATE download
SET state = ?, display_name = ?, updated_at = ?
WHERE id = ? AND state = ?`,
string(StateDownloading), displayName, FormatTime(Now()), id, string(StateCatched))
if err != nil {
return fmt.Errorf("promote catched %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("promote catched %s: %w", id, err)
}
if n == 0 {
return fmt.Errorf("promote catched %s: not in catched (already added or cancelled)", id)
}
return nil
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.