Быстрый приём: сохранение в 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:
@@ -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 не даёт молча оживить терминальную задачу.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user