Приём: дедуп по 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:
av
2026-07-10 20:15:37 +03:00
co-authored by Claude Opus 4.8
parent b8657120fe
commit 1369a9cabe
26 changed files with 1157 additions and 68 deletions
+13 -2
View File
@@ -65,8 +65,10 @@ stateDiagram-v2
deleted --> [*]
note right of cancelled
«Отклонить» доступно из любого
нетерминального состояния
В cancelled ведут: «Отклонить»
(из нетерминальных) и «Закрыть»
(стоп-кран — из любого состояния,
кроме deleted; только статус)
end note
```
@@ -134,6 +136,15 @@ stateDiagram-v2
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.
**Закрыть (dismiss).** Универсальный стоп-кран из любого состояния, кроме
`deleted`: переводит запись в терминальный `cancelled` (`error_code =
"user_dismiss"`), **только меняя статус** — ни файлы (библиотечные хардлинки
`done`/`orphaned` остаются на месте), ни раздачу в qBittorrent не трогает, в
отличие от «Удалить». Служит закрытием зависшей/спорной/лишней записи (напр.
дубля-близнеца в `target_missing`); из `cancelled` дальше доступна перепривязка.
Для нетерминальных ту же роль штатно играет «Отменить» — в UI стоп-кран
показывается там, где иного выхода нет (терминальные, кроме `deleted`).
Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
+2
View File
@@ -26,6 +26,7 @@ type actionReviewer struct {
stubReviewer
undoErr error
deleteErr error
dismissErr error
relinkErr error
rerecognizeErr error
refineErr error
@@ -34,6 +35,7 @@ type actionReviewer struct {
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
func (a actionReviewer) Delete(context.Context, string) error { return a.deleteErr }
func (a actionReviewer) Dismiss(context.Context, string) error { return a.dismissErr }
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
+7
View File
@@ -59,6 +59,11 @@ type downloadDetailView struct {
Relinkable bool
Retriable bool
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
// Dismissable — доступен стоп-кран «Закрыть» (перевод в cancelled без
// действий над файлами/раздачей). Показываем в danger-зоне для терминальных,
// кроме deleted (строго терминален) и cancelled (уже закрыта, no-op); у
// не-терминальных ту же роль играет обычная «Отменить» — не дублируем.
Dismissable bool
}
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
@@ -119,6 +124,8 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
d.State == store.StateTargetMissing,
Dismissable: d.State.IsTerminal() &&
d.State != store.StateDeleted && d.State != store.StateCancelled,
}
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
// как в порядке и карточках списка); неразбираемое время просто опускаем.
+9
View File
@@ -140,6 +140,7 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
r.Post("/ui/downloads/{id}/dismiss", s.handleDismiss)
// REST API.
r.Route("/api", func(r chi.Router) {
@@ -432,6 +433,14 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, userErr(r, err, res.DownloadID))
return
}
if res.Deduplicated {
// Приём привязался к существующей записи (активной или «спящей» desync —
// target_missing/orphaned): ведём пользователя на её страницу, а не на
// список. Так видно, что нового не завели, и доступны действия записи
// (привязать заново / danger-зона «Закрыть»).
http.Redirect(w, r, "/download/"+res.DownloadID, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+66
View File
@@ -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 (есть план) она всё равно доступна.
+1
View File
@@ -50,6 +50,7 @@ func (stubReviewer) IgnoreFile(context.Context, string, string) error
func (stubReviewer) Defer(context.Context, string) error { return nil }
func (stubReviewer) Undo(context.Context, string) error { return nil }
func (stubReviewer) Delete(context.Context, string) error { return nil }
func (stubReviewer) Dismiss(context.Context, string) error { return nil }
func (stubReviewer) Relink(context.Context, string) error { return nil }
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
+12
View File
@@ -22,6 +22,7 @@ type Reviewer interface {
Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
Dismiss(ctx context.Context, id string) error
Relink(ctx context.Context, id string) error
Rerecognize(ctx context.Context, id string) error
ChooseCandidate(ctx context.Context, id, candidateID string) error
@@ -370,6 +371,17 @@ func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
}
// handleDismiss — универсальный стоп-кран: перевод задачи в cancelled только
// сменой статуса (файлы/раздачу не трогает), из danger-секции с подтверждением.
func (s *server) handleDismiss(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
s.surfaceAction(w, r, id, s.deps.Reviewer.Dismiss(r.Context(), id))
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
+22 -15
View File
@@ -23,9 +23,11 @@ const capIngest = "ingest"
// Store — нужная ingest часть хранилища.
type Store interface {
// FindActiveByInfohash — быстрый читающий дедуп-чек; авторитетная проверка —
// внутри CreateDownloadIfNoActive.
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
// FindReingestBlockingByInfohash — быстрый читающий дедуп-чек: активная задача
// ЛИБО удерживающая источник desync-запись (target_missing/orphaned). Активный
// инвариант «≤1 активной» авторитетно держит CreateDownloadIfNoActive; desync —
// устойчивый пред-рид, коротко замыкающий приём на возврат существующей записи.
FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
@@ -87,15 +89,18 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
ctx = logctx.With(ctx, log)
// Быстрый дедуп-чек; авторитетная (атомарная) проверка — внутри
// CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из хешей источника:
// гибридный несёт и v1, и v2.
if existing, err := s.store.FindActiveByInfohash(ctx, src.infohashes...); err != nil {
// Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
// активная задача ЛИБО удерживающая источник desync-запись
// (target_missing/orphaned) блокируют повторный приём. Для активной
// авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже;
// desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не
// размыть инвариант «≤1 активной»).
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
log.Error("ingest failed", "stage", "lookup-active", "error", err)
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err)
} else if existing != nil {
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
log.Info("download attached", "download_id", existing.ID, "state", existing.State)
return s.attached(ctx, src, existing), nil
}
@@ -204,11 +209,13 @@ func mergeContext(userText, synth string) string {
return strings.Join(parts, "\n")
}
// attached — итог дедупа на быстром чеке: присоединились к уже активной
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
// принести хеш, которого задача ещё не знает; guarded-путь через
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
// хеша с другой активной задачей логируется, приём не валится.
// attached — итог дедупа на быстром чеке: присоединились к блокирующей записи
// (активной ЛИБО удерживающей источник desync — target_missing/orphaned) и
// доносим ей недостающие хеши источника (гибридный magnet мог принести хеш,
// которого задача ещё не знает; guarded-путь через CreateDownloadIfNoActive сюда
// не доходит). Для desync-записи состояние не меняем (возвращаем «спящей» — relink
// или закрытие делает пользователь). Донос — best-effort: конфликт хеша с другой
// активной задачей логируется, приём не валится.
func (s *Service) attached(ctx context.Context, src parsedSource, existing *store.Download) Result {
if len(src.infohashes) > len(existing.Infohashes) {
if err := s.store.AddInfohashes(ctx, existing.ID, src.infohashes); err != nil {
+26 -1
View File
@@ -26,7 +26,7 @@ type fakeStore struct {
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
return f.active, nil
}
@@ -159,6 +159,31 @@ func TestIngestIdempotent(t *testing.T) {
}
}
// Повторный приём привязывается к удерживающей источник desync-записи
// (target_missing/orphaned) вместо создания близнеца: возвращается существующая
// «спящей» (её состояние не меняется, к qBittorrent не ходим), Deduplicated=true.
func TestIngestAttachesToDesyncRecord(t *testing.T) {
for _, s := range []store.State{store.StateTargetMissing, store.StateOrphaned} {
t.Run(string(s), func(t *testing.T) {
existing := &store.Download{ID: "01hzzzexisting000000000000", State: s}
fs := &fakeStore{active: existing}
res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if !res.Deduplicated || res.DownloadID != existing.ID {
t.Errorf("ожидалось присоединение к desync-записи: %+v", res)
}
if res.State != s {
t.Errorf("состояние существующей записи должно вернуться как есть (%s), got %s", s, res.State)
}
if len(fs.created) != 0 {
t.Error("не должно создаваться новой задачи (близнеца)")
}
})
}
}
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
// гибридного magnet (иначе последующий приём по второму хешу создал бы
// вторую активную задачу).
+75 -8
View File
@@ -78,9 +78,14 @@ func (s State) IsTerminal() bool {
// (ActivateIfNoOtherActive): гейт графа ортогонален гарду терминальности в
// setState — граф говорит «ребро есть», гард «но не мимо ActivateIfNoOtherActive».
// Так, failed → downloading объявлено, но обычным SetDownloadState отклоняется.
// - cancelled/deferred — легальная цель из КАЖДОГО не-терминального состояния
// (Cancel/Defer проверяют лишь IsTerminal); инвариант закреплён тестом, а не
// ручной аккуратностью.
// - deferred — легальная цель из КАЖДОГО не-терминального состояния (Defer
// проверяет лишь IsTerminal); инвариант закреплён тестом, а не ручной
// аккуратностью.
// - cancelled — легальная цель из ЛЮБОГО состояния, кроме deleted: помимо
// Cancel из не-терминальных её даёт универсальный стоп-кран Dismiss, доступный
// и из терминальных (done/failed/reverted/target_missing/orphaned) — только
// смена статуса, файлы/раздачу не трогает (см. state-reconciliation «Ручное
// закрытие загрузки»). deleted строго терминален и цель cancelled не получает.
//
// Правка воркера, вводящая новое ребро, ОБЯЗАНА отразить его здесь — иначе
// setState отклонит переход (0 строк UPDATE → ошибка).
@@ -91,14 +96,14 @@ var allowedTransitions = map[State][]State{
StateRecognizing: {StateLinking, StateReview, StateCancelled, StateDeferred},
StateReview: {StateLinking, StateRecognizing, StateCancelled, StateDeferred, StateOrphaned, StateDeleted},
StateLinking: {StateDone, StateReview, StateFailed, StateCancelled, StateDeferred},
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted},
StateDone: {StateReverted, StateTargetMissing, StateOrphaned, StateDeleted, StateCancelled},
StateDeferred: {StateLinking, StateRecognizing, StateCancelled, StateOrphaned, StateDeleted},
StateStuck: {StateDownloading, StateCompleted, StateCancelled, StateDeferred},
StateFailed: {StateDownloading, StateCompleted},
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted},
StateFailed: {StateDownloading, StateCompleted, StateCancelled},
StateReverted: {StateRecognizing, StateOrphaned, StateDeleted, StateCancelled},
StateCancelled: {StateRecognizing, StateOrphaned, StateDeleted},
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted},
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted},
StateTargetMissing: {StateRecognizing, StateDone, StateOrphaned, StateDeleted, StateCancelled},
StateOrphaned: {StateDone, StateTargetMissing, StateDeleted, StateCancelled},
StateDeleted: nil, // окончательно терминально: сверка его не переоценивает
}
@@ -616,6 +621,68 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Do
return d, nil
}
// reingestHoldingStates — desync-состояния, которые удерживают источник ради
// незакрытого намерения и потому БЛОКИРУЮТ повторный приём наравне с активными:
// target_missing (источник жив, ждёт relink) и orphaned (источник пропал, запись
// держит претензию на последнюю копию). Прочие терминальные (done/cancelled/
// failed/reverted/deleted) повторный приём НЕ блокируют — это осознанная свежая
// попытка. «Блокирующие» = активные (не-терминальные) reingestHoldingStates.
var reingestHoldingStates = []State{StateTargetMissing, StateOrphaned}
// FindReingestBlockingByInfohash возвращает задачу, блокирующую повторный приём
// любого из hashes: активную (строго не-терминальную) ЛИБО удерживающую источник
// desync-запись (target_missing/orphaned), приоритет — активной. Либо (nil, nil).
// Читающая основа расширенного дедупа приёма (пред-рид ДО создания); инвариант
// «≤1 активной на infohash» держит отдельный active-гард CreateDownloadIfNoActive,
// в который desync-состояния НЕ заводятся.
func (s *Store) FindReingestBlockingByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
norm := normalizeHashes(hashes)
// Активная имеет приоритет: если по хешу есть и активная, и desync-запись
// (инвариант это допускает), присоединяемся к активной.
d, err := findActiveByInfohash(ctx, s.DB, norm, "")
if err != nil {
return nil, err
}
if d == nil {
d, err = findByInfohashInStates(ctx, s.DB, norm, reingestHoldingStates)
if err != nil {
return nil, err
}
}
if d != nil {
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
return nil, err
}
}
return d, nil
}
// findByInfohashInStates — выборка «задача по любому из хешей в одном из states»
// (позитивный фильтр `state IN (...)`, в отличие от findActiveByInfohash с
// `NOT IN terminalStates`). hashes уже нормализованы; хеши найденной загрузки НЕ
// подгружаются. Пустые hashes/states → (nil, nil).
func findByInfohashInStates(ctx context.Context, q sqlx.QueryerContext, hashes []string, states []State) (*Download, error) {
if len(hashes) == 0 || len(states) == 0 {
return nil, nil
}
var args []any
hashPh := placeholders(&args, hashes)
statePh := placeholders(&args, states)
query := `SELECT download.* FROM download
JOIN download_infohash dh ON dh.download_id = download.id
WHERE dh.infohash IN (` + hashPh + `) AND download.state IN (` + statePh + `)
ORDER BY download.id DESC LIMIT 1`
var d Download
err := sqlx.GetContext(ctx, q, &d, query, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find by infohash in states: %w", err)
}
return &d, nil
}
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
// excludeID исключает саму проверяемую задачу (она может быть активной,
+65
View File
@@ -260,6 +260,71 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
}
}
// Дедуп повторного приёма блокируют не только активные, но и удерживающие
// источник desync-записи (target_missing/orphaned): по ним приём привязывается к
// существующей, а не плодит близнеца. Прочие терминальные (done/cancelled/failed/
// reverted/deleted) повторный приём НЕ блокируют — осознанная свежая попытка.
func TestFindReingestBlockingByInfohash(t *testing.T) {
ctx := context.Background()
blocking := []State{
StateCatched, StateDownloading, StateReview, // активные (примеры)
StateTargetMissing, StateOrphaned, // desync, удерживающие источник
}
for _, s := range blocking {
t.Run("blocking/"+string(s), func(t *testing.T) {
st := newTestStore(t)
ih := hashN(1)
id := mustCreate(t, st, ih)
forceState(t, st, id, s)
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
if err != nil {
t.Fatal(err)
}
if d == nil || d.ID != id {
t.Fatalf("%s должна блокировать приём, получили %v", s, d)
}
if len(d.Infohashes) != 1 {
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
}
})
}
nonBlocking := []State{
StateDone, StateCancelled, StateFailed, StateReverted, StateDeleted,
}
for _, s := range nonBlocking {
t.Run("non-blocking/"+string(s), func(t *testing.T) {
st := newTestStore(t)
ih := hashN(2)
id := mustCreate(t, st, ih)
forceState(t, st, id, s)
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
if err != nil || d != nil {
t.Fatalf("%s блокировать приём не должна, получили (%v,%v)", s, d, err)
}
})
}
t.Run("priority-active-over-desync", func(t *testing.T) {
st := newTestStore(t)
ih := hashN(3)
// Старая запись ушла в target_missing (terminal освобождает хеш), затем по
// тому же хешу завелась новая активная — инвариант «≤1 активной» это
// допускает. Дедуп обязан присоединиться к активной, а не к desync.
oldID := mustCreate(t, st, ih)
forceState(t, st, oldID, StateTargetMissing)
newID := mustCreate(t, st, ih)
d, err := st.FindReingestBlockingByInfohash(ctx, ih)
if err != nil {
t.Fatal(err)
}
if d == nil || d.ID != newID {
t.Fatalf("ожидалась активная %s (приоритет над desync %s), получили %v", newID, oldID, d)
}
})
}
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
// новой задачей (повторная закачка спустя время) — активность выводится
// только из state.
+29 -12
View File
@@ -74,19 +74,12 @@ func TestTransitionGraphWellFormed(t *testing.T) {
}
}
// Инвариант generic-команд Cancel/Defer: cancelled и deferred — легальная цель
// из КАЖДОГО не-терминального состояния (кроме самого deferred для deferred —
// это самопереход). Ловит класс дыры «забыли состояние» (напр. linking после
// краха процесса).
func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
// Инвариант Defer: deferred — легальная цель из КАЖДОГО не-терминального
// состояния (кроме самого deferred — это самопереход). Ловит класс дыры «забыли
// состояние» (напр. linking после краха процесса).
func TestDeferReachableFromEveryNonTerminal(t *testing.T) {
for _, s := range allStates {
if s.IsTerminal() {
continue
}
if !slices.Contains(transitionSources[StateCancelled], s) {
t.Errorf("%s → cancelled не легально (Cancel допускает любое не-терминальное)", s)
}
if s == StateDeferred {
if s.IsTerminal() || s == StateDeferred {
continue // deferred → deferred покрыт самопереходом
}
if !slices.Contains(transitionSources[StateDeferred], s) {
@@ -95,6 +88,22 @@ func TestCancelDeferReachableFromEveryNonTerminal(t *testing.T) {
}
}
// Инвариант универсального стоп-крана Dismiss: cancelled — легальная цель из
// ЛЮБОГО состояния, кроме deleted (строго терминален) и самого cancelled
// (самопереход). Шире инварианта Defer: покрывает и терминальные
// done/failed/reverted/target_missing/orphaned. НЕ объединять с проверкой
// deferred — у них разные множества источников.
func TestCancelledReachableFromEveryStateButDeleted(t *testing.T) {
for _, s := range allStates {
if s == StateDeleted || s == StateCancelled {
continue // deleted строго терминален; cancelled → cancelled — самопереход
}
if !slices.Contains(transitionSources[StateCancelled], s) {
t.Errorf("%s → cancelled не легально (Dismiss/Cancel допускают любое состояние, кроме deleted)", s)
}
}
}
// Объявленные не-revive рёбра проходят через SetDownloadState.
func TestSetStateAllowsDeclaredEdges(t *testing.T) {
edges := []struct{ from, to State }{
@@ -110,6 +119,14 @@ func TestSetStateAllowsDeclaredEdges(t *testing.T) {
{StateDone, StateReverted},
{StateStuck, StateCancelled},
{StateReview, StateDeferred},
// Стоп-кран Dismiss: терминал → cancelled идёт обычным SetDownloadState
// (цель cancelled терминальна → гард терминальности не мешает, revive не
// нужен).
{StateDone, StateCancelled},
{StateFailed, StateCancelled},
{StateReverted, StateCancelled},
{StateTargetMissing, StateCancelled},
{StateOrphaned, StateCancelled},
}
for i, e := range edges {
st := newTestStore(t)
+29 -2
View File
@@ -17,6 +17,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logging"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -47,6 +48,7 @@ type Reviewer interface {
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
Delete(ctx context.Context, id string) error
Dismiss(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -267,8 +269,20 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
return
}
if res.Deduplicated {
// Дубль на уже активную задачу: новую загрузку не заводим, лишь сообщаем.
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil)
// Дубль: новую загрузку не заводим. Различаем активную задачу и «спящую»
// desync-запись (target_missing/orphaned) — у последней действие вперёд
// не «ждите», а «привяжите заново или закройте».
switch res.State {
case store.StateTargetMissing:
// Источник жив, цель удалена — из target_missing доступна перепривязка.
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", res.DownloadID), nil)
case store.StateOrphaned:
// Источник пропал: relink из orphaned нет, рабочий путь — закрыть и
// добавить заново (тогда приём заведёт свежую загрузку).
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", res.DownloadID), nil)
default:
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", res.DownloadID), nil)
}
return
}
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil)
@@ -327,6 +341,19 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "delete_confirm":
err = b.reviewer.Delete(ctx, id)
note = "Удаляю…"
case "dismiss":
// Первый шаг: подтверждение. Стоп-кран лишь меняет статус (файлы/раздачу
// не трогает), но убирает запись из активной — подтверждаем сознательно.
b.answer(cq.ID, "")
b.editMarkup(chatID, msgID, b.dismissConfirmKeyboard(id))
return
case "dismiss_cancel":
b.answer(cq.ID, "Отменено")
b.refreshCard(ctx, chatID, msgID, id)
return
case "dismiss_confirm":
err = b.reviewer.Dismiss(ctx, id)
note = "Закрываю…"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
+30 -8
View File
@@ -62,14 +62,15 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
}
type fakeReviewer struct {
data *worker.ReviewData
applied []string
refined map[string]string
typed map[string]string
deferred []string
canceled []string
retried []string
deleted []string
data *worker.ReviewData
applied []string
refined map[string]string
typed map[string]string
deferred []string
canceled []string
retried []string
deleted []string
dismissed []string
}
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
@@ -109,6 +110,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
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
@@ -179,6 +184,23 @@ func TestBot_IngestDeduplicated(t *testing.T) {
}
}
// Дедуп на «спящую» desync-запись (target_missing) → сообщение зовёт привязать
// заново/закрыть, а не «дубль активной».
func TestBot_IngestDeduplicatedDesync(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
ing.res = ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
if len(api.sent) != 1 {
t.Fatalf("sent = %+v", api.sent)
}
txt := api.sent[0].text
if !strings.Contains(txt, "без цели") || !strings.Contains(txt, tid) {
t.Errorf("ожидалось сообщение о записи без цели с #%s, got %q", tid, txt)
}
}
func TestBot_DeniesUnknownUser(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
+19 -3
View File
@@ -160,6 +160,8 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
row := []tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
// Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу.
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
}
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
@@ -169,14 +171,18 @@ func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
}
// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Удалить». Само удаление
// двухшаговое — кнопка ведёт на подтверждение (deleteConfirmKeyboard).
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
// ведёт на подтверждение.
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
var row []tgbotapi.InlineKeyboardButton
if url := b.reviewURL(id); url != "" {
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
row = append(row, tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id))
row = append(row,
tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id),
)
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
return &kb
}
@@ -191,6 +197,16 @@ func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
return &kb
}
// dismissConfirmKeyboard — шаг подтверждения закрытия (стоп-кран): перевод в
// «отменено» без действий над файлами/раздачей. Явное «Да» отделено от отмены.
func (b *Bot) dismissConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("✖️ Да, закрыть", "dismiss_confirm:"+id),
tgbotapi.NewInlineKeyboardButtonData("Отмена", "dismiss_cancel:"+id),
))
return &kb
}
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
+31
View File
@@ -897,6 +897,37 @@ func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
return nil
}
// Dismiss — универсальный стоп-кран: переводит задачу в терминальный cancelled из
// ЛЮБОГО состояния, кроме deleted, ТОЛЬКО меняя статус. В отличие от Delete не
// трогает ни файлы (библиотечные хардлинки done/orphaned остаются на месте), ни
// раздачу в qBittorrent, ни цель; source-preflight не делает. Служит закрытием
// зависшей/спорной/лишней записи (в т.ч. дубля-близнеца в target_missing). Из
// cancelled — идемпотентный no-op БЕЗ setState: иначе перезаписал бы error_code,
// подменив причину прежнего Cancel/Dismiss. Помечает переход user_dismiss
// (отличает от reconcile и от штатного Cancel с пустым кодом).
func (w *Worker) Dismiss(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "dismiss", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("dismiss: %w", err)
}
if d.State == store.StateDeleted {
return fmt.Errorf("dismiss: download %s is deleted (strictly terminal): %w", id, ErrConflict)
}
if d.State == store.StateCancelled {
return nil // уже закрыта — no-op, error_code прежней отмены не трогаем
}
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "user_dismiss", "закрыто пользователем"); err != nil {
return fmt.Errorf("dismiss: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
"from", d.State, "to", store.StateCancelled, "code", "user_dismiss")
return nil
}
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
+52
View File
@@ -446,6 +446,58 @@ func TestCancel(t *testing.T) {
}
}
// Dismiss — стоп-кран: переводит в cancelled только сменой статуса, не трогая
// раздачу и файлы, из любого состояния, кроме deleted; из cancelled — no-op без
// перезаписи error_code.
func TestDismiss(t *testing.T) {
t.Run("from-done-keeps-source-and-files", func(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateDone
qb := &fakeQbt{}
w := newTestWorker(st, qb)
if err := w.Dismiss(context.Background(), "1"); err != nil {
t.Fatalf("Dismiss: %v", err)
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
if got := st.downloads["1"].ErrorCode.String; got != "user_dismiss" {
t.Errorf("error_code = %q, want user_dismiss", got)
}
if len(qb.deleted) != 0 {
t.Errorf("раздачу трогать не должны, Delete вызван %d раз", len(qb.deleted))
}
})
t.Run("from-deleted-rejected", func(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateDeleted
w := newTestWorker(st, &fakeQbt{})
if err := w.Dismiss(context.Background(), "1"); err == nil {
t.Error("ожидалась ошибка Dismiss из deleted")
}
if st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted (не изменилось)", st.downloads["1"].State)
}
})
t.Run("from-cancelled-noop-keeps-code", func(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateCancelled
st.downloads["1"].ErrorCode = store.NullString("prior_reason")
w := newTestWorker(st, &fakeQbt{})
if err := w.Dismiss(context.Background(), "1"); err != nil {
t.Fatalf("Dismiss no-op: %v", err)
}
if got := st.downloads["1"].ErrorCode.String; got != "prior_reason" {
t.Errorf("error_code = %q, no-op не должен его переписывать", got)
}
if len(st.transitions) != 0 {
t.Errorf("no-op не должен писать переход, got %d", len(st.transitions))
}
})
}
func TestRetry(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads["1"].State = store.StateStuck
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-10
@@ -0,0 +1,204 @@
## Context
Дедуп приёма (`ingest`) держит инвариант «не более одной активной загрузки на
infohash», где «активная» = `state NOT IN terminalStates`. Терминальный набор
(`done, cancelled, failed, reverted, target_missing, orphaned, deleted`) смешивает
две разные ситуации:
- **«Отработали, забыли»** — `done`/`cancelled`/`failed`/`reverted`/`deleted`.
Повторный приём такого инфохэша — осознанное «хочу заново», новая загрузка
легитимна (`ingest/spec.md`, сценарий «Повторный приём после завершения»).
- **«Держим источник ради незакрытого намерения»** — `target_missing` (источник
жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись держит
претензию на последнюю копию данных).
Дедуп трактует обе группы одинаково: терминально → не активна → плоди новую. Во
второй группе это рождает близнеца (реальный прод-случай: `target_missing` +
`done` на один торрент). Причём усыновление раздачи воркером
(`download-tracking`, change `catched-promote-without-readd`) делает близнеца
«боевым» — он раскладывается и занимает целевой путь, из-за чего у осиротевшей
записи `Привязать заново` упирается в коллизию владения путём.
Отдельно: закрыть лишнюю `target_missing`-запись сейчас нечем. Единственная
команда, убирающая её из активного внимания, — «Удалить», но она **сносит
раздачу** в qBittorrent (`deleteFiles=true`), а этого как раз не нужно: раздача
общая, её ведёт `done`-близнец.
## Goals / Non-Goals
**Goals:**
- Повторный приём инфохэша, удерживаемого записью в `target_missing`/`orphaned`,
не создаёт новую загрузку, а возвращает существующую (attach), сохраняя приём
быстрым и без обращения к qBittorrent.
- Пользователь может вручную закрыть **любую** зависшую/спорную загрузку
(стоп-кран), ничего не делая с файлами и раздачей.
- Инвариант «≤1 активной на infohash» и его атомарные гарды не ослабляются.
**Non-Goals:**
- Авто-схлопывание/слияние уже существующих дублей фоновой сверкой — сознательно
ручной путь.
- Изменение логики усыновления раздачи в воркере и общей матрицы «источник ×
цель» сверки.
- Авто-relink при повторном приёме (это обращение к qBittorrent — противоречит
«быстрому приёму без сети»); relink остаётся отдельным явным действием.
- Разбор коллизии владения целевым путём при relink `target_missing`, чьи файлы
уже разложены другой записью, — эту ситуацию закрывает команда «Закрыть», а не
relink.
- Любое удаление/создание файлов или снятие раздачи командой «Закрыть» — для
этого есть «Удалить». «Закрыть» — чисто смена статуса.
## Decisions
### Р1. Дедуп: reingest-blocking states = active {target_missing, orphaned}
Вводим понятие «состояний, блокирующих повторный приём»: активные состояния
**плюс** `target_missing` и `orphaned`. Поиск дедупа при приёме
(`FindActiveByInfohash` → по сути `FindReingestBlockingByInfohash`) ищет запись в
любом из этих состояний по любому из хешей и, найдя, возвращает её вместо
создания новой. Приоритет — активная (если вдруг есть и активная, и desync-запись
на один хеш, что допускает текущий инвариант), иначе desync-запись.
- Почему не «сделать `target_missing`/`orphaned` активными»: сломает семантику
«активность выводится только из state» и потянет за собой сверку, healing,
выборки активных. Дедуп — единственное место, которому нужна расширенная
оптика; локализуем изменение там.
- Атомарность: гард создания (`CreateDownloadIfNoActive`) остаётся про активные —
он бэкстоп инварианта «≤1 активной». Расширенная проверка — это read-ветка
дедупа ДО создания; она короткозамыкает на attach. Гонка «два приёма
одновременно на свежий target_missing» в худшем случае даёт одну лишнюю
попытку create, которую по-прежнему отсекает активный гард; близнец на
`target_missing` при этом не создаётся, т.к. обе ветки видят одну и ту же
desync-запись (она уже в БД, коммитнута ранее).
### Р2. Attach для desync-записи не воскрешает и не доносит хеши сам по себе
Ветка attach для `target_missing`/`orphaned` возвращает запись как «спящую,
требует relink» (флаг в результате приёма), НЕ переводя её в активное состояние и
НЕ вызывая qBittorrent. Донесение недостающих хешей (гибридный торрент) для
desync-записи допустимо и безопасно (терминальная запись не «активна», гонки за
хеш нет), но подчиняется тому же правилу «не красть хеш у другой активной»
(`ingest/spec.md`, «Атомарность возврата…»). Бот/веб сообщают: запись существует,
приложите relink или закройте.
- Почему не авто-relink: relink делает синхронный source-preflight (обращение к
qBittorrent) — это нарушает инвариант «синхронный приём не ходит в
qBittorrent». Явный relink пользователем сохраняет разделение шагов.
Почему `failed`/`reverted` НЕ блокирующие (в отличие от `target_missing`/
`orphaned`): у них нет удерживаемого источника ради незакрытого намерения —
повторный приём осознанно трактуется как **свежая попытка**. Новая активная
загрузка забирает хеш, старая терминальная им не владеет; её фоновое
самовосстановление (revive `failed`) корректно отклонится активным гардом «infohash
занят». Дубля-призрака (как с `target_missing`) при этом не возникает: старая
запись остаётся терминальной и не раскладывается повторно. Реализация не
переиспользует общий active-хелпер для desync-проверки — расширенная оптика нужна
только дедуп-пред-риду (см. Р1, Б-развязка с `ActivateIfNoOtherActive`/
`AddInfohashes`).
### Р3. «Закрыть» = переход в cancelled, без нового статуса (принято)
Команда «Закрыть» (dismiss) переводит запись в **существующее терминальное
`cancelled`** с `error_code`-дискриминатором (`user_dismiss`), человекочитаемой
причиной в `error_msg` и логом перехода.
Почему `cancelled`, а не новый `dismissed`:
- Прецедент в коде: Delete переиспользует `deleted` + `error_code="user_delete"`
и явно постулирует «новый статус вводить SHALL NOT» — терминальный набор
завязан на семантику активности, любой новый статус её разъедает и тянет
правки во все выборки/сверку.
- `cancelled` уже значит «пользователь отказался от этой записи, источник не
трогаем», из него доступен relink — естественный safety valve, если передумал.
- `cancelled` не входит в reingest-blocking (Р1) → после «Закрыть» повторный
приём заведёт свежую загрузку. Это осознанно: запись закрыта, дубля-призрака
больше нет.
Различение причины отмены (закрытие стоп-краном vs. отклонение на ревью) несёт
`error_code`, а не отдельный статус.
### Р4. «Закрыть» — универсальный стоп-кран из любого состояния, кроме deleted (принято)
Команда доступна из **любого** состояния, кроме `deleted` (строго терминально,
сверка его не переоценивает — не воскрешаем граф). На `cancelled` — идемпотентный
no-op (самопереход). Инвариант команды: **только меняет статус**, файлы под
`paths.*` и раздачу в qBittorrent НЕ трогает.
- Из `target_missing` (исходный прод-случай) — закрытие инертно (целевых ссылок
нет, источник жив и остаётся раздаваться).
- Из `done`/`orphaned` — библиотечные хардлинки **сознательно остаются** на месте
(не удаляем: «Закрыть» ≠ «Удалить»). Запись перестаёт отслеживаться.
- Из активных/`stuck`/`failed`/`deferred` — источник в qBittorrent остаётся как
есть (докачивается/раздаётся); мы лишь снимаем запись из внимания.
Отличие от существующего Cancel/«Отклонить» (review-флоу, только из
нетерминальных): «Закрыть» — универсальный стоп-кран, доступный и из терминальных
`done`/`failed`/`reverted`/`target_missing`/`orphaned`, и живёт в отдельной danger
zone внизу страницы. Оба ведут в `cancelled`; различаются гардом источника и
`error_code`.
Рёбра `allowedTransitions`, которые нужно добавить (у нетерминальных `cancelled`
как цель уже есть): `done → cancelled`, `failed → cancelled`,
`reverted → cancelled`, `target_missing → cancelled`, `orphaned → cancelled`.
После этого `cancelled` — легальная цель из любого состояния, кроме `deleted`.
## Risks / Trade-offs
- **Relink `target_missing` при существующем `done`-близнеце всё ещё упрётся в
коллизию пути.** → Ожидаемо и допустимо: правильное действие для лишней
записи — «Закрыть», а не relink; коллизия владения путём (`state-reconciliation`,
«Занятый путь даёт коллизию») отрабатывает штатно и не портит данные.
- **Reingest-blocking расширен → пользователь, реально желающий переснять
`target_missing`-торрент заново, получит attach, а не новую загрузку.** →
Приемлемо: у него есть relink (вперёд) и «Закрыть» (закрыть и, при желании,
переслать снова — новая загрузка заведётся из `cancelled`).
- **Гонка двух одновременных приёмов на свежую desync-запись.** → Оба видят уже
коммитнутую desync-запись → attach; активный гард отсекает случайный create.
Близнец не рождается.
- **`error_code=user_dismiss` в `cancelled` смешивает две причины отмены.** →
Дискриминатор в `error_code` + `error_msg`/лог различают их; телеметрия по
причине доступна без нового статуса.
- **«Закрыть» из `done`/`orphaned` оставляет неотслеживаемые хардлинки** под
`paths.movies`/`series`, чей `file_link` продолжает «владеть» путём (`cancelled`
сверкой не переоценивается). → Осознанный компромисс стоп-крана «только
статус»: файлы оставляем как есть, реальную зачистку делает «Удалить». Повторная
закачка того же пути упрётся в штатную коллизию владения путём
(`state-reconciliation`, «Занятый путь даёт коллизию»), а не в порчу данных.
- **«Закрыть» из активных состояний рвёт запись из-под воркера** (напр. в
`linking`/`downloading`). → Команды сериализуются воркером под единой
блокировкой (как прочие команды ревью) — «Закрыть» применяется как последняя
валидная команда, а не посреди операции; частично созданные ссылки остаются, что
соответствует контракту «только статус».
- **Восстановление `orphaned` через приём — двухшаговое.** `orphaned` (источник
пропал) блокирует повторный приём (attach), но relink из `orphaned` не
реализован, а приём не добавляет источник в qBittorrent. → Рабочий путь возврата
источника: «Закрыть» (→ `cancelled`) → повторный приём (уже не блокируется) →
свежая активная загрузка, которую воркер добавит и разложит. Прямой приём без
attach создал бы близнеца с коллизией целевого пути (файл `orphaned` ещё на
месте), поэтому attach выбран сознательно; транспорты в `orphaned` формулируют
действие как «закройте, затем добавьте заново» (не «привяжите заново»). Прямой
relink-из-`orphaned` — возможное будущее улучшение вне scope этого change.
- **danger zone скрывает завершённую (`done`) запись одним действием.** →
Разместить «Закрыть» в отдельной danger zone внизу страницы; для необратимо
выглядящих случаев (`done` и прочие терминальные) UI SHOULD запрашивать
подтверждение (относительно дёшево — из `cancelled` доступен relink).
## Migration Plan
- Схема БД не меняется (нет таблиц/столбцов/статусов). Миграции не требуются.
- Изменения — код + дельта-спеки; деплой обычным бинарём. Откат — откат бинаря;
данные не мигрированы, несовместимости нет.
- Обновить граф переходов в тесте (`cancelled` как цель из
`done`/`failed`/`reverted`/`target_missing`/`orphaned`) и описание
статусов/переходов в `docs/specs/database.md`.
## Open Questions
- Р3 (`cancelled` + `error_code`) и Р4 (универсальный стоп-кран из любого
состояния, кроме `deleted`) — **приняты**.
- Требует ли «Закрыть» из терминальных/`done` подтверждения в UI (см. риск) —
решить на реализации веб-UI.
- Тексты для транспортов: формулировка ответа приёма при attach на desync-запись
(«существует как #id без цели — привяжите заново или закройте») и подпись кнопки
«Закрыть» в веб/Telegram.
@@ -0,0 +1,73 @@
## Why
При повторном приёме торрента, у которого уже есть запись в `target_missing`
(«разложено, но файлов в библиотеке нет»), рождается загрузка-близнец: дедуп
приёма ищет только **активную** задачу по инфохэшу, а `target_missing`
терминально — активной нет, заводится новая загрузка, воркер усыновляет
присутствующую в qBittorrent раздачу и раскладывает её. В итоге на один торрент
две записи (`done` + `target_missing`), причём у осиротевшей единственное
действие «Привязать заново» упрётся в уже занятый целевой путь. Пользователю
нечем аккуратно закрыть лишнюю запись, не снося при этом раздачу.
## What Changes
- **Предотвращение дубля на приёме.** Повторный приём инфохэша, которым владеет
запись в `target_missing` или `orphaned`, SHALL привязываться к этой записи
(возврат существующей, `Deduplicated`), а не заводить новую загрузку. Критерий
дедупа расширяется с «активной» до «активной **или** удерживающей источник
ради незакрытого намерения» (`target_missing`/`orphaned`). `done` из дедупа
сознательно остаётся размножаемым (повторный приём завершённого = осознанное
«хочу заново»). Приём остаётся быстрым: qBittorrent не трогаем, авто-relink не
запускаем — пользователю сообщается, что запись существует и её нужно привязать
заново.
- **Команда «Закрыть» — универсальный стоп-кран.** Добавляется ручная команда,
доступная из **любого** состояния (кроме `deleted`) во всех транспортах,
переводящая запись в терминальное `cancelled` (с `error_code`-дискриминатором)
и убирающая её из активного списка. Команда **только меняет статус**: файлы под
`paths.*` не трогает (в т.ч. из `done`/`orphaned` библиотечные хардлинки
сознательно остаются на месте) и раздачу в qBittorrent не снимает (в отличие от
«Удалить»). Размещается в отдельной danger zone внизу страницы. Так
пользователь закрывает лишнего близнеца, а заодно получает страховку для любой
зависшей/спорной загрузки.
Явно вне scope: авто-схлопывание дублей в фоновой сверке (выбран ручной путь);
изменение логики усыновления в воркере; введение нового статуса (переиспользуем
`cancelled`, как Delete переиспользует `deleted`); удаление/создание каких-либо
файлов или раздач командой «Закрыть».
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `ingest`: критерий дедупликации приёма расширяется — блокирующими повторный
приём становятся не только активные, но и `target_missing`/`orphaned` записи
(attach вместо создания новой); повторный приём завершённой (`done`) остаётся
созданием новой. Модифицируются требования «Дедупликация приёма по любому из
хешей» и «Приём источника и заведение загрузки» (терминология «блокирующей»
задачи). Требование «Приём из .torrent-файла» текст НЕ правит: оно уже явно
делегирует критерий модифицированному требованию через inline-ссылку — оба
дедуп-упоминания там наследуют расширенный критерий без риска дрейфа.
- `state-reconciliation`: добавляется пользовательская команда «Закрыть»
(dismiss) из любого состояния (кроме `deleted`) в терминальное `cancelled`,
ничего не делающая с файлами и раздачей; фиксируется её отличие от «Удалить» и
новые рёбра перехода `<любое> → cancelled` (в т.ч. из терминальных
`done`/`failed`/`reverted`/`target_missing`/`orphaned`).
## Impact
- Код: `internal/ingest/ingest.go` (`Ingest`/`attached` — ветка attach для
desync-записей, флаг «нужен relink»), `internal/store/download.go`
(критерий поиска дедупа: reingest-blocking states = active
`{target_missing, orphaned}`; новое ребро `allowedTransitions`
`target_missing → cancelled`; `error_code`-дискриминатор dismiss),
`internal/worker/review.go` (обработчик команды «Закрыть» рядом с
Delete/Undo/Relink — только setState, без файлов и qBittorrent), веб-UI
(danger zone внизу страницы с кнопкой «Закрыть»), `internal/tgbot`.
- Данные: новых таблиц/столбцов нет; терминальный набор не меняется (dismiss →
существующее `cancelled`). Обновляется граф переходов и, при необходимости,
описание статусов в `docs/specs/database.md`.
- Инварианты безопасности данных: «Закрыть» источник неприкосновенен —
qBittorrent не вызывается, файлы под `paths.downloads`/`movies`/`series` не
трогаются.
@@ -0,0 +1,113 @@
## MODIFIED Requirements
### Requirement: Приём источника и заведение загрузки
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
состояние»).
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
отображаемое имя (потенциально медленный LLM): и добавление источника в
qBittorrent, и вывод имени выполняются отдельным асинхронным шагом машины
состояний (worker) — см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent».
`catched` — нетерминальное активное состояние: оно участвует в инварианте «не
более одной активной загрузки на infohash» наравне с прочими активными.
#### Scenario: Быстрый приём magnet
- **GIVEN** валидная magnet-ссылка и контекст
- **WHEN** вызывается приём
- **THEN** создаётся `download` в состоянии `catched` с записями
`download_infohash`
- **AND** ответ транспорту отдан без обращения к qBittorrent и без вывода имени
#### Scenario: Дубль по активной задаче на быстром пути
- **GIVEN** уже есть активная (в т.ч. `catched`) загрузка с тем же infohash
- **WHEN** вызывается приём
- **THEN** новая загрузка не создаётся, возвращается существующая
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
новую загрузку.
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
перепривязки либо закрытия.
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
NOT — активность выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
- **GIVEN** активная загрузка с infohash `h`
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём при записи без цели
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
удалена)
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
qBittorrent обращения нет)
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
заново или закрыть
#### Scenario: Повторный приём при осиротевшей записи
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём после закрытия записи
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
`target_missing`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём при прочих терминальных состояниях
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
источник ради незакрытого намерения)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём —
свежая попытка; старая терминальная запись хешем не владеет)
@@ -0,0 +1,66 @@
## ADDED Requirements
### Requirement: Ручное закрытие загрузки (стоп-кран)
Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss),
доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
`cancelled`, убирая её из активного списка/внимания, и SHALL служить
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для
загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в
`cancelled` команда SHALL быть идемпотентным no-op.
Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина —
в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
существующее терминальное `cancelled` (сверка его не переоценивает). Из
`cancelled` пользователю остаётся доступной перепривязка (relink), если он
передумает.
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу
страницы загрузки), обособленно от штатных действий.
#### Scenario: Закрытие записи без цели не трогает раздачу
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** раздача с файлами в qBittorrent не удаляется
- **AND** запись пропадает из активного списка
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** библиотечные хардлинки не удаляются
- **AND** раздача в qBittorrent не снимается
#### Scenario: Закрытие зависшей загрузки
- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`)
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled`
- **AND** источник в qBittorrent не трогается
#### Scenario: «Закрыть» недоступна для deleted
- **GIVEN** загрузка в `deleted`
- **WHEN** пользователь пытается вызвать «Закрыть»
- **THEN** команда недоступна, состояние остаётся `deleted`
#### Scenario: Закрытую запись можно привязать заново
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
- **WHEN** пользователь даёт команду «Привязать заново»
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
из `cancelled`)
@@ -0,0 +1,58 @@
## 1. Дедуп: reingest-blocking states (предотвращение дубля)
- [x] 1.1 В `internal/store/download.go` ввести множество reingest-blocking
состояний (active ∪ `{target_missing, orphaned}`) и **отдельный** метод
поиска по любому из хешей (напр. `FindReingestBlockingByInfohash`) с
приоритетом активной записи над desync-записью. НЕ расширять общий
`findActiveByInfohash` — на нём стоят `ActivateIfNoOtherActive`/
`AddInfohashes`, где «активная» ОБЯЗАНА значить строго не-терминальную
(иначе ломается инвариант «≤1 активной»). Desync-проверка — устойчивый
пред-рид ДО create, активный гард (`CreateDownloadIfNoActive`) остаётся про
строго активные.
- [x] 1.2 В `internal/ingest/ingest.go` (`Ingest`/`attached`) при найденной
desync-записи (`target_missing`/`orphaned`) вернуть её как `Deduplicated`
«спящей»: без перевода в активное, без обращения к qBittorrent; добавить
в результат приёма признак «требует relink/закрытия».
- [x] 1.3 Убедиться, что донесение недостающих хешей и атомарный гард создания
(`CreateDownloadIfNoActive`) не крадут хеш у другой активной загрузки и
что гонка двух приёмов на свежую desync-запись не рождает близнеца.
- [x] 1.4 Обновить ответы транспортов при attach на desync-запись: бот
(`internal/tgbot`) и HTTP/веб — текст «существует как #id без цели —
привяжите заново или закройте».
## 2. Команда «Закрыть» (dismiss) — универсальный стоп-кран
- [x] 2.1 В `internal/store/download.go` добавить рёбра `allowedTransitions` в
`cancelled` из терминальных `done`/`failed`/`reverted`/`target_missing`/
`orphaned` (у нетерминальных цель уже есть; `deleted` исключён). Обновить
граф-тесты (`internal/store/transition_test.go`): (а) РАСЩЕПИТЬ
`TestCancelDeferReachableFromEveryNonTerminal``cancelled` теперь цель из
любого состояния кроме `deleted` (в т.ч. терминальных), `deferred`
по-прежнему только из не-терминальных (не расширять общий цикл наивно, иначе
ложно потребует `deferred` из терминалов или оставит дырявый гард);
(б) в `TestSetStateAllowsDeclaredEdges` добавить новые терминал→`cancelled`
рёбра (переход обычным `SetDownloadState`, без `ActivateIfNoOtherActive`).
- [x] 2.2 В `internal/worker/review.go` реализовать **отдельный** обработчик
«Закрыть» (не ветка `Cancel`, который отклоняет терминальные): перевод
`<любое, кроме deleted> → cancelled` с `error_code = "user_dismiss"` и
причиной в `error_msg`/логе. Только setState: qBittorrent не вызывать,
хардлинки не трогать (в т.ч. из `done`/`orphaned`), source-preflight не
выполнять; из `deleted` — отказ; из `cancelled` — короткозамкнуть без
`setState` (иначе идемпотентный no-op перезапишет `error_code`, подменив
причину прежнего `Отклонить`).
- [x] 2.3 Веб-UI: отдельная danger zone внизу страницы загрузки с кнопкой
«Закрыть» (доступна из любого состояния, кроме `deleted`; htmx, деградация
без JS, ошибка на htmx-пути = 200 + фрагмент). Рассмотреть подтверждение
для `done`/терминальных.
- [x] 2.4 Telegram (`internal/tgbot`): добавить действие «Закрыть» для загрузок.
## 3. Спеки, документация, проверка
- [x] 3.1 Обновить описание статусов/переходов в `docs/specs/database.md`
(рёбра `<терминальные> → cancelled`, `error_code = "user_dismiss"`).
- [x] 3.2 `task test` и `task lint` зелёные; добавить тесты: дедуп-attach на
`target_missing`/`orphaned`, повторный приём после `cancelled`/`done`
заводит новую, команда «Закрыть» переводит в `cancelled` из разных
состояний и НЕ трогает qBittorrent/хардлинки (в т.ч. из `done`), отказ из
`deleted`.
- [x] 3.3 `openspec validate dedup-target-missing-and-dismiss --strict`.
+68 -12
View File
@@ -179,10 +179,12 @@ SHALL NOT проваливать обновление — `download.display_name
Приём SHALL быть единым **быстрым** use-case, общим для всех транспортов (HTTP,
Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL
синхронно извлечь инфохэши, синтезировать контекст из полей ссылки (без сети),
дедуплицировать по активной задаче и при отсутствии дубля завести загрузку
(`download` в состоянии **`catched`** + записи `download_infohash`), после чего
**сразу вернуть ответ** транспорту. Заведение загрузки и запись её хешей SHALL
выполняться атомарно (см. «Атомарность возврата загрузки в активное
дедуплицировать по **блокирующей повторный приём** задаче (активной либо
удерживающей источник ради незакрытого намерения — `target_missing`/`orphaned`;
см. «Дедупликация приёма по любому из хешей») и при отсутствии дубля завести
загрузку (`download` в состоянии **`catched`** + записи `download_infohash`),
после чего **сразу вернуть ответ** транспорту. Заведение загрузки и запись её
хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное
состояние»).
Синхронный путь приёма SHALL NOT обращаться к qBittorrent и SHALL NOT выводить
@@ -238,13 +240,33 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
### Requirement: Дедупликация приёма по любому из хешей
При приёме система SHALL искать **активную** (нетерминальную) загрузку по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания
новой. Проверка активности и вставка новой загрузки с её хешами SHALL
выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не
более одной активной загрузки на infohash». Отдельного снимаемого/
восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность
выводится только из `state`.
При приёме система SHALL искать загрузку, **блокирующую повторный приём**, по
любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой.
Блокирующими SHALL считаться загрузки в активном (нетерминальном) состоянии
**либо** удерживающие источник ради незакрытого намерения — `target_missing`
(источник жив в qBittorrent, ждёт relink) и `orphaned` (источник пропал, запись
держит претензию на последнюю копию). Прочие терминальные состояния (`done`,
`cancelled`, `failed`, `reverted`, `deleted`) блокирующими быть SHALL NOT:
повторный приём такого инфохэша — осознанное «хочу заново» и SHALL заводить
новую загрузку.
Когда найденная блокирующая загрузка терминальна (`target_missing`/`orphaned`),
приём SHALL возвращать её как существующую (`Deduplicated`) **спящей**: система
SHALL NOT переводить её в активное состояние и SHALL NOT обращаться к qBittorrent
(перепривязка — отдельное явное действие пользователя, а не побочный эффект
приёма); ответ транспорту SHALL сообщать, что запись существует и требует
перепривязки либо закрытия.
Атомарный инвариант касается **активной** составляющей: проверка отсутствия
другой активной загрузки на любом из хешей и вставка новой загрузки с её хешами
SHALL выполняться в одной write-транзакции, поддерживая «не более одной активной
загрузки на infohash» (тот же общий active-гард, что у прочих путей активации).
Расширение критерия на desync-состояния (`target_missing`/`orphaned`) SHALL быть
устойчивым пред-ридом до создания, коротко замыкающим приём на возврат
существующей записи; desync-состояния в общий active-гард заводиться SHALL NOT
(их терминальность оставляет `state`-инвариант «активности» нетронутым).
Отдельного снимаемого/восстанавливаемого ключа идемпотентности в схеме быть SHALL
NOT — активность выводится только из `state`.
#### Scenario: Повторный приём при активной загрузке
@@ -252,12 +274,46 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая
#### Scenario: Повторный приём при записи без цели
- **GIVEN** загрузка с infohash `h` в `target_missing` (источник жив, цель
удалена)
- **WHEN** принимается magnet с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
- **AND** её состояние остаётся `target_missing` (в активное не переводится, к
qBittorrent обращения нет)
- **AND** ответ транспорту указывает, что запись существует и её нужно привязать
заново или закрыть
#### Scenario: Повторный приём при осиротевшей записи
- **GIVEN** загрузка с infohash `h` в `orphaned` (источник пропал)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** новая загрузка не создаётся, возвращается существующая запись как
`Deduplicated`
#### Scenario: Повторный приём после завершения
- **GIVEN** загрузка с infohash `h` в терминальном состоянии (`done`)
- **GIVEN** загрузка с infohash `h` в терминальном состоянии `done`
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём после закрытия записи
- **GIVEN** загрузка с infohash `h` в `cancelled` (в т.ч. закрытая из
`target_missing`)
- **WHEN** принимается magnet с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h`
#### Scenario: Повторный приём при прочих терминальных состояниях
- **GIVEN** загрузка с infohash `h` в `failed` или `reverted` (не удерживает
источник ради незакрытого намерения)
- **WHEN** принимается magnet/torrent с тем же `h`
- **THEN** создаётся новая загрузка со своим ULID и записью `h` (повторный приём —
свежая попытка; старая терминальная запись хешем не владеет)
### Requirement: Атомарность возврата загрузки в активное состояние
Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути,
@@ -534,3 +534,68 @@ SHALL задевать раскладку в полёте.
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- **AND** повторный delete идемпотентно дожимает удаление
### Requirement: Ручное закрытие загрузки (стоп-кран)
Система SHALL предоставлять пользователю команду **«Закрыть»** (dismiss),
доступную из **любого** состояния, кроме `deleted`, во всех транспортах (веб-UI и
Telegram, опц. REST). Команда SHALL переводить загрузку в терминальное
`cancelled`, убирая её из активного списка/внимания, и SHALL служить
универсальным стоп-краном для любой зависшей или спорной загрузки (в т.ч. лишнего
дубля-близнеца в `target_missing`, чьи файлы уже разложены другой загрузкой). Для
загрузки в `deleted` команда доступна SHALL NOT (состояние строго терминально); в
`cancelled` команда SHALL быть идемпотентным no-op.
Команда SHALL **только менять статус** и SHALL NOT производить никаких действий с
файлами или раздачей: система SHALL NOT вызывать qBittorrent (раздача не
снимается, продолжает раздаваться) и SHALL NOT удалять либо создавать хардлинки
под `paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие
библиотечные ссылки сознательно остаются на месте. Синхронный source-preflight
«Закрыть» выполнять SHALL NOT (источник в действии не участвует).
Переход SHALL помечаться `error_code = "user_dismiss"` (человекочитаемая причина —
в `error_msg` и логе перехода), отличающим стоп-кран от отклонения на ревью и от
удаления. Новый статус для этого система вводить SHALL NOT — переиспользуется
существующее терминальное `cancelled` (сверка его не переоценивает). Из
`cancelled` пользователю остаётся доступной перепривязка (relink), если он
передумает.
В интерфейсе команда SHALL размещаться в отдельной «danger zone» (напр. внизу
страницы загрузки), обособленно от штатных действий.
#### Scenario: Закрытие записи без цели не трогает раздачу
- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** раздача с файлами в qBittorrent не удаляется
- **AND** запись пропадает из активного списка
#### Scenario: Закрытие done оставляет библиотечные файлы на месте
- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** библиотечные хардлинки не удаляются
- **AND** раздача в qBittorrent не снимается
#### Scenario: Закрытие зависшей загрузки
- **GIVEN** загрузка в `stuck` (или `failed`/`deferred`)
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled`
- **AND** источник в qBittorrent не трогается
#### Scenario: «Закрыть» недоступна для deleted
- **GIVEN** загрузка в `deleted`
- **WHEN** пользователь пытается вызвать «Закрыть»
- **THEN** команда недоступна, состояние остаётся `deleted`
#### Scenario: Закрытую запись можно привязать заново
- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
- **WHEN** пользователь даёт команду «Привязать заново»
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
из `cancelled`)
+20 -5
View File
@@ -92,13 +92,27 @@
{{end}}
</div>
<!-- Опасная зона: полное удаление загрузки (снять хардлинки + снести раздачу с
файлами из qBittorrent). Секция скрыта в details (раскрыть сознательно). С JS
hx-confirm показывает диалог подтверждения; без JS гейт — только раскрытие
details и явный submit (диалога нет). -->
{{if .Deletable}}
<!-- Опасная зона: закрытие (только смена статуса) и полное удаление (снять
хардлинки + снести раздачу). Секция скрыта в details (раскрыть сознательно).
С JS hx-confirm показывает диалог подтверждения; без JS гейт — только
раскрытие details и явный submit (диалога нет). -->
{{if or .Dismissable .Deletable}}
<details class="section danger-zone" style="margin-bottom:0;border-color:color-mix(in srgb,var(--st-err) 35%,transparent)">
<summary style="color:var(--st-err);cursor:pointer;font-weight:600">Опасная зона</summary>
{{if .Dismissable}}
<p class="muted" style="margin:var(--sp-3) 0">
Закрытие снимет запись из активного списка (перевод в «отменено») —
<strong>файлы и раздачу не трогает</strong>. Стоп-кран для зависшей, спорной
или лишней загрузки; при необходимости позже можно привязать заново.
</p>
<form method="post" action="/ui/downloads/{{.ID}}/dismiss"
hx-post="/ui/downloads/{{.ID}}/dismiss" hx-target="#download-main" hx-swap="outerHTML"
hx-confirm="Закрыть загрузку? Запись уйдёт в «отменено». Файлы и раздача останутся нетронутыми.">
<input type="hidden" name="surface" value="download">
<button class="btn btn-danger" type="submit">Закрыть загрузку</button>
</form>
{{end}}
{{if .Deletable}}
<p class="muted" style="margin:var(--sp-3) 0">
Полное удаление снимет библиотечные хардлинки <strong>и</strong> снесёт раздачу
с файлами из qBittorrent — освободит место. Действие необратимо.
@@ -109,6 +123,7 @@
<input type="hidden" name="surface" value="download">
<button class="btn btn-danger" type="submit">Удалить загрузку</button>
</form>
{{end}}
</details>
{{end}}
</main>