Быстрый приём: сохранение в 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
+2
View File
@@ -20,6 +20,7 @@ type downloadDetailView struct {
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string
State string
SelfPoll bool // catched → страница сама опрашивает себя до перехода
Error string
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
Note string
@@ -99,6 +100,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
SelfPoll: d.State == store.StateCatched,
Error: d.ErrorMsg.String,
Note: desyncNote(d.State),
CreatedAt: d.CreatedAt,
+5
View File
@@ -113,6 +113,9 @@ func NewRouter(d Deps) (http.Handler, error) {
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
// имя и появившийся прогресс обновляются без перезагрузки).
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
r.Post("/ui/downloads", s.handleUIAdd)
@@ -195,6 +198,7 @@ type downloadView struct {
Error string
Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
@@ -618,6 +622,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading,
SelfPoll: d.State == store.StateCatched,
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
+27
View File
@@ -246,6 +246,33 @@ func TestIndexRenders(t *testing.T) {
}
}
// Карточка catched несёт самозавершающийся htmx-поллинг (обновится без
// перезагрузки при переходе в downloading) и человекочитаемый бейдж.
func TestCatchedCardSelfPolls(t *testing.T) {
reader := &fakeReader{get: &store.Download{
ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
State: store.StateCatched,
}}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
s := string(body)
if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) {
t.Errorf("карточка catched без самополлинга: %s", s)
}
if !strings.Contains(s, "принято, добавляется") {
t.Errorf("нет подписи бейджа catched: %s", s)
}
}
type ingestErr string
func (e ingestErr) Error() string { return string(e) }
+20
View File
@@ -96,6 +96,26 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
s.render(w, "progress", buildProgress(id, active, l, ok))
}
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched):
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая
// карточка уже не несёт самополлинга — цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
// живого снимка внутри buildCardView.
s.render(w, "card", s.buildCardView(*d, time.Now(), 0))
}
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
+2
View File
@@ -40,6 +40,8 @@ func staticHandler(fsys fs.FS) http.Handler {
// как есть, чтобы не терять его в UI.
func badgeLabel(state string) string {
switch state {
case "catched":
return "🎣 принято, добавляется"
case "downloading":
return "⬇ качается"
case "completed":