Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.
- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
привязывается к существующей записи (спящей, без обращения к qBittorrent),
а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
дедупе ведёт на страницу существующей записи.
Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -490,6 +490,7 @@ type fakeReviewer struct {
|
||||
deferred []string
|
||||
undone []string
|
||||
deleted []string
|
||||
dismissed []string
|
||||
relinked []string
|
||||
rerecognized []string
|
||||
cleared []string
|
||||
@@ -532,6 +533,10 @@ func (f *fakeReviewer) Delete(_ context.Context, id string) error {
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Dismiss(_ context.Context, id string) error {
|
||||
f.dismissed = append(f.dismissed, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
|
||||
f.relinked = append(f.relinked, id)
|
||||
return nil
|
||||
@@ -614,6 +619,28 @@ func noRedirectClient() *http.Client {
|
||||
}}
|
||||
}
|
||||
|
||||
// Веб-приём при дедупе (в т.ч. на «спящую» desync-запись) ведёт на страницу
|
||||
// существующей записи, а не на список — пользователь видит, что нового не завели.
|
||||
func TestUIAddDeduplicatedRedirectsToRecord(t *testing.T) {
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads",
|
||||
strings.NewReader("source=magnet:?xt=urn:btih:abc"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := noRedirectClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
|
||||
t.Errorf("Location = %q, want /download/%s", loc, tid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewRenders(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
@@ -765,6 +792,45 @@ func TestRefreshNameHTMXSwapsMain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Стоп-кран «Закрыть» рендерится в danger-зоне для терминального состояния
|
||||
// (target_missing) и постит на /dismiss.
|
||||
func TestDownloadPageShowsDismissButtonOnTerminal(t *testing.T) {
|
||||
rd := seriesReviewData()
|
||||
rd.Download.State = store.StateTargetMissing
|
||||
rv := &fakeReviewer{data: rd}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Get(srv.URL + "/download/" + tid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "/dismiss") || !strings.Contains(string(body), "Закрыть загрузку") {
|
||||
t.Error("кнопка «Закрыть» не показана в danger-зоне для target_missing")
|
||||
}
|
||||
}
|
||||
|
||||
// POST /ui/downloads/{id}/dismiss вызывает Reviewer.Dismiss.
|
||||
func TestUIDismiss(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/dismiss", "application/x-www-form-urlencoded", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if len(rv.dismissed) != 1 || rv.dismissed[0] != tid {
|
||||
t.Errorf("Dismiss вызван неверно: %v", rv.dismissed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
|
||||
// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
|
||||
// done (есть план) она всё равно доступна.
|
||||
|
||||
Reference in New Issue
Block a user