Быстрый приём: сохранение в 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 не даёт молча оживить терминальную задачу.
+58
View File
@@ -27,6 +27,64 @@ func newDownloading() *Download {
}
}
func newCatched() *Download {
return &Download{
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:test",
Context: "ctx",
State: StateCatched,
}
}
// catched — нетерминальное активное состояние: его наличие блокирует повторный
// приём того же infohash (инвариант «≤1 активная на infohash»).
func TestCatchedIsActiveForDedup(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "aabbccddeeff00112233445566778899aabbccdd"
d1 := newCatched()
if existing, err := st.CreateDownloadIfNoActive(ctx, d1, []string{ih}); err != nil || existing != nil {
t.Fatalf("первый catched: existing=%v err=%v", existing, err)
}
// Повторный приём того же хеша → дедуп на активную catched-задачу.
existing, err := st.CreateDownloadIfNoActive(ctx, newCatched(), []string{ih})
if err != nil {
t.Fatalf("повторный приём: %v", err)
}
if existing == nil || existing.ID != d1.ID {
t.Errorf("ожидался дедуп на catched %s, got %v", d1.ID, existing)
}
}
func TestPromoteCatched(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "aabbccddeeff00112233445566778899aabbccdd"
d := newCatched()
if _, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih}); err != nil {
t.Fatalf("create: %v", err)
}
if err := st.PromoteCatched(ctx, d.ID, "Дюна (2024)"); err != nil {
t.Fatalf("promote: %v", err)
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.State != StateDownloading {
t.Errorf("state = %q, want downloading", got.State)
}
if got.DisplayName != "Дюна (2024)" {
t.Errorf("display_name = %q", got.DisplayName)
}
// Повторный promote (уже не catched) — отклоняется гардом state='catched'.
if err := st.PromoteCatched(ctx, d.ID, "X"); err == nil {
t.Error("ожидалась ошибка promote для не-catched задачи")
}
}
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
// существующую активную — ошибка теста.
func mustCreate(t *testing.T, st *Store, infohash string) string {
+1 -1
View File
@@ -28,7 +28,7 @@ func statesInGroup(g StateGroup) []State {
case GroupReview:
return []State{StateReview, StateDeferred}
case GroupActive:
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
return []State{StateCatched, StateDownloading, StateCompleted, StateRecognizing, StateLinking}
case GroupDone:
return []State{StateDone}
case GroupProblem: