Добавил ручную перепривязку

This commit is contained in:
2026-06-15 07:42:50 +03:00
parent 093211c9c7
commit 16a82572e7
10 changed files with 206 additions and 10 deletions
+5
View File
@@ -54,6 +54,7 @@ ingest → downloading → completed → recognizing ──┬─ авто ─
└─ failed ⇄ retry └─ failed ⇄ retry
done → undo → reverted done → undo → reverted
reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
review → «Позже» → deferred → review review → «Позже» → deferred → review
любой → «Отклонить» → cancelled любой → «Отклонить» → cancelled
``` ```
@@ -74,6 +75,10 @@ review → «Позже» → deferred → review
созданные ссылки). созданные ссылки).
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить», - **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
ошибка (ретраибельна), не качается дольше таймаута. ошибка (ретраибельна), не качается дольше таймаута.
- **reverted → recognizing** — «Привязать заново»: после отката можно
перезапустить распознавание для той же раздачи. Перепривязка всегда идёт
через review с ручным подтверждением (авто-раскладку не делаем), и требует,
чтобы раздача всё ещё была в qBittorrent.
Все переходы и команды идут через `worker` под per-download блокировкой — Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в два транспорта не гонятся за одно состояние. Состояние персистентно в
+4
View File
@@ -123,6 +123,10 @@ Telegram = одобрить / подсказать / выбрать кандид
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md). `media`). Полная карта состояний — в [architecture.md](architecture.md).
- После отката доступна **«Привязать заново»**: перезапускает распознавание
для той же раздачи (`reverted → recognizing`) и снова приводит в review —
раскладка всегда требует ручного подтверждения, авто не делаем. Нужна,
когда распознали неверно: откатил, перепривязал, поправил и применил.
## Объём по версиям ## Объём по версиям
+3
View File
@@ -92,6 +92,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase) r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
// REST API. // REST API.
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
@@ -126,6 +127,7 @@ type downloadView struct {
Terminal bool Terminal bool
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted — можно перепривязать заново
} }
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -304,6 +306,7 @@ func toView(d store.Download) downloadView {
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted,
} }
} }
+19
View File
@@ -193,6 +193,7 @@ type fakeReviewer struct {
applied []int64 applied []int64
deferred []int64 deferred []int64
undone []int64 undone []int64
relinked []int64
cleared []int64 cleared []int64
} }
@@ -235,6 +236,10 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id) f.undone = append(f.undone, id)
return nil 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 { func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil { if f.chosen == nil {
f.chosen = map[int64]int64{} f.chosen = map[int64]int64{}
@@ -444,3 +449,17 @@ func TestUndoAndDefer(t *testing.T) {
t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred) 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)
}
}
+17
View File
@@ -20,6 +20,7 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id int64, src string) error IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Undo(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 ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) 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) 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 — общий помощник: выполнить действие и вернуться на страницу // reviewAction — общий помощник: выполнить действие и вернуться на страницу
// ревью (с ошибкой в ?err при неудаче). // ревью (с ошибкой в ?err при неудаче).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) { func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) {
+51 -3
View File
@@ -25,6 +25,7 @@ const (
ovrProviderID = "provider_id" // id в выбранной базе ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год ovrYear = "year" // запиненный год
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
) )
// recognizePending распознаёт завершённые загрузки и перезапускает те, что // recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -178,9 +179,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
} }
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован. // иначе — review. Раскладчик может быть не сконфигурирован. При ручной
if res.Decision.Auto && w.layouter != nil { // перепривязке (force_review) авто-раскладку не делаем — нужно явное
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id)) // подтверждение человеком.
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, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil { if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
w.log.Warn("recognize: auto-apply failed, left for review", 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 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 добавляет подсказку и отправляет задачу на перераспознавание. // Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error { func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
hint = strings.TrimSpace(hint) hint = strings.TrimSpace(hint)
+84
View File
@@ -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. // memStore — полноценный in-memory store для тестов Ф3.
type memStore struct { type memStore struct {
downloads map[int64]*store.Download downloads map[int64]*store.Download
@@ -138,6 +212,16 @@ func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, e
return false, nil 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) { func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(m.downloads) + 1) id := int64(len(m.downloads) + 1)
cp := *d cp := *d
+1
View File
@@ -33,6 +33,7 @@ type Store interface {
// Discovery (усыновление раздач по категории/тегу). // Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error) 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) CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Ф3: распознавание, ревью, раскладка. // Ф3: распознавание, ревью, раскладка.
+10
View File
@@ -60,6 +60,16 @@ func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool,
return false, nil 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) { func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1) id := int64(len(f.downloads) + 1)
cp := *d cp := *d
+5
View File
@@ -69,6 +69,11 @@
<button type="submit">Откатить</button> <button type="submit">Откатить</button>
</form> </form>
{{end}} {{end}}
{{if .Relinkable}}
<form method="post" action="/ui/downloads/{{.ID}}/relink">
<button type="submit">Привязать заново</button>
</form>
{{end}}
{{if not .Terminal}} {{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel"> <form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button> <button type="submit">Отклонить</button>