Compare commits

...
3 Commits
Author SHA1 Message Date
avandClaude Opus 4.8 e8ce828296 Беклог: полный формат ярлыка «Обновить имя» + скрытие cancelled в списке
- Кнопка «Обновить имя» должна давать формат add-шага (Название (режиссёр,
  год), сезон; всё опц. кроме названия) вместо усечённого «Title (Year)».
- Отменённые (cancelled) скрывать в общем списке наравне с удалёнными;
  тумблер «показать удалённые» раскрывает и cancelled, и deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:25:11 +03:00
avandClaude Opus 4.8 1369a9cabe Приём: дедуп по 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>
2026-07-10 20:15:37 +03:00
avandClaude Opus 4.8 b8657120fe Приём: усыновление присутствующего в qBittorrent торрента вместо дубль-Add (409)
processCatched перед Add проверяет присутствие торрента в qBittorrent (один
листинг на тик): если раздача уже есть — усыновляем (promote catched→downloading
без повторного Add и без LLM-namer, имя из раздачи), иначе добавляем как раньше.
Это убирает бесконечный цикл дубль-Add → 409 → ретрай и лишние вызовы LLM.
Инвариант приёма «одна активная на infohash» делает различие «наш/чужой»
ненужным. source_type перечитывается под замком (сужение гонки апгрейда F6);
при недоступности qBittorrent тик пропускается без вызова LLM.

Дедуп на приёме (дубль на уже активную задачу) теперь отражается явным ответом
бота «дубль уже активной #id — добавление отменено».

Спека download-tracking обновлена (OpenSpec change заархивирован); закрыта
задача беклога review-f2-promote-without-add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:36:47 +03:00
37 changed files with 1773 additions and 113 deletions
+2 -1
View File
@@ -36,11 +36,12 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [Confidence-гейт авто-раскладки: узаконить в спеке + сделать выключаемым (дефолт 0.7)](gate-confidence-spec-vs-code.md) — Решено (B): гейт оставляем как доп. проверку на ревью — выключаемый порог, дефолт 0.85→0.7, записать в спеку
- [Внешние субтитры: пары VobSub и языковой суффикс](vneshnie-subtitry.md) — Привязка субтитр→серия уже работает; остались пары VobSub .idx+.sub и потеря Lang/Flags
- [Defer из catched → лимбо → необратимый deleted (MAJOR-6)](review-major6-defer-catched.md) — Defer из ещё-не-добавленного catched уводит задачу в необратимый deleted _(ревью 2026-07-08)_
- [processCatched: promote-without-add если торрент уже в qBittorrent (F2)](review-f2-promote-without-add.md) — торрент уже в qBittorrent → processCatched зациклен на Add вместо promote _(ревью 2026-07-08)_
- [Cancel во время add оставляет неуправляемый торрент в qBittorrent (F3/NIT-13)](review-f3-cancel-during-add.md) — Cancel во время add оставляет неуправляемый торрент в qBittorrent _(ревью 2026-07-08)_
## Низкий
- [Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)](knopka-obnovit-imya-polnyj-format.md) — Перелив имени даёт усечённое «Title (Year)» вместо полного формата add-шага (режиссёр+сезон); нужный `render` уже есть
- [Скрывать отменённые (cancelled) в общем списке наравне с удалёнными](skryvat-otmenennye-v-spiske.md) — cancelled шумит в ленте; тумблер «показать удалённые» должен раскрывать и cancelled, и deleted
- [Панель действий ревью вне htmx-свопа блока источника](panel-review-vne-swap.md) — При выборе источника одним кликом обновляется только блок источника (#source-block)…
- [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает…
- [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_
@@ -0,0 +1,37 @@
# Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)
**Приоритет:** низкий · **Теги:** web-ui, naming, review
Кнопка «Обновить имя» (перелив распознанного названия в `display_name` и ярлык
раздачи) сейчас генерирует усечённый формат `Title (Year)` через
`naming.FormatTitleYear(plan.Title, plan.Year)`
(`internal/worker/review.go:1110`). Ожидается тот же формат, что и «полное»
отображаемое имя add-шага: **`Название (режиссёр, год)`**, а для сериала — плюс
**`. Сезон N`**. Всё опционально, кроме названия.
Нужный формат уже реализован приватной `render()` в
`internal/naming/naming.go:104-140` (`movie: "Title (Director, Year)"`, `series`
+ `". Сезон N"`), но `RefreshDisplayName` его не использует — комментарий у
`FormatTitleYear` (`naming.go:88-95`) прямо отмечает: «у перелива режиссёра и
сезона нет». Задача — устранить это расхождение: перелив должен давать тот же
ярлык, что и add-шаг.
Шаги:
- Экспонировать `render`-логику (публичная обёртка в `internal/naming`) или
собрать `extracted` из плана и позвать её из `RefreshDisplayName`.
- **Сезон** — из плана (`recognize.PlanFile.Season`, `recognize.go:77`): для
сериала взять сезон (единый — подставить, разные — решить: опустить или взять
минимальный; свериться с логикой сводки сезонов на странице).
- **Режиссёр** — открытый вопрос: в `recognize.Plan` его сейчас нет. Найти
источник (матч метабазы TMDB/TVDB несёт режиссёра? recognition?) или, если
недоступен, опустить (формат допускает отсутствие). Уточнить, откуда add-шаг
берёт режиссёра, и переиспользовать тот же источник.
- Все части, кроме названия, опциональны — пустой режиссёр/год/сезон просто
выпадают из скобок/хвоста (как уже делает `render`).
Проверить: единый формат ярлыка раздачи и `display_name`; санитайзинг и лимит
длины (`maxNameLen`) сохраняются; поведение на `done`/`orphaned` (кнопка гейтится
наличием распознавания, не состоянием — `internal/httpapi/download.go:142`).
Связано: `openspec/specs/recognition`, `openspec/specs/review`, пакеты
`internal/naming`, `internal/worker`, `internal/recognize`.
@@ -1,19 +0,0 @@
# processCatched: promote-without-add если торрент уже в qBittorrent (F2)
**Приоритет:** средний · **Теги:** ingest, review-2026-07-08, lifecycle
Ревью Fable 2026-07-08 (приём). worker.go:361-391, sourceAddParts :407-432, qbt.go:243-246.
Сценарий: торрент уже в qBittorrent БЕЗ нашей категории/тега (юзер добавил вручную раньше → discover не усыновляет). Юзер грузит тот же .torrent в jellybit → catched → qbt.Add файлом; для file-add дубль → «Fails.» → Add ошибка → «will retry» каждый тик, вечно, до catch_timeout → failed/qbit_add, который reconcileRecovery НЕ воскрешает (worker.go:124-132). Торрент жив всё это время; юзер видит failed. Retry уже решает это alive-проверкой (worker.go:692-698 «повторный Add вреден»), а processCatched — нет, хотя live-снимок byHash того же тика доступен. Также лечит сценарий B (Add успех, PromoteCatched падает на транзиентной ошибке → снова Add дубля).
Замечание: поведение qBit на дубль file-add версионно-зависимо («Fails.» vs «Ok.») — проверить на целевой версии.
Фикс: перед Add проверить присутствие хешей в qBit; есть → promote без Add (зеркалит Retry).
Смежное (ревью 2026-07-08, кластер A): апгрейд F6 (`UpgradeCatchedMagnetToTorrent`)
оставил узкое окно — `processCatched` читает снимок `source_type` вне `w.mu`, поэтому
при точном оверлапе тика воркера с апгрейдом воркер добавит magnet из устаревшего
снимка, хотя БД уже `torrent`. Тот же фикс закрывает и это: перечитать источник под
`w.mu` (или проверить присутствие хешей в qBit) перед Add.
Вердикт: change (малая спека-дельта download-tracking + код).
@@ -0,0 +1,35 @@
# Скрывать отменённые (cancelled) в общем списке наравне с удалёнными
**Приоритет:** низкий · **Теги:** web-ui
Отменённые загрузки (`cancelled`) должны по умолчанию скрываться в общем списке
так же, как удалённые (`deleted`), а тумблер «Показать всё, включая удалённые»
(`web/templates/index.html:52`) должен показывать **и те, и те**. Сейчас под
группой `all` скрыт только `deleted`, а `cancelled` всегда виден и шумит в ленте
— тем более что новый стоп-кран «Закрыть» будет плодить `cancelled`-записи.
Где править:
- `internal/store/list.go:62-66` — ветка `else if !f.IncludeDeleted` под группой
`all` скрывает только `StateDeleted` (`state <> ?`). Расширить до
`state NOT IN (deleted, cancelled)`.
- Осмыслить семантику флага `ListFilter.IncludeDeleted` (`list.go:45`): теперь он
«показывать скрытые терминальные» (deleted **и** cancelled). Либо переименовать
(`IncludeHidden`/`IncludeTerminal`), либо оставить имя и уточнить комментарий.
- Метка тумблера (`index.html:52`) и текст — при желании подправить («…включая
отменённые и удалённые»); `ShowAll`/`all=1` уже управляет показом.
- Комментарии-инварианты про «под all видны» в `internal/store/list.go:22-26` и
`statesInGroup` (cancelled/reverted/deleted не имеют чипа) — привести в
соответствие.
Замечания:
- `cancelled` своей группы-чипа не имеет (`statesInGroup`, `list.go:29-40`) — под
группами `review/active/done/problem` он и так не появляется, правка нужна
только в ветке `all`.
- `reverted` пользователь не упоминал — по умолчанию оставляем видимым; при
желании обсудить симметрию отдельно.
- Обновить тест `TestListDownloadsPageFilterAndDeleted`
(`internal/store/list_test.go`) — добавить `cancelled` в скрытые по умолчанию и
видимые под `IncludeDeleted`.
Связано: `openspec/specs/web-ui`, пакеты `internal/store`, `internal/httpapi`,
конвенция [web-ui](../conventions/web-ui.md).
+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)
+31 -3
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 — параметры бота.
@@ -266,11 +268,24 @@ func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Reque
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return
}
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
// Дубль: новую загрузку не заводим. Различаем активную задачу и «спящую»
// 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)
}
b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
return
}
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", res.DownloadID), nil)
}
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота поставлю на закачку.
@@ -326,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 = "Меняю тип…"
+39
View File
@@ -70,6 +70,7 @@ type fakeReviewer struct {
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"
@@ -162,6 +167,40 @@ func TestBot_IngestFromMagnet(t *testing.T) {
}
}
// Дедуп на приёме (дубль на уже активную задачу) → явный ответ «дубль …
// добавление отменено», а не «Принято».
func TestBot_IngestDeduplicated(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
ing.res = ingest.Result{DownloadID: tid, State: store.StateDownloading, 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) || strings.Contains(txt, "Принято") {
t.Errorf("ожидалось сообщение о дубле с #%s, got %q", tid, txt)
}
}
// Дедуп на «спящую» 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 == "" {
+81
View File
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
@@ -14,10 +15,12 @@ import (
type fakeNamer struct {
name string
gotContext string
calls int
onCall func()
}
func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string {
f.calls++
f.gotContext = contextText
if f.onCall != nil {
f.onCall()
@@ -77,6 +80,84 @@ func TestProcessCatchedAddsToQbit(t *testing.T) {
}
}
// Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее
// вручную/другим клиентом) → усыновляем: promote в downloading без повторного
// Add (и без 409) и без namer; имя берём из раздачи снимка.
func TestProcessCatchedPresentAdopts(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
qb := &fakeQbt{torrents: []qbt.Torrent{
{Hash: catchedIH, Name: "Dune.2024.1080p"}, // без нашей категории — добавлен вручную
}}
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "не должно вызваться"}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("qbt.Add не должен вызываться для уже присутствующего торрента, calls = %d", len(qb.added))
}
if nm.calls != 0 {
t.Errorf("namer (LLM) не должен вызываться, calls = %d", nm.calls)
}
d := st.downloads["1"]
if d.State != store.StateDownloading {
t.Errorf("state = %q, want downloading", d.State)
}
if d.DisplayName != "Dune.2024.1080p" {
t.Errorf("display_name = %q, want имя раздачи из снимка", d.DisplayName)
}
}
// Гонка F6: список catched снят как magnet, но апгрейд до .torrent случился
// между снимком и re-read под замком. processCatched перечитывает source_type
// под w.mu, поэтому добавляет файлом (Torrents), а не magnet-ссылкой.
func TestProcessCatchedReReadsSourceTypeUnderLock(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx") // снят как magnet
st.torrents = map[string][]byte{}
qb := &fakeQbt{} // раздачи нет → absent-ветка (обычное добавление)
// Апгрейд «под носом»: между листингом (снимок magnet) и re-read под замком
// БД уже стала torrent с сохранёнными байтами.
qb.onTorrents = func() {
st.downloads["1"].SourceType = store.SourceTorrent
st.torrents["1"] = []byte("d4:infod-fake-torrent-bytes-ee")
}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
}
if len(qb.added[0].Torrents) != 1 {
t.Errorf("после апгрейда ожидалось добавление файлом (Torrents), got Torrents=%v URLs=%v",
qb.added[0].Torrents, qb.added[0].URLs)
}
if len(qb.added[0].URLs) != 0 {
t.Errorf("magnet-ссылка не должна использоваться после апгрейда в torrent: %v", qb.added[0].URLs)
}
}
// Листинг qBittorrent провалился (недоступен) → пойманную не трогаем: остаётся
// catched, ни namer, ни Add не вызываются (повтор на следующем тике).
func TestProcessCatchedListErrorKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{torrentsErr: errors.New("connection refused")}
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "X"}
w.SetNamer(nm)
w.processCatched(context.Background())
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
}
if nm.calls != 0 || len(qb.added) != 0 {
t.Errorf("при недоступности qBit namer/Add не должны вызываться: namer=%d add=%d", nm.calls, len(qb.added))
}
}
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
+109 -7
View File
@@ -387,10 +387,34 @@ func (w *Worker) processCatched(ctx context.Context) {
w.log.Warn("list catched failed", "capability", capIngest, "error", err)
return
}
if len(catched) == 0 {
return
}
// Снимок присутствия раздач в qBittorrent (один листинг на тик): по нему ДО
// вызова namer решаем, добавлять ли задачу вообще. Провал листинга —
// qBittorrent недоступен: пойманные в этот тик не трогаем (ни namer, ни Add),
// повтор на следующем; устойчивая недоступность отсекается предохранителем
// catch_timeout.
torrents, err := w.qbt.Torrents(ctx, "")
if err != nil {
w.log.Warn("list torrents for catched failed", "capability", capIngest, "error", err)
return
}
byHash := torrentsByHash(torrents)
for _, d := range catched {
cctx := w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
// Предохранитель: устойчивая невозможность добавить в qBittorrent.
// Торрент уже в qBittorrent — повторный Add не нужен (и вреден: qBittorrent
// отверг бы дубль, 409) и LLM-namer не зовём: усыновляем раздачу, доводя
// задачу до downloading. См. promoteExisting.
if t, ok := torrentFor(d, byHash); ok {
w.promoteExisting(cctx, d, t)
continue
}
// Предохранитель: устойчивая невозможность добавить в qBittorrent (раздачи
// в снимке нет и висит дольше catch_timeout).
if w.cfg.CatchTimeout > 0 {
if age, ok := w.catchedAge(d); ok && age > w.cfg.CatchTimeout {
w.mu.Lock()
@@ -406,9 +430,20 @@ func (w *Worker) processCatched(ctx context.Context) {
}
}
// Вне w.mu: сбор параметров добавления по типу источника (для torrent —
// чтение байтов), вывод имени (потенциально медленный LLM) и добавление.
hint, addReq, prepErr := w.sourceAddParts(cctx, d)
// Раздачи в qBittorrent нет — обычный путь добавления. Перечитываем запись
// под замком: (а) актуальный source_type (апгрейд magnet→torrent мог
// случиться после снятия списка catched — иначе добавили бы magnet из
// устаревшего снимка), (б) ре-валидация state=catched. Тяжёлые вызовы
// (чтение байтов, namer, Add) — вне замка.
w.mu.Lock()
cur, gerr := w.store.GetDownload(cctx, d.ID)
fresh := gerr == nil && cur != nil && cur.State == store.StateCatched
w.mu.Unlock()
if !fresh {
continue // отменили/пропала, пока шёл листинг — не трогаем
}
hint, addReq, prepErr := w.sourceAddParts(cctx, *cur)
if prepErr != nil {
// Байты torrent недоступны (не должно быть при штатном приёме) —
// остаёмся в catched, повтор на следующем тике.
@@ -417,13 +452,13 @@ func (w *Worker) processCatched(ctx context.Context) {
}
var rename string
if w.namer != nil {
rename = w.namer.DeriveName(cctx, d.Context, hint)
rename = w.namer.DeriveName(cctx, cur.Context, hint)
}
addReq.Rename = rename
addErr := w.qbt.Add(cctx, addReq)
if addErr != nil {
// Транзиентный сбой (qBit недоступен) — остаёмся в catched, повтор на
// следующем тике. Поведение вызова qBit уже залогировал клиент (ext.*).
// Транзиентный сбой (qBit отверг/недоступен) — остаёмся в catched,
// повтор на следующем тике. Вызов qBit уже залогировал клиент (ext.*).
logctx.From(cctx).Warn("catched add to qbittorrent failed, will retry", "error", addErr)
continue
}
@@ -441,6 +476,42 @@ func (w *Worker) processCatched(ctx context.Context) {
}
}
// promoteExisting усыновляет пойманную загрузку, чей торрент уже присутствует в
// qBittorrent (снимок тика): переводит catched → downloading БЕЗ повторного Add
// (иначе qBittorrent отверг бы дубль — 409 — и задача зациклилась бы) и без LLM.
// Имя берём из раздачи снимка (t.Name); у свежего magnet без метаданных (metaDL)
// оно может быть пустым — распознавание дольёт имя позже. Инвариант приёма
// гарантирует, что сюда доходит лишь загрузка без другой активной задачи на тот
// же infohash, поэтому присутствие раздачи трактуем как «усыновить и разложить»,
// а не как конфликт. Короткий DB-переход под w.mu; атомарный гард PromoteCatched
// (WHERE state='catched') сам отсекает гонку отмены, случившуюся, пока шёл листинг
// вне замка, — отдельный re-read не нужен.
func (w *Worker) promoteExisting(ctx context.Context, d store.Download, t qbt.Torrent) {
w.mu.Lock()
defer w.mu.Unlock()
if err := w.store.PromoteCatched(ctx, d.ID, t.Name); err != nil {
logctx.From(ctx).Info("catched promote skipped", "reason", err.Error())
return
}
logctx.From(ctx).Info("state transition", "from", store.StateCatched,
"to", store.StateDownloading, "reason", "already present in qbittorrent")
}
// torrentsByHash индексирует раздачи по каждому из их хешей (lowercase), как это
// делает Poll для своего снимка. Использует processCatched, чтобы проверить, есть
// ли торрент пойманной загрузки уже в qBittorrent.
func torrentsByHash(torrents []qbt.Torrent) map[string]qbt.Torrent {
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" {
byHash[strings.ToLower(h)] = t
}
}
}
return byHash
}
// catchedAge — возраст пойманной загрузки от created_at (у catched раздачи в
// qBittorrent ещё нет, added_on недоступен). ok=false — created_at не разобрать.
func (w *Worker) catchedAge(d store.Download) (time.Duration, bool) {
@@ -826,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) {
+56
View File
@@ -269,6 +269,7 @@ func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { r
type fakeQbt struct {
torrents []qbt.Torrent
torrentsErr error
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
added []qbt.AddRequest
addErr error
files []qbt.File
@@ -288,6 +289,9 @@ type renameCall struct {
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
if f.onTorrents != nil {
f.onTorrents()
}
if f.torrentsErr != nil {
return nil, f.torrentsErr
}
@@ -442,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,87 @@
## Context
`processCatched` (`internal/worker/worker.go`) вызывается из `pollOnce` сразу
после `Poll`. Сейчас он безусловно зовёт namer (LLM) и `qbt.Add`, а `409`/`Fails.`
на дубле трактует как транзиентный сбой → вечный повтор с тратой LLM.
Приём (`ingest`) уже дедуплицирует по infohash на **активную** задачу
(`FindActiveByInfohash`/`CreateDownloadIfNoActive`). Значит до `processCatched`
доходит только загрузка, для которой в jellybit нет другой активной задачи.
Отсюда упрощение: если торрент такой загрузки уже присутствует в qBittorrent,
это не «конфликт с чужой задачей», а «раздачу уже кто-то (пользователь вручную,
прошлый тик) добавил» — надо просто **усыновить** её и разложить.
## Goals / Non-Goals
**Goals:**
- Пойманная загрузка, чей торрент уже в qBittorrent, доводится до `downloading`
без повторного `add` (без 409) и без LLM; дальше — обычная раскладка.
- LLM-namer не вызывается ни при усыновлении, ни при недоступности qBittorrent.
- Гонка апгрейда F6 сужена перечитыванием `source_type` под блокировкой.
- Повторное добавление уже активной в jellybit загрузки транспорт отражает как
дубль (сообщение + лог), без новой записи.
**Non-Goals:**
- Не заводим новых состояний загрузки. Дедуп на приёме записи не создаёт;
усыновление — это `downloading`, а не отдельный статус.
- Не различаем «наш/чужой» торрент по категории/тегу: инвариант приёма делает
различие ненужным (до воркера доходит лишь загрузка без другой активной).
- Не добавляем счётчик попыток `add` (предел — время `catch_timeout`).
- Не проверяем присутствие на приёме (`ingest` остаётся быстрым, без qBittorrent).
## Decisions
**1. Источник снимка присутствия: один листинг `qbt.Torrents("")` на входе в
`processCatched`.** Строим `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`,
lowercase), переиспользуем для всех catched-задач тика (как это делает Poll для
своего снимка). Провал листинга → qBittorrent недоступен → в этот тик пойманные
не трогаем (namer не зовём), повтор на следующем; отсечка — `catch_timeout`.
Поиск торрента задачи — существующий `torrentFor(d, byHash)`.
**2. Присутствует → усыновляем; ветвление до namer.** Если `torrentFor` нашёл
раздачу — `PromoteCatched(id, t.Name)` (перевод `catched → downloading` + имя из
`qbt.Torrent.Name`, без LLM), под коротким замком с ре-валидацией `state='catched'`.
Иначе — обычный путь: re-read под замком → namer → `sourceAddParts``Add`
`PromoteCatched`. namer (LLM) на ветке усыновления и при недоступности qBit не
зовётся.
**3. Никакого различия «наш/чужой» и никакого `duplicated`.** Инвариант приёма
(«одна активная на infohash») гарантирует, что усыновляемая раздача не отберётся
у другой активной задачи. `PromoteCatched` (гард `state='catched'`) корректен;
`ActivateIfNoOtherActive` (как в `Retry` из терминального `failed`) здесь не
нужен — `catched` нетерминален и уже единственный активный владелец infohash.
Имя раздачи в `display_name` полезно и уведомлениям, и заголовку в UI (у `catched`
оно пусто).
**4. Re-read `source_type` под замком перед добавлением (сужение гонки F6).**
На absent-ветке перед сбором `addReq` берём короткий замок, перечитываем запись
(`GetDownload`): ре-валидация `state='catched'` и актуальный `source_type`
(апгрейд magnet→torrent мог случиться после снятия списка). Тяжёлые вызовы
(`GetTorrentData`, namer, `Add`) — вне замка.
**5. Дедуп на приёме (case 1) — только сообщение.** `ingest` при попадании на
активную задачу уже возвращает `Deduplicated=true` (запись не создаётся). Меняем
лишь текст ответа транспорта: вместо «Уже в работе #id» — «♻️ дубль уже активной
#id, добавление отменено». Лог дедупа (`download attached to active`) уже есть.
Новых состояний/записей не заводим.
## Risks / Trade-offs
- **[Остаточное окно F6]** → re-read `source_type` под замком + `Add` вне замка
окно резко **сужают**, но не закрывают полностью. Полное закрытие требует
держать замок через `Add`, что нарушает инвариант «тяжёлые вызовы вне замка».
Оверлап крайне редок, цена промаха — один неудачный magnet-add, повтор на
следующем тике уже увидит `torrent`. Принимаем суженное окно осознанно.
- **[Снимок присутствия на тик «отстаёт»]** → каждая catched-задача
обрабатывается раз за тик; если наш `add` прошёл, а запись перехода сорвалась,
усыновление случится на следующем тике, где листинг уже видит раздачу.
- **[Усыновление раздачи, добавленной вручную с иными savepath/категорией]** →
инвариант источника не нарушается: файлы не наши, раскладка хардлинчит
отдельно; infohash совпадает — контент тот же. Осознанное поведение (как
`discover`).
## Open Questions
Нет.
@@ -0,0 +1,57 @@
## Why
Пойманная (`catched`) загрузка, чей торрент **уже присутствует в qBittorrent**
(добавлен раньше вручную/другим клиентом или прошлой попыткой jellybit), уходит в
бесконечный цикл: `processCatched` на каждом тике зовёт `qbt.Add`, qBittorrent
отбивает дубль (`409 Conflict`), сбой трактуется как транзиентный → задача
остаётся в `catched` → повтор, и на каждом безнадёжном тике впустую вызывается
LLM-namer. Диагноз: `docs/backlog/review-f2-promote-without-add.md`.
Решение: перед добавлением проверять присутствие торрента в qBittorrent. Раз
инвариант приёма гарантирует, что до воркера доходит лишь загрузка, для которой в
jellybit нет другой активной задачи (дубль на активную отсекается ещё на приёме),
присутствие торрента в qBittorrent означает «его надо **усыновить**» — довести до
`downloading` без повторного `add` и разложить, а не пытаться добавить дубль и
ловить 409.
Отдельно: повторное добавление торрента, который jellybit **уже ведёт активной
задачей**, транспорт должен явно отражать как дубль (сообщение «добавление
отменено»), а не молчаливым «уже в работе».
## What Changes
- В `processCatched` перед `qbt.Add` — **проверка присутствия торрента в
qBittorrent** (один листинг на тик). Присутствует → `catched → downloading`
**без `add`** (усыновление; `display_name` из имени раздачи, без LLM); нет →
прежний путь добавления. Проверка — **до namer**, чтобы не жечь LLM.
- При недоступности qBittorrent (листинг не удался) тик пропускается без вызова
LLM; предел ретрая — существующий предохранитель `catch_timeout`.
- Гонка апгрейда F6 сужается: `source_type` перечитывается под блокировкой
переходов перед добавлением.
- Транспорт Telegram на дедуп приёма (дубль на уже активную задачу) отвечает
явным «дубль уже активной #id — добавление отменено» (+ лог), без создания
новой записи.
## Capabilities
### New Capabilities
<!-- нет новых capability -->
### Modified Capabilities
- `download-tracking`: требование «Добавление пойманной загрузки в qBittorrent»
дополняется проверкой присутствия и усыновлением (promote без повторного
`add`) при наличии торрента, перечитыванием источника под замком и пропуском
тика при недоступности qBittorrent.
## Impact
- Код: `internal/worker/worker.go` (`processCatched`, presence-check,
усыновление вместо повторного `add`), `internal/tgbot/bot.go` (текст ответа на
дедуп).
- Внешние вызовы: убирает лишние `qbt.Add` (и 409) и `chat.completions`
(LLM-namer) на повторах; добавляет один `qbt.Torrents`-листинг на тик в
`processCatched`.
- Тесты: `internal/worker/catched_test.go`.
- **БД-миграции, новых состояний, конфигурации и внешнего API — нет.**
@@ -0,0 +1,114 @@
## MODIFIED Requirements
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
перевести загрузку `catched → downloading`. Отдельного состояния между `catched`
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
(которое и означает «в qBit, возможно `metaDL`»).
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
загрузку, которую добавлять не требуется.
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
«усыновить и разложить», а не как конфликт с чужой задачей.
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
(см. «Предохранитель зависшего catched»).
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
(`source_type`):
- Для `magnet`/`url` — передавать `source_ref` как ссылку (`urls` API
`/torrents/add`); подсказку отображаемого имени брать из полей самой ссылки.
- Для `torrent` — загружать сохранённые байты `.torrent` (привязанные к
загрузке при приёме) и передавать их **файлом** (`torrents` API
`/torrents/add`), НЕ как ссылку; подсказку отображаемого имени брать из
метаданных торрента (имя раздачи). Добавление байтами SHALL сохранять полные
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
magnet-хешу вместо файла система SHALL NOT.
`source_type` для выбора способа добавления worker SHALL перечитывать **под
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
устаревшего снимка, хотя БД уже `torrent`.
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
загрузку в `catched` для повторной попытки на следующем тике; переход в
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
естественными тиками поллинга, отсечка — `catch_timeout`).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене).
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
нашей категорией и `rename`
- **AND** загрузка переходит в `downloading`
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
сохранёнными байтами файла, торрента ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
нашей категорией и `rename`, без обращения к magnet-хешу
- **AND** загрузка переходит в `downloading`
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
прошлом тике, а запись перехода не удалась)
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
- **AND** `display_name` записывается из имени присутствующей раздачи
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
#### Scenario: Отмена во время добавления
- **GIVEN** загрузка в `catched`, worker выводит имя и добавляет её вне
блокировки
- **WHEN** параллельно приходит команда отмены (`catched → cancelled`), а затем
worker берёт блокировку для записи перехода
- **THEN** ре-валидация видит, что загрузка уже не в `catched`, и переход в
`downloading` не применяется
@@ -0,0 +1,37 @@
## 1. Усыновление в processCatched
- [x] 1.1 В начале `processCatched` один раз получить листинг `qbt.Torrents("")`
и построить `byHash` (по `Hash`/`InfohashV1`/`InfohashV2`, lowercase); провал
листинга → WARN и ранний выход (пойманные не трогаем этот тик, namer не зовём).
- [x] 1.2 Для каждой catched-задачи: `torrentFor(d, byHash)`. Присутствует →
усыновление БЕЗ namer/Add: под замком с ре-валидацией `state='catched'`
`PromoteCatched(id, t.Name)` (`catched → downloading`, имя из раздачи снимка).
- [x] 1.3 Отсутствует в `byHash` — прежний путь, но с re-read записи под `w.mu`
перед сбором `addReq` (свежий `source_type`, ре-валидация `state='catched'`);
тяжёлые вызовы (`GetTorrentData`, namer, `qbt.Add`) — вне замка.
- [x] 1.4 Убедиться, что namer и `qbt.Add` не вызываются на ветке присутствия и
при провале листинга.
## 2. Сообщение о дубле на приёме
- [x] 2.1 В `internal/tgbot/bot.go` (`ingestAndReply`) на `res.Deduplicated`
отвечать явным «♻️ Дубль уже активной загрузки #id — добавление отменено»
(вместо «Уже в работе #id»). Лог дедупа в `ingest` уже есть.
## 3. Тесты
- [x] 3.1 `catched_test.go`: торрент присутствует в снимке qBittorrent →
`downloading` без `Add` и без namer; `display_name` = имя раздачи. (Фейк qBit
отдаёт торрент в снимке ДО обработки задачи.)
- [x] 3.2 `catched_test.go`: листинг qBittorrent провалился → задача осталась
`catched`, namer/Add не вызывались.
- [x] 3.3 `catched_test.go`: торрента нет в снимке → обычный путь (namer + Add +
promote) остаётся зелёным; re-read `source_type` под замком берёт актуальный тип.
- [x] 3.4 Регресс: catch_timeout-предохранитель, отмена во время добавления.
## 4. Проверки и ревью
- [x] 4.1 `task test` и `task lint` зелёные.
- [x] 4.2 Ревью кода (чекпоинт перед archive): jellybit-review-code +
jellybit-review-specs (сверка со спекой download-tracking).
- [x] 4.3 `openspec validate --strict catched-promote-without-readd`.
@@ -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`.
+63 -17
View File
@@ -95,12 +95,34 @@ Worker SHALL периодически сверять раздачи qBittorrent
### Requirement: Добавление пойманной загрузки в qBittorrent
Worker SHALL периодически (в поллинг-цикле, под единой блокировкой переходов)
подхватывать загрузки в состоянии `catched` и для каждой: вывести отображаемое
имя из контекста (см. `ingest` «Отображаемое имя торрента из контекста»),
добавить источник в qBittorrent (категория `qbittorrent.category`, savepath,
`rename`) и перевести загрузку `catched → downloading`. Отдельного состояния
между `catched` и `downloading` быть SHALL NOT — успешный `add` сразу переводит
в `downloading` (которое и означает «в qBit, возможно `metaDL`»).
подхватывать загрузки в состоянии `catched` и для каждой (кроме случая уже
присутствующего в qBittorrent торрента, см. ниже): вывести отображаемое имя из
контекста (см. `ingest` «Отображаемое имя торрента из контекста»), добавить
источник в qBittorrent (категория `qbittorrent.category`, savepath, `rename`) и
перевести загрузку `catcheddownloading`. Отдельного состояния между `catched`
и `downloading` быть SHALL NOT — успешный `add` сразу переводит в `downloading`
(которое и означает «в qBit, возможно `metaDL`»).
Перед добавлением worker SHALL проверять, **присутствует ли торрент загрузки уже
в qBittorrent** (по любому из её infohash), опираясь на листинг раздач того же
тика. Если торрент уже присутствует, worker SHALL **усыновить** его: перевести
загрузку `catched → downloading` **без повторного `add`** и без вывода имени
через LLM (`display_name` берётся из имени присутствующей раздачи). Повторный
`add` здесь не нужен и вреден — qBittorrent отверг бы дубль (напр. `409
Conflict`), и загрузка зациклилась бы на ретраях. Усыновлённая раздача дальше
идёт обычным путём отслеживания и раскладки. Проверка присутствия SHALL
выполняться **до вывода отображаемого имени**, чтобы не тратить LLM-вызов на
загрузку, которую добавлять не требуется.
Инвариант приёма («одна активная загрузка на infohash», см. `ingest`) гарантирует,
что до этого шага доходит лишь загрузка, для которой в jellybit НЕТ другой
активной задачи; поэтому присутствие торрента в qBittorrent worker трактует как
«усыновить и разложить», а не как конфликт с чужой задачей.
Если листинг раздач qBittorrent недоступен (сетевой сбой), worker пойманную
загрузку в этот тик трогать SHALL NOT (ни `add`, ни namer) и повторить на
следующем; устойчивая недоступность отсекается предохранителем `catch_timeout`
(см. «Предохранитель зависшего catched»).
Добавление в qBittorrent worker SHALL выполнять **по типу источника**
(`source_type`):
@@ -114,21 +136,28 @@ Worker SHALL периодически (в поллинг-цикле, под ед
метаданные (qBittorrent стартует без докачки), поэтому воскрешать раздачу по
magnet-хешу вместо файла система SHALL NOT.
Неуспешный `add` (qBittorrent недоступен и т.п.) SHALL оставлять загрузку в
`catched` для повторной попытки на следующем тике; переход в терминальное
состояние по единичному сбою происходить SHALL NOT (ретраи — естественными
тиками поллинга).
`source_type` для выбора способа добавления worker SHALL перечитывать **под
блокировкой переходов** непосредственно перед добавлением (а не полагаться на
снимок, снятый ранее вне блокировки): иначе при точном оверлапе тика с апгрейдом
пойманной magnet-задачи до `.torrent` (см. `ingest`) воркер добавил бы magnet из
устаревшего снимка, хотя БД уже `torrent`.
Неуспешный `add` (qBittorrent временно отверг/недоступен) SHALL оставлять
загрузку в `catched` для повторной попытки на следующем тике; переход в
терминальное состояние по единичному сбою происходить SHALL NOT (ретраи —
естественными тиками поллинга, отсечка — `catch_timeout`).
Медленные вызовы (вывод имени через LLM, `qbt.Add`) SHALL выполняться **вне**
блокировки сериализации переходов, чтобы не задерживать команды транспортов и
поллинг. Под блокировкой сериализуется только **запись перехода** `catched →
downloading` (см. «Переходы состояний сериализуются воркером»), с
ре-валидацией, что загрузка всё ещё в `catched` (иначе переход отклоняется —
например, при параллельной отмене).
downloading` (см. «Переходы состояний сериализуются воркером»), с ре-валидацией,
что загрузка всё ещё в `catched` (иначе переход отклоняется — например, при
параллельной отмене).
#### Scenario: Пойманная magnet-загрузка добавляется в qBittorrent
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`
- **GIVEN** загрузка в состоянии `catched` с `source_type = magnet`, торрента
ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** выводится отображаемое имя, ссылка добавляется в qBittorrent с
нашей категорией и `rename`
@@ -137,16 +166,33 @@ downloading` (см. «Переходы состояний сериализуют
#### Scenario: Пойманная .torrent-загрузка добавляется файлом
- **GIVEN** загрузка в состоянии `catched` с `source_type = torrent` и
сохранёнными байтами файла
сохранёнными байтами файла, торрента ещё нет в qBittorrent
- **WHEN** worker обрабатывает тик
- **THEN** сохранённые байты добавляются в qBittorrent файлом (`torrents`), с
нашей категорией и `rename`, без обращения к magnet-хешу
- **AND** загрузка переходит в `downloading`
#### Scenario: Торрент уже присутствует в qBittorrent — усыновление без add
- **GIVEN** загрузка в состоянии `catched`, торрент которой уже присутствует в
qBittorrent (добавлен ранее вручную/другим клиентом либо `add` прошёл на
прошлом тике, а запись перехода не удалась)
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает `qbt.Add` и НЕ выводит отображаемое имя через LLM
- **AND** `display_name` записывается из имени присутствующей раздачи
- **AND** загрузка переходит в `downloading` и идёт обычным путём к раскладке
#### Scenario: qBittorrent недоступен при проверке присутствия — повтор
- **GIVEN** загрузка в `catched`, листинг раздач qBittorrent не удался
- **WHEN** worker обрабатывает тик
- **THEN** worker НЕ вызывает namer и НЕ добавляет источник
- **AND** загрузка остаётся в `catched` и попытка повторяется на следующем тике
#### Scenario: Временный сбой добавления — повтор
- **GIVEN** загрузка в `catched`, qBittorrent временно недоступен
- **WHEN** worker пытается добавить источник и `add` не удался
- **GIVEN** загрузка в `catched`, торрента в qBittorrent нет, но `add` не удался
- **WHEN** worker пытается добавить источник и `add` возвращает ошибку
- **THEN** загрузка остаётся в `catched`
- **AND** на следующем тике попытка добавления повторяется
+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>