From 16a82572e78b33ca7d9d502ab9c721e966722685 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 15 Jun 2026 07:42:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D1=83=D1=8E=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/architecture.md | 11 +++-- docs/specs/review-ux.md | 4 ++ internal/httpapi/httpapi.go | 3 ++ internal/httpapi/httpapi_test.go | 19 ++++++++ internal/httpapi/review.go | 17 +++++++ internal/worker/review.go | 62 ++++++++++++++++++++--- internal/worker/review_test.go | 84 ++++++++++++++++++++++++++++++++ internal/worker/worker.go | 1 + internal/worker/worker_test.go | 10 ++++ web/templates/index.html | 5 ++ 10 files changed, 206 insertions(+), 10 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index d16be85..554aaaf 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -53,9 +53,10 @@ ingest → downloading → completed → recognizing ──┬─ авто ─ │ └─ stuck (не качается дольше таймаута) └─ failed ⇄ retry -done → undo → reverted -review → «Позже» → deferred → review -любой → «Отклонить» → cancelled +done → undo → reverted +reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review) +review → «Позже» → deferred → review +любой → «Отклонить» → cancelled ``` - **ingest** — приняли источник + контекст, отдали в qBittorrent @@ -74,6 +75,10 @@ review → «Позже» → deferred → review созданные ссылки). - **deferred / cancelled / failed / stuck** — «Позже», «Отклонить», ошибка (ретраибельна), не качается дольше таймаута. +- **reverted → recognizing** — «Привязать заново»: после отката можно + перезапустить распознавание для той же раздачи. Перепривязка всегда идёт + через review с ручным подтверждением (авто-раскладку не делаем), и требует, + чтобы раздача всё ещё была в qBittorrent. Все переходы и команды идут через `worker` под per-download блокировкой — два транспорта не гонятся за одно состояние. Состояние персистентно в diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index d7c1f8e..8e413c5 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -123,6 +123,10 @@ Telegram = одобрить / подсказать / выбрать кандид действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** после применения → `reverted` (удаляет только ссылки своего батча, под `media`). Полная карта состояний — в [architecture.md](architecture.md). +- После отката доступна **«Привязать заново»**: перезапускает распознавание + для той же раздачи (`reverted → recognizing`) и снова приводит в review — + раскладка всегда требует ручного подтверждения, авто не делаем. Нужна, + когда распознали неверно: откатил, перепривязал, поправил и применил. ## Объём по версиям diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 4c0be61..9f67cfa 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -92,6 +92,7 @@ func NewRouter(d Deps) (http.Handler, error) { r.Post("/ui/downloads/{id}/nobase", s.handleNoBase) r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/undo", s.handleUndo) + r.Post("/ui/downloads/{id}/relink", s.handleRelink) // REST API. r.Route("/api", func(r chi.Router) { @@ -126,6 +127,7 @@ type downloadView struct { Terminal bool Reviewable bool // review/deferred — есть экран ревью Undoable bool // done — можно откатить раскладку + Relinkable bool // reverted — можно перепривязать заново } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -304,6 +306,7 @@ func toView(d store.Download) downloadView { Terminal: d.State.IsTerminal(), Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, + Relinkable: d.State == store.StateReverted, } } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index a5ba495..028302d 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -193,6 +193,7 @@ type fakeReviewer struct { applied []int64 deferred []int64 undone []int64 + relinked []int64 cleared []int64 } @@ -235,6 +236,10 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error { f.undone = append(f.undone, id) return nil } +func (f *fakeReviewer) Relink(_ context.Context, id int64) error { + f.relinked = append(f.relinked, id) + return nil +} func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error { if f.chosen == nil { f.chosen = map[int64]int64{} @@ -444,3 +449,17 @@ func TestUndoAndDefer(t *testing.T) { t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred) } } + +func TestRelink(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + cl := noRedirectClient() + + if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil { + t.Fatal(err) + } + if len(rv.relinked) != 1 || rv.relinked[0] != 1 { + t.Errorf("relinked = %v, want [1]", rv.relinked) + } +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 6293158..32a7781 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -20,6 +20,7 @@ type Reviewer interface { IgnoreFile(ctx context.Context, id int64, src string) error Defer(ctx context.Context, id int64) error Undo(ctx context.Context, id int64) error + Relink(ctx context.Context, id int64) error ChooseCandidate(ctx context.Context, id, candidateID int64) error SetProviderID(ctx context.Context, id int64, provider, providerID string) error ClearProvider(ctx context.Context, id int64) error @@ -233,6 +234,22 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } +// handleRelink повторно привязывает откатанную задачу: перезапускает +// распознавание, задача пройдёт recognizing → review для подтверждения. +func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil { + s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err) + redirectErr(w, r, err.Error()) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + // reviewAction — общий помощник: выполнить действие и вернуться на страницу // ревью (с ошибкой в ?err при неудаче). func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) { diff --git a/internal/worker/review.go b/internal/worker/review.go index 46ee97a..8a1c434 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -21,10 +21,11 @@ import ( const ( ovrMediaType = "media_type" ovrIgnoredFiles = "ignored_files" - ovrProvider = "provider" // выбранная база ("none" = без базы) - ovrProviderID = "provider_id" // id в выбранной базе - ovrTitle = "title" // запиненное каноническое название - ovrYear = "year" // запиненный год + ovrProvider = "provider" // выбранная база ("none" = без базы) + ovrProviderID = "provider_id" // id в выбранной базе + ovrTitle = "title" // запиненное каноническое название + ovrYear = "year" // запиненный год + ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать ) // recognizePending распознаёт завершённые загрузки и перезапускает те, что @@ -178,9 +179,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize. } // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); - // иначе — review. Раскладчик может быть не сконфигурирован. - if res.Decision.Auto && w.layouter != nil { - plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id)) + // иначе — review. Раскладчик может быть не сконфигурирован. При ручной + // перепривязке (force_review) авто-раскладку не делаем — нужно явное + // подтверждение человеком. + overrides := w.overridesOrNil(ctx, id) + forceReview := overrides[ovrForceReview] == "1" + if res.Decision.Auto && !forceReview && w.layouter != nil { + plan := applyOverrides(res.Plan, overrides) w.transition(ctx, *d, store.StateLinking, "", "") if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil { w.log.Warn("recognize: auto-apply failed, left for review", @@ -281,6 +286,49 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize return nil } +// Relink повторно привязывает откатанную задачу (reverted): возвращает её на +// распознавание, и поллинг-цикл перезапустит recognize. Авто-раскладку при +// этом не делаем — ручная перепривязка всегда проходит через ревью с +// подтверждением (force_review). Источник (раздача в qBittorrent) для этого +// должен быть на месте. +func (w *Worker) Relink(ctx context.Context, id int64) error { + w.mu.Lock() + defer w.mu.Unlock() + + d, err := w.store.GetDownload(ctx, id) + if err != nil { + return fmt.Errorf("relink: %w", err) + } + if d.State != store.StateReverted { + return fmt.Errorf("relink: download %d is in state %s (expected reverted)", id, d.State) + } + if !d.Infohash.Valid { + return fmt.Errorf("relink: download %d has no infohash", id) + } + // Раздача должна ещё быть в qBittorrent — без неё распознавать нечего. + if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil { + return fmt.Errorf("relink: %w", terr) + } else if !ok { + return fmt.Errorf("relink: торрент не найден в qBittorrent") + } + // Вернуть задачу в активную обработку можно, только если другой активной + // задачи на этот infohash нет (partial unique index по idempotency_key). + active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String) + if err != nil { + return fmt.Errorf("relink: %w", err) + } + if active != nil { + return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID) + } + // Ручная перепривязка — всегда с подтверждением, без авто-раскладки. + if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil { + return fmt.Errorf("relink: %w", err) + } + w.transition(ctx, *d, store.StateRecognizing, "", "") + w.log.Info("relink: re-recognizing reverted download", "download_id", id) + return nil +} + // Refine добавляет подсказку и отправляет задачу на перераспознавание. func (w *Worker) Refine(ctx context.Context, id int64, hint string) error { hint = strings.TrimSpace(hint) diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 52de072..eada4f4 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -97,6 +97,80 @@ func TestScanner_FiresOnDone(t *testing.T) { } } +func revertedDownload(id int64) *store.Download { + d := completedDownload(id) + d.State = store.StateReverted + return d +} + +func TestRelink_RevertedToRecognizing(t *testing.T) { + st := newMemStore() + st.put(revertedDownload(1)) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}} + w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) + + if err := w.Relink(context.Background(), 1); err != nil { + t.Fatalf("Relink: %v", err) + } + if st.downloads[1].State != store.StateRecognizing { + t.Fatalf("state = %q, want recognizing", st.downloads[1].State) + } + if st.overrides[1][ovrForceReview] != "1" { + t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview]) + } +} + +func TestRelink_RejectsNonReverted(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) // не reverted + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} + w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) + + if err := w.Relink(context.Background(), 1); err == nil { + t.Fatal("ожидали ошибку для не-reverted задачи, получили nil") + } +} + +func TestRelink_TorrentMissing(t *testing.T) { + st := newMemStore() + st.put(revertedDownload(1)) + qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет + w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) + + if err := w.Relink(context.Background(), 1); err == nil { + t.Fatal("ожидали ошибку при отсутствии торрента, получили nil") + } + if st.downloads[1].State != store.StateReverted { + t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State) + } +} + +// TestRelink_ForceReviewSkipsAuto проверяет, что после перепривязки даже +// уверенный матч не уходит в авто-раскладку, а ждёт подтверждения в review. +func TestRelink_ForceReviewSkipsAuto(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + // Готовим состояние «как после Relink»: reverted, force_review выставлен. + f.st.downloads[1].State = store.StateReverted + _ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1") + + auto := seriesResult() + auto.Decision.Auto = true + auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"} + f.w.recognizer = &fakeRecognizer{result: auto} + + if err := f.w.Relink(context.Background(), 1); err != nil { + t.Fatalf("Relink: %v", err) + } + f.w.recognizeOne(context.Background(), 1) + + if f.st.downloads[1].State != store.StateReview { + t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State) + } + if len(f.st.links) != 0 { + t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links)) + } +} + // memStore — полноценный in-memory store для тестов Ф3. type memStore struct { downloads map[int64]*store.Download @@ -138,6 +212,16 @@ func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, e return false, nil } +func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) { + for _, d := range m.downloads { + if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() { + cp := *d + return &cp, nil + } + } + return nil, nil +} + func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) { id := int64(len(m.downloads) + 1) cp := *d diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 1f46c52..73e6761 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -33,6 +33,7 @@ type Store interface { // Discovery (усыновление раздач по категории/тегу). ExistsByInfohash(ctx context.Context, infohash string) (bool, error) + FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error) CreateDownload(ctx context.Context, d *store.Download) (int64, error) // Ф3: распознавание, ревью, раскладка. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index a82fddb..2ed19e1 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -60,6 +60,16 @@ func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, return false, nil } +func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) { + for _, d := range f.downloads { + if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() { + cp := *d + return &cp, nil + } + } + return nil, nil +} + func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) { id := int64(len(f.downloads) + 1) cp := *d diff --git a/web/templates/index.html b/web/templates/index.html index a07ebe0..4885fe4 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -69,6 +69,11 @@ {{end}} + {{if .Relinkable}} +
+ +
+ {{end}} {{if not .Terminal}}