Добавил ручную перепривязку
This commit is contained in:
@@ -53,9 +53,10 @@ ingest → downloading → completed → recognizing ──┬─ авто ─
|
|||||||
│ └─ stuck (не качается дольше таймаута)
|
│ └─ stuck (не качается дольше таймаута)
|
||||||
└─ failed ⇄ retry
|
└─ failed ⇄ retry
|
||||||
|
|
||||||
done → undo → reverted
|
done → undo → reverted
|
||||||
review → «Позже» → deferred → review
|
reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
|
||||||
любой → «Отклонить» → cancelled
|
review → «Позже» → deferred → review
|
||||||
|
любой → «Отклонить» → cancelled
|
||||||
```
|
```
|
||||||
|
|
||||||
- **ingest** — приняли источник + контекст, отдали в qBittorrent
|
- **ingest** — приняли источник + контекст, отдали в qBittorrent
|
||||||
@@ -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 блокировкой —
|
||||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ Telegram = одобрить / подсказать / выбрать кандид
|
|||||||
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
|
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
|
||||||
после применения → `reverted` (удаляет только ссылки своего батча, под
|
после применения → `reverted` (удаляет только ссылки своего батча, под
|
||||||
`media`). Полная карта состояний — в [architecture.md](architecture.md).
|
`media`). Полная карта состояний — в [architecture.md](architecture.md).
|
||||||
|
- После отката доступна **«Привязать заново»**: перезапускает распознавание
|
||||||
|
для той же раздачи (`reverted → recognizing`) и снова приводит в review —
|
||||||
|
раскладка всегда требует ручного подтверждения, авто не делаем. Нужна,
|
||||||
|
когда распознали неверно: откатил, перепривязал, поправил и применил.
|
||||||
|
|
||||||
## Объём по версиям
|
## Объём по версиям
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
ovrMediaType = "media_type"
|
ovrMediaType = "media_type"
|
||||||
ovrIgnoredFiles = "ignored_files"
|
ovrIgnoredFiles = "ignored_files"
|
||||||
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
||||||
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: распознавание, ревью, раскладка.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user